From 46df6ca70521bfad886699ee4857524a6d22720b Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Jul 2026 19:35:04 -0500 Subject: [PATCH] fix(desktop): keep a run live between sequential calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A run counted as live only while one of its calls was unresolved, which is false for the instant between one sequential call finishing and the next arriving — and for a string of commands that instant is most of the run. It settled and re-opened between every call, so the ticker unmounted and came back at the top of its reel instead of scrolling, and the summary flipped to past tense while work was still going. Live is now "the turn is working and nothing follows this run", with the tail bound still settling a run the agent has moved past. The summary takes its present-tense clause from the most recent call when none is pending — the same call the ticker is showing. The stall spinner stays out of the way while a run narrates, rather than stacking a second timer under it. --- .../components/assistant-ui/thread/status.tsx | 7 ++- .../components/assistant-ui/tool/fallback.tsx | 17 ++++--- .../assistant-ui/tool/run-summary.test.ts | 7 +++ .../assistant-ui/tool/run-summary.ts | 14 ++++-- .../assistant-ui/tool/tool-group.test.tsx | 49 +++++++++++++++++++ 5 files changed, 80 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/components/assistant-ui/thread/status.tsx b/apps/desktop/src/components/assistant-ui/thread/status.tsx index c0e7a3a3cc8..dba6f0b46ba 100644 --- a/apps/desktop/src/components/assistant-ui/thread/status.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/status.tsx @@ -202,6 +202,11 @@ export const StreamStallIndicator: FC = () => { const { awaitingInput, compacting, drafting, turnTimerKey } = useThreadSessionStatus() const hint = useStatusHint(compacting, drafting) + // 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') + useEffect(() => { setQuietSince(undefined) const seenAt = Date.now() @@ -213,7 +218,7 @@ export const StreamStallIndicator: FC = () => { // 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 + const active = (quietSince !== undefined || Boolean(hint)) && !awaitingInput && !toolNarrating // 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 diff --git a/apps/desktop/src/components/assistant-ui/tool/fallback.tsx b/apps/desktop/src/components/assistant-ui/tool/fallback.tsx index 9b2b806b512..3cb3445c089 100644 --- a/apps/desktop/src/components/assistant-ui/tool/fallback.tsx +++ b/apps/desktop/src/components/assistant-ui/tool/fallback.tsx @@ -825,14 +825,15 @@ function useToolRun(startIndex: number, endIndex: number): ToolRunState { const parts = state.message.parts const tools = parts.slice(Math.max(0, startIndex), endIndex + 1).filter(isToolCallPart) - // A missing result only means "still working" while this run is the tail of - // a running message — the same qualification ToolEntry puts on a row's - // pending state. A turn that ends, or an agent that moves on to later - // parts, can leave a call unresolved forever; treating that as live would - // strand the run in the present tense AND leave it uncollapsible, since a - // live run deliberately withholds its toggle. - const live = - selectMessageRunning(state) && endIndex >= parts.length - 1 && tools.some(tool => tool.result === undefined) + // Live means the turn is still working and nothing has come after this run + // — not that some call is unresolved. Those differ in the gap between one + // call finishing and the next arriving, which for sequential calls is most + // of the run: it fell back to past tense there, unmounting the ticker and + // dropping its reel to the top instead of scrolling. + // + // The tail bound is what keeps this honest — a turn that ends, or an agent + // that moves on to later parts, leaves the run settled and collapsible. + const live = selectMessageRunning(state) && endIndex >= parts.length - 1 const signature = tools .map(tool => `${tool.toolCallId}:${tool.result === undefined ? 0 : 1}`) diff --git a/apps/desktop/src/components/assistant-ui/tool/run-summary.test.ts b/apps/desktop/src/components/assistant-ui/tool/run-summary.test.ts index b2d4e5ac90b..ba47368a031 100644 --- a/apps/desktop/src/components/assistant-ui/tool/run-summary.test.ts +++ b/apps/desktop/src/components/assistant-ui/tool/run-summary.test.ts @@ -44,6 +44,13 @@ describe('summarizeToolRun', () => { expect(running([tool('terminal', { command: 'npm run typecheck' })])).toMatch(/^Running /) }) + // Sequential calls leave a gap where the run is still going but nothing is + // pending. Falling back to past tense there contradicted the ticker still + // scrolling underneath, so the most recent call carries the present tense. + it('stays in the present tense between two sequential calls', () => { + expect(running([read('a.ts'), ran('x'), ran('y')])).toBe('Explored a.ts, running 2 commands') + }) + // A turn can end — or the agent can simply move on — with a call that never // got a result. The run is history at that point and has to read as history, // or it narrates work that stopped happening and never offers its toggle. diff --git a/apps/desktop/src/components/assistant-ui/tool/run-summary.ts b/apps/desktop/src/components/assistant-ui/tool/run-summary.ts index 7d7974ad10b..6abf559a33f 100644 --- a/apps/desktop/src/components/assistant-ui/tool/run-summary.ts +++ b/apps/desktop/src/components/assistant-ui/tool/run-summary.ts @@ -112,9 +112,9 @@ function lowerFirst(text: string): string { /** * Collapse a run of tool calls into the single grey line that stands in for it - * — "Explored 3 files, ran 5 commands". The category holding the still-running - * tool speaks in the present tense so a live run reads as work in progress - * rather than work already done. + * — "Explored 3 files, ran 5 commands". While the run is live, the category + * holding its most recent call speaks in the present tense so the line reads as + * work in progress rather than work already done. * * Whether the run is `live` is the caller's to say, not something readable off * the calls: a call can be left without a result by a turn that ended or an @@ -126,8 +126,12 @@ function lowerFirst(text: string): string { * diff to report here; each edit carries its own +N/−M on its card. */ export function summarizeToolRun(tools: readonly ToolCallLike[], live: boolean): string { - const running = live ? tools.find(isPending) : undefined - const liveCategory = running ? toolCategory(running.toolName) : null + // Which clause narrates in the present tense: normally the outstanding call, + // but sequential calls leave gaps where the run is still going and nothing is + // pending. The most recent call covers those, and it's the one the ticker is + // showing anyway. + const narrating = live ? (tools.find(isPending) ?? tools.at(-1)) : undefined + const liveCategory = narrating ? toolCategory(narrating.toolName) : null const byCategory = new Map() diff --git a/apps/desktop/src/components/assistant-ui/tool/tool-group.test.tsx b/apps/desktop/src/components/assistant-ui/tool/tool-group.test.tsx index e94917226de..9ed31b76ae4 100644 --- a/apps/desktop/src/components/assistant-ui/tool/tool-group.test.tsx +++ b/apps/desktop/src/components/assistant-ui/tool/tool-group.test.tsx @@ -316,6 +316,43 @@ function abandonedRunMessage(): ThreadMessage { } as ThreadMessage } +// The gap between one sequential call finishing and the next arriving: the +// turn is still running, the run is still the tail, but for this instant every +// call has a result. +function betweenSequentialCallsMessage(): ThreadMessage { + return { + id: 'assistant-between-calls', + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'term-a', + toolName: 'terminal', + args: { command: 'sleep 2; echo alpha' }, + argsText: JSON.stringify({ command: 'sleep 2; echo alpha' }), + result: { exit_code: 0, stdout: 'alpha' } + }, + { + type: 'tool-call', + toolCallId: 'term-b', + toolName: 'terminal', + args: { command: 'sleep 2; echo bravo' }, + argsText: JSON.stringify({ command: 'sleep 2; echo bravo' }), + result: { exit_code: 0, stdout: 'bravo' } + } + ], + status: { type: 'running' }, + createdAt, + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: {} + } + } as ThreadMessage +} + // Still streaming, but the agent has moved past its first run and left both of // its calls unresolved. Only the run at the tail is still live. function movedOnMessage(): ThreadMessage { @@ -457,6 +494,18 @@ describe('live tool run', () => { expect(container.querySelector('[data-tool-summary] button[aria-expanded]')).toBeNull() }) + + // Liveness used to also require an unresolved call, which is false for the + // instant between one sequential call finishing and the next arriving — so a + // string of commands settled and re-opened between every one, unmounting the + // ticker and dropping its reel back to the first row instead of scrolling. + it('stays live in the gap between two sequential calls', async () => { + const { container } = render() + + expect(await screen.findByText('Running 2 commands')).toBeTruthy() + expect(container.querySelector('[data-tool-ticker]')).not.toBeNull() + expect(container.querySelector('[data-tool-summary] button[aria-expanded]')).toBeNull() + }) }) // A run whose calls never resolved used to read as live forever, which stranded