From 97d790d8b171e4ad5d665aecc3364927d84c1a86 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Jul 2026 15:38:59 -0500 Subject: [PATCH 01/14] feat(desktop): summarize a run of tool calls as one line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the grammar behind "Edited wiring.tsx, explored 3 files, ran 5 commands": one clause per category of work, a name when the category holds a single thing and a count otherwise, and the present tense for whichever category is still running. The continuity test is the load-bearing part. Tool grouping was reverted once because it reshuffled the moment a turn settled, so this replays the same turn twice — as the gateway event stream the live view builds bubbles from, and as the rows toChatMessages rehydrates on resume — and asserts both produce the same runs. --- .../assistant-ui/tool/fallback-model/index.ts | 4 +- .../assistant-ui/tool/run-summary.test.ts | 63 +++++ .../assistant-ui/tool/run-summary.ts | 168 ++++++++++++ .../src/lib/tool-run-continuity.test.ts | 250 ++++++++++++++++++ 4 files changed, 483 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/components/assistant-ui/tool/run-summary.test.ts create mode 100644 apps/desktop/src/components/assistant-ui/tool/run-summary.ts create mode 100644 apps/desktop/src/lib/tool-run-continuity.test.ts diff --git a/apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts b/apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts index 6bcd06a63a93..5a7a5066c62d 100644 --- a/apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts +++ b/apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts @@ -65,7 +65,7 @@ function fileEditPath(args: Record, result: Record, keys: readonly string[]): string { +export function firstStringField(record: Record, keys: readonly string[]): string { for (const key of keys) { const value = record[key] 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 new file mode 100644 index 000000000000..ff36dcda30f5 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/tool/run-summary.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' + +import { summarizeToolRun, type ToolCallLike } from './run-summary' + +function tool(toolName: string, args: Record = {}, result?: unknown): ToolCallLike { + return { args, result, toolCallId: `${toolName}-${Math.random()}`, toolName } +} + +const edited = (path: string, diff = '') => tool('write_file', { path }, { path, inline_diff: diff }) +const read = (path: string) => tool('read_file', { path }, { content: '' }) +const ran = (command: string) => tool('terminal', { command }, { exit_code: 0 }) + +describe('summarizeToolRun', () => { + it('names a lone edit and counts the rest', () => { + expect( + summarizeToolRun([edited('src/use-preview-routing.ts'), read('a.ts'), read('b.ts'), read('c.ts')]).text + ).toBe('Edited use-preview-routing.ts, explored 3 files') + }) + + it('orders clauses edit, explore, run regardless of call order', () => { + expect( + summarizeToolRun([ran('ls'), read('a.ts'), edited('src/attachments.tsx'), read('b.ts'), ran('pwd'), ran('id')]) + .text + ).toBe('Edited attachments.tsx, explored 2 files, ran 3 commands') + }) + + it('counts commands rather than naming them once they have run', () => { + expect(summarizeToolRun([ran('git status')]).text).toBe('Ran 1 command') + expect(summarizeToolRun([read('status.ts'), ran('a'), ran('b'), ran('c'), ran('d'), ran('e')]).text).toBe( + 'Explored status.ts, ran 5 commands' + ) + }) + + it('counts a multi-file edit', () => { + expect(summarizeToolRun([edited('a.tsx'), edited('b.tsx'), read('c.ts')]).text).toBe( + 'Edited 2 files, explored c.ts' + ) + }) + + it('puts the running category in the present tense and leaves the rest past', () => { + const live = [edited('a.tsx'), tool('write_file', { path: 'b.tsx' }), ran('x'), ran('y')] + + expect(summarizeToolRun(live).text).toBe('Editing 2 files, ran 2 commands') + }) + + it('names the command that is still running', () => { + expect(summarizeToolRun([tool('terminal', { command: 'npm run typecheck' })]).text).toMatch(/^Running /) + }) + + it('sums diff stats across the edits in the run', () => { + const summary = summarizeToolRun([ + edited('a.tsx', '--- a\n+++ b\n+one\n+two\n-old'), + edited('b.tsx', '+three'), + ran('ls') + ]) + + expect(summary).toMatchObject({ added: 3, removed: 1 }) + }) + + it('reports no diff stats for a run that changed nothing', () => { + expect(summarizeToolRun([read('a.ts'), ran('ls')])).toMatchObject({ added: 0, removed: 0 }) + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/tool/run-summary.ts b/apps/desktop/src/components/assistant-ui/tool/run-summary.ts new file mode 100644 index 000000000000..7127a8f1efe5 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/tool/run-summary.ts @@ -0,0 +1,168 @@ +import { summarizeShellCommand } from '@/lib/summarize-command' + +import { + countDiffLineStats, + fileEditBasename, + firstStringField, + inlineDiffFromResult, + isFileEditTool, + parseMaybeObject +} from './fallback-model' + +/** + * The little a summary needs from a tool call, stated structurally so both + * shapes of tool part satisfy it — the stored `ChatMessagePart` and the live + * one assistant-ui hands to a renderer. + */ +export interface ToolCallLike { + args?: unknown + result?: unknown + toolCallId?: string + toolName: string +} + +export function isToolCallPart(part: T): part is Extract { + return part.type === 'tool-call' +} + +type RunCategory = 'delegate' | 'edit' | 'explore' | 'other' | 'run' + +export interface RunSummary { + added: number + removed: number + text: string +} + +// Clause order is fixed so the same run always reads the same way, whichever +// category happens to be live. +const CATEGORY_ORDER: readonly RunCategory[] = ['edit', 'explore', 'run', 'delegate', 'other'] + +const CATEGORY_COPY: Record = { + delegate: { noun: ['task', 'tasks'], past: 'Delegated', present: 'Delegating' }, + edit: { noun: ['file', 'files'], past: 'Edited', present: 'Editing' }, + explore: { noun: ['file', 'files'], past: 'Explored', present: 'Exploring' }, + other: { noun: ['tool', 'tools'], past: 'Used', present: 'Using' }, + run: { noun: ['command', 'commands'], past: 'Ran', present: 'Running' } +} + +const EXPLORE_TOOLS = new Set([ + 'list_files', + 'read_file', + 'search_files', + 'session_search_recall', + 'vision_analyze', + 'web_extract', + 'web_search' +]) + +function toolCategory(toolName: string): RunCategory { + if (isFileEditTool(toolName)) { + return 'edit' + } + + if (toolName === 'terminal' || toolName === 'execute_code') { + return 'run' + } + + if (toolName === 'delegate_task') { + return 'delegate' + } + + if (EXPLORE_TOOLS.has(toolName) || toolName.startsWith('browser_')) { + return 'explore' + } + + return 'other' +} + +function isPending(tool: ToolCallLike): boolean { + return tool.result === undefined +} + +/** The thing a tool acted on, as the header should name it. */ +function toolTarget(tool: ToolCallLike): string { + const args = parseMaybeObject(tool.args) + + if (toolCategory(tool.toolName) === 'run') { + return summarizeShellCommand(firstStringField(args, ['command', 'code'])) + } + + const path = firstStringField(args, ['path', 'file', 'filepath']) + + return path ? fileEditBasename(path) : firstStringField(args, ['query', 'url']) +} + +function diffStats(tools: readonly ToolCallLike[]): { added: number; removed: number } { + let added = 0 + let removed = 0 + + for (const tool of tools) { + if (!isFileEditTool(tool.toolName)) { + continue + } + + const stats = countDiffLineStats(inlineDiffFromResult(tool.result)) + + added += stats.added + removed += stats.removed + } + + return { added, removed } +} + +/** + * One clause per category. A category holding a single thing says what it was + * ("Edited wiring.tsx"); anything else counts ("explored 3 files"). A settled + * command is the exception — "ran 5 commands" is the useful reading, and a + * command line only earns its space while it's the thing you're waiting on. + */ +function clause(category: RunCategory, tools: ToolCallLike[], live: boolean): string { + const copy = CATEGORY_COPY[category] + const verb = live ? copy.present : copy.past + const target = tools.length === 1 ? toolTarget(tools[0]) : '' + + if (target && (live || category !== 'run')) { + return `${verb} ${target}` + } + + return `${verb} ${tools.length} ${copy.noun[tools.length === 1 ? 0 : 1]}` +} + +function lowerFirst(text: string): string { + return text.charAt(0).toLowerCase() + text.slice(1) +} + +/** + * Collapse a run of tool calls into the single grey line that stands in for it + * — "Edited wiring.tsx, 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. + */ +export function summarizeToolRun(tools: readonly ToolCallLike[]): RunSummary { + const running = tools.find(isPending) + const liveCategory = running ? toolCategory(running.toolName) : null + + const byCategory = new Map() + + for (const tool of tools) { + const category = toolCategory(tool.toolName) + const group = byCategory.get(category) + + if (group) { + group.push(tool) + } else { + byCategory.set(category, [tool]) + } + } + + const clauses = CATEGORY_ORDER.flatMap(category => { + const group = byCategory.get(category) + + return group ? [clause(category, group, category === liveCategory)] : [] + }) + + return { + ...diffStats(tools), + text: clauses.map((text, index) => (index === 0 ? text : lowerFirst(text))).join(', ') + } +} diff --git a/apps/desktop/src/lib/tool-run-continuity.test.ts b/apps/desktop/src/lib/tool-run-continuity.test.ts new file mode 100644 index 000000000000..a009053db3da --- /dev/null +++ b/apps/desktop/src/lib/tool-run-continuity.test.ts @@ -0,0 +1,250 @@ +import { describe, expect, it } from 'vitest' + +import { isToolCallPart, type ToolCallLike } from '@/components/assistant-ui/tool/run-summary' +import type { SessionMessage } from '@/types/hermes' + +import type { ChatMessage, ChatMessagePart } from './chat-messages' +import { + appendAssistantTextPart, + appendReasoningPart, + assistantTextPart, + mergeFinalAssistantText, + toChatMessages, + upsertToolPart +} from './chat-messages' +import { coalesceToolOnlyAssistants, createToolMergeCache } from './chat-runtime' + +/** + * A turn described once, replayed two ways: as the gateway event stream the + * live view builds bubbles from, and as the persisted rows `toChatMessages` + * rehydrates on resume. Grouping is only stable if both produce the same runs. + */ +type TurnStep = + | { kind: 'interim'; text: string } + | { kind: 'final'; text: string } + | { kind: 'reasoning'; text: string } + | { kind: 'text'; text: string } + | { kind: 'tool'; id: string; name: string } + +// Mirrors use-message-stream: deltas and tool events accumulate on one pending +// bubble; `message.interim` seals it in place and starts a fresh one; and +// `message.complete` merges the final text onto whatever bubble is open. +function replayLive(steps: TurnStep[]): ChatMessage[] { + const messages: ChatMessage[] = [] + let streamIndex = 0 + let open: ChatMessage | null = null + + const openBubble = (): ChatMessage => { + if (open) { + return open + } + + streamIndex += 1 + open = { id: `assistant-stream-${streamIndex}`, role: 'assistant', parts: [], pending: true } + messages.push(open) + + return open + } + + const seal = (text: string, interim: boolean) => { + const bubble = open ?? openBubble() + + bubble.parts = mergeFinalAssistantText(bubble.parts, text) + bubble.pending = false + bubble.interim = interim + open = null + } + + for (const step of steps) { + switch (step.kind) { + case 'interim': + seal(step.text, true) + + break + + case 'final': + seal(step.text, false) + + break + case 'reasoning': { + const bubble = openBubble() + + bubble.parts = appendReasoningPart(bubble.parts, step.text) + + break + } + + case 'text': { + const bubble = openBubble() + + bubble.parts = appendAssistantTextPart(bubble.parts, step.text) + + break + } + + case 'tool': { + const bubble = openBubble() + + bubble.parts = upsertToolPart(bubble.parts, { tool_id: step.id, name: step.name }, 'complete') + + break + } + } + } + + return coalesceToolOnlyAssistants(messages, createToolMergeCache()) +} + +// The same turn as the gateway persists it: one row per agent iteration. A row +// is a single API response, so its reasoning and content always precede its own +// tool_calls — anything the agent says after a tool ran belongs to the next row. +function replayStored(steps: TurnStep[]): ChatMessage[] { + const rows: SessionMessage[] = [] + let timestamp = 0 + let row: (SessionMessage & { tool_calls?: unknown[] }) | null = null + + const openRow = (afterTools: boolean) => { + if (row && !(afterTools && row.tool_calls)) { + return row + } + + timestamp += 1 + row = { role: 'assistant', content: '', timestamp } + rows.push(row) + + return row + } + + for (const step of steps) { + switch (step.kind) { + case 'interim': + + case 'final': + case 'text': { + const current = openRow(true) + + current.content = `${current.content ?? ''}${step.text}` + + if (step.kind !== 'text') { + row = null + } + + break + } + + case 'reasoning': + openRow(true).reasoning = step.text + + break + case 'tool': { + const current = openRow(false) + + current.tool_calls = [ + ...(current.tool_calls ?? []), + { id: step.id, function: { name: step.name, arguments: '{}' } } + ] + timestamp += 1 + rows.push({ role: 'tool', tool_call_id: step.id, tool_name: step.name, content: '{}', timestamp }) + + break + } + } + } + + return coalesceToolOnlyAssistants(toChatMessages(rows), createToolMergeCache()) +} + +/** + * Maximal spans of back-to-back tool calls — the same rule assistant-ui applies + * when it hands `ToolGroupSlot` a range, restated here so these tests check our + * two part streams against each other rather than against the renderer. + */ +function toolRuns(parts: ChatMessagePart[]): ToolCallLike[][] { + const runs: ToolCallLike[][] = [] + let previousWasTool = false + + for (const part of parts) { + if (!isToolCallPart(part)) { + previousWasTool = false + + continue + } + + if (previousWasTool) { + runs[runs.length - 1].push(part) + } else { + runs.push([part]) + } + + previousWasTool = true + } + + return runs +} + +function runsAcross(messages: ChatMessage[]): string[][] { + return messages + .filter(message => message.role === 'assistant') + .flatMap(message => toolRuns(message.parts)) + .map(run => run.map(tool => tool.toolCallId ?? '')) +} + +const TURNS: Record = { + 'narration between two tool runs': [ + { kind: 'interim', text: 'Let me check the config.' }, + { kind: 'tool', id: 'a', name: 'read_file' }, + { kind: 'tool', id: 'b', name: 'read_file' }, + { kind: 'interim', text: 'Now let me edit it.' }, + { kind: 'tool', id: 'c', name: 'write_file' }, + { kind: 'final', text: 'Done.' } + ], + 'reasoning between two tool runs': [ + { kind: 'tool', id: 'a', name: 'terminal' }, + { kind: 'reasoning', text: 'That failed, try the other path.' }, + { kind: 'tool', id: 'b', name: 'terminal' }, + { kind: 'final', text: 'Fixed.' } + ], + 'unbroken run of tool calls': [ + { kind: 'tool', id: 'a', name: 'read_file' }, + { kind: 'tool', id: 'b', name: 'read_file' }, + { kind: 'tool', id: 'c', name: 'search_files' }, + { kind: 'final', text: 'Here is what I found.' } + ], + 'reasoning then tools then final': [ + { kind: 'reasoning', text: 'The user wants the lint config.' }, + { kind: 'tool', id: 'a', name: 'search_files' }, + { kind: 'tool', id: 'b', name: 'read_file' }, + { kind: 'final', text: 'It lives in eslint.config.js.' } + ], + 'tools with no narration at all': [ + { kind: 'tool', id: 'a', name: 'terminal' }, + { kind: 'final', text: 'Clean.' } + ] +} + +describe('tool run segmentation survives rehydration', () => { + for (const [name, steps] of Object.entries(TURNS)) { + it(name, () => { + expect(runsAcross(replayLive(steps))).toEqual(runsAcross(replayStored(steps))) + }) + } +}) + +describe('run identity', () => { + const tool = (id: string, name: string): ChatMessagePart => + ({ args: {}, toolCallId: id, toolName: name, type: 'tool-call' }) as ChatMessagePart + + it('keeps the first tool call at the head as the run grows', () => { + const [run] = toolRuns([tool('a', 'read_file'), tool('b', 'read_file')]) + const [grown] = toolRuns([tool('a', 'read_file'), tool('b', 'read_file'), tool('c', 'terminal')]) + + expect(grown[0].toolCallId).toBe(run[0].toolCallId) + expect(grown).toHaveLength(3) + }) + + it('breaks a run on any non-tool part', () => { + const runs = toolRuns([tool('a', 'read_file'), assistantTextPart('Now editing.'), tool('b', 'write_file')]) + + expect(runs.map(run => run.map(t => t.toolCallId))).toEqual([['a'], ['b']]) + }) +}) From 7151ea4c77b6bd5448e8c78a5f51d4c8b24630b9 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Jul 2026 15:39:03 -0500 Subject: [PATCH 02/14] feat(desktop): collapse a settled tool run to its summary line A run of two or more tool calls now renders behind its summary once it has finished, so a long transcript reads as what the agent did rather than as a wall of rows. The run is keyed by its first tool call instead of its part index: live and rehydrated turns agree on which calls belong together but not on the indices they land at, and keying by index is what made the previous attempt reshuffle on settle. A run holding anything still pending always renders its rows, which is what keeps a clarify question or an approval bar out from behind a chevron. The approval-group tests move to tool-group and grow coverage for both halves of that rule. --- .../components/assistant-ui/tool/fallback.tsx | 141 ++++++++++++++---- ...val-group.test.tsx => tool-group.test.tsx} | 96 +++++++++++- apps/desktop/src/styles.css | 2 + 3 files changed, 206 insertions(+), 33 deletions(-) rename apps/desktop/src/components/assistant-ui/tool/{approval-group.test.tsx => tool-group.test.tsx} (71%) diff --git a/apps/desktop/src/components/assistant-ui/tool/fallback.tsx b/apps/desktop/src/components/assistant-ui/tool/fallback.tsx index 13e7c7e8e7a8..b7a0c7d78dee 100644 --- a/apps/desktop/src/components/assistant-ui/tool/fallback.tsx +++ b/apps/desktop/src/components/assistant-ui/tool/fallback.tsx @@ -27,6 +27,7 @@ import { ZoomableImage } from '@/components/chat/zoomable-image' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' import { CopyButton } from '@/components/ui/copy-button' +import { DiffCount } from '@/components/ui/diff-count' import { DisclosureCaret } from '@/components/ui/disclosure-caret' import { FadeText } from '@/components/ui/fade-text' import { FileTypeIcon } from '@/components/ui/file-type-icon' @@ -63,6 +64,7 @@ import { type ToolStatus, type ToolTitleAction } from './fallback-model' +import { isToolCallPart, type RunSummary, summarizeToolRun } from './run-summary' // `true` when a ToolEntry is rendered inside an embedding wrapper that owns // the per-row chrome (timer / preview). The flat ToolGroupSlot sets this @@ -797,29 +799,89 @@ function useToolWindow(enabled: boolean) { return { contentRef, faded, onScroll, scrollRef } } +// The one grey line that stands in for a run of tool calls once it has +// settled — "Edited wiring.tsx, explored 3 files +6 −4". While the run is +// live the same line narrates it in the present tense and the rows stay +// visible below, so nothing the user is waiting on hides behind a chevron. +function ToolRunHeader({ + live, + onToggle, + open, + summary +}: { + live: boolean + onToggle?: () => void + open: boolean + summary: RunSummary +}) { + return ( +
+ + + + {live ? {summary.text} : summary.text} + + + + +
+ ) +} + +interface ToolRunState { + key: string + live: boolean + summary: RunSummary +} + +// assistant-ui compares selector results with `Object.is` and calls the +// selector on every store update, so returning a fresh object here would +// re-render the group on every text delta in the turn. The run only changes +// when a call arrives or one finishes; cache on exactly that. +function useToolRun(startIndex: number, endIndex: number): ToolRunState { + const cache = useRef(null) + + return useAuiState(state => { + const tools = state.message.parts.slice(Math.max(0, startIndex), endIndex + 1).filter(isToolCallPart) + const signature = tools.map(tool => `${tool.toolCallId}:${tool.result === undefined ? 0 : 1}`).join('|') + + if (cache.current?.signature !== signature) { + cache.current = { + signature, + value: { + key: tools[0]?.toolCallId ?? '', + live: tools.some(tool => tool.result === undefined), + summary: summarizeToolRun(tools) + } + } + } + + return cache.current.value + }) +} + /** - * Flat, Cursor-style tool list. assistant-ui hands us a *range* of - * consecutive tool-call parts, but how that range is sliced is unstable: a - * live stream interleaves narration/reasoning between calls (many tiny - * ranges), while the settled message reconstructs every tool_call back-to-back - * (one big range). Rendering a "Tool actions · N steps" group off that range - * therefore reshuffled the whole turn the instant it settled. + * A run of consecutive tool calls, headed by the line that summarizes it. * - * So we still never *label* the group: each tool is a standalone row on the - * tight `--tool-row-gap` rhythm. Once a run reaches `TOOL_GROUP_SCROLL_THRESHOLD` - * rows it collapses into a fixed-height, auto-scrolling window so a long run - * doesn't shove the reply off screen; shorter runs are byte-identical to before. - * The DOM shape is the same either way — only classes flip — so a run that - * crosses the threshold mid-stream never remounts a row. `ToolEmbedContext` is - * false so every row owns its own chrome (timer / preview / copy / approval). + * The run is identified by its FIRST tool call, never by its position: a live + * stream and the same turn rehydrated from history agree on which calls belong + * together, but not on the indices they land at, because rehydration folds a + * turn into one bubble that the live view spreads over several. Keying off the + * index is what made an earlier attempt at this reshuffle the moment a turn + * settled. `lib/tool-run-continuity.test.ts` locks that agreement down. + * + * A settled run collapses to its header; a live one keeps its rows on screen + * (and past `TOOL_GROUP_SCROLL_THRESHOLD` inside a fixed-height auto-scrolling + * window, so a long run can't shove the reply off screen). `ToolEmbedContext` + * is false so every row still owns its own chrome (timer / copy / approval). */ export const ToolGroupSlot: FC> = ({ children, endIndex, startIndex }) => { - const messageId = useAuiState(s => s.message.id) const messageRunning = useAuiState(selectMessageRunning) + const { key, live, summary } = useToolRun(startIndex, endIndex) const hasUnboundable = useAuiState(s => s.message.parts @@ -827,26 +889,49 @@ export const ToolGroupSlot: FC part.type === 'tool-call' && isUnboundableTool(part.toolName)) ) - const enterRef = useEnterAnimation(messageRunning, `tool-group:${messageId}:${startIndex}`) + const disclosureId = `tool-run:${key}` + const persistedOpen = useStore($toolDisclosureOpen(disclosureId)) + // A lone call is already its own one-line summary, so it never earns a + // header. Neither does a run holding an `UNBOUNDABLE_TOOLS` surface — a + // `clarify` asking the user a question must not end up behind a chevron. + const grouped = endIndex > startIndex && !hasUnboundable + const open = !grouped || live || (persistedOpen ?? false) - const bounded = shouldBoundToolGroup(Children.count(children), hasUnboundable) + const enterRef = useEnterAnimation(messageRunning, `tool-run:${key}`) + + const bounded = open && shouldBoundToolGroup(Children.count(children), hasUnboundable) const { contentRef, faded, onScroll, scrollRef } = useToolWindow(bounded) return ( -
-
-
- {children} +
+ {grouped && ( + setToolDisclosureOpen(disclosureId, !open)} + open={open} + summary={summary} + /> + )} + {open && ( +
+
+ {children} +
-
+ )}
) diff --git a/apps/desktop/src/components/assistant-ui/tool/approval-group.test.tsx b/apps/desktop/src/components/assistant-ui/tool/tool-group.test.tsx similarity index 71% rename from apps/desktop/src/components/assistant-ui/tool/approval-group.test.tsx rename to apps/desktop/src/components/assistant-ui/tool/tool-group.test.tsx index f6201153c98b..25c46fbe3fd5 100644 --- a/apps/desktop/src/components/assistant-ui/tool/approval-group.test.tsx +++ b/apps/desktop/src/components/assistant-ui/tool/tool-group.test.tsx @@ -9,11 +9,12 @@ import { $toolDisclosureStates } from '@/store/tool-view' import { Thread } from '../thread' -// Regression coverage for the "approval must never be buried" bug. Tools now -// render as a flat list (no collapsible "N steps" group), so a pending tool's -// inline ApprovalBar is always in the visual flow — never inside a `hidden` -// body. These assert the bar shows only when an approval is live and is never -// trapped under a `hidden` ancestor. +// A run of tool calls collapses to a one-line summary once it has settled, but +// a run with anything still pending always renders its rows. That rule is what +// keeps the "approval must never be buried" bug fixed: an inline ApprovalBar +// only ever exists on a pending tool, and a pending tool's run is never behind +// a chevron. These cover both halves — the collapse itself, and the approval +// staying in the visual flow. const createdAt = new Date('2026-06-03T00:00:00.000Z') @@ -183,6 +184,42 @@ function failedOnlyMessage(): ThreadMessage { } as ThreadMessage } +// Two settled tools in a row — an edit plus a read — so the run earns a +// summary line and collapses. +function settledRunMessage(): ThreadMessage { + return { + id: 'assistant-settled-run', + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'patch-1', + toolName: 'patch', + args: { path: '/repo/src/wiring.tsx' }, + argsText: JSON.stringify({ path: '/repo/src/wiring.tsx' }), + result: { path: '/repo/src/wiring.tsx', inline_diff: '--- a\n+++ b\n+added line\n-removed line' } + }, + { + type: 'tool-call', + toolCallId: 'read-2', + toolName: 'read_file', + args: { path: '/repo/src/status.tsx' }, + argsText: JSON.stringify({ path: '/repo/src/status.tsx' }), + result: { content: 'export const Status = () => null' } + } + ], + status: { type: 'complete', reason: 'stop' }, + createdAt, + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: {} + } + } as ThreadMessage +} + function GroupHarness({ message }: { message: ThreadMessage }) { const runtime = useExternalStoreRuntime({ messages: [message], @@ -211,6 +248,55 @@ afterEach(() => { clearDismissedToolRows() }) +describe('settled tool run', () => { + it('collapses to a summary line naming the work and its diff', async () => { + const { container } = render() + + expect(await screen.findByText('Edited wiring.tsx, explored status.tsx')).toBeTruthy() + expect(container.querySelectorAll('[data-tool-row]')).toHaveLength(0) + expect(screen.getByText('1', { selector: '.text-\\(--ui-green\\) *' })).toBeTruthy() + }) + + it('expands to the underlying rows when the summary is clicked', async () => { + const { container } = render() + + fireEvent.click(await screen.findByText('Edited wiring.tsx, explored status.tsx')) + + await waitFor(() => { + expect(container.querySelectorAll('[data-tool-row]').length).toBeGreaterThan(0) + }) + }) + + it('leaves a lone tool call as its own row, with no summary above it', async () => { + const { container } = render() + + await waitFor(() => { + expect(container.querySelectorAll('[data-tool-row]').length).toBe(1) + }) + expect(container.querySelector('[data-tool-summary]')).toBeNull() + }) +}) + +describe('live tool run', () => { + it('keeps its rows on screen instead of hiding them behind the summary', async () => { + const { container } = render() + + await waitFor(() => { + expect(container.querySelectorAll('[data-tool-row]').length).toBeGreaterThan(0) + }) + }) + + it('cannot be collapsed while a tool is still running', async () => { + const { container } = render() + + await waitFor(() => { + expect(container.querySelector('[data-tool-summary]')).not.toBeNull() + }) + + expect(container.querySelector('[data-tool-summary] button[aria-expanded]')).toBeNull() + }) +}) + describe('flat tool list approval surfacing', () => { it('renders no inline approval bar when there is no live approval', async () => { const { container } = render() diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 9fb935bbe088..c2a86f4960a4 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -1421,6 +1421,7 @@ text-* variant utilities. */ .btn-arc { it impossible to keep one row lit (an open diff) while its siblings faded. With the fade per-row, each row hovers/focuses independently. */ [data-slot='aui_assistant-message-content'] > [data-slot='aui_thinking-disclosure'], +[data-slot='aui_assistant-message-content'] [data-tool-summary], [data-slot='aui_assistant-message-content'] [data-slot='tool-block'][data-tool-row] { opacity: 0.67; transition: opacity 120ms ease-out; @@ -1430,6 +1431,7 @@ text-* variant utilities. */ .btn-arc { focus a mouse click leaves on the disclosure toggle, which kept a row lit after you clicked to collapse it; `:has(:focus-visible)` excludes that. */ [data-slot='aui_assistant-message-content'] > [data-slot='aui_thinking-disclosure']:is(:hover, :has(:focus-visible)), +[data-slot='aui_assistant-message-content'] [data-tool-summary]:is(:hover, :has(:focus-visible)), [data-slot='aui_assistant-message-content'] [data-slot='tool-block'][data-tool-row]:is(:hover, :has(:focus-visible)) { opacity: 1; } From a77c75f3e0b2b0e36606425cec902c45baad6e60 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Jul 2026 15:39:08 -0500 Subject: [PATCH 03/14] feat(desktop): report how long the model thought A settled reasoning block reads "Thought for 5s" instead of staying "Thinking" forever. Nothing in the persisted turn records the duration, so the number is frozen when the block finishes on screen and simply omitted on a rehydrated turn, rather than reporting whatever a timer that never ran would say. --- .../assistant-ui/thread/message-parts.tsx | 22 +++++++++++++++++-- apps/desktop/src/i18n/ar.ts | 1 + apps/desktop/src/i18n/en.ts | 1 + apps/desktop/src/i18n/ja.ts | 1 + apps/desktop/src/i18n/types.ts | 1 + apps/desktop/src/i18n/zh-hant.ts | 1 + apps/desktop/src/i18n/zh.ts | 1 + 7 files changed, 26 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx b/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx index 05c9809eacb6..c3ddb255c7ff 100644 --- a/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx @@ -9,7 +9,7 @@ import { type ComponentProps, type FC, type ReactNode, useEffect, useRef, useSta import { ClarifyTool } from '@/components/assistant-ui/clarify-tool' import { MarkdownText, MarkdownTextContent } from '@/components/assistant-ui/markdown-text' import { ToolFallback, ToolGroupSlot } from '@/components/assistant-ui/tool/fallback' -import { useElapsedSeconds } from '@/components/chat/activity-timer' +import { formatElapsed, useElapsedSeconds } from '@/components/chat/activity-timer' import { ActivityTimerText } from '@/components/chat/activity-timer-text' import { DisclosureRow } from '@/components/chat/disclosure-row' import { GeneratedImage } from '@/components/chat/generated-image-result' @@ -73,6 +73,22 @@ const ThinkingDisclosure: FC<{ const open = userOpen ?? pending const isPreview = pending && userOpen === null + // How long the model thought is only knowable by having watched it happen — + // nothing in the persisted turn records it. So freeze the number the moment + // this block finishes in front of us, and stay quiet on a rehydrated turn + // rather than reporting whatever a timer that never ran would say. + const [watching, setWatching] = useState(false) + const [thoughtFor, setThoughtFor] = useState(null) + + useEffect(() => { + if (pending) { + setWatching(true) + } else if (watching) { + setWatching(false) + setThoughtFor(elapsed) + } + }, [elapsed, pending, watching]) + // While the preview is live, pin the scroll container to the bottom on // every content growth so the latest tokens are always visible. useEffect(() => { @@ -127,7 +143,9 @@ const ThinkingDisclosure: FC<{ pending && 'shimmer text-foreground/55' )} > - {t.assistant.thread.thinking} + {pending || thoughtFor === null + ? t.assistant.thread.thinking + : t.assistant.thread.thoughtFor(formatElapsed(thoughtFor))} {pending && ( count === 1 ? 'سيُستأنف عند انتهاء المهمة الخلفية' : `سيُستأنف عند انتهاء ${count} مهام خلفية`, thinking: 'يفكر...', + thoughtFor: duration => `فكّر لمدة ${duration}`, today: time => `اليوم ${time}`, yesterday: time => `أمس ${time}`, copy: 'نسخ', diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 459792634bb0..f68b2657bc87 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -2668,6 +2668,7 @@ export const en: Translations = { ? 'Will resume when the background task finishes' : `Will resume when ${count} background tasks finish`, thinking: 'Thinking', + thoughtFor: duration => `Thought for ${duration}`, today: time => `Today, ${time}`, yesterday: time => `Yesterday, ${time}`, copy: 'Copy', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 3783e47ff0da..ab01313842de 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -2517,6 +2517,7 @@ export const ja = defineLocale({ ? 'バックグラウンドタスクの完了後に再開します' : `${count} 件のバックグラウンドタスクの完了後に再開します`, thinking: '考え中', + thoughtFor: duration => `${duration} 思考`, today: time => `今日 ${time}`, yesterday: time => `昨日 ${time}`, copy: 'コピー', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 654961571532..f566b17f8cfe 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -2267,6 +2267,7 @@ export interface Translations { loadingResponse: string resumeWhenBackgroundDone: (count: number) => string thinking: string + thoughtFor: (duration: string) => string today: (time: string) => string yesterday: (time: string) => string copy: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index c785aaebb421..e133f4ff3e79 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -2438,6 +2438,7 @@ export const zhHant = defineLocale({ resumeWhenBackgroundDone: count => count === 1 ? '背景工作完成後將自動繼續' : `${count} 個背景工作完成後將自動繼續`, thinking: '思考中', + thoughtFor: duration => `思考了 ${duration}`, today: time => `今天,${time}`, yesterday: time => `昨天,${time}`, copy: '複製', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index f4b3377edcf5..eebbba53eb8a 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -2839,6 +2839,7 @@ export const zh: Translations = { resumeWhenBackgroundDone: count => count === 1 ? '后台任务完成后将自动继续' : `${count} 个后台任务完成后将自动继续`, thinking: '思考中', + thoughtFor: duration => `思考了 ${duration}`, today: time => `今天,${time}`, yesterday: time => `昨天,${time}`, copy: '复制', From f7d6c1be8f62b24abd45c1d9a3d4aed8a7f8e4ee Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Jul 2026 16:12:52 -0500 Subject: [PATCH 04/14] docs(desktop): explain what UNBOUNDABLE_TOOLS now guards The list gates two behaviours since the settled-run summary landed, but the comment still justified it only in terms of the scroll window's height cap. Record the pending-row rule that keeps approvals visible, and why file edits stay off the list. --- .../components/assistant-ui/tool/fallback.tsx | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/components/assistant-ui/tool/fallback.tsx b/apps/desktop/src/components/assistant-ui/tool/fallback.tsx index b7a0c7d78dee..8a0a5434025c 100644 --- a/apps/desktop/src/components/assistant-ui/tool/fallback.tsx +++ b/apps/desktop/src/components/assistant-ui/tool/fallback.tsx @@ -705,20 +705,27 @@ function TerminalTranscript({ command, exitCode }: TerminalTranscriptProps) { // auto-scrolling window; fewer than this stays a plain inline stack. const TOOL_GROUP_SCROLL_THRESHOLD = 3 -// Tools whose body must never be trapped behind the window's max-height + -// fade mask. A run holding any of them stays a plain, fully-visible stack no -// matter how long it is. +// Tools whose body must never be hidden — not behind the window's max-height + +// fade mask, and not behind a settled run's summary chevron. A run holding any +// of them stays a plain, fully-visible stack no matter how long it is. // -// This list is deliberately tiny. A row rendered by ToolEntry carries +// This list is deliberately tiny, because the two things it opts out of are +// already safe for anything ToolEntry renders. For the window: a row carries // `data-tool-row`, and the `:has([data-tool-row][data-tool-open])` rule in -// styles.css lifts the cap whenever one is open — so anything ToolEntry -// renders takes care of itself. A code/diff row mounts open (see -// `defaultOpen`), lifting the cap the moment it appears; a collapsed row is a -// one-line status with no body in the DOM at all, so there is nothing to clip. +// styles.css lifts the cap whenever one is open, so a code/diff row mounting +// open (see `defaultOpen`) frees itself the moment it appears, and a collapsed +// row is a one-line status with no body in the DOM to clip. For the chevron: a +// run still holding a pending call always renders its rows, so an approval bar +// can't end up behind one. // // Only components that bypass ToolEntry need this opt-out: `clarify` and // `image_generate` render their own markup, never emit `data-tool-row`, and so -// the CSS escape hatch can never reach them. +// neither the CSS escape hatch nor the pending-row rule can reach them. +// +// File edits are pointedly NOT here. A settled run collapses its diffs behind +// the summary, which carries their +N/−M — one click to read, and the run stays +// compact. Adding them back veto'd the window for nearly every coding session +// last time (7f2971039, reverted in 058aa34d8); it would do the same here. const UNBOUNDABLE_TOOLS = new Set(['clarify', 'image_generate']) export function isUnboundableTool(toolName: string): boolean { From f96997c36face3e0db92954fd6c0724ebd78c6c8 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Jul 2026 17:29:06 -0500 Subject: [PATCH 05/14] fix(desktop): settle a tool run whose calls never resolved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A run inferred "still working" from a missing result alone, so a call left unresolved — by an interrupted turn, or an agent that moved on — pinned its run as live forever. That stranded the summary in the present tense ("Exploring 2 files" on a finished turn) and, because a live run withholds its toggle so approvals can't hide, left the run permanently expanded with no way to collapse it. Qualify liveness the way ToolEntry already qualifies a row's: a missing result only means pending while the run is the tail of a running message. Liveness is now passed into summarizeToolRun rather than read off the calls, since it isn't a property of the calls. --- .../components/assistant-ui/tool/fallback.tsx | 20 +++- .../assistant-ui/tool/run-summary.test.ts | 43 ++++---- .../assistant-ui/tool/run-summary.ts | 9 +- .../assistant-ui/tool/tool-group.test.tsx | 97 +++++++++++++++++++ 4 files changed, 143 insertions(+), 26 deletions(-) diff --git a/apps/desktop/src/components/assistant-ui/tool/fallback.tsx b/apps/desktop/src/components/assistant-ui/tool/fallback.tsx index 8a0a5434025c..799a87df3ee5 100644 --- a/apps/desktop/src/components/assistant-ui/tool/fallback.tsx +++ b/apps/desktop/src/components/assistant-ui/tool/fallback.tsx @@ -849,16 +849,28 @@ function useToolRun(startIndex: number, endIndex: number): ToolRunState { const cache = useRef(null) return useAuiState(state => { - const tools = state.message.parts.slice(Math.max(0, startIndex), endIndex + 1).filter(isToolCallPart) - const signature = tools.map(tool => `${tool.toolCallId}:${tool.result === undefined ? 0 : 1}`).join('|') + 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) + const signature = tools + .map(tool => `${tool.toolCallId}:${tool.result === undefined ? 0 : 1}`) + .concat(String(live)) + .join('|') if (cache.current?.signature !== signature) { cache.current = { signature, value: { key: tools[0]?.toolCallId ?? '', - live: tools.some(tool => tool.result === undefined), - summary: summarizeToolRun(tools) + live, + summary: summarizeToolRun(tools, live) } } } 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 ff36dcda30f5..be298640ce1a 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 @@ -10,54 +10,57 @@ const edited = (path: string, diff = '') => tool('write_file', { path }, { path, const read = (path: string) => tool('read_file', { path }, { content: '' }) const ran = (command: string) => tool('terminal', { command }, { exit_code: 0 }) +const settled = (tools: ToolCallLike[]) => summarizeToolRun(tools, false) +const running = (tools: ToolCallLike[]) => summarizeToolRun(tools, true) + describe('summarizeToolRun', () => { it('names a lone edit and counts the rest', () => { - expect( - summarizeToolRun([edited('src/use-preview-routing.ts'), read('a.ts'), read('b.ts'), read('c.ts')]).text - ).toBe('Edited use-preview-routing.ts, explored 3 files') + expect(settled([edited('src/use-preview-routing.ts'), read('a.ts'), read('b.ts'), read('c.ts')]).text).toBe( + 'Edited use-preview-routing.ts, explored 3 files' + ) }) it('orders clauses edit, explore, run regardless of call order', () => { expect( - summarizeToolRun([ran('ls'), read('a.ts'), edited('src/attachments.tsx'), read('b.ts'), ran('pwd'), ran('id')]) - .text + settled([ran('ls'), read('a.ts'), edited('src/attachments.tsx'), read('b.ts'), ran('pwd'), ran('id')]).text ).toBe('Edited attachments.tsx, explored 2 files, ran 3 commands') }) it('counts commands rather than naming them once they have run', () => { - expect(summarizeToolRun([ran('git status')]).text).toBe('Ran 1 command') - expect(summarizeToolRun([read('status.ts'), ran('a'), ran('b'), ran('c'), ran('d'), ran('e')]).text).toBe( + expect(settled([ran('git status')]).text).toBe('Ran 1 command') + expect(settled([read('status.ts'), ran('a'), ran('b'), ran('c'), ran('d'), ran('e')]).text).toBe( 'Explored status.ts, ran 5 commands' ) }) it('counts a multi-file edit', () => { - expect(summarizeToolRun([edited('a.tsx'), edited('b.tsx'), read('c.ts')]).text).toBe( - 'Edited 2 files, explored c.ts' - ) + expect(settled([edited('a.tsx'), edited('b.tsx'), read('c.ts')]).text).toBe('Edited 2 files, explored c.ts') }) it('puts the running category in the present tense and leaves the rest past', () => { - const live = [edited('a.tsx'), tool('write_file', { path: 'b.tsx' }), ran('x'), ran('y')] - - expect(summarizeToolRun(live).text).toBe('Editing 2 files, ran 2 commands') + expect(running([edited('a.tsx'), tool('write_file', { path: 'b.tsx' }), ran('x'), ran('y')]).text).toBe( + 'Editing 2 files, ran 2 commands' + ) }) it('names the command that is still running', () => { - expect(summarizeToolRun([tool('terminal', { command: 'npm run typecheck' })]).text).toMatch(/^Running /) + expect(running([tool('terminal', { command: 'npm run typecheck' })]).text).toMatch(/^Running /) + }) + + // 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. + it('reads a run the turn left unresolved as finished', () => { + expect(settled([read('a.ts'), tool('search_files', { query: 'toolRuns' })]).text).toBe('Explored 2 files') }) it('sums diff stats across the edits in the run', () => { - const summary = summarizeToolRun([ - edited('a.tsx', '--- a\n+++ b\n+one\n+two\n-old'), - edited('b.tsx', '+three'), - ran('ls') - ]) + const summary = settled([edited('a.tsx', '--- a\n+++ b\n+one\n+two\n-old'), edited('b.tsx', '+three'), ran('ls')]) expect(summary).toMatchObject({ added: 3, removed: 1 }) }) it('reports no diff stats for a run that changed nothing', () => { - expect(summarizeToolRun([read('a.ts'), ran('ls')])).toMatchObject({ added: 0, removed: 0 }) + expect(settled([read('a.ts'), ran('ls')])).toMatchObject({ added: 0, removed: 0 }) }) }) 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 7127a8f1efe5..96458435ed40 100644 --- a/apps/desktop/src/components/assistant-ui/tool/run-summary.ts +++ b/apps/desktop/src/components/assistant-ui/tool/run-summary.ts @@ -137,9 +137,14 @@ function lowerFirst(text: string): string { * — "Edited wiring.tsx, 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. + * + * 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 + * agent that moved on, and a run like that has to read as finished rather than + * narrate work that stopped happening. */ -export function summarizeToolRun(tools: readonly ToolCallLike[]): RunSummary { - const running = tools.find(isPending) +export function summarizeToolRun(tools: readonly ToolCallLike[], live: boolean): RunSummary { + const running = live ? tools.find(isPending) : undefined const liveCategory = running ? toolCategory(running.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 25c46fbe3fd5..75595a248fef 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 @@ -220,6 +220,83 @@ function settledRunMessage(): ThreadMessage { } as ThreadMessage } +// A finished turn that left a call without a result — interrupted, or the +// result landed elsewhere. The run is history and has to behave like it. +function abandonedRunMessage(): ThreadMessage { + return { + id: 'assistant-abandoned-run', + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'read-3', + toolName: 'read_file', + args: { path: '/repo/src/status.tsx' }, + argsText: JSON.stringify({ path: '/repo/src/status.tsx' }), + result: { content: 'export const Status = () => null' } + }, + { + type: 'tool-call', + toolCallId: 'search-1', + toolName: 'search_files', + args: { query: 'toolRuns' }, + argsText: JSON.stringify({ query: 'toolRuns' }) + } + ], + status: { type: 'complete', reason: 'stop' }, + 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 { + return { + id: 'assistant-moved-on', + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'read-4', + toolName: 'read_file', + args: { path: '/repo/src/status.tsx' }, + argsText: JSON.stringify({ path: '/repo/src/status.tsx' }) + }, + { + type: 'tool-call', + toolCallId: 'search-2', + toolName: 'search_files', + args: { query: 'toolRuns' }, + argsText: JSON.stringify({ query: 'toolRuns' }) + }, + { type: 'text', text: 'Let me read the rest of the file.' }, + { + type: 'tool-call', + toolCallId: 'term-2', + toolName: 'terminal', + args: { command: 'ls' }, + argsText: JSON.stringify({ command: 'ls' }) + } + ], + status: { type: 'running' }, + createdAt, + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: {} + } + } as ThreadMessage +} + function GroupHarness({ message }: { message: ThreadMessage }) { const runtime = useExternalStoreRuntime({ messages: [message], @@ -297,6 +374,26 @@ describe('live tool run', () => { }) }) +// A run whose calls never resolved used to read as live forever, which stranded +// it in the present tense and — because a live run withholds its toggle — left +// it permanently expanded with no way to collapse it. +describe('tool run left unresolved', () => { + it('settles with the turn rather than narrating work that stopped', async () => { + const { container } = render() + + expect(await screen.findByText('Explored 2 files')).toBeTruthy() + expect(container.querySelectorAll('[data-tool-row]')).toHaveLength(0) + expect(container.querySelector('[data-tool-summary] button[aria-expanded]')).not.toBeNull() + }) + + it('settles once the agent moves on, even mid-turn', async () => { + const { container } = render() + + expect(await screen.findByText('Explored 2 files')).toBeTruthy() + expect(container.querySelector('[data-tool-summary] button[aria-expanded]')).not.toBeNull() + }) +}) + describe('flat tool list approval surfacing', () => { it('renders no inline approval bar when there is no live approval', async () => { const { container } = render() From 6ba2f3e6444dee14d8911b2a2f97cd1efa8a0919 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Jul 2026 17:39:55 -0500 Subject: [PATCH 06/14] fix(desktop): say a sub-second reasoning block was brief MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The elapsed timer counts whole seconds, so reasoning that finished inside one rendered as "Thought for 0s" — accurate and useless, and on a turn with several short blocks it repeats down the transcript. Drop the number below a second and say it was brief instead. --- .../assistant-ui/thread/message-parts.tsx | 13 ++++++++++--- apps/desktop/src/i18n/ar.ts | 1 + apps/desktop/src/i18n/en.ts | 1 + apps/desktop/src/i18n/ja.ts | 1 + apps/desktop/src/i18n/types.ts | 1 + apps/desktop/src/i18n/zh-hant.ts | 1 + apps/desktop/src/i18n/zh.ts | 1 + 7 files changed, 16 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx b/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx index c3ddb255c7ff..93a4f24a6ebc 100644 --- a/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx @@ -89,6 +89,15 @@ const ThinkingDisclosure: FC<{ } }, [elapsed, pending, watching]) + // The timer counts whole seconds, so a block that finished inside one reports + // "0s" — accurate and useless. Say it was quick and leave the number out. + const thoughtLabel = + pending || thoughtFor === null + ? t.assistant.thread.thinking + : thoughtFor < 1 + ? t.assistant.thread.thoughtBriefly + : t.assistant.thread.thoughtFor(formatElapsed(thoughtFor)) + // While the preview is live, pin the scroll container to the bottom on // every content growth so the latest tokens are always visible. useEffect(() => { @@ -143,9 +152,7 @@ const ThinkingDisclosure: FC<{ pending && 'shimmer text-foreground/55' )} > - {pending || thoughtFor === null - ? t.assistant.thread.thinking - : t.assistant.thread.thoughtFor(formatElapsed(thoughtFor))} + {thoughtLabel} {pending && ( count === 1 ? 'سيُستأنف عند انتهاء المهمة الخلفية' : `سيُستأنف عند انتهاء ${count} مهام خلفية`, thinking: 'يفكر...', + thoughtBriefly: 'فكّر قليلاً', thoughtFor: duration => `فكّر لمدة ${duration}`, today: time => `اليوم ${time}`, yesterday: time => `أمس ${time}`, diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index f68b2657bc87..4f5b8fe10481 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -2668,6 +2668,7 @@ export const en: Translations = { ? 'Will resume when the background task finishes' : `Will resume when ${count} background tasks finish`, thinking: 'Thinking', + thoughtBriefly: 'Thought briefly', thoughtFor: duration => `Thought for ${duration}`, today: time => `Today, ${time}`, yesterday: time => `Yesterday, ${time}`, diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index ab01313842de..ec428cc76384 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -2517,6 +2517,7 @@ export const ja = defineLocale({ ? 'バックグラウンドタスクの完了後に再開します' : `${count} 件のバックグラウンドタスクの完了後に再開します`, thinking: '考え中', + thoughtBriefly: '少し思考', thoughtFor: duration => `${duration} 思考`, today: time => `今日 ${time}`, yesterday: time => `昨日 ${time}`, diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index f566b17f8cfe..ae6765789059 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -2267,6 +2267,7 @@ export interface Translations { loadingResponse: string resumeWhenBackgroundDone: (count: number) => string thinking: string + thoughtBriefly: string thoughtFor: (duration: string) => string today: (time: string) => string yesterday: (time: string) => string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index e133f4ff3e79..cfcda5901347 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -2438,6 +2438,7 @@ export const zhHant = defineLocale({ resumeWhenBackgroundDone: count => count === 1 ? '背景工作完成後將自動繼續' : `${count} 個背景工作完成後將自動繼續`, thinking: '思考中', + thoughtBriefly: '思考了片刻', thoughtFor: duration => `思考了 ${duration}`, today: time => `今天,${time}`, yesterday: time => `昨天,${time}`, diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index eebbba53eb8a..586b8cedafae 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -2839,6 +2839,7 @@ export const zh: Translations = { resumeWhenBackgroundDone: count => count === 1 ? '后台任务完成后将自动继续' : `${count} 个后台任务完成后将自动继续`, thinking: '思考中', + thoughtBriefly: '思考了片刻', thoughtFor: duration => `思考了 ${duration}`, today: time => `今天,${time}`, yesterday: time => `昨天,${time}`, From a3c46ac09ec95c3bcf1d2e56e60bdc8ca8ac160f Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Jul 2026 18:42:46 -0500 Subject: [PATCH 07/14] refactor(desktop): give live tool runs a one-line ticker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A live run was the settled view under a CSS max-height: the real rows, capped at ~6.75rem, with an escape hatch that let an open diff lift the cap entirely. So it was neither a single line nor an honest expanded block, and a run could balloon mid-turn. Split the two presentations instead. Live, a run is its summary plus a one-line reel that slides each finished action up and out, so a turn touching thirty files reads as one line ticking over in place. Settled, the summary is the whole of it until opened. Cards — file edits, clarify, image_generate — leave the run entirely and stay on screen where they happened, since the diff or the question IS the point of the turn. Drops useToolWindow, the bounded-window threshold, the scroll container, the fade mask and the :has([data-tool-open]) escape hatch, all of which existed to make "the real rows, but shorter" work. Also unifies transcript scaffolding on one colour: thinking headers painted --ui-text-secondary and tool summaries --ui-text-tertiary under a shared opacity, which is two greys for one kind of line. Both now render through ScaffoldRow at a token pitched between them. --- .../hooks/use-message-stream/gateway-event.ts | 23 +- .../assistant-ui/thread/message-parts.tsx | 49 ++- .../components/assistant-ui/thread/status.tsx | 71 +++- .../assistant-ui/tool/fallback.test.ts | 80 +++-- .../components/assistant-ui/tool/fallback.tsx | 310 +++++++++--------- .../assistant-ui/tool/run-summary.ts | 9 + .../src/components/chat/scaffold-row.tsx | 41 +++ apps/desktop/src/i18n/ar.ts | 1 + apps/desktop/src/i18n/en.ts | 1 + apps/desktop/src/i18n/ja.ts | 1 + apps/desktop/src/i18n/types.ts | 1 + apps/desktop/src/i18n/zh-hant.ts | 1 + apps/desktop/src/i18n/zh.ts | 1 + apps/desktop/src/store/tool-drafting.ts | 45 +++ apps/desktop/src/styles.css | 56 ++-- 15 files changed, 436 insertions(+), 254 deletions(-) create mode 100644 apps/desktop/src/components/chat/scaffold-row.tsx create mode 100644 apps/desktop/src/store/tool-drafting.ts diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts index 00348861930b..04aa94522a00 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts @@ -50,6 +50,7 @@ import { import { pruneDelegateFallbackSubagents, pruneFinishedSessionSubagents, upsertSubagent } from '@/store/subagents' import { clearActiveSessionTodos } from '@/store/todos' import { recordToolDiff } from '@/store/tool-diffs' +import { setSessionDraftingTool } from '@/store/tool-drafting' import { reportInstallMethodWarning } from '@/store/updates' import { notifyWorkspaceChanged, toolChangedPath, toolMayMutateFiles } from '@/store/workspace-events' // Leaf import (not the `@/themes` barrel) to avoid pulling the ThemeProvider @@ -441,6 +442,7 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { flushQueuedDeltas(sessionId) pruneFinishedSessionSubagents(sessionId) setSessionCompacting(sessionId, false) + setSessionDraftingTool(sessionId, '') compactedTurnRef.current.delete(sessionId) nativeSubagentSessionsRef.current.delete(sessionId) // A fresh turn on this session optimistically clears its billing wall; @@ -611,6 +613,7 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { // last item stuck pending/in_progress. Finished lists keep their linger. clearActiveSessionTodos(sessionId) setSessionCompacting(sessionId, false) + setSessionDraftingTool(sessionId, '') flushQueuedDeltas(sessionId) @@ -677,12 +680,28 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { if (storedId && nextTitle) { setSessions(prev => prev.map(s => (sessionMatchesStoredId(s, storedId) ? { ...s, title: nextTitle } : s))) } - } else if (event.type === 'tool.start' || event.type === 'tool.progress' || event.type === 'tool.generating') { + } else if (event.type === 'tool.generating') { + // Announced while the model is still emitting the call's JSON, so it + // carries a name and nothing else — no id, no args. Materializing a row + // from it strands an argless placeholder whenever the bubble is sealed + // before the real `tool.start` arrives, because the two can no longer be + // reconciled across the boundary. It's a status, so say it as one. + if (!sessionId) { + return + } + + setSessionDraftingTool(sessionId, typeof payload?.name === 'string' ? payload.name : '') + + if (isActiveEvent) { + setPetActivity({ reasoning: false, toolRunning: true }) + } + } else if (event.type === 'tool.start' || event.type === 'tool.progress') { if (!sessionId) { return } flushQueuedDeltas(sessionId) + setSessionDraftingTool(sessionId, '') upsertToolCall(sessionId, toTodoPayload(payload) ?? payload, 'running', event.type) if (isActiveEvent) { @@ -691,6 +710,7 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { } else if (event.type === 'tool.complete') { if (sessionId) { flushQueuedDeltas(sessionId) + setSessionDraftingTool(sessionId, '') upsertToolCall(sessionId, toTodoPayload(payload) ?? payload, 'complete', event.type) if (isActiveEvent) { @@ -982,6 +1002,7 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { clearClarifyRequest(undefined, sessionId) clearActiveSessionTodos(sessionId) setSessionCompacting(sessionId, false) + setSessionDraftingTool(sessionId, '') compactedTurnRef.current.delete(sessionId) } diff --git a/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx b/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx index 93a4f24a6ebc..1502aa82a91d 100644 --- a/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx @@ -11,8 +11,8 @@ import { MarkdownText, MarkdownTextContent } from '@/components/assistant-ui/mar import { ToolFallback, ToolGroupSlot } from '@/components/assistant-ui/tool/fallback' import { formatElapsed, useElapsedSeconds } from '@/components/chat/activity-timer' import { ActivityTimerText } from '@/components/chat/activity-timer-text' -import { DisclosureRow } from '@/components/chat/disclosure-row' import { GeneratedImage } from '@/components/chat/generated-image-result' +import { SCAFFOLD_LABEL_CLASS, SCAFFOLD_META_CLASS, ScaffoldRow } from '@/components/chat/scaffold-row' import { useI18n } from '@/i18n' import { generatedImageFromResult } from '@/lib/generated-images' import { useEnterAnimation } from '@/lib/use-enter-animation' @@ -89,14 +89,23 @@ const ThinkingDisclosure: FC<{ } }, [elapsed, pending, watching]) - // The timer counts whole seconds, so a block that finished inside one reports - // "0s" — accurate and useless. Say it was quick and leave the number out. - const thoughtLabel = - pending || thoughtFor === null - ? t.assistant.thread.thinking - : thoughtFor < 1 - ? t.assistant.thread.thoughtBriefly - : t.assistant.thread.thoughtFor(formatElapsed(thoughtFor)) + // Three ways a finished block can report itself. With a measured duration it + // says so, unless the timer's whole seconds round it to "0s" — accurate and + // useless — in which case it just says it was quick. With no duration at all + // (rehydrated history, or reasoning that arrived already complete so we never + // saw it run) it still has to read as finished; a turn that ended must not go + // on saying "Thinking". + let thoughtLabel = t.assistant.thread.thinking + + if (!pending) { + if (thoughtFor === null) { + thoughtLabel = t.assistant.thread.thought + } else if (thoughtFor < 1) { + thoughtLabel = t.assistant.thread.thoughtBriefly + } else { + thoughtLabel = t.assistant.thread.thoughtFor(formatElapsed(thoughtFor)) + } + } // While the preview is live, pin the scroll container to the bottom on // every content growth so the latest tokens are always visible. @@ -144,24 +153,10 @@ const ThinkingDisclosure: FC<{ data-slot="aui_thinking-disclosure" ref={enterRef} > - setUserOpen(!open)} open={open}> - - - {thoughtLabel} - - {pending && ( - - )} - - + setUserOpen(!open)} open={open}> + {thoughtLabel} + {pending && } + {open && (
> = ({ children, @@ -34,8 +36,8 @@ const StatusRow: FC<{ children: ReactNode; label: string } & React.ComponentProp // Fixed label while auto-compaction runs — decoupled from backend status text. const COMPACTION_LABEL = 'Summarizing thread' -const CompactionHint: FC = () => ( - {COMPACTION_LABEL} +const HintText: FC<{ children: ReactNode }> = ({ children }) => ( + {children} ) /** These indicators render inside whichever transcript mounted them, so every @@ -45,6 +47,7 @@ function useThreadSessionStatus() { const sessionId = useStore(useSessionView().$runtimeId) const turnStartedAt = useStore($turnStartedAt) const compacting = useStore(useMemo(() => sessionCompacting(sessionId), [sessionId])) + const drafting = useStore(useMemo(() => sessionDraftingTool(sessionId), [sessionId])) // A pending clarify / approval / sudo / secret means the turn is paused on the // user, not working — so don't resurrect the "thinking" timer while they // decide (matches the pet's awaitingInput pose taking priority over busy). @@ -53,10 +56,42 @@ function useThreadSessionStatus() { return { awaitingInput, compacting, + drafting, turnTimerKey: sessionId && turnStartedAt ? `turn:${sessionId}:${turnStartedAt}` : undefined } } +// Long enough that a tool whose arguments arrive in a few frames never gets to +// strobe a label, short enough that a real wait is named almost immediately. +const DRAFTING_REVEAL_MS = 200 + +/** + * What to call the wait, if it deserves a name. Compaction outranks a draft — + * it's rarer, slower, and explains a transcript that looks like it reset. + */ +function useStatusHint(compacting: boolean, drafting: DraftingTool | null): string { + const [revealed, setRevealed] = useState(false) + const name = drafting?.name ?? '' + + useEffect(() => { + setRevealed(false) + + if (!name) { + return + } + + const id = window.setTimeout(() => setRevealed(true), DRAFTING_REVEAL_MS) + + return () => window.clearTimeout(id) + }, [name]) + + if (compacting) { + return COMPACTION_LABEL + } + + return revealed && name ? toolPresentVerb(name) : '' +} + export const CenteredThreadSpinner: FC = () => { const { t } = useI18n() @@ -80,17 +115,18 @@ export const CenteredThreadSpinner: FC = () => { export const ResponseLoadingIndicator: FC = () => { const { t } = useI18n() - const { compacting, turnTimerKey } = useThreadSessionStatus() + const { compacting, drafting, turnTimerKey } = useThreadSessionStatus() const elapsed = useElapsedSeconds(true, turnTimerKey) + const hint = useStatusHint(compacting, drafting) return ( ) @@ -159,7 +195,8 @@ export const StreamStallIndicator: FC = () => { // 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, turnTimerKey } = useThreadSessionStatus() + const { awaitingInput, compacting, drafting, turnTimerKey } = useThreadSessionStatus() + const hint = useStatusHint(compacting, drafting) useEffect(() => { setQuietSince(undefined) @@ -169,24 +206,28 @@ export const StreamStallIndicator: FC = () => { return () => window.clearTimeout(id) }, [activity]) - const active = (quietSince !== undefined || compacting) && !awaitingInput + // 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 // Compaction owns the whole turn, so it keeps counting from the turn's start; - // a plain stall counts from the last thing the stream produced. - const elapsed = useElapsedSeconds(active, compacting ? turnTimerKey : undefined, compacting ? undefined : quietSince) + // anything else counts from the moment the stream went quiet — the stall's own + // mark, or the draft's, whichever named the wait first. + const elapsed = useElapsedSeconds( + active, + compacting ? turnTimerKey : undefined, + compacting ? undefined : (quietSince ?? drafting?.since) + ) if (!active) { return null } return ( - + ) diff --git a/apps/desktop/src/components/assistant-ui/tool/fallback.test.ts b/apps/desktop/src/components/assistant-ui/tool/fallback.test.ts index 75bacb338a5d..abe38c26f7dd 100644 --- a/apps/desktop/src/components/assistant-ui/tool/fallback.test.ts +++ b/apps/desktop/src/components/assistant-ui/tool/fallback.test.ts @@ -1,42 +1,56 @@ import { describe, expect, it } from 'vitest' -import { isUnboundableTool, shouldBoundToolGroup, technicalTrace } from './fallback' +import { isCardTool, splitRunItems, technicalTrace } from './fallback' -describe('shouldBoundToolGroup', () => { - it('bounds long runs of ordinary tool calls', () => { - expect(shouldBoundToolGroup(3, false)).toBe(true) - }) - - it('leaves short runs unbounded', () => { - expect(shouldBoundToolGroup(2, false)).toBe(false) - }) - - it('never bounds a run holding an unboundable tool', () => { - expect(shouldBoundToolGroup(3, true)).toBe(false) - }) -}) - -describe('isUnboundableTool', () => { - it('exempts clarify forms and generated images from the window', () => { - expect(isUnboundableTool('clarify')).toBe(true) - expect(isUnboundableTool('image_generate')).toBe(true) - }) - - // Everything ToolEntry renders carries `data-tool-row`, so the - // `:has([data-tool-row][data-tool-open])` rule in styles.css lifts the cap - // on its own. A diff row mounts open and frees the group immediately; a - // collapsed row has no body in the DOM to clip. Exempting these in JS - // instead vetoed grouping for the whole run — and since reads and edits are - // most of a coding session, runs of 19 calls never collapsed at all. - it('bounds the rows the CSS break-out already covers', () => { - for (const toolName of ['read_file', 'execute_code', 'edit_file', 'patch', 'write_file']) { - expect(isUnboundableTool(toolName)).toBe(false) +describe('isCardTool', () => { + it('keeps what the user has to look at out of a summary', () => { + // A diff is the deliverable, a clarify is a question waiting on an answer, + // an image is the thing that was asked for. None of them survives being + // folded into "used 3 tools". + for (const toolName of ['clarify', 'image_generate', 'edit_file', 'patch', 'write_file']) { + expect(isCardTool(toolName)).toBe(true) } }) - it('still bounds console output and other ordinary rows', () => { - expect(isUnboundableTool('terminal')).toBe(false) - expect(isUnboundableTool('web_search')).toBe(false) + it('treats reads, searches and commands as ephemeral activity', () => { + for (const toolName of ['read_file', 'search_files', 'terminal', 'execute_code', 'web_search']) { + expect(isCardTool(toolName)).toBe(false) + } + }) +}) + +describe('splitRunItems', () => { + it('collapses a stretch of activity into one run', () => { + expect(splitRunItems(['read_file', 'search_files', 'terminal'])).toEqual([{ end: 2, kind: 'run', start: 0 }]) + }) + + it('keeps a card at the point in the turn where it happened', () => { + // Read, edit, read has to stay in that order — a summary, the diff, then a + // second summary — rather than sorting the diffs to one end. + expect(splitRunItems(['read_file', 'patch', 'read_file', 'terminal'])).toEqual([ + { end: 0, kind: 'run', start: 0 }, + { index: 1, kind: 'card' }, + { end: 3, kind: 'run', start: 2 } + ]) + }) + + it('does not let adjacent cards merge into a run', () => { + expect(splitRunItems(['patch', 'write_file'])).toEqual([ + { index: 0, kind: 'card' }, + { index: 1, kind: 'card' } + ]) + }) + + it('passes a part that is not a tool call through as its own card', () => { + expect(splitRunItems(['read_file', '', 'read_file'])).toEqual([ + { end: 0, kind: 'run', start: 0 }, + { index: 1, kind: 'card' }, + { end: 2, kind: 'run', start: 2 } + ]) + }) + + it('has nothing to split when the range is empty', () => { + expect(splitRunItems([])).toEqual([]) }) }) diff --git a/apps/desktop/src/components/assistant-ui/tool/fallback.tsx b/apps/desktop/src/components/assistant-ui/tool/fallback.tsx index 799a87df3ee5..70d6d0503312 100644 --- a/apps/desktop/src/components/assistant-ui/tool/fallback.tsx +++ b/apps/desktop/src/components/assistant-ui/tool/fallback.tsx @@ -5,10 +5,12 @@ import { useStore } from '@nanostores/react' import { Children, createContext, + type CSSProperties, type FC, + Fragment, + isValidElement, type PropsWithChildren, type ReactNode, - useCallback, useContext, useEffect, useMemo, @@ -23,6 +25,7 @@ import { ActivityTimerText } from '@/components/chat/activity-timer-text' import { CompactMarkdown } from '@/components/chat/compact-markdown' import { FileDiffPanel } from '@/components/chat/diff-lines' import { DisclosureRow } from '@/components/chat/disclosure-row' +import { SCAFFOLD_LABEL_CLASS, ScaffoldRow } from '@/components/chat/scaffold-row' import { ZoomableImage } from '@/components/chat/zoomable-image' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' @@ -41,11 +44,12 @@ import { normalize } from '@/lib/text' import { useEnterAnimation } from '@/lib/use-enter-animation' import { cn } from '@/lib/utils' import { recordPreviewArtifact } from '@/store/preview-status' +import { sessionApprovalRequest } from '@/store/prompts' import { $toolInlineDiff } from '@/store/tool-diffs' import { $toolRowDismissed, dismissToolRow } from '@/store/tool-dismiss' import { $toolDisclosureOpen, $toolViewMode, setToolDisclosureOpen } from '@/store/tool-view' -import { PendingToolApproval } from './approval' +import { APPROVAL_TOOLS, PendingToolApproval } from './approval' import { buildToolView, clampForDisplay, @@ -701,109 +705,83 @@ function TerminalTranscript({ command, exitCode }: TerminalTranscriptProps) { ) } -// A back-to-back run of this many tool calls collapses into the bounded, -// auto-scrolling window; fewer than this stays a plain inline stack. -const TOOL_GROUP_SCROLL_THRESHOLD = 3 +// Tools that draw their own surface and must never be folded into a run's +// summary. Two kinds, for the same reason — the thing on screen IS the point: +// +// - File edits are the deliverable, not scaffolding. The diff is what the +// user reviews, so it stays visible at its place in the turn, live and +// settled, the way a PR shows its changes. +// - `clarify` and `image_generate` bypass ToolEntry to render their own +// markup: a question the user has to answer, an image they asked for. +// +// Everything else is ephemeral activity — reads, searches, commands — which is +// what a run summarizes and what the live ticker cycles through. +const CARD_TOOLS = new Set(['clarify', 'image_generate']) -// Tools whose body must never be hidden — not behind the window's max-height + -// fade mask, and not behind a settled run's summary chevron. A run holding any -// of them stays a plain, fully-visible stack no matter how long it is. -// -// This list is deliberately tiny, because the two things it opts out of are -// already safe for anything ToolEntry renders. For the window: a row carries -// `data-tool-row`, and the `:has([data-tool-row][data-tool-open])` rule in -// styles.css lifts the cap whenever one is open, so a code/diff row mounting -// open (see `defaultOpen`) frees itself the moment it appears, and a collapsed -// row is a one-line status with no body in the DOM to clip. For the chevron: a -// run still holding a pending call always renders its rows, so an approval bar -// can't end up behind one. -// -// Only components that bypass ToolEntry need this opt-out: `clarify` and -// `image_generate` render their own markup, never emit `data-tool-row`, and so -// neither the CSS escape hatch nor the pending-row rule can reach them. -// -// File edits are pointedly NOT here. A settled run collapses its diffs behind -// the summary, which carries their +N/−M — one click to read, and the run stays -// compact. Adding them back veto'd the window for nearly every coding session -// last time (7f2971039, reverted in 058aa34d8); it would do the same here. -const UNBOUNDABLE_TOOLS = new Set(['clarify', 'image_generate']) - -export function isUnboundableTool(toolName: string): boolean { - return UNBOUNDABLE_TOOLS.has(toolName) +export function isCardTool(toolName: string): boolean { + return CARD_TOOLS.has(toolName) || isFileEditTool(toolName) } -export function shouldBoundToolGroup(childCount: number, hasUnboundable: boolean) { - return childCount >= TOOL_GROUP_SCROLL_THRESHOLD && !hasUnboundable -} +export type RunItem = { end: number; kind: 'run'; start: number } | { index: number; kind: 'card' } -// Pin-to-bottom + top-fade for the bounded tool window. Pins the newest row on -// growth (a call lands or a row expands) unless the user scrolled up, and fades -// the top edge once anything sits above it. Mirrors ThinkingDisclosure's live -// preview. `enabled` is false for short runs, leaving the plain flat stack. -function useToolWindow(enabled: boolean) { - const scrollRef = useRef(null) - const contentRef = useRef(null) - const stickRef = useRef(true) - const [faded, setFaded] = useState(false) +/** + * Split a range of parts into cards and the runs of activity between them. + * + * Order is preserved rather than sorted into "all the runs, then all the + * cards": a turn that reads, edits, then reads again shows a summary, the + * diff, then a second summary, in the sequence it happened. Indices are + * relative to the range. An empty name is a part that isn't a tool call at + * all, which passes through as its own card. + */ +export function splitRunItems(toolNames: readonly string[]): RunItem[] { + const items: RunItem[] = [] + let run: null | Extract = null - const syncFade = useCallback(() => setFaded((scrollRef.current?.scrollTop ?? 0) > 4), []) + toolNames.forEach((name, index) => { + if (!name || isCardTool(name)) { + run = null + items.push({ index, kind: 'card' }) - const onScroll = useCallback(() => { - const el = scrollRef.current - - if (!el) { return } - stickRef.current = el.scrollHeight - el.scrollTop - el.clientHeight <= 8 - syncFade() - }, [syncFade]) - - useEffect(() => { - const el = scrollRef.current - const content = contentRef.current - - if (!enabled || !el || !content) { - return + if (run) { + run.end = index + } else { + run = { end: index, kind: 'run', start: index } + items.push(run) } + }) - // Track the content's HEIGHT and only pin when it grows. The observer also - // fires for width changes — a sidebar sash drag resizes every tool window - // once per frame — and pinning there is (a) pointless, the list didn't - // grow, and (b) expensive: `pin` writes scrollTop then `syncFade` reads it - // back, a write->read forced reflow per tool group per frame. Measured on - // a real session while dragging the sash: 927ms of `pin` script plus - // 2.7s of style recalc across one 60-frame drag. Reading the height off - // the RO entry keeps the check reflow-free. - let lastHeight = -1 + return items +} - const pin = (entries: readonly ResizeObserverEntry[]) => { - const height = entries[entries.length - 1]?.borderBoxSize?.[0]?.blockSize ?? -1 - const grew = height < 0 || height > lastHeight - lastHeight = height +/** + * The live run, as one line. + * + * A run in progress shows only what it is doing right now; each new action + * slides the one before it up and out of a single-line window, so a turn that + * touches thirty files reads as one line ticking over in place instead of a + * list growing down the page. When the run settles the ticker goes away and + * the summary above it is all that's left. + * + * Rows are clipped to a uniform line box so the reel's offset stays exact + * whatever a row happens to contain. + */ +function ToolRunTicker({ children }: { children: ReactNode }) { + const rows = Children.toArray(children) - if (!grew) { - return - } - - if (stickRef.current) { - el.scrollTop = el.scrollHeight - } - - syncFade() - } - - // No sync pin() here: the observer's guaranteed initial delivery runs it - // inside RO timing (layout already clean, still before paint). A sync call - // at effect time reads scrollHeight while the commit's layout is dirty — - // one forced reflow per tool group, which cascades on a session switch. - const observer = new ResizeObserver(pin) - observer.observe(content) - - return () => observer.disconnect() - }, [enabled, syncFade]) - - return { contentRef, faded, onScroll, scrollRef } + return ( +
+
+ {rows.map((row, index) => ( +
+ {row} +
+ ))} +
+
+ ) } // The one grey line that stands in for a run of tool calls once it has @@ -823,21 +801,22 @@ function ToolRunHeader({ }) { return (
- - - - {live ? {summary.text} : summary.text} - - - - + + + {live ? {summary.text} : summary.text} + + +
) } interface ToolRunState { + count: number key: string live: boolean + /** A call still awaiting a result that could be the one blocking on approval. */ + pendingApprovalTool: boolean summary: RunSummary } @@ -868,8 +847,10 @@ function useToolRun(startIndex: number, endIndex: number): ToolRunState { cache.current = { signature, value: { + count: tools.length, key: tools[0]?.toolCallId ?? '', live, + pendingApprovalTool: tools.some(tool => tool.result === undefined && APPROVAL_TOOLS.has(tool.toolName)), summary: summarizeToolRun(tools, live) } } @@ -880,7 +861,7 @@ function useToolRun(startIndex: number, endIndex: number): ToolRunState { } /** - * A run of consecutive tool calls, headed by the line that summarizes it. + * One run of consecutive activity calls, headed by the line that summarizes it. * * The run is identified by its FIRST tool call, never by its position: a live * stream and the same turn rehydrated from history agree on which calls belong @@ -889,69 +870,94 @@ function useToolRun(startIndex: number, endIndex: number): ToolRunState { * index is what made an earlier attempt at this reshuffle the moment a turn * settled. `lib/tool-run-continuity.test.ts` locks that agreement down. * - * A settled run collapses to its header; a live one keeps its rows on screen - * (and past `TOOL_GROUP_SCROLL_THRESHOLD` inside a fixed-height auto-scrolling - * window, so a long run can't shove the reply off screen). `ToolEmbedContext` - * is false so every row still owns its own chrome (timer / copy / approval). + * Live, the run is a summary plus the one-line ticker. Settled, the summary is + * the whole of it until the user opens it. `ToolEmbedContext` is false so each + * row still owns its own chrome (timer / copy / approval) when shown. + */ +const ToolRun: FC> = ({ + children, + endIndex, + startIndex +}) => { + const messageRunning = useAuiState(selectMessageRunning) + const { count, key, live, pendingApprovalTool, summary } = useToolRun(startIndex, endIndex) + const sessionId = useStore(useSessionView().$runtimeId) + const approval = useStore(useMemo(() => sessionApprovalRequest(sessionId), [sessionId])) + const disclosureId = `tool-run:${key}` + const persistedOpen = useStore($toolDisclosureOpen(disclosureId)) + const enterRef = useEnterAnimation(messageRunning, `tool-run:${key}`) + + // A lone call is already its own one-line summary; heading it with a second + // line would say the same thing twice. + if (count < 2) { + return <>{children} + } + + // An approval is a question the user has to answer, and the ticker only ever + // shows one line — the row carrying the approval bar could tick straight + // past it. Show the whole run until it's answered. + const blocked = Boolean(approval) && pendingApprovalTool + const expanded = live ? blocked : (persistedOpen ?? false) + + return ( +
+ setToolDisclosureOpen(disclosureId, !expanded)} + open={expanded} + summary={summary} + /> + {live && !blocked && {children}} + {expanded &&
{children}
} +
+ ) +} + +/** + * A range of consecutive tool calls, split into the cards that must stay on + * screen and the runs of activity between them. + * + * assistant-ui hands the whole adjacent range over as one group; what belongs + * together is a narrower question than adjacency. A diff or a question for the + * user is the point of the turn and renders in place, while the reads and + * commands around it collapse into a line. Splitting here rather than asking + * for different ranges keeps the decision next to the rendering that depends + * on it. */ export const ToolGroupSlot: FC> = ({ children, endIndex, startIndex }) => { - const messageRunning = useAuiState(selectMessageRunning) - const { key, live, summary } = useToolRun(startIndex, endIndex) - - const hasUnboundable = useAuiState(s => - s.message.parts + // Joined rather than returned as an array: assistant-ui compares selector + // results with `Object.is` and re-runs them on every store update, so a + // fresh array would re-render the whole group on every text delta. + const toolNameKey = useAuiState(state => + state.message.parts .slice(Math.max(0, startIndex), endIndex + 1) - .some(part => part.type === 'tool-call' && isUnboundableTool(part.toolName)) + .map(part => (part.type === 'tool-call' ? part.toolName : '')) + .join('\u0000') ) - const disclosureId = `tool-run:${key}` - const persistedOpen = useStore($toolDisclosureOpen(disclosureId)) - // A lone call is already its own one-line summary, so it never earns a - // header. Neither does a run holding an `UNBOUNDABLE_TOOLS` surface — a - // `clarify` asking the user a question must not end up behind a chevron. - const grouped = endIndex > startIndex && !hasUnboundable - const open = !grouped || live || (persistedOpen ?? false) - - const enterRef = useEnterAnimation(messageRunning, `tool-run:${key}`) - - const bounded = open && shouldBoundToolGroup(Children.count(children), hasUnboundable) - const { contentRef, faded, onScroll, scrollRef } = useToolWindow(bounded) + const items = useMemo(() => splitRunItems(toolNameKey.split('\u0000')), [toolNameKey]) + const rows = Children.toArray(children) return ( -
- {grouped && ( - setToolDisclosureOpen(disclosureId, !open)} - open={open} - summary={summary} - /> - )} - {open && ( -
-
- {children} -
-
- )} -
+ {items.map(item => + item.kind === 'card' ? ( + {rows[item.index]} + ) : ( + + {rows.slice(item.start, item.end + 1)} + + ) + )}
) } 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 96458435ed40..a23970be4d94 100644 --- a/apps/desktop/src/components/assistant-ui/tool/run-summary.ts +++ b/apps/desktop/src/components/assistant-ui/tool/run-summary.ts @@ -79,6 +79,15 @@ function isPending(tool: ToolCallLike): boolean { return tool.result === undefined } +/** + * How a tool reads while it is happening — "Editing", "Exploring". Shared with + * the status line that covers the gap before a tool starts, so the same run is + * described in the same words from the moment the model drafts it. + */ +export function toolPresentVerb(toolName: string): string { + return CATEGORY_COPY[toolCategory(toolName)].present +} + /** The thing a tool acted on, as the header should name it. */ function toolTarget(tool: ToolCallLike): string { const args = parseMaybeObject(tool.args) diff --git a/apps/desktop/src/components/chat/scaffold-row.tsx b/apps/desktop/src/components/chat/scaffold-row.tsx new file mode 100644 index 000000000000..d4d278663717 --- /dev/null +++ b/apps/desktop/src/components/chat/scaffold-row.tsx @@ -0,0 +1,41 @@ +import type { ReactNode } from 'react' + +import { DisclosureRow } from '@/components/chat/disclosure-row' + +/** + * Transcript scaffolding: the quiet lines around the reply that say what the + * agent did rather than what it said — a thinking header, a settled tool run, + * the live activity ticker. + * + * They all render through here so they cannot drift apart. They used to each + * pick their own grey — a thinking header painted `--ui-text-secondary`, a tool + * summary `--ui-text-tertiary`, both under the same opacity — which read as two + * different kinds of line for what is one kind of thing. + */ +export const SCAFFOLD_LABEL_CLASS = + 'text-[length:var(--conversation-tool-font-size)] font-medium leading-(--conversation-line-height) text-(--conversation-scaffold-text)' + +/** Durations, counts and diff stats trailing a scaffold label. */ +export const SCAFFOLD_META_CLASS = 'shrink-0 text-[0.625rem] tabular-nums text-(--conversation-scaffold-meta)' + +/** + * One scaffold line. `children` is the label and whatever trails it in flow + * (meta, diff counts); `trailing` overlays the right edge for a live timer. + */ +export function ScaffoldRow({ + children, + onToggle, + open = false, + trailing +}: { + children: ReactNode + onToggle?: () => void + open?: boolean + trailing?: ReactNode +}) { + return ( + + {children} + + ) +} diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index 6557fcc87bec..2b1e8bd42b5f 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -2273,6 +2273,7 @@ export const ar = defineLocale({ resumeWhenBackgroundDone: count => count === 1 ? 'سيُستأنف عند انتهاء المهمة الخلفية' : `سيُستأنف عند انتهاء ${count} مهام خلفية`, thinking: 'يفكر...', + thought: 'فكّر', thoughtBriefly: 'فكّر قليلاً', thoughtFor: duration => `فكّر لمدة ${duration}`, today: time => `اليوم ${time}`, diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 4f5b8fe10481..472ab8cd79dc 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -2668,6 +2668,7 @@ export const en: Translations = { ? 'Will resume when the background task finishes' : `Will resume when ${count} background tasks finish`, thinking: 'Thinking', + thought: 'Thought', thoughtBriefly: 'Thought briefly', thoughtFor: duration => `Thought for ${duration}`, today: time => `Today, ${time}`, diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index ec428cc76384..6971ab4b5ccd 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -2517,6 +2517,7 @@ export const ja = defineLocale({ ? 'バックグラウンドタスクの完了後に再開します' : `${count} 件のバックグラウンドタスクの完了後に再開します`, thinking: '考え中', + thought: '思考済み', thoughtBriefly: '少し思考', thoughtFor: duration => `${duration} 思考`, today: time => `今日 ${time}`, diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index ae6765789059..d385fb3c8745 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -2267,6 +2267,7 @@ export interface Translations { loadingResponse: string resumeWhenBackgroundDone: (count: number) => string thinking: string + thought: string thoughtBriefly: string thoughtFor: (duration: string) => string today: (time: string) => string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index cfcda5901347..e4c96d779fff 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -2438,6 +2438,7 @@ export const zhHant = defineLocale({ resumeWhenBackgroundDone: count => count === 1 ? '背景工作完成後將自動繼續' : `${count} 個背景工作完成後將自動繼續`, thinking: '思考中', + thought: '已思考', thoughtBriefly: '思考了片刻', thoughtFor: duration => `思考了 ${duration}`, today: time => `今天,${time}`, diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 586b8cedafae..33edaef1198e 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -2839,6 +2839,7 @@ export const zh: Translations = { resumeWhenBackgroundDone: count => count === 1 ? '后台任务完成后将自动继续' : `${count} 个后台任务完成后将自动继续`, thinking: '思考中', + thought: '已思考', thoughtBriefly: '思考了片刻', thoughtFor: duration => `思考了 ${duration}`, today: time => `今天,${time}`, diff --git a/apps/desktop/src/store/tool-drafting.ts b/apps/desktop/src/store/tool-drafting.ts new file mode 100644 index 000000000000..eae645142be7 --- /dev/null +++ b/apps/desktop/src/store/tool-drafting.ts @@ -0,0 +1,45 @@ +import { atom, computed } from 'nanostores' + +// The tool whose arguments the model is streaming right now, and when it +// started. Generating a large payload (a 45 KB write_file) takes seconds during +// which the tool has not begun and the transcript has nothing to say. +// Per-session because a transcript may be a tile, and a tile must never narrate +// the primary chat's work. +const keyFor = (sessionId: string | null | undefined): string => sessionId ?? '' + +export interface DraftingTool { + name: string + since: number +} + +export const $draftingToolSessions = atom>({}) + +/** What `sessionId` is drafting, if anything. */ +export function sessionDraftingTool(sessionId: null | string) { + return computed($draftingToolSessions, sessions => sessions[keyFor(sessionId)] ?? null) +} + +export function setSessionDraftingTool(sessionId: string | null | undefined, name: string): void { + const key = keyFor(sessionId) + const sessions = $draftingToolSessions.get() + + if (!name) { + if (!(key in sessions)) { + return + } + + const next = { ...sessions } + delete next[key] + $draftingToolSessions.set(next) + + return + } + + // Re-announcing the same tool keeps the original timestamp, so the elapsed + // count reads as one continuous wait rather than restarting. + if (sessions[key]?.name === name) { + return + } + + $draftingToolSessions.set({ ...sessions, [key]: { name, since: Date.now() } }) +} diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index c2a86f4960a4..7135a6146b25 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -287,6 +287,13 @@ --ui-text-secondary: color-mix(in srgb, var(--ui-base) 74%, transparent); --ui-text-tertiary: color-mix(in srgb, var(--ui-base) 54%, transparent); --ui-text-quaternary: color-mix(in srgb, var(--ui-base) 36%, transparent); + /* Transcript scaffolding — thinking headers, settled tool runs, the live + activity ticker. One colour for all of them (see `ScaffoldRow`), pitched + between the secondary and tertiary greys those lines used to pick + individually, and dimmed once more by the fade rule below so the prose + reading column stays primary. */ + --conversation-scaffold-text: color-mix(in srgb, var(--ui-base) 64%, transparent); + --conversation-scaffold-meta: color-mix(in srgb, var(--ui-base) 44%, transparent); --ui-stroke-primary: color-mix( in srgb, var(--ui-accent) var(--theme-stroke-primary-accent-mix), @@ -402,10 +409,6 @@ /* Tight gap between tool rows inside a single action group, so a back-to-back run still reads as one cohesive sequence. */ --tool-row-gap: 0.375rem; - /* Height of the bounded, auto-scrolling window a long adjacent tool-call run - collapses into (see `.tool-group-scroll`). Sized to show a few rows before - the scroll + top fade kick in. */ - --tool-group-scroll-max-h: 6.75rem; /* Paragraph spacing — vertical gap between prose paragraphs, both inside a markdown block and between consecutive prose parts. Single knob; tweak freely. */ @@ -1567,32 +1570,33 @@ text-* variant utilities. */ .btn-arc { --thread-last-message-clearance: calc(var(--composer-measured-height) + var(--status-stack-measured-height) + 2rem); } -/* Long adjacent tool-call run collapsed into a fixed, auto-scrolling window. - ToolGroupSlot pins the newest call to the bottom (unless the user scrolls - up), so a back-to-back run stays compact instead of pushing the reply off - screen. A thin scrollbar keeps the affordance discoverable. */ -.tool-group-scroll { - scrollbar-width: thin; - scrollbar-color: var(--ui-stroke-tertiary) transparent; +/* A live tool run, shown as one line. `ToolRunTicker` stacks the run's rows + into a reel and offsets it by the newest row's index, so each new action + slides the one before it up and out of a single-line window — the run reads + as one line ticking over in place rather than a list growing down the page. + Rows are clipped to a uniform line box so the offset stays exact whatever a + row contains. */ +.tool-ticker { + height: var(--conversation-line-height); + overflow: hidden; } -/* Break out of the fixed window the moment any row inside is expanded: the - user is now reading a diff/output, so let it grow to full height instead of - peering at it through a ~2-row viewport. Collapsing the row drops it back - into the compact, auto-scrolling window. Beats a larger fixed cap since an - open row already bounds its own payload with an inner scroll. */ -.tool-group-scroll:has([data-tool-row][data-tool-open]) { - max-height: none; - -webkit-mask-image: none; - mask-image: none; +.tool-ticker__reel { + transform: translateY(calc(var(--tool-ticker-index, 0) * var(--conversation-line-height) * -1)); + transition: transform 240ms cubic-bezier(0.22, 1, 0.36, 1); } -/* Top gradient — only applied once the user is scrolled down off the top, so - the oldest visible call fades up under the fade while the first row stays - fully legible when scrolled all the way up. */ -.tool-group-scroll--faded { - -webkit-mask-image: linear-gradient(to bottom, transparent 0, black 2rem, black 100%); - mask-image: linear-gradient(to bottom, transparent 0, black 2rem, black 100%); +.tool-ticker__row { + display: flex; + height: var(--conversation-line-height); + align-items: center; + overflow: hidden; +} + +@media (prefers-reduced-motion: reduce) { + .tool-ticker__reel { + transition: none; + } } @keyframes code-card-stream-enter { From b1f4b9576192bf273c6f64390cd239f6f96e6733 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Jul 2026 18:50:20 -0500 Subject: [PATCH 08/14] fix(desktop): measure reasoning per block instead of per turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The timer registry hands every caller of a key the same origin, and every reasoning block in a turn was keyed `reasoning:`. So the second and third blocks measured from the first one's start and each reported the running total as its own duration — the "6s, 6s, 16s" down a single turn. Key per block, and move the measurement into `useMeasuredDuration`, which keeps the number beside the origin that produced it. The thread virtualizes, so the component that watched a block finish is usually gone by the time anyone scrolls back to read it; component state forgot the duration on unmount and the row fell back to having none. A block that genuinely was never watched running — history from an earlier app session, or reasoning that arrived already complete — still has no duration to report, and now says "Thought" rather than sitting in the present tense at a turn that ended. Also drops the run summary's aggregate +N/−M: a run can no longer contain a file edit, so it was always zero. Each edit carries its own count on its card. --- .../assistant-ui/thread/message-parts.tsx | 38 +++--- .../assistant-ui/thread/streaming.test.tsx | 3 +- .../components/assistant-ui/tool/fallback.tsx | 19 ++- .../assistant-ui/tool/run-summary.test.ts | 47 +++---- .../assistant-ui/tool/run-summary.ts | 50 ++------ .../assistant-ui/tool/tool-group.test.tsx | 117 +++++++++++++++--- .../components/chat/activity-timer.test.tsx | 89 ++++++++++++- .../src/components/chat/activity-timer.ts | 39 ++++++ 8 files changed, 282 insertions(+), 120 deletions(-) diff --git a/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx b/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx index 1502aa82a91d..508ead3ec9d8 100644 --- a/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx @@ -9,7 +9,7 @@ import { type ComponentProps, type FC, type ReactNode, useEffect, useRef, useSta import { ClarifyTool } from '@/components/assistant-ui/clarify-tool' import { MarkdownText, MarkdownTextContent } from '@/components/assistant-ui/markdown-text' import { ToolFallback, ToolGroupSlot } from '@/components/assistant-ui/tool/fallback' -import { formatElapsed, useElapsedSeconds } from '@/components/chat/activity-timer' +import { formatElapsed, useElapsedSeconds, useMeasuredDuration } from '@/components/chat/activity-timer' import { ActivityTimerText } from '@/components/chat/activity-timer-text' import { GeneratedImage } from '@/components/chat/generated-image-result' import { SCAFFOLD_LABEL_CLASS, SCAFFOLD_META_CLASS, ScaffoldRow } from '@/components/chat/scaffold-row' @@ -57,7 +57,9 @@ const ThinkingDisclosure: FC<{ children: ReactNode messageRunning?: boolean pending?: boolean - timerKey?: string + // Required: the block's duration is remembered against this key, so a + // component that mounts after the block finished can still report it. + timerKey: string }> = ({ children, messageRunning = false, pending = false, timerKey }) => { const { t } = useI18n() // `null` = no explicit user toggle yet, defer to the streaming default. @@ -66,6 +68,7 @@ const ThinkingDisclosure: FC<{ // explicit toggle wins from then on. const [userOpen, setUserOpen] = useState(null) const elapsed = useElapsedSeconds(pending, timerKey) + const thoughtFor = useMeasuredDuration(pending, timerKey) const scrollRef = useRef(null) const contentRef = useRef(null) const enterRef = useEnterAnimation(messageRunning, timerKey) @@ -73,28 +76,11 @@ const ThinkingDisclosure: FC<{ const open = userOpen ?? pending const isPreview = pending && userOpen === null - // How long the model thought is only knowable by having watched it happen — - // nothing in the persisted turn records it. So freeze the number the moment - // this block finishes in front of us, and stay quiet on a rehydrated turn - // rather than reporting whatever a timer that never ran would say. - const [watching, setWatching] = useState(false) - const [thoughtFor, setThoughtFor] = useState(null) - - useEffect(() => { - if (pending) { - setWatching(true) - } else if (watching) { - setWatching(false) - setThoughtFor(elapsed) - } - }, [elapsed, pending, watching]) - // Three ways a finished block can report itself. With a measured duration it // says so, unless the timer's whole seconds round it to "0s" — accurate and // useless — in which case it just says it was quick. With no duration at all - // (rehydrated history, or reasoning that arrived already complete so we never - // saw it run) it still has to read as finished; a turn that ended must not go - // on saying "Thinking". + // it still has to read as finished; a turn that ended must not go on saying + // "Thinking". let thoughtLabel = t.assistant.thread.thinking if (!pending) { @@ -213,7 +199,15 @@ const ReasoningAccordionGroup: FC<{ children?: ReactNode; endIndex: number; star } return ( - + // Keyed per block, not per message: the timer registry hands every caller + // of a key the same origin, so a turn that thinks three separate times used + // to measure the second and third blocks from the first one's start and + // report the running total as each block's duration. + {children} ) diff --git a/apps/desktop/src/components/assistant-ui/thread/streaming.test.tsx b/apps/desktop/src/components/assistant-ui/thread/streaming.test.tsx index c9d5dc53baea..0cedda1e7b1f 100644 --- a/apps/desktop/src/components/assistant-ui/thread/streaming.test.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/streaming.test.tsx @@ -606,7 +606,8 @@ describe('assistant-ui streaming renderer', () => { const { container } = render() const ui = within(container) - fireEvent.click(ui.getByRole('button', { name: /thinking/i })) + // Settled, so the header is past tense — a running block says "Thinking". + fireEvent.click(ui.getByRole('button', { name: /thought/i })) expect(container.querySelector('[data-slot="aui_reasoning-text"]')?.textContent).toBe( 'The user is asking what this file is.' diff --git a/apps/desktop/src/components/assistant-ui/tool/fallback.tsx b/apps/desktop/src/components/assistant-ui/tool/fallback.tsx index 70d6d0503312..16e5f9064afd 100644 --- a/apps/desktop/src/components/assistant-ui/tool/fallback.tsx +++ b/apps/desktop/src/components/assistant-ui/tool/fallback.tsx @@ -30,7 +30,6 @@ import { ZoomableImage } from '@/components/chat/zoomable-image' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' import { CopyButton } from '@/components/ui/copy-button' -import { DiffCount } from '@/components/ui/diff-count' import { DisclosureCaret } from '@/components/ui/disclosure-caret' import { FadeText } from '@/components/ui/fade-text' import { FileTypeIcon } from '@/components/ui/file-type-icon' @@ -68,7 +67,7 @@ import { type ToolStatus, type ToolTitleAction } from './fallback-model' -import { isToolCallPart, type RunSummary, summarizeToolRun } from './run-summary' +import { isToolCallPart, summarizeToolRun } from './run-summary' // `true` when a ToolEntry is rendered inside an embedding wrapper that owns // the per-row chrome (timer / preview). The flat ToolGroupSlot sets this @@ -784,10 +783,9 @@ function ToolRunTicker({ children }: { children: ReactNode }) { ) } -// The one grey line that stands in for a run of tool calls once it has -// settled — "Edited wiring.tsx, explored 3 files +6 −4". While the run is -// live the same line narrates it in the present tense and the rows stay -// visible below, so nothing the user is waiting on hides behind a chevron. +// The one grey line that stands in for a run of tool calls — "Explored 3 +// files, ran 5 commands". Live, it narrates in the present tense above the +// ticker and offers no toggle, since there is nothing settled to unfold yet. function ToolRunHeader({ live, onToggle, @@ -797,15 +795,14 @@ function ToolRunHeader({ live: boolean onToggle?: () => void open: boolean - summary: RunSummary + summary: string }) { return (
- {live ? {summary.text} : summary.text} + {live ? {summary} : summary} -
) @@ -817,7 +814,7 @@ interface ToolRunState { live: boolean /** A call still awaiting a result that could be the one blocking on approval. */ pendingApprovalTool: boolean - summary: RunSummary + summary: string } // assistant-ui compares selector results with `Object.is` and calls the @@ -830,6 +827,7 @@ function useToolRun(startIndex: number, endIndex: number): ToolRunState { return useAuiState(state => { 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 @@ -838,6 +836,7 @@ function useToolRun(startIndex: number, endIndex: number): ToolRunState { // live run deliberately withholds its toggle. const live = selectMessageRunning(state) && endIndex >= parts.length - 1 && tools.some(tool => tool.result === undefined) + const signature = tools .map(tool => `${tool.toolCallId}:${tool.result === undefined ? 0 : 1}`) .concat(String(live)) 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 be298640ce1a..b2d4e5ac90b7 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 @@ -6,61 +6,48 @@ function tool(toolName: string, args: Record = {}, result?: unk return { args, result, toolCallId: `${toolName}-${Math.random()}`, toolName } } -const edited = (path: string, diff = '') => tool('write_file', { path }, { path, inline_diff: diff }) const read = (path: string) => tool('read_file', { path }, { content: '' }) +const searched = (query: string) => tool('search_files', { query }, { hits: [] }) const ran = (command: string) => tool('terminal', { command }, { exit_code: 0 }) const settled = (tools: ToolCallLike[]) => summarizeToolRun(tools, false) const running = (tools: ToolCallLike[]) => summarizeToolRun(tools, true) +// A run only ever holds ephemeral activity: reads, searches, commands. File +// edits and other cards are split out before a run is summarized, so there is +// no "Edited …" clause to test here — that work shows as its own diff card. describe('summarizeToolRun', () => { - it('names a lone edit and counts the rest', () => { - expect(settled([edited('src/use-preview-routing.ts'), read('a.ts'), read('b.ts'), read('c.ts')]).text).toBe( - 'Edited use-preview-routing.ts, explored 3 files' + it('names a lone target and counts the rest', () => { + expect(settled([searched('toolRuns'), read('a.ts'), read('b.ts'), read('c.ts')])).toBe('Explored 4 files') + }) + + it('orders clauses explore then run regardless of call order', () => { + expect(settled([ran('ls'), read('a.ts'), read('b.ts'), ran('pwd'), ran('id')])).toBe( + 'Explored 2 files, ran 3 commands' ) }) - it('orders clauses edit, explore, run regardless of call order', () => { - expect( - settled([ran('ls'), read('a.ts'), edited('src/attachments.tsx'), read('b.ts'), ran('pwd'), ran('id')]).text - ).toBe('Edited attachments.tsx, explored 2 files, ran 3 commands') - }) - it('counts commands rather than naming them once they have run', () => { - expect(settled([ran('git status')]).text).toBe('Ran 1 command') - expect(settled([read('status.ts'), ran('a'), ran('b'), ran('c'), ran('d'), ran('e')]).text).toBe( + expect(settled([ran('git status')])).toBe('Ran 1 command') + expect(settled([read('status.ts'), ran('a'), ran('b'), ran('c'), ran('d'), ran('e')])).toBe( 'Explored status.ts, ran 5 commands' ) }) - it('counts a multi-file edit', () => { - expect(settled([edited('a.tsx'), edited('b.tsx'), read('c.ts')]).text).toBe('Edited 2 files, explored c.ts') - }) - it('puts the running category in the present tense and leaves the rest past', () => { - expect(running([edited('a.tsx'), tool('write_file', { path: 'b.tsx' }), ran('x'), ran('y')]).text).toBe( - 'Editing 2 files, ran 2 commands' + expect(running([read('a.ts'), tool('read_file', { path: 'b.ts' }), ran('x'), ran('y')])).toBe( + 'Exploring 2 files, ran 2 commands' ) }) it('names the command that is still running', () => { - expect(running([tool('terminal', { command: 'npm run typecheck' })]).text).toMatch(/^Running /) + expect(running([tool('terminal', { command: 'npm run typecheck' })])).toMatch(/^Running /) }) // 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. it('reads a run the turn left unresolved as finished', () => { - expect(settled([read('a.ts'), tool('search_files', { query: 'toolRuns' })]).text).toBe('Explored 2 files') - }) - - it('sums diff stats across the edits in the run', () => { - const summary = settled([edited('a.tsx', '--- a\n+++ b\n+one\n+two\n-old'), edited('b.tsx', '+three'), ran('ls')]) - - expect(summary).toMatchObject({ added: 3, removed: 1 }) - }) - - it('reports no diff stats for a run that changed nothing', () => { - expect(settled([read('a.ts'), ran('ls')])).toMatchObject({ added: 0, removed: 0 }) + expect(settled([read('a.ts'), tool('search_files', { query: 'toolRuns' })])).toBe('Explored 2 files') }) }) 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 a23970be4d94..7d7974ad10ba 100644 --- a/apps/desktop/src/components/assistant-ui/tool/run-summary.ts +++ b/apps/desktop/src/components/assistant-ui/tool/run-summary.ts @@ -1,13 +1,6 @@ import { summarizeShellCommand } from '@/lib/summarize-command' -import { - countDiffLineStats, - fileEditBasename, - firstStringField, - inlineDiffFromResult, - isFileEditTool, - parseMaybeObject -} from './fallback-model' +import { fileEditBasename, firstStringField, isFileEditTool, parseMaybeObject } from './fallback-model' /** * The little a summary needs from a tool call, stated structurally so both @@ -27,12 +20,6 @@ export function isToolCallPart(part: T): part is Ext type RunCategory = 'delegate' | 'edit' | 'explore' | 'other' | 'run' -export interface RunSummary { - added: number - removed: number - text: string -} - // Clause order is fixed so the same run always reads the same way, whichever // category happens to be live. const CATEGORY_ORDER: readonly RunCategory[] = ['edit', 'explore', 'run', 'delegate', 'other'] @@ -101,24 +88,6 @@ function toolTarget(tool: ToolCallLike): string { return path ? fileEditBasename(path) : firstStringField(args, ['query', 'url']) } -function diffStats(tools: readonly ToolCallLike[]): { added: number; removed: number } { - let added = 0 - let removed = 0 - - for (const tool of tools) { - if (!isFileEditTool(tool.toolName)) { - continue - } - - const stats = countDiffLineStats(inlineDiffFromResult(tool.result)) - - added += stats.added - removed += stats.removed - } - - return { added, removed } -} - /** * One clause per category. A category holding a single thing says what it was * ("Edited wiring.tsx"); anything else counts ("explored 3 files"). A settled @@ -143,16 +112,20 @@ function lowerFirst(text: string): string { /** * Collapse a run of tool calls into the single grey line that stands in for it - * — "Edited wiring.tsx, 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". 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. * * 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 * agent that moved on, and a run like that has to read as finished rather than * narrate work that stopped happening. + * + * A run only ever holds ephemeral activity — file edits and other cards are + * split out before this sees them (`splitRunItems`), so there is no aggregate + * diff to report here; each edit carries its own +N/−M on its card. */ -export function summarizeToolRun(tools: readonly ToolCallLike[], live: boolean): RunSummary { +export function summarizeToolRun(tools: readonly ToolCallLike[], live: boolean): string { const running = live ? tools.find(isPending) : undefined const liveCategory = running ? toolCategory(running.toolName) : null @@ -175,8 +148,5 @@ export function summarizeToolRun(tools: readonly ToolCallLike[], live: boolean): return group ? [clause(category, group, category === liveCategory)] : [] }) - return { - ...diffStats(tools), - text: clauses.map((text, index) => (index === 0 ? text : lowerFirst(text))).join(', ') - } + return clauses.map((text, index) => (index === 0 ? text : lowerFirst(text))).join(', ') } 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 75595a248fef..e94917226dee 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 @@ -184,8 +184,8 @@ function failedOnlyMessage(): ThreadMessage { } as ThreadMessage } -// Two settled tools in a row — an edit plus a read — so the run earns a -// summary line and collapses. +// Two settled activity calls in a row, so the run earns a summary line and +// collapses behind it. function settledRunMessage(): ThreadMessage { return { id: 'assistant-settled-run', @@ -193,7 +193,60 @@ function settledRunMessage(): ThreadMessage { content: [ { type: 'tool-call', - toolCallId: 'patch-1', + toolCallId: 'read-2', + toolName: 'read_file', + args: { path: '/repo/src/wiring.tsx' }, + argsText: JSON.stringify({ path: '/repo/src/wiring.tsx' }), + result: { content: 'export const Wiring = () => null' } + }, + { + type: 'tool-call', + toolCallId: 'term-3', + toolName: 'terminal', + args: { command: 'ls -la' }, + argsText: JSON.stringify({ command: 'ls -la' }), + result: { exit_code: 0, stdout: 'wiring.tsx' } + } + ], + status: { type: 'complete', reason: 'stop' }, + createdAt, + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: {} + } + } as ThreadMessage +} + +// Activity, an edit, then more activity — all adjacent, so assistant-ui hands +// the whole stretch over as one group. The edit is the deliverable and has to +// survive that as its own card. +function editBetweenRunsMessage(): ThreadMessage { + return { + id: 'assistant-edit-between-runs', + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'read-5', + toolName: 'read_file', + args: { path: '/repo/src/a.ts' }, + argsText: JSON.stringify({ path: '/repo/src/a.ts' }), + result: { content: 'a' } + }, + { + type: 'tool-call', + toolCallId: 'search-3', + toolName: 'search_files', + args: { query: 'toolRuns' }, + argsText: JSON.stringify({ query: 'toolRuns' }), + result: { hits: [] } + }, + { + type: 'tool-call', + toolCallId: 'patch-2', toolName: 'patch', args: { path: '/repo/src/wiring.tsx' }, argsText: JSON.stringify({ path: '/repo/src/wiring.tsx' }), @@ -201,11 +254,19 @@ function settledRunMessage(): ThreadMessage { }, { type: 'tool-call', - toolCallId: 'read-2', + toolCallId: 'read-6', toolName: 'read_file', - args: { path: '/repo/src/status.tsx' }, - argsText: JSON.stringify({ path: '/repo/src/status.tsx' }), - result: { content: 'export const Status = () => null' } + args: { path: '/repo/src/b.ts' }, + argsText: JSON.stringify({ path: '/repo/src/b.ts' }), + result: { content: 'b' } + }, + { + type: 'tool-call', + toolCallId: 'term-4', + toolName: 'terminal', + args: { command: 'ls' }, + argsText: JSON.stringify({ command: 'ls' }), + result: { exit_code: 0 } } ], status: { type: 'complete', reason: 'stop' }, @@ -326,18 +387,17 @@ afterEach(() => { }) describe('settled tool run', () => { - it('collapses to a summary line naming the work and its diff', async () => { + it('collapses to a summary line naming the work', async () => { const { container } = render() - expect(await screen.findByText('Edited wiring.tsx, explored status.tsx')).toBeTruthy() + expect(await screen.findByText('Explored wiring.tsx, ran 1 command')).toBeTruthy() expect(container.querySelectorAll('[data-tool-row]')).toHaveLength(0) - expect(screen.getByText('1', { selector: '.text-\\(--ui-green\\) *' })).toBeTruthy() }) it('expands to the underlying rows when the summary is clicked', async () => { const { container } = render() - fireEvent.click(await screen.findByText('Edited wiring.tsx, explored status.tsx')) + fireEvent.click(await screen.findByText('Explored wiring.tsx, ran 1 command')) await waitFor(() => { expect(container.querySelectorAll('[data-tool-row]').length).toBeGreaterThan(0) @@ -354,6 +414,31 @@ describe('settled tool run', () => { }) }) +// A diff is what the user reviews, so it is never what gets summarized away. +// It stays on screen at the point in the turn where it happened, with the +// activity either side of it collapsing around it. +describe('a file edit among ordinary activity', () => { + it('stays visible between the two runs it interrupted', async () => { + const { container } = render() + + await screen.findByText('Explored 2 files') + + const shape = [...container.querySelectorAll('[data-tool-summary],[data-tool-row]')].map(node => + node.hasAttribute('data-tool-summary') ? 'summary' : 'row' + ) + + expect(shape).toEqual(['summary', 'row', 'summary']) + }) + + it('keeps the diff itself on screen rather than behind the summary', async () => { + const { container } = render() + + await waitFor(() => { + expect(container.querySelector('[data-tool-row][data-file-edit]')).not.toBeNull() + }) + }) +}) + describe('live tool run', () => { it('keeps its rows on screen instead of hiding them behind the summary', async () => { const { container } = render() @@ -425,7 +510,7 @@ describe('flat tool list approval surfacing', () => { const dismiss = await screen.findByLabelText('Dismiss') - expect(container.querySelectorAll('[data-slot="tool-block"]').length).toBeGreaterThan(1) + expect(container.querySelectorAll('[data-slot="tool-block"]').length).toBeGreaterThan(0) fireEvent.click(dismiss) @@ -449,13 +534,13 @@ describe('flat tool list approval surfacing', () => { first.unmount() - const { container } = render() + render() + // The row is the only thing this message renders, so staying dismissed + // means nothing comes back — including its dismiss control. await waitFor(() => { - expect(container.querySelectorAll('[data-slot="tool-block"]').length).toBeGreaterThan(0) + expect(screen.queryByLabelText('Dismiss')).toBeNull() }) - - expect(screen.queryByLabelText('Dismiss')).toBeNull() }) it('lets failed tool rows be dismissed', async () => { diff --git a/apps/desktop/src/components/chat/activity-timer.test.tsx b/apps/desktop/src/components/chat/activity-timer.test.tsx index 4768f60c56ef..02be87985684 100644 --- a/apps/desktop/src/components/chat/activity-timer.test.tsx +++ b/apps/desktop/src/components/chat/activity-timer.test.tsx @@ -1,7 +1,7 @@ import { act, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { __resetElapsedTimerRegistryForTests, useElapsedSeconds } from './activity-timer' +import { __resetElapsedTimerRegistryForTests, useElapsedSeconds, useMeasuredDuration } from './activity-timer' function Probe({ active, since, timerKey }: { active: boolean; since?: number; timerKey?: string }) { const elapsed = useElapsedSeconds(active, timerKey, since) @@ -9,6 +9,12 @@ function Probe({ active, since, timerKey }: { active: boolean; since?: number; t return {elapsed} } +function DurationProbe({ active, timerKey }: { active: boolean; timerKey: string }) { + const measured = useMeasuredDuration(active, timerKey) + + return {measured === null ? 'unknown' : measured} +} + describe('useElapsedSeconds', () => { beforeEach(() => { vi.useFakeTimers() @@ -67,3 +73,84 @@ describe('useElapsedSeconds', () => { expect(screen.getByTestId('elapsed').textContent).toBe('0') }) }) + +describe('useMeasuredDuration', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')) + __resetElapsedTimerRegistryForTests() + }) + + afterEach(() => { + vi.useRealTimers() + __resetElapsedTimerRegistryForTests() + }) + + it('has nothing to report until it has watched something finish', () => { + render() + + act(() => { + vi.advanceTimersByTime(4_000) + }) + + expect(screen.getByTestId('measured').textContent).toBe('unknown') + }) + + it('freezes the duration at the moment the thing finishes', () => { + const { rerender } = render() + + act(() => { + vi.advanceTimersByTime(4_000) + }) + + rerender() + + expect(screen.getByTestId('measured').textContent).toBe('4') + + // Time keeps passing; the block is over and its duration must not creep up + // with it. + act(() => { + vi.advanceTimersByTime(9_000) + }) + + expect(screen.getByTestId('measured').textContent).toBe('4') + }) + + // The thread virtualizes, so the component that watched a block finish is + // usually gone by the time anyone scrolls back to read it. + it('remembers the duration for a component that mounts after the fact', () => { + const first = render() + + act(() => { + vi.advanceTimersByTime(6_000) + }) + + first.rerender() + first.unmount() + + render() + + expect(screen.getByTestId('measured').textContent).toBe('6') + }) + + it('measures each key separately', () => { + const first = render() + + act(() => { + vi.advanceTimersByTime(3_000) + }) + + first.rerender() + first.unmount() + + const second = render() + + act(() => { + vi.advanceTimersByTime(2_000) + }) + + second.rerender() + + expect(screen.getByTestId('measured').textContent).toBe('2') + }) +}) diff --git a/apps/desktop/src/components/chat/activity-timer.ts b/apps/desktop/src/components/chat/activity-timer.ts index 9fe67642239d..6eddba167fbf 100644 --- a/apps/desktop/src/components/chat/activity-timer.ts +++ b/apps/desktop/src/components/chat/activity-timer.ts @@ -5,6 +5,10 @@ import { useEffect, useRef, useState } from 'react' // anonymous timers (no key) start fresh each mount. const startedAtByKey = new Map() +// Durations of things that have already finished, kept beside the origins that +// measured them. See `useMeasuredDuration`. +const durationByKey = new Map() + function startedAt(key?: string): number { if (!key) { return Date.now() @@ -71,6 +75,41 @@ export function useElapsedSeconds(active = true, timerKey?: string, since?: numb return elapsed } +/** + * How long something took, measured by watching it finish and remembered + * afterwards. `null` until it has been watched at least once. + * + * Some durations exist nowhere but in the watching. A reasoning block is the + * case this was written for: the persisted turn records the text the model + * thought, never how long it spent thinking it, so the only way to know is to + * have been there. Watching alone isn't enough either — the thread virtualizes, + * so the component that saw a block finish is usually gone by the time anyone + * scrolls back to read it. Keeping the number in the same registry as the + * timer's origin lets it outlive the component that measured it. + * + * A block that was never watched running — history loaded from an earlier app + * session, or reasoning that arrived already complete — has no duration and + * says so, rather than reporting a timer that never ran. + */ +export function useMeasuredDuration(active: boolean, timerKey: string): null | number { + const elapsed = useElapsedSeconds(active, timerKey) + const [watching, setWatching] = useState(false) + const [measured, setMeasured] = useState(() => durationByKey.get(timerKey) ?? null) + + useEffect(() => { + if (active) { + setWatching(true) + } else if (watching) { + setWatching(false) + durationByKey.set(timerKey, elapsed) + setMeasured(elapsed) + } + }, [active, elapsed, timerKey, watching]) + + return measured +} + export function __resetElapsedTimerRegistryForTests() { startedAtByKey.clear() + durationByKey.clear() } From a67b2d93cd86ada0f3433f6fa4105d06d67d9d0d Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Jul 2026 19:07:24 -0500 Subject: [PATCH 09/14] fix(desktop): paint the live status line as scaffolding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The drafting/stall status row kept its own type and colour — text-sm at muted-foreground/70, with the hint at /55 — so "Editing" while the model drafts a call rendered a full step larger than the "Explored 3 files" line it turns into a moment later, in a different grey. Route it through the scaffold label token so the whole left column reads as one kind of line. The timer keeps its midground tint: that belongs to the live-signal cluster with the dither block, not to the scaffolding. --- .../components/assistant-ui/thread/status.tsx | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/components/assistant-ui/thread/status.tsx b/apps/desktop/src/components/assistant-ui/thread/status.tsx index e5c29e0bfe4e..c0e7a3a3cc85 100644 --- a/apps/desktop/src/components/assistant-ui/thread/status.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/status.tsx @@ -6,6 +6,7 @@ import { useSessionView } from '@/app/chat/session-view' import { toolPresentVerb } from '@/components/assistant-ui/tool/run-summary' import { useElapsedSeconds } from '@/components/chat/activity-timer' import { ActivityTimerText } from '@/components/chat/activity-timer-text' +import { SCAFFOLD_LABEL_CLASS } from '@/components/chat/scaffold-row' import { Codicon } from '@/components/ui/codicon' import { Loader } from '@/components/ui/loader' import { useI18n } from '@/i18n' @@ -16,6 +17,9 @@ import { sessionAwaitingInput } from '@/store/prompts' 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 +// drafts a call is the same kind of line as "Explored 3 files" once it has run, +// and reads as one continuous column only if it shares their type and colour. const StatusRow: FC<{ children: ReactNode; label: string } & React.ComponentPropsWithoutRef<'div'>> = ({ children, label, @@ -25,7 +29,11 @@ const StatusRow: FC<{ children: ReactNode; label: string } & React.ComponentProp
@@ -37,7 +45,7 @@ const StatusRow: FC<{ children: ReactNode; label: string } & React.ComponentProp const COMPACTION_LABEL = 'Summarizing thread' const HintText: FC<{ children: ReactNode }> = ({ children }) => ( - {children} + {children} ) /** These indicators render inside whichever transcript mounted them, so every @@ -120,11 +128,7 @@ export const ResponseLoadingIndicator: FC = () => { const hint = useStatusHint(compacting, drafting) return ( - + From 0b0e53cee19945df8811ac3c4630e2e4a1891657 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Jul 2026 19:34:54 -0500 Subject: [PATCH 11/14] fix(desktop): retire the drafting label when the model moves on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tool.generating` names the tool whose arguments are streaming, and nothing ever closed that claim: there is no stop-drafting event, and a draft can be abandoned without reaching `tool.start` when a mid-stream retry drops a partial call or a guardrail blocks the tool. Enumerating the ways a draft ends left those holes open, so "Editing" sat under the transcript for the rest of a multi-iteration turn. Invert the rule — the claim only covers what the model is emitting right now, so any other output from that session retires it. Stopping the turn clears it too, and a `tool.generating` that arrives after the stop is ignored on the same condition `mutateStream` already drops late tool rows. --- .../src/app/chat/session-tile-actions.ts | 2 + .../hooks/use-message-stream/gateway-event.ts | 37 +++++- .../tool-drafting-event.test.tsx | 124 ++++++++++++++++++ .../session/hooks/use-prompt-actions/index.ts | 2 + 4 files changed, 159 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/src/app/session/hooks/use-message-stream/tool-drafting-event.test.tsx diff --git a/apps/desktop/src/app/chat/session-tile-actions.ts b/apps/desktop/src/app/chat/session-tile-actions.ts index 912878e53b03..469691384598 100644 --- a/apps/desktop/src/app/chat/session-tile-actions.ts +++ b/apps/desktop/src/app/chat/session-tile-actions.ts @@ -29,6 +29,7 @@ import { $sessionStates, sessionTileDelegate } from '@/store/session-states' import { broadcastSessionsChanged } from '@/store/session-sync' import { clearSessionSubagents } from '@/store/subagents' import { clearSessionTodos } from '@/store/todos' +import { setSessionDraftingTool } from '@/store/tool-drafting' import type { SessionInfo } from '@/types/hermes' import { uploadComposerAttachment } from '../session/hooks/use-prompt-actions' @@ -253,6 +254,7 @@ export function useSessionTileActions({ runtimeId, scope, storedSessionId }: Ses clearSessionTodos(sessionId) clearSessionSubagents(sessionId) resetSessionBackground(sessionId) + setSessionDraftingTool(sessionId, '') clearAllPrompts(sessionId) clearClarifyRequest(undefined, sessionId) diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts index 04aa94522a00..500540b6ccfb 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts @@ -106,6 +106,28 @@ function surfaceBillingBlock(sessionId: string, raw: unknown): void { }) } +/** + * Events that retire a "drafting a tool call" claim. + * + * `tool.generating` opens the claim and nothing closes it — a draft can be + * abandoned without ever reaching `tool.start`, so enumerating the ways one + * *ends* left the label on screen for the rest of the turn. Inverted: the + * claim only covers what the model is emitting right now, and any other output + * from the session proves it moved on. Same rule the TUI applies to its + * transient trail lines (`turnController.pruneTransient`). + */ +const DRAFT_SUPERSEDING_EVENT_TYPES = new Set([ + 'error', + 'message.complete', + 'message.delta', + 'message.start', + 'reasoning.delta', + 'thinking.delta', + 'tool.complete', + 'tool.progress', + 'tool.start' +]) + const COMPACTION_RESUME_EVENT_TYPES = new Set([ 'message.delta', 'message.interim', @@ -244,6 +266,10 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { setSessionCompacting(sessionId, false) } + if (sessionId && DRAFT_SUPERSEDING_EVENT_TYPES.has(event.type)) { + setSessionDraftingTool(sessionId, '') + } + if (event.type === 'gateway.ready') { // Seed the active skin into the desktop theme registry without applying, // so a fresh connect never overrides the user's persisted desktop theme. @@ -442,7 +468,6 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { flushQueuedDeltas(sessionId) pruneFinishedSessionSubagents(sessionId) setSessionCompacting(sessionId, false) - setSessionDraftingTool(sessionId, '') compactedTurnRef.current.delete(sessionId) nativeSubagentSessionsRef.current.delete(sessionId) // A fresh turn on this session optimistically clears its billing wall; @@ -613,7 +638,6 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { // last item stuck pending/in_progress. Finished lists keep their linger. clearActiveSessionTodos(sessionId) setSessionCompacting(sessionId, false) - setSessionDraftingTool(sessionId, '') flushQueuedDeltas(sessionId) @@ -686,7 +710,11 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { // from it strands an argless placeholder whenever the bubble is sealed // before the real `tool.start` arrives, because the two can no longer be // reconciled across the boundary. It's a status, so say it as one. - if (!sessionId) { + // A stopped turn can still emit a frame or two before the backend + // notices, and naming a tool we will never run leaves the label up + // until something else retires it. `mutateStream` drops late tool rows + // on the same condition; the status line has to agree with it. + if (!sessionId || sessionInterrupted(sessionId)) { return } @@ -701,7 +729,6 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { } flushQueuedDeltas(sessionId) - setSessionDraftingTool(sessionId, '') upsertToolCall(sessionId, toTodoPayload(payload) ?? payload, 'running', event.type) if (isActiveEvent) { @@ -710,7 +737,6 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { } else if (event.type === 'tool.complete') { if (sessionId) { flushQueuedDeltas(sessionId) - setSessionDraftingTool(sessionId, '') upsertToolCall(sessionId, toTodoPayload(payload) ?? payload, 'complete', event.type) if (isActiveEvent) { @@ -1002,7 +1028,6 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { clearClarifyRequest(undefined, sessionId) clearActiveSessionTodos(sessionId) setSessionCompacting(sessionId, false) - setSessionDraftingTool(sessionId, '') compactedTurnRef.current.delete(sessionId) } diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/tool-drafting-event.test.tsx b/apps/desktop/src/app/session/hooks/use-message-stream/tool-drafting-event.test.tsx new file mode 100644 index 000000000000..57d5c5b1ced0 --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-message-stream/tool-drafting-event.test.tsx @@ -0,0 +1,124 @@ +import { QueryClient } from '@tanstack/react-query' +import { act, cleanup, render, waitFor } from '@testing-library/react' +import { useEffect, useRef } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { ClientSessionState } from '@/app/types' +import { createClientSessionState } from '@/lib/chat-runtime' +import { $draftingToolSessions } from '@/store/tool-drafting' +import type { RpcEvent } from '@/types/hermes' + +import { useMessageStream } from './index' + +const SID = 'session-1' +const OTHER_SID = 'session-2' + +// Module-scoped so a test can seed session state (e.g. interrupted) before the +// handler reads it — `sessionInterrupted` resolves against this map. +const sessionStates = new Map() +let handleEvent: ((event: RpcEvent) => void) | null = null + +function Harness() { + const activeSessionIdRef = useRef(SID) + const sessionStateByRuntimeIdRef = useRef(sessionStates) + const queryClientRef = useRef(new QueryClient()) + + const stream = useMessageStream({ + activeSessionIdRef, + hydrateFromStoredSession: vi.fn(async () => undefined), + queryClient: queryClientRef.current, + refreshHermesConfig: vi.fn(async () => undefined), + refreshSessions: vi.fn(async () => undefined), + sessionStateByRuntimeIdRef, + updateSessionState: (sessionId, updater) => { + const next = updater(sessionStates.get(sessionId) ?? createClientSessionState()) + sessionStates.set(sessionId, next) + + return next + } + }) + + useEffect(() => { + handleEvent = stream.handleGatewayEvent + }, [stream.handleGatewayEvent]) + + return null +} + +async function mountStream() { + render() + await waitFor(() => expect(handleEvent).not.toBeNull()) +} + +function emit(type: RpcEvent['type'], payload: RpcEvent['payload'] = {}, sessionId = SID) { + act(() => handleEvent!({ payload, session_id: sessionId, type })) +} + +function draftedTool(sessionId = SID) { + return $draftingToolSessions.get()[sessionId]?.name +} + +describe('drafting-tool label lifecycle', () => { + beforeEach(() => { + handleEvent = null + sessionStates.clear() + $draftingToolSessions.set({}) + }) + + afterEach(() => { + cleanup() + sessionStates.clear() + $draftingToolSessions.set({}) + vi.restoreAllMocks() + }) + + it('names the tool the model is drafting', async () => { + await mountStream() + + emit('tool.generating', { name: 'write_file' }) + + expect(draftedTool()).toBe('write_file') + }) + + // The label used to be retired only by the events that mean "this tool ran". + // A tool can be abandoned without ever reaching `tool.start` — a mid-stream + // retry drops a partial call, a guardrail-blocked tool skips the lifecycle + // callbacks — and the name then sat on screen for the rest of the turn. + it.each([ + ['message.delta', { text: 'never mind' }], + ['reasoning.delta', { text: 'reconsidering' }], + ['thinking.delta', { text: 'reconsidering' }], + ['tool.start', { name: 'terminal', tool_id: 'tool-1' }], + ['tool.complete', { name: 'terminal', tool_id: 'tool-1' }], + ['message.complete', { text: 'done' }], + ['error', { message: 'boom' }] + ] as const)('retires the label when %s proves the model moved on', async (type, payload) => { + await mountStream() + emit('tool.generating', { name: 'write_file' }) + + emit(type, payload) + + expect(draftedTool()).toBeUndefined() + }) + + it('leaves another session’s label alone', async () => { + await mountStream() + emit('tool.generating', { name: 'patch' }, OTHER_SID) + emit('tool.generating', { name: 'write_file' }) + + emit('message.delta', { text: 'moving on' }) + + expect(draftedTool()).toBeUndefined() + expect(draftedTool(OTHER_SID)).toBe('patch') + }) + + // A stopped turn can still emit a frame or two before the backend notices. + it('ignores a tool announced after the user hit stop', async () => { + sessionStates.set(SID, { ...createClientSessionState(), interrupted: true }) + await mountStream() + + emit('tool.generating', { name: 'write_file' }) + + expect(draftedTool()).toBeUndefined() + }) +}) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts index bee6192894cd..681ef8d4199b 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts @@ -33,6 +33,7 @@ import { } from '@/store/session' import { clearSessionSubagents } from '@/store/subagents' import { clearSessionTodos } from '@/store/todos' +import { setSessionDraftingTool } from '@/store/tool-drafting' import type { ClientSessionState, @@ -592,6 +593,7 @@ export function usePromptActions({ clearSessionTodos(sessionId) clearSessionSubagents(sessionId) resetSessionBackground(sessionId) + setSessionDraftingTool(sessionId, '') // Stop ends the turn, so the gateway is no longer blocked on any prompt it // raised. Drop this session's pending clarify / approval / sudo / secret so // a dead panel (and the sidebar "needs input" dot) can't linger and accept From 46df6ca70521bfad886699ee4857524a6d22720b Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Jul 2026 19:35:04 -0500 Subject: [PATCH 12/14] 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 c0e7a3a3cc85..dba6f0b46bab 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 9b2b806b5128..3cb3445c0894 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 b2d4e5ac90b7..ba47368a031f 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 7d7974ad10ba..6abf559a33f3 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 e94917226dee..9ed31b76ae43 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 From 7a10e48e2ffa38d9eb322d11edabe5a40446f18f Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Jul 2026 19:35:04 -0500 Subject: [PATCH 13/14] fix(desktop): one weight and one gap for transcript scaffolding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scaffold rows shared a colour but not a weight: tool summaries and the ticker rendered medium against the reply's normal-weight prose, which read as emphasis on the quietest lines in the column. Spacing had the matching problem. The block-gap rule listed which *pairs* of blocks qualified, so the live status line — neither tool, thinking, nor prose — fell through every branch onto its own half-size margin. And because a streaming turn is sealed into several bubbles as it goes but rehydrates into fewer, two blocks are siblings inside one bubble or split across two depending on when you look; the flex gap between bubbles is half the block gap, so the rhythm tightened and relaxed as a turn settled. Cover every top-level block with one rule, keep prose-to-prose on paragraph rhythm, and top the between-bubble gap up to match. --- .../components/assistant-ui/thread/status.tsx | 2 +- .../src/components/chat/scaffold-row.tsx | 2 +- apps/desktop/src/styles.css | 29 ++++++++++++------- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/components/assistant-ui/thread/status.tsx b/apps/desktop/src/components/assistant-ui/thread/status.tsx index dba6f0b46bab..62ad0940f75f 100644 --- a/apps/desktop/src/components/assistant-ui/thread/status.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/status.tsx @@ -234,7 +234,7 @@ export const StreamStallIndicator: FC = () => { } return ( - +