diff --git a/apps/web/src/components/avatar/chat-avatar.tsx b/apps/web/src/components/avatar/chat-avatar.tsx index ef4f2b568a9..875d09214d1 100644 --- a/apps/web/src/components/avatar/chat-avatar.tsx +++ b/apps/web/src/components/avatar/chat-avatar.tsx @@ -1,6 +1,8 @@ import { motion, useReducedMotion } from "motion/react"; import { useCallback, useMemo, useState, type CSSProperties } from "react"; +import { BusyIndicator } from "@/domains/chat/components/busy-indicator"; +import { isProgressBadgeEnabled } from "@/lib/feature-flags/progress-badge-flag"; import type { CharacterComponents, CharacterTraits } from "@/types/avatar"; import { AnimatedAvatar } from "./animated-avatar"; @@ -12,6 +14,39 @@ export interface ChatAvatarProps { className?: string; interactive?: boolean; isStreaming?: boolean; + isProcessing?: boolean; +} + +/** Tunable badge geometry. Sizes scale with avatar size for visual consistency. */ +const BADGE_DOT_RATIO = 0.16; // dot diameter / avatar size — 56px avatar → ~9px dot +const BADGE_RING_RATIO = 0.04; // ring thickness / avatar size + +/** + * Pulsing dot in the bottom-right corner of the avatar. Reuses + * `BusyIndicator` for the pulse so the visual matches every other + * "busy" affordance in the app (card-header status, tool-call chip). + * + * A solid ring (same color as the surrounding chat surface) separates + * the dot from the avatar background so it reads cleanly against either + * a character avatar or a custom image. + */ +function ProgressBadge({ size }: { size: number }) { + const dot = Math.max(6, Math.round(size * BADGE_DOT_RATIO)); + const ring = Math.max(1, Math.round(size * BADGE_RING_RATIO)); + return ( + + ); } /** @@ -27,6 +62,9 @@ export interface ChatAvatarProps { * - Mount plays an entrance spring (scale 0.6 → 1, opacity 0 → 1). * - When `interactive`, click triggers a spring bounce. * - `prefers-reduced-motion` short-circuits both. + * - When `isProcessing` and the `useProgressBadge` debug flag is on, + * the bottom-right `ProgressBadge` pulses. Default behavior (flag + * off) leaves the old transcript "thinking…" dots in charge. */ export function ChatAvatar({ components, @@ -36,6 +74,7 @@ export function ChatAvatar({ className, interactive = false, isStreaming = false, + isProcessing = false, }: ChatAvatarProps) { const reduce = useReducedMotion(); const [isPoking, setIsPoking] = useState(false); @@ -67,6 +106,7 @@ export function ChatAvatar({ flexShrink: 0, cursor: interactive ? "pointer" : undefined, transformOrigin: "center", + position: "relative", }; const transition = reduce @@ -78,6 +118,8 @@ export function ChatAvatar({ : { scale: 0.6, opacity: 0 }; const animate = { scale: isPoking ? 1.15 : 1, opacity: 1 }; + const showBadge = isProcessing && isProgressBadgeEnabled(); + if (preferCharacter) { return ( + {showBadge && } ); } @@ -108,6 +151,10 @@ export function ChatAvatar({ style={{ cursor: interactive ? "pointer" : undefined, transformOrigin: "center", + position: "relative", + width: size, + height: size, + flexShrink: 0, }} > + {showBadge && } ); } @@ -131,7 +179,7 @@ export function ChatAvatar({ animate={animate} transition={transition} > - V + V{showBadge && } ); } diff --git a/apps/web/src/domains/chat/components/chat-route-content.tsx b/apps/web/src/domains/chat/components/chat-route-content.tsx index 5552b2c85f2..890f60d0dc5 100644 --- a/apps/web/src/domains/chat/components/chat-route-content.tsx +++ b/apps/web/src/domains/chat/components/chat-route-content.tsx @@ -56,6 +56,7 @@ import { QueuedMessagesDrawer } from "@/domains/chat/components/queued-messages- import { AppViewerContainer } from "@/components/app-viewer-container"; import { DocumentViewerContainer } from "@/domains/chat/components/document-viewer-container"; import { ChatAvatar } from "@/components/avatar/chat-avatar"; +import { isProgressBadgeEnabled } from "@/lib/feature-flags/progress-badge-flag"; import { ComposerSettingsMenu } from "@/domains/chat/components/composer-settings-menu"; import { ContextWindowIndicator, type ContextWindowUsage } from "@/domains/chat/components/context-window-indicator"; // SubagentDetailPanel is only rendered when the user opens a subagent's @@ -625,8 +626,21 @@ export function ChatRouteContent({ return false; }, [messages]); + // Derive "is this conversation processing?" as an OR of the local + // optimistic set (driven by `useSendMessage` and the SSE start + // handlers) and the server's cached snapshot (`isProcessing` on the + // conversation row, mirroring the daemon's `Conversation.isProcessing()`). + // + // Either signal is sufficient to light the avatar progress badge. + // They converge via terminal SSE handlers (which clear the local set + // AND patch the cached snapshot via `patchConversation`) and the + // next list/detail GET refreshing the server snapshot. The OR also + // makes us robust to pre-0.8.7 daemons that omit `isProcessing` on + // the wire — the fallback to the local set still drives the badge. const activeConversationIsProcessing = - activeConversationId != null && processingConversationIds.has(activeConversationId); + (activeConversationId != null && + processingConversationIds.has(activeConversationId)) || + !!activeConversation?.isProcessing; const activeConversationHasPendingAssistantResponse = useMemo( () => hasPendingAssistantResponse(messages), @@ -646,7 +660,13 @@ export function ChatRouteContent({ hasPendingAssistantResponse: activeConversationHasPendingAssistantResponse, }; - const showThinking = shouldShowThinkingIndicator(turnState, uiContext); + // When the `useProgressBadge` debug flag is on, suppress the + // transcript-trailer thinking dots — the avatar badge takes over the + // "the assistant is working" affordance. Default (flag off) keeps the + // long-standing dots in charge. + const showThinking = + !isProgressBadgeEnabled() && + shouldShowThinkingIndicator(turnState, uiContext); const isAssistantStreaming = showThinking || hasStreamingAssistantMessage; const canStopGenerating = canStopGeneration(turnState, uiContext); @@ -1258,6 +1278,7 @@ export function ChatRouteContent({ size={56} interactive isStreaming={isAssistantStreaming} + isProcessing={activeConversationIsProcessing} /> ) : undefined, diff --git a/apps/web/src/domains/chat/utils/debug-api.test.ts b/apps/web/src/domains/chat/utils/debug-api.test.ts index 40d35246aa5..fdee00f7726 100644 --- a/apps/web/src/domains/chat/utils/debug-api.test.ts +++ b/apps/web/src/domains/chat/utils/debug-api.test.ts @@ -698,16 +698,16 @@ type DebugWindow = Window & { chat?: unknown; events?: { getClients: unknown; getEvents: unknown }; flags?: { - toggleTranscriptScrollController?: (v?: boolean) => boolean; impersonateVersion?: (v?: string | null) => string | null; + toggleProgressBadge?: (v?: boolean | null) => boolean; }; other?: unknown; }; }; const makeFlagsApi = () => ({ - toggleTranscriptScrollController: (_value?: boolean): boolean => false, impersonateVersion: (_value?: string | null): string | null => null, + toggleProgressBadge: (_value?: boolean | null): boolean => false, }); describe("installVellumDebugApi", () => { @@ -720,10 +720,8 @@ describe("installVellumDebugApi", () => { expect(root?.events).toBeDefined(); expect(typeof root?.events?.getClients).toBe("function"); expect(typeof root?.events?.getEvents).toBe("function"); - expect(typeof root?.flags?.toggleTranscriptScrollController).toBe( - "function", - ); expect(typeof root?.flags?.impersonateVersion).toBe("function"); + expect(typeof root?.flags?.toggleProgressBadge).toBe("function"); uninstall(); }); diff --git a/apps/web/src/domains/chat/utils/debug-api.ts b/apps/web/src/domains/chat/utils/debug-api.ts index d026e6e0158..6b23d0feb01 100644 --- a/apps/web/src/domains/chat/utils/debug-api.ts +++ b/apps/web/src/domains/chat/utils/debug-api.ts @@ -44,6 +44,7 @@ import { recordDiagnostic } from "@/lib/diagnostics"; import type { DisplayMessage } from "@/domains/chat/utils/reconcile"; import type { ReconcileActiveConversationResult } from "@/domains/chat/hooks/use-message-reconciliation"; import { setImpersonatedAssistantVersion } from "@/lib/backwards-compat/impersonate-version-flag"; +import { setProgressBadgeEnabled } from "@/lib/feature-flags/progress-badge-flag"; import { classifyScrollPosition, type TranscriptHandle, @@ -711,6 +712,20 @@ export interface VellumDebugFlagsApi { * * Returns the value in effect after the call. */ impersonateVersion(value?: string | null): string | null; + + /** Opt into the new avatar progress-badge UX. When off (default), the + * chat shows the long-standing transcript "thinking…" dots; when on, + * the dots are hidden and a small pulsing badge renders on the + * assistant avatar instead. Persists to localStorage and reloads. + * + * - `toggleProgressBadge(true)` — enable + reload. + * - `toggleProgressBadge(false)` — disable + reload. + * - `toggleProgressBadge(null)` — clear + reload (same as false). + * - `toggleProgressBadge()` — log + return current value + * (no reload, no mutation). + * + * Returns the value in effect after the call. */ + toggleProgressBadge(value?: boolean | null): boolean; } interface VellumDebugRoot extends Record { @@ -738,7 +753,7 @@ declare global { * can pull canonical SSE schemas (`RelationshipStateUpdatedEventSchema`, …) * out of the shipped bundle from the console. * - `flags` — dev-toggleable feature flags - * (`toggleTranscriptScrollController`, `impersonateVersion`). + * (`impersonateVersion`, `toggleProgressBadge`). * Stable singleton; pure module exports backed by localStorage. * * Consolidating these into one installer guarantees they're set at the @@ -826,6 +841,7 @@ export function useChatDebugApi(refs: ChatDebugRefs): void { const api = createChatDebugApi(stableRefs); const flagsApi: VellumDebugFlagsApi = { impersonateVersion: setImpersonatedAssistantVersion, + toggleProgressBadge: setProgressBadgeEnabled, }; const uninstall = installVellumDebugApi(api, flagsApi); return uninstall; diff --git a/apps/web/src/domains/chat/utils/stream-handlers/error-handlers.ts b/apps/web/src/domains/chat/utils/stream-handlers/error-handlers.ts index 1ace325e911..a76ce7ff680 100644 --- a/apps/web/src/domains/chat/utils/stream-handlers/error-handlers.ts +++ b/apps/web/src/domains/chat/utils/stream-handlers/error-handlers.ts @@ -5,6 +5,7 @@ import { } from "@/domains/chat/hooks/stream-message-updaters"; import { ERROR_MESSAGES } from "@/domains/chat/utils/chat"; import type { StreamHandlerContext } from "@/domains/chat/utils/stream-handlers/types"; +import { patchConversation } from "@/utils/conversation-cache"; import type { ConversationErrorEvent, StreamErrorEvent } from "@/types/event-types"; @@ -12,10 +13,16 @@ export function handleStreamError( event: StreamErrorEvent, ctx: StreamHandlerContext, ): void { - ctx.endTurn({ - conversationId: ctx.streamContextRef.current?.conversationId, - reason: "error", - }); + const convId = ctx.streamContextRef.current?.conversationId; + if (convId) { + // Mirrors the cache patch in `handleMessageComplete` — terminal + // errors must also clear the cached `isProcessing: true` snapshot + // so the OR derivation in chat-route-content can't latch. + patchConversation(ctx.queryClient, ctx.assistantIdRef.current, convId, { + isProcessing: false, + }); + } + ctx.endTurn({ conversationId: convId, reason: "error" }); ctx.setMessages((prev) => stopStreaming(prev)); const detail = (event.code && ERROR_MESSAGES[event.code]) || @@ -41,10 +48,15 @@ export function handleConversationErrorEvent( // (which is a mirror that may be cleared by a stream teardown // racing the error event) — same fallback shape as the other // terminal handlers. - ctx.endTurn({ - conversationId: event.conversationId ?? ctx.streamContextRef.current?.conversationId, - reason: "error", - }); + const convId = + event.conversationId ?? ctx.streamContextRef.current?.conversationId; + if (convId) { + // See `handleStreamError` for the stale-snapshot rationale. + patchConversation(ctx.queryClient, ctx.assistantIdRef.current, convId, { + isProcessing: false, + }); + } + ctx.endTurn({ conversationId: convId, reason: "error" }); ctx.setMessages(handleConversationError); diff --git a/apps/web/src/domains/chat/utils/stream-handlers/message-handlers.ts b/apps/web/src/domains/chat/utils/stream-handlers/message-handlers.ts index a85b77e6857..9acc1918fac 100644 --- a/apps/web/src/domains/chat/utils/stream-handlers/message-handlers.ts +++ b/apps/web/src/domains/chat/utils/stream-handlers/message-handlers.ts @@ -6,6 +6,11 @@ import { stopStreaming, } from "@/domains/chat/hooks/stream-message-updaters"; import type { StreamHandlerContext } from "@/domains/chat/utils/stream-handlers/types"; +import { + findConversation, + patchConversation, +} from "@/utils/conversation-cache"; +import { useConversationStore } from "@/stores/conversation-store"; import type { AssistantActivityStateEvent } from "@/types/event-types"; import type { AssistantTextDeltaEvent, @@ -16,6 +21,21 @@ import type { } from "@vellumai/assistant-api"; import { useSubagentStore } from "@/domains/chat/subagent-store"; +/** + * Resolve the conversation id for SSE handlers — events that carry it on + * the wire win, otherwise we fall back to the stream's anchor. + * + * Both `assistant_turn_start` and `assistant_text_delta` reliably carry a + * `conversationId`, but the resolver matches the same fallback chain used + * by the terminal handlers (`handleMessageComplete`, + * `handleGenerationCancelled`) for symmetry. + */ +function resolveConversationId( + event: { conversationId?: string }, + ctx: StreamHandlerContext, +): string | undefined { + return event.conversationId ?? ctx.streamContextRef.current?.conversationId; +} /** * Apply an `assistant_turn_start` event. @@ -43,6 +63,31 @@ export function handleAssistantTurnStart( ctx: StreamHandlerContext, ): void { ctx.currentAssistantMessageIdRef.current = event.messageId; + + // Mark the conversation as processing the moment the daemon emits its + // first start signal. Covers external-channel turns (Slack/Telegram) + // where the local `useSendMessage` flow never ran, and serves as a + // belt-and-suspenders fallback against pre-0.8.7 daemons that don't + // surface `conversation.isProcessing` on the wire. + // + // Seed `processingSnapshots` with the cached conversation's + // `latestAssistantMessageAt` so attention tracking has a baseline to + // graduate against. Without this seed, switching away from an + // SSE-only turn would immediately graduate (comparing `number !== + // undefined`) and drop the sidebar processing affordance while the + // assistant is still running. + const convId = resolveConversationId(event, ctx); + if (convId) { + const cached = findConversation( + ctx.queryClient, + ctx.assistantIdRef.current, + convId, + ); + useConversationStore + .getState() + .markConversationProcessing(convId, cached?.latestAssistantMessageAt); + } + ctx.setMessages((prev) => { let touched = false; const next = prev.map((m) => { @@ -61,6 +106,25 @@ export function handleAssistantTextDelta( ): void { ctx.cancelReconciliation(); ctx.turnActions.onTextDelta(); + + // First delta on a conversation that never saw `assistant_turn_start` + // (e.g. pre-B3 daemons, or the start event being dropped on a + // reconnect) still needs to flip the badge on. Idempotent — no-op + // when the conversation is already marked processing AND the snapshot + // is already seeded. See `handleAssistantTurnStart` for the snapshot + // rationale. + const convId = resolveConversationId(event, ctx); + if (convId) { + const cached = findConversation( + ctx.queryClient, + ctx.assistantIdRef.current, + convId, + ); + useConversationStore + .getState() + .markConversationProcessing(convId, cached?.latestAssistantMessageAt); + } + ctx.setMessages((prev) => { const next = appendTextDelta(prev, event.text, event.messageId); const tail = next[next.length - 1]; @@ -78,12 +142,10 @@ export function handleAssistantActivityState( event: AssistantActivityStateEvent, ctx: StreamHandlerContext, ): void { - const convId = - event.conversationId ?? ctx.streamContextRef.current?.conversationId; + const convId = resolveConversationId(event, ctx); if (convId) { - const lastSeen = - ctx.lastActivityVersionRef.current.get(convId) ?? 0; + const lastSeen = ctx.lastActivityVersionRef.current.get(convId) ?? 0; if (event.activityVersion <= lastSeen) { recordDiagnostic("sse_activity_state_version_skipped", { convId, @@ -117,6 +179,14 @@ export function handleAssistantActivityState( } ctx.setMessages(finalizeOnIdle); + if (convId) { + // Mirrors the cache patch in `handleMessageComplete` / + // `handleGenerationCancelled` — see those handlers for the + // stale-snapshot rationale. + patchConversation(ctx.queryClient, ctx.assistantIdRef.current, convId, { + isProcessing: false, + }); + } const turnPhaseBefore = ctx.getTurnState().phase; ctx.endTurn({ conversationId: convId, reason: "complete" }); recordDiagnostic("sse_activity_state_idle_handled", { @@ -153,8 +223,17 @@ export function handleMessageComplete( // `handleMessageComplete`, `handleGenerationCancelled`) use this same // fallback chain so the processing-key clear stays reliable across // reconnects. - const convId = - event.conversationId ?? ctx.streamContextRef.current?.conversationId; + const convId = resolveConversationId(event, ctx); + if (convId) { + // Patch the cached conversation row so the server-snapshot half of + // the processing OR (`activeConversation?.isProcessing`) can't stay + // latched on a stale `true` after the local set is cleared. Without + // this, conversations opened or refreshed mid-turn would keep the + // badge / Stop / streaming state lit until an unrelated refetch. + patchConversation(ctx.queryClient, ctx.assistantIdRef.current, convId, { + isProcessing: false, + }); + } const turnPhaseBefore = ctx.getTurnState().phase; ctx.endTurn({ conversationId: convId, reason: "complete" }); recordDiagnostic("sse_message_complete_handled", { @@ -180,9 +259,13 @@ export function handleGenerationCancelled( ctx: StreamHandlerContext, ): void { // See `handleMessageComplete` for the rationale on the event-first - // fallback chain. - const convId = - event.conversationId ?? ctx.streamContextRef.current?.conversationId; + // fallback chain and the cache-patch. + const convId = resolveConversationId(event, ctx); + if (convId) { + patchConversation(ctx.queryClient, ctx.assistantIdRef.current, convId, { + isProcessing: false, + }); + } ctx.endTurn({ conversationId: convId, reason: "cancelled" }); ctx.setMessages((prev) => stopStreaming(prev)); } diff --git a/apps/web/src/domains/conversations/conversation-queries.ts b/apps/web/src/domains/conversations/conversation-queries.ts index 8ab018a2074..6bfb8c337eb 100644 --- a/apps/web/src/domains/conversations/conversation-queries.ts +++ b/apps/web/src/domains/conversations/conversation-queries.ts @@ -54,6 +54,12 @@ import { conversationsQueryKey, } from "@/lib/sync/query-tags"; import type { Conversation, ConversationGroup } from "@/types/conversation-types"; +import { + findConversation, + getConversations, + patchConversation, + updateConversationsCache, +} from "@/utils/conversation-cache"; import { CONVERSATION_NOT_FOUND, @@ -255,79 +261,15 @@ const EMPTY_GROUPS: ConversationGroup[] = []; // --------------------------------------------------------------------------- // Cache helpers — conversations // -// These mutate the conversations query cache (a flat `Conversation[]`). -// They are the domain-level "change this conversation locally" operations; -// `queryClient.setQueryData` is implementation detail. +// The low-level `Conversation[]` cache primitives (`updateConversationsCache`, +// `findConversation`, `getConversations`, `patchConversation`) live in +// `@/utils/conversation-cache` so the chat stream handlers can share them +// without a cross-domain import. They're re-exported here so existing +// conversations-domain consumers keep their import site. The domain-level +// mutations below build on `updateConversationsCache`. // --------------------------------------------------------------------------- -function updateConversationsCache( - queryClient: QueryClient, - assistantId: string | null, - updater: (conversations: Conversation[]) => Conversation[], -): void { - queryClient.setQueryData( - conversationsQueryKey(assistantId), - (prev) => { - const list = prev ?? []; - const next = updater(list); - if (next === list) return prev; - return next; - }, - ); -} - -/** - * Read a single conversation from the conversations query cache. Used by - * imperative callers (send pipeline, attention tracking) that need the - * current value without subscribing to re-renders. - */ -export function findConversation( - queryClient: QueryClient, - assistantId: string | null, - key: string, -): Conversation | undefined { - const list = - queryClient.getQueryData( - conversationsQueryKey(assistantId), - ) ?? []; - return list.find((c) => c.conversationId === key); -} - -/** - * Read all conversations from the conversations query cache. Returns an - * empty array when the query hasn't populated yet. - */ -export function getConversations( - queryClient: QueryClient, - assistantId: string | null, -): Conversation[] { - return ( - queryClient.getQueryData( - conversationsQueryKey(assistantId), - ) ?? [] - ); -} - -/** - * Immutably patch the conversation matching `key`, leaving all others - * untouched. No-op when the key is not in the cache. - */ -export function patchConversation( - queryClient: QueryClient, - assistantId: string | null, - key: string, - patch: Partial, -): void { - updateConversationsCache(queryClient, assistantId, (conversations) => { - let changed = false; - const next = conversations.map((c) => { - if (c.conversationId !== key) return c; - changed = true; - return { ...c, ...patch }; - }); - return changed ? next : conversations; - }); -} +export { findConversation, getConversations, patchConversation }; /** * Mark the conversation as seen in the local cache. The matching server diff --git a/apps/web/src/domains/conversations/conversation-transforms.ts b/apps/web/src/domains/conversations/conversation-transforms.ts index 5c3a115df09..4e297d2a672 100644 --- a/apps/web/src/domains/conversations/conversation-transforms.ts +++ b/apps/web/src/domains/conversations/conversation-transforms.ts @@ -107,6 +107,7 @@ export function toConversation(raw: RawConversationSummary): Conversation { displayOrder: asNumber(raw.displayOrder), channelBinding: mapChannelBinding(raw.channelBinding), originChannel, + isProcessing: raw.isProcessing, }; } diff --git a/apps/web/src/lib/feature-flags/progress-badge-flag.ts b/apps/web/src/lib/feature-flags/progress-badge-flag.ts new file mode 100644 index 00000000000..305afcffb5c --- /dev/null +++ b/apps/web/src/lib/feature-flags/progress-badge-flag.ts @@ -0,0 +1,64 @@ +// Dev flag: opt in to the new avatar progress-badge UX. When disabled +// (default), the chat shows the long-standing transcript "thinking…" +// dots; when enabled, the dots are hidden and a small pulsing badge +// renders on the assistant avatar instead. +// +// Mechanism: `setProgressBadgeEnabled(...)` writes to localStorage and +// reloads. The flag is read synchronously by both the transcript +// builder (to suppress the old `ThinkingItem`) and `ChatAvatar` (to +// gate the new badge), so we want a uniform world post-flip and a +// reload is the cheapest way to get one. +// +// Surface (exposed under `window._vellumDebug.flags`): +// +// toggleProgressBadge(true) — enable + reload +// toggleProgressBadge(false) — disable + reload +// toggleProgressBadge(null) — clear + reload (same as false) +// toggleProgressBadge() — log + return current value, no reload + +import { + getLocalSetting, + removeLocalSetting, + setLocalSetting, +} from "@/utils/local-settings"; + +const STORAGE_KEY = "vellum:debug:useProgressBadge"; + +/** + * Read the flag synchronously. Returns `false` when no override is set, + * the key is missing, or localStorage throws (private browsing / + * sandboxed iframes). Safe to call during render. + */ +export function isProgressBadgeEnabled(): boolean { + return getLocalSetting(STORAGE_KEY, "") === "true"; +} + +/** + * Flip the flag and reload, or inspect-only when called with no args. + * + * Returns the value that will be in effect after the call (post-reload + * for set/clear, current for inspect). + */ +export function setProgressBadgeEnabled(value?: boolean | null): boolean { + if (typeof window === "undefined") return false; + + if (value === undefined) { + const current = isProgressBadgeEnabled(); + console.info( + `[vellumDebug] useProgressBadge (current) = ${String(current)}`, + ); + return current; + } + + if (value === null || value === false) { + removeLocalSetting(STORAGE_KEY); + console.info( + "[vellumDebug] useProgressBadge = false (cleared) — reloading…", + ); + } else { + setLocalSetting(STORAGE_KEY, "true"); + console.info("[vellumDebug] useProgressBadge = true — reloading…"); + } + window.location.reload(); + return value === true; +} diff --git a/apps/web/src/stores/conversation-store.ts b/apps/web/src/stores/conversation-store.ts index 015980cb053..01f3b40ffa7 100644 --- a/apps/web/src/stores/conversation-store.ts +++ b/apps/web/src/stores/conversation-store.ts @@ -85,6 +85,22 @@ export interface ConversationListActions { // --- Processing conversation ids (and their snapshots, kept atomic) --- addProcessingConversationId: (conversationId: string, snapshot?: number) => void; + /** + * Idempotent "this conversation is mid-turn" mark for SSE start events. + * Like `addProcessingConversationId` but tolerant of repeat firings + * (start events fire many times per turn). + * + * Snapshot semantics: if a snapshot is supplied AND none is recorded + * yet, it's seeded. Subsequent calls don't overwrite — first writer + * wins, so the send-side `addProcessingConversationId` always takes + * precedence over later SSE marks. Without this seed, attention + * tracking compares `latestAssistantMessageAt` against `undefined` + * and graduates SSE-only (external-channel) turns prematurely. + * + * No-op when the id is already in the set and a snapshot is already + * recorded. + */ + markConversationProcessing: (conversationId: string, snapshot?: number) => void; removeProcessingConversationId: (conversationId: string) => void; removeMultipleProcessingConversationIds: (conversationIds: string[]) => void; transferProcessingConversationId: ( @@ -146,6 +162,23 @@ export const useConversationStore = createSelectors( }); }, + markConversationProcessing: (conversationId, snapshot) => { + const { processingConversationIds, processingSnapshots } = get(); + const alreadyInSet = processingConversationIds.has(conversationId); + const alreadyHasSnapshot = processingSnapshots.has(conversationId); + // Already fully tracked — no work. + if (alreadyInSet && alreadyHasSnapshot) return; + const nextSnapshots = alreadyHasSnapshot + ? processingSnapshots + : new Map(processingSnapshots).set(conversationId, snapshot); + set({ + processingConversationIds: alreadyInSet + ? processingConversationIds + : addToSet(processingConversationIds, conversationId), + processingSnapshots: nextSnapshots, + }); + }, + removeProcessingConversationId: (conversationId) => { set({ processingConversationIds: removeFromSet(get().processingConversationIds, conversationId), diff --git a/apps/web/src/types/conversation-types.ts b/apps/web/src/types/conversation-types.ts index 138f2876790..8afd409a905 100644 --- a/apps/web/src/types/conversation-types.ts +++ b/apps/web/src/types/conversation-types.ts @@ -45,6 +45,8 @@ export interface Conversation { originChannel?: string; /** True for optimistic stubs not yet confirmed by the server. */ draft?: boolean; + /** Server-seeded flag mirroring the daemon's `Conversation.isProcessing()`. Optional: pre-0.8.7 daemons and optimistic drafts omit it. */ + isProcessing?: boolean; } export interface ConversationChannelBinding { diff --git a/apps/web/src/utils/conversation-cache.ts b/apps/web/src/utils/conversation-cache.ts new file mode 100644 index 00000000000..b45319ddd29 --- /dev/null +++ b/apps/web/src/utils/conversation-cache.ts @@ -0,0 +1,88 @@ +/** + * Low-level read/write helpers over the conversations query cache (a flat + * `Conversation[]` stored under `conversationsQueryKey`). + * + * These primitives are shared cross-domain — the conversations domain's + * higher-level mutations build on `updateConversationsCache`, attention + * tracking reads the list, and the chat stream handlers patch a row's + * `isProcessing` snapshot on terminal events. They live at the top level + * so neither domain reaches into the other; `queryClient.setQueryData` / + * `getQueryData` is an implementation detail callers shouldn't repeat. + * + * References: + * - https://tanstack.com/query/latest/docs/framework/react/guides/updates-from-mutation-responses + */ + +import type { QueryClient } from "@tanstack/react-query"; + +import { conversationsQueryKey } from "@/lib/sync/query-tags"; +import type { Conversation } from "@/types/conversation-types"; + +export function updateConversationsCache( + queryClient: QueryClient, + assistantId: string | null, + updater: (conversations: Conversation[]) => Conversation[], +): void { + queryClient.setQueryData( + conversationsQueryKey(assistantId), + (prev) => { + const list = prev ?? []; + const next = updater(list); + if (next === list) return prev; + return next; + }, + ); +} + +/** + * Read a single conversation from the conversations query cache. Used by + * imperative callers (send pipeline, attention tracking) that need the + * current value without subscribing to re-renders. + */ +export function findConversation( + queryClient: QueryClient, + assistantId: string | null, + key: string, +): Conversation | undefined { + const list = + queryClient.getQueryData( + conversationsQueryKey(assistantId), + ) ?? []; + return list.find((c) => c.conversationId === key); +} + +/** + * Read all conversations from the conversations query cache. Returns an + * empty array when the query hasn't populated yet. + */ +export function getConversations( + queryClient: QueryClient, + assistantId: string | null, +): Conversation[] { + return ( + queryClient.getQueryData( + conversationsQueryKey(assistantId), + ) ?? [] + ); +} + +/** + * Immutably patch the conversation matching `key`, leaving all others + * untouched. No-op when the key is not in the cache. + */ +export function patchConversation( + queryClient: QueryClient, + assistantId: string | null, + key: string, + patch: Partial, +): void { + updateConversationsCache(queryClient, assistantId, (conversations) => { + let changed = false; + const next = conversations.map((c) => { + if (c.conversationId !== key) return c; + changed = true; + return { ...c, ...patch }; + }); + return changed ? next : conversations; + }); +}