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;
+ });
+}