diff --git a/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx b/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx index 55b49e6bd1..e75121de8d 100644 --- a/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx @@ -17,7 +17,7 @@ import { } from '@/components/assistant-ui/thread/content' import { MESSAGE_PARTS_COMPONENTS } from '@/components/assistant-ui/thread/message-parts' import { ReactionPicker } from '@/components/assistant-ui/thread/message-reactions' -import { ResponseLoadingIndicator, StreamStallIndicator } from '@/components/assistant-ui/thread/status' +import { ResponseLoadingIndicator, TurnActivityIndicator } from '@/components/assistant-ui/thread/status' import { MessageTimelineTimestamp } from '@/components/assistant-ui/thread/timeline-timestamp' import { useMessageReactions, useTapbackDoubleClick } from '@/components/assistant-ui/thread/use-message-reactions' import { AGENT_MESSAGE_RE } from '@/components/assistant-ui/thread/user-message' @@ -96,7 +96,7 @@ export const AssistantMessage: FC<{ // selector here returns a value that stays referentially stable across // token flushes (booleans, status strings, '' while running), so the // 30 Hz delta stream only re-renders the markdown part and the tiny - // StreamStallIndicator leaf — not the footer/preview/root subtree. + // TurnActivityIndicator leaf — not the footer/preview/root subtree. const messageStatus = useAuiState(s => s.message.status?.type) const isRunning = messageStatus === 'running' const isPlaceholder = useAuiState(s => s.message.status?.type === 'running' && s.message.content.length === 0) @@ -199,7 +199,14 @@ export const AssistantMessage: FC<{ > {/* Todos render in the composer status stack now, not inline. */} - {isLastMessage && (isPlaceholder ? : isRunning && )} + {/* The activity row is mounted by the TAIL of the thread and decides + for itself whether the turn owes the user a line. Gating the mount + on this bubble's own `running` status was the hole: a turn that + seals a bubble mid-flight (message.interim) or finishes one while + the agent keeps going leaves a settled message at the tail, so the + row unmounted and the seconds went uncounted while the composer's + arc border and Stop button said work was still happening. */} + {isLastMessage && (isPlaceholder ? : )} {previewTargets.length > 0 && (
{previewTargets.map(target => ( diff --git a/apps/desktop/src/components/assistant-ui/thread/duplicate-activity-indicator.test.tsx b/apps/desktop/src/components/assistant-ui/thread/duplicate-activity-indicator.test.tsx index 024c8eab39..ac659b99a5 100644 --- a/apps/desktop/src/components/assistant-ui/thread/duplicate-activity-indicator.test.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/duplicate-activity-indicator.test.tsx @@ -1,4 +1,4 @@ -// Loading and stall indicators mount only on the thread's last message. +// Loading and activity indicators mount only on the thread's last message. // The tail-only gate from ba756333 keeps non-tail running bubbles silent, // including assistants followed only by a user or system row. The optimistic // placeholder flow renders exactly one status row. These contracts pin the @@ -101,13 +101,13 @@ function runningAssistantMessage(id: string, text: string): ThreadMessage { function Harness({ messages, isRunning = false }: { messages: ThreadMessage[]; isRunning?: boolean }) { // isRunning: false at the runtime level. Per-message `status: {type: - // 'running'}` is what drives StreamStallIndicator mounting. + // 'running'}` is what drives TurnActivityIndicator mounting. // Passing isRunning: true makes useExternalStoreRuntime auto-append a // synthetic empty trailing assistant placeholder whenever the last message // is not already a running assistant, such as the trailing user prompt // cases below. That is the real production flow. The isRunning:true tests // prove that the placeholder is treated as the tail and renders its own - // loading row while the real bubble's stall row stays silent. + // loading row while the real bubble's activity row stays silent. const runtime = useExternalStoreRuntime({ messages, isRunning, @@ -121,7 +121,7 @@ function Harness({ messages, isRunning = false }: { messages: ThreadMessage[]; i ) } -describe('StreamStallIndicator tail gating (#68634)', () => { +describe('TurnActivityIndicator tail gating (#68634)', () => { beforeEach(() => { vi.useFakeTimers() vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')) @@ -162,8 +162,8 @@ describe('StreamStallIndicator tail gating (#68634)', () => { const roots = container.querySelectorAll('[data-slot="aui_assistant-message-root"]') expect(roots.length).toBe(2) // The second assistant root is also the thread's any-role tail. - expect(roots[0]?.querySelector('[data-slot="aui_stream-stall"]')).toBeNull() - expect(roots[1]?.querySelector('[data-slot="aui_stream-stall"]')).not.toBeNull() + expect(roots[0]?.querySelector('[data-slot="aui_turn-activity"]')).toBeNull() + expect(roots[1]?.querySelector('[data-slot="aui_turn-activity"]')).not.toBeNull() }) it('keeps a running assistant silent when a queued user prompt trails it and the runtime is idle', () => { @@ -182,7 +182,7 @@ describe('StreamStallIndicator tail gating (#68634)', () => { }) expect(container.querySelector('[data-slot="aui_response-loading"]')).toBeNull() - expect(container.querySelector('[data-slot="aui_stream-stall"]')).toBeNull() + expect(container.querySelector('[data-slot="aui_turn-activity"]')).toBeNull() }) it('keeps a running assistant silent when a steer system note trails it and the runtime is idle', () => { @@ -201,7 +201,7 @@ describe('StreamStallIndicator tail gating (#68634)', () => { }) expect(container.querySelector('[data-slot="aui_response-loading"]')).toBeNull() - expect(container.querySelector('[data-slot="aui_stream-stall"]')).toBeNull() + expect(container.querySelector('[data-slot="aui_turn-activity"]')).toBeNull() }) // In the production flow, isRunning: true with a trailing queued user prompt @@ -231,7 +231,7 @@ describe('StreamStallIndicator tail gating (#68634)', () => { // The surviving row belongs to the placeholder, while the real running // bubble's stall row stays silent. expect(document.querySelectorAll('[data-slot="aui_response-loading"]').length).toBe(1) - expect(document.querySelectorAll('[data-slot="aui_stream-stall"]').length).toBe(0) + expect(document.querySelectorAll('[data-slot="aui_turn-activity"]').length).toBe(0) }) // Outside compaction, the placeholder uses the plain loading label and the @@ -255,6 +255,6 @@ describe('StreamStallIndicator tail gating (#68634)', () => { }) expect(document.querySelectorAll('[data-slot="aui_response-loading"]').length).toBe(1) - expect(document.querySelectorAll('[data-slot="aui_stream-stall"]').length).toBe(0) + expect(document.querySelectorAll('[data-slot="aui_turn-activity"]').length).toBe(0) }) }) diff --git a/apps/desktop/src/components/assistant-ui/thread/status-tail-only.test.tsx b/apps/desktop/src/components/assistant-ui/thread/status-tail-only.test.tsx index d48ff5a095..1ec8feb7fc 100644 --- a/apps/desktop/src/components/assistant-ui/thread/status-tail-only.test.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/status-tail-only.test.tsx @@ -102,6 +102,6 @@ describe('thinking indicator is tail-only', () => { await screen.findByText('answered') expect(container.querySelector('[data-slot="aui_response-loading"]')).toBeNull() - expect(container.querySelector('[data-slot="aui_stream-stall"]')).toBeNull() + expect(container.querySelector('[data-slot="aui_turn-activity"]')).toBeNull() }) }) diff --git a/apps/desktop/src/components/assistant-ui/thread/status.tsx b/apps/desktop/src/components/assistant-ui/thread/status.tsx index 1eb53d3ea3..d7ee789ba1 100644 --- a/apps/desktop/src/components/assistant-ui/thread/status.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/status.tsx @@ -3,6 +3,7 @@ import { useStore } from '@nanostores/react' import { type FC, type ReactNode, useEffect, useMemo, useState } from 'react' import { useSessionView } from '@/app/chat/session-view' +import { activitySignature, toolNarratesWait, TURN_QUIET_S } from '@/components/assistant-ui/thread/turn-activity' import { toolPresentVerb } from '@/components/assistant-ui/tool/run-summary' import { useElapsedSeconds } from '@/components/chat/activity-timer' import { ActivityTimerText } from '@/components/chat/activity-timer-text' @@ -16,7 +17,6 @@ import { $backgroundResume } from '@/store/background-delegation' import { sessionCompacting } from '@/store/compaction' import { sessionAwaitingInput } from '@/store/prompts' import { sessionProviderWait } from '@/store/provider-wait' -import { $turnStartedAt } from '@/store/session' import { type DraftingTool, sessionDraftingTool } from '@/store/tool-drafting' // A status line is scaffolding like any other — "Editing" while the model @@ -55,8 +55,15 @@ const HintText: FC<{ children: ReactNode }> = ({ children }) => ( * session-scoped signal comes from that surface's view — a tile must never * show the primary chat's compaction, prompt-wait, or turn timer. */ function useThreadSessionStatus() { - const sessionId = useStore(useSessionView().$runtimeId) - const turnStartedAt = useStore($turnStartedAt) + const view = useSessionView() + const sessionId = useStore(view.$runtimeId) + // The same turn-busy the composer's arc border and Stop button read. The + // message-level `running` flag is a weaker signal: it goes false in the gaps + // between bubbles (a sealed interim row, a settled turn the backend hasn't + // finished with), which is exactly when the transcript used to fall silent + // while the app still said it was working. + const busy = useStore(view.$busy) + const turnStartedAt = useStore(view.$turnStartedAt) const compacting = useStore(useMemo(() => sessionCompacting(sessionId), [sessionId])) const drafting = useStore(useMemo(() => sessionDraftingTool(sessionId), [sessionId])) const providerWait = useStore(useMemo(() => sessionProviderWait(sessionId), [sessionId])) @@ -67,10 +74,15 @@ function useThreadSessionStatus() { return { awaitingInput, + busy, compacting, drafting, providerWait, - turnTimerKey: sessionId && turnStartedAt ? `turn:${sessionId}:${turnStartedAt}` : undefined + // Epoch ms this surface's turn began, or undefined between turns. The + // origin for anything measuring the WHOLE turn rather than one phase of + // it — including the first seconds of a brand-new chat, where the value is + // seeded at submit and there is no runtime session to key off yet. + turnStartedAt: turnStartedAt ?? undefined } } @@ -132,8 +144,8 @@ export const CenteredThreadSpinner: FC = () => { export const ResponseLoadingIndicator: FC = () => { const { t } = useI18n() - const { compacting, drafting, providerWait, turnTimerKey } = useThreadSessionStatus() - const elapsed = useElapsedSeconds(true, turnTimerKey) + const { compacting, drafting, providerWait, turnStartedAt } = useThreadSessionStatus() + const elapsed = useElapsedSeconds(true, undefined, turnStartedAt) const hint = useStatusHint(compacting, drafting, providerWait) return ( @@ -179,67 +191,65 @@ export const BackgroundResumeNotice: FC = () => { ) } -// Seconds of no visible output (text or part count) before a still-running turn -// is treated as stalled and the thinking indicator returns at the tail. -const STREAM_STALL_S = 2 - -// Tail "still thinking" indicator: the pre-first-token spinner goes away once -// text flows, but if the stream then goes quiet mid-turn (tool think-time, -// provider stall) nothing signals that work continues. Watch a per-flush -// activity signal; when it hasn't changed for STREAM_STALL_S, re-show the -// dither + a timer counting from the last activity. +// Tail activity row. The pre-first-token spinner goes away once content flows, +// but a turn keeps working through gaps it produces nothing during — between +// one tool result landing and the next call arriving, while the provider +// thinks, while a sealed bubble waits on the next one. The composer's arc +// border and Stop button are lit through all of it; the transcript used to be +// silent for most of it, and those seconds went uncounted. +// +// So this row follows the SAME busy signal the composer does, and times every +// gap from the moment the turn last showed something rather than from its own +// mount. What it doesn't do is double-narrate: a tool call in flight already +// carries its own row and timer. // // Subscribes to the activity signal ITSELF (rather than taking it as a prop) // so that per-token updates re-render only this leaf, not the whole // AssistantMessage subtree. -export const StreamStallIndicator: FC = () => { - const activity = useAuiState(s => { - let textLength = 0 +export const TurnActivityIndicator: FC = () => { + const activity = useAuiState(s => activitySignature(s.message.content)) - for (const part of s.message.content) { - const text = (part as { text?: unknown }).text - - if (typeof text === 'string') { - textLength += text.length - } - } - - return `${s.message.content.length}:${textLength}` - }) - - // Timestamp of the activity that preceded the current quiet spell, set once - // the spell qualifies as a stall. Holding the timestamp (not a boolean) is - // what lets the timer read "quiet for 12s" rather than the age of this - // component, which is the whole turn so far. + // Timestamp of the last visible progress, held from the moment the quiet + // spell qualifies. Holding the timestamp (not a boolean) is what lets the + // timer read "quiet for 12s" rather than the age of this component, which is + // the whole turn so far. const [quietSince, setQuietSince] = useState(undefined) - const { awaitingInput, compacting, drafting, providerWait, turnTimerKey } = useThreadSessionStatus() + const { awaitingInput, busy, compacting, drafting, providerWait, turnStartedAt } = useThreadSessionStatus() const hint = useStatusHint(compacting, drafting, providerWait) // A tool run at the tail already narrates the wait — its summary counts the // calls, its ticker names the current one, and it carries its own timer. A - // second spinner under that adds a line and says nothing new. - const toolNarrating = useAuiState(s => s.message.content.at(-1)?.type === 'tool-call') + // second spinner under that adds a line and says nothing new. Silent tools + // (`todo`, reactions) render nothing, so they narrate nothing. + const toolNarrating = useAuiState(s => toolNarratesWait(s.message.content)) + + // Streaming counts as working too, and it leads busy by a flush on the first + // turn of a fresh chat — so the row can't wait for the store to catch up. + const messageRunning = useAuiState(s => s.message.status?.type === 'running') useEffect(() => { setQuietSince(undefined) const seenAt = Date.now() - const id = window.setTimeout(() => setQuietSince(seenAt), STREAM_STALL_S * 1000) + const id = window.setTimeout(() => setQuietSince(seenAt), TURN_QUIET_S * 1000) return () => window.clearTimeout(id) }, [activity]) - // A named wait doesn't have to earn the stall threshold first — we already - // know what the turn is doing, so say it as soon as the label is ready rather - // than leaving the transcript silent for STREAM_STALL_S. - const active = (quietSince !== undefined || Boolean(hint)) && !awaitingInput && !toolNarrating + // Every second the app claims to be working belongs to something. A named + // wait says what it is straight away; an unnamed gap has to go quiet for + // TURN_QUIET_S first, or a run of quick calls would strobe a row between + // each one. The two exemptions are waits already accounted for elsewhere: a + // question the user is answering, and a tool call carrying its own timer. + const working = busy || messageRunning + const active = working && !awaitingInput && !toolNarrating && (Boolean(hint) || quietSince !== undefined) // Compaction owns the whole turn, so it keeps counting from the turn's start; - // anything else counts from the moment the stream went quiet — the stall's own - // mark, or the draft's, whichever named the wait first. + // anything else counts from the moment the turn last produced something — the + // gap's own mark, or the draft's, whichever named the wait first. const elapsed = useElapsedSeconds( active, - compacting ? turnTimerKey : undefined, - compacting ? undefined : (quietSince ?? drafting?.since) + undefined, + compacting ? turnStartedAt : (quietSince ?? drafting?.since ?? turnStartedAt) ) if (!active) { @@ -247,7 +257,7 @@ export const StreamStallIndicator: FC = () => { } return ( - +