From ccaa4872d8d60f705a7f0ab044920920fc223a8f Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 19 Aug 2026 01:19:33 -0500 Subject: [PATCH] fix(desktop): time the gaps a working turn spends producing nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The transcript's activity row went silent for most of an agentic turn. Two gates were too narrow. It mounted only while the tail bubble's own status was `running`, so a turn that seals a bubble mid-flight (message.interim) or finishes one while the agent keeps going left a settled message at the tail and unmounted the row entirely. And its activity signature was part count plus text length, which a landing tool result does not change — a result mutates the part that was already there — so the pause after a call ended was read as more of the same silence and dated from whenever the call started. Between them, the app spent whole stretches with the composer's arc border lit and Stop armed while the thread showed nothing and counted nothing. The row now follows the same busy signal the composer does, is mounted by the tail unconditionally, and decides for itself whether the turn owes the user a line. A named wait (compaction, provider wait, a tool being drafted) says so immediately; an unnamed gap earns two seconds of quiet first, so a run of quick calls doesn't strobe. It still defers to the two waits already accounted for elsewhere: a prompt the user is answering, and a tool call in flight carrying its own row and timer — anywhere in the message now, not just the last part, and excluding the silent tools that render nothing to defer to. Renamed to TurnActivityIndicator / `aui_turn-activity`: it hasn't measured a stall since it started measuring the whole turn. --- .../assistant-ui/thread/assistant-message.tsx | 13 +- .../duplicate-activity-indicator.test.tsx | 20 +-- .../thread/status-tail-only.test.tsx | 2 +- .../components/assistant-ui/thread/status.tsx | 102 ++++++------ .../assistant-ui/thread/turn-activity.test.ts | 60 +++++++ .../assistant-ui/thread/turn-activity.ts | 60 +++++++ .../thread/turn-gap-indicator.test.tsx | 149 ++++++++++++++++++ apps/desktop/src/styles.css | 16 +- 8 files changed, 356 insertions(+), 66 deletions(-) create mode 100644 apps/desktop/src/components/assistant-ui/thread/turn-activity.test.ts create mode 100644 apps/desktop/src/components/assistant-ui/thread/turn-activity.ts create mode 100644 apps/desktop/src/components/assistant-ui/thread/turn-gap-indicator.test.tsx 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 ( - +