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