Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 49 additions & 1 deletion apps/web/src/components/avatar/chat-avatar.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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 (
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's put this behind a _vellumDebug.flags.toggleProgressBadge() flag for now, where when it's disabled, we show the old thinking indicator

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in a8e9115c03. New flag module at apps/web/src/lib/feature-flags/progress-badge-flag.ts modeled after impersonate-version-flag.ts (localStorage-backed, reload-on-flip, inspect-only when called with no args). Registered as _vellumDebug.flags.toggleProgressBadge() via the existing flagsApi installer in debug-api.ts. Default is off → legacy transcript thinking-dots indicator renders. Flag on → dots are suppressed (shouldShowThinkingIndicator gated) and the new avatar badge lights up via isProgressBadgeEnabled() inside ChatAvatar. Same OR-derived activeConversationIsProcessing signal drives both modes.

<span
aria-hidden="true"
className="absolute rounded-full"
style={{
bottom: 0,
right: 0,
padding: ring,
backgroundColor: "var(--surface-base)",
}}
>
<BusyIndicator size={dot} />
</span>
);
}

/**
Expand All @@ -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,
Expand All @@ -36,6 +74,7 @@ export function ChatAvatar({
className,
interactive = false,
isStreaming = false,
isProcessing = false,
}: ChatAvatarProps) {
const reduce = useReducedMotion();
const [isPoking, setIsPoking] = useState(false);
Expand Down Expand Up @@ -67,6 +106,7 @@ export function ChatAvatar({
flexShrink: 0,
cursor: interactive ? "pointer" : undefined,
transformOrigin: "center",
position: "relative",
};

const transition = reduce
Expand All @@ -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 (
<motion.div
Expand All @@ -94,6 +136,7 @@ export function ChatAvatar({
size={size}
isStreaming={isStreaming}
/>
{showBadge && <ProgressBadge size={size} />}
</motion.div>
);
}
Expand All @@ -108,6 +151,10 @@ export function ChatAvatar({
style={{
cursor: interactive ? "pointer" : undefined,
transformOrigin: "center",
position: "relative",
width: size,
height: size,
flexShrink: 0,
}}
>
<img
Expand All @@ -118,6 +165,7 @@ export function ChatAvatar({
className={`rounded-full object-cover ${className ?? ""}`}
style={{ width: size, height: size, flexShrink: 0 }}
/>
{showBadge && <ProgressBadge size={size} />}
</motion.div>
);
}
Expand All @@ -131,7 +179,7 @@ export function ChatAvatar({
animate={animate}
transition={transition}
>
V
V{showBadge && <ProgressBadge size={size} />}
</motion.div>
);
}
25 changes: 23 additions & 2 deletions apps/web/src/domains/chat/components/chat-route-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand All @@ -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);
Expand Down Expand Up @@ -1258,6 +1278,7 @@ export function ChatRouteContent({
size={56}
interactive
isStreaming={isAssistantStreaming}
isProcessing={activeConversationIsProcessing}
/>
)
: undefined,
Expand Down
8 changes: 3 additions & 5 deletions apps/web/src/domains/chat/utils/debug-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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();
});

Expand Down
18 changes: 17 additions & 1 deletion apps/web/src/domains/chat/utils/debug-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, unknown> {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
28 changes: 20 additions & 8 deletions apps/web/src/domains/chat/utils/stream-handlers/error-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,24 @@ 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";


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]) ||
Expand All @@ -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);

Expand Down
Loading