From 641a7f36d98af01d9ebf53ceceae6a3fe0ba2b10 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 10:49:04 -0700 Subject: [PATCH] fix(native-chat): keep one live tool-run header from a call's start to the turn's end (#22432) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(native-chat): keep one live tool-run header from a call's start to the turn's end The collapsed tool run's header was two elements, one for "a call is running" and one for "nothing is", chosen call by call. Every call start and end remounted it, the count disappeared while a call ran and came back one higher, and a call that finished inside a frame still bought the whole swap. That is the 42→43 flicker in the report. The header is now one element whose live state belongs to the turn, not to any call: it stays live from the run's first call until the agent moves past it (prose, a further run, or the turn's end), and settles in place. While live the sentence speaks in the present tense and counts the call in flight ("Running 3 commands"), with the latest call's command beside it as a muted preview; once settled it reads as before ("Ran 3 commands ✓"). The category glyph is the run's in both states, and the completion mark only appears once settled, so nothing pops between calls. Which run is live is derived where the transcript is sliced into rows: the last row that speaks or acts is the trailing one. A reasoning aside after it leaves it live; an answer or a further run settles it. Present-tense forms for the ten sentence categories are added to the shared copy and the English catalog. The transcript-file lane, which renders with the structured activity UI off, is unchanged. * fix(native-chat): settle a run blocked on the reader, keep it live past an approval - A run whose question is awaiting the reader's answer no longer pulses "Reading 1 file" while the agent is blocked; it falls back to its calls. - An approval's receipt no longer moves past the run above it, so the call it just approved reads as running while it runs. - The header button is the live region, so the count is announced too. - Drop the unused live option and record from the shared English sentence; nothing renders it yet. * fix(native-chat): stop the settled run's check from fading in on every mount Windowing remounts settled rows as the reader scrolls, and a restored transcript mounts them all at once, so the fade replayed where nothing had changed. Also pin that the live header counts the next call on the same element. --- .../NativeChatMessageList.test.tsx | 53 ++++++++- ...iveChatMessageList.turn-indicator.test.tsx | 26 +++-- .../native-chat/NativeChatMessageRow.tsx | 4 + .../NativeChatToolRun.ask-row.test.tsx | 7 +- .../native-chat/NativeChatToolRun.test.tsx | 104 +++++++++++++---- .../native-chat/NativeChatToolRun.tsx | 85 ++++++++------ .../native-chat/NativeChatTranscriptRow.tsx | 1 + .../native-chat-tool-activity-label.ts | 20 ---- .../native-chat/native-chat-tool-run-label.ts | 107 +++++++++++++++++- .../native-chat-transcript-slots.test.ts | 50 ++++++++ .../native-chat-transcript-slots.ts | 17 +++ ...ve-chat-transcript-window.options.test.tsx | 1 + src/renderer/src/i18n/locales/en.json | 22 +++- src/shared/native-chat-tool-activity.ts | 17 ++- 14 files changed, 419 insertions(+), 95 deletions(-) delete mode 100644 src/renderer/src/components/native-chat/native-chat-tool-activity-label.ts diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.test.tsx index 426493ee3ba..19a3eff4d09 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.test.tsx @@ -90,11 +90,59 @@ describe('NativeChatMessageList assistant messages', () => { /> ) - expect(screen.getByText('Running sleep 5')).toBeInTheDocument() + expect(screen.getByText('Running 1 command')).toBeInTheDocument() + expect(screen.getByText('sleep 5')).toBeInTheDocument() expect(screen.queryByText('1×')).toBeNull() expect(document.querySelector('.text-destructive')).toBeNull() }) + // Only the turn's trailing run is live. Once the agent has said something + // after it, that run is done whatever its last call still reports; a + // reasoning aside is not "after it" — the agent is still inside the batch. + it('settles a run once prose follows it, but not for a reasoning aside', () => { + const run = { + id: 'assistant-tool-1', + role: 'assistant' as const, + blocks: [ + { + type: 'tool-call' as const, + name: 'shell', + input: { command: 'sleep 5' }, + state: 'running' as const + } + ], + timestamp: 1, + source: 'transcript' as const + } + const after = (role: 'assistant' | 'reasoning') => ({ + id: `after-${role}`, + role, + blocks: [{ type: 'text' as const, text: 'Looking at the output.' }], + timestamp: 2, + source: 'transcript' as const + }) + const { rerender } = render( + + ) + expect(screen.getByText('Running 1 command')).toBeInTheDocument() + + rerender( + + ) + expect(screen.queryByText('Running 1 command')).toBeNull() + expect(screen.getByText('sleep 5')).toBeInTheDocument() + }) + it('keeps the current tool live when a stale completed lifecycle meets active hook state', () => { render( { /> ) - expect(screen.getByText('Running sleep 5')).toBeInTheDocument() + expect(screen.getByText('Running 1 command')).toBeInTheDocument() + expect(screen.getByText('sleep 5')).toBeInTheDocument() }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.turn-indicator.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-indicator.test.tsx index 061076aa967..0b0aa932a2d 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.turn-indicator.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-indicator.test.tsx @@ -126,9 +126,10 @@ describe('NativeChatMessageList turn indicator', () => { /> ) - const toolLabel = screen.getByText('Running pnpm test') + const toolLabel = screen.getByText('Running 1 command') expect(toolLabel).toHaveClass('animate-pulse') - expect(screen.getAllByText('Running pnpm test')).toHaveLength(1) + expect(screen.getAllByText('Running 1 command')).toHaveLength(1) + expect(screen.getByText('pnpm test')).toBeInTheDocument() const activity = screen.getByText('Working for 0s') expect(activity.textContent).not.toBe(toolLabel.textContent) expect(activity).not.toHaveTextContent('shell') @@ -172,7 +173,7 @@ describe('NativeChatMessageList turn indicator', () => { expect(container.querySelector('[data-native-chat-turn-activity]')).toBeNull() expect(screen.queryByText(/Working for/)).toBeNull() expect(screen.queryByText('Thinking')).toBeNull() - expect(screen.getByText('Running pnpm test')).toHaveClass('animate-pulse') + expect(screen.getByText('Running 1 command')).toHaveClass('animate-pulse') }) it('keeps the live row up after a tool settles', () => { @@ -217,7 +218,10 @@ describe('NativeChatMessageList turn indicator', () => { ) }) - it('keeps a completed tool row static while the turn tail spins, then removes the tail', () => { + // The run is the turn's trailing one, so it stays live between calls and only + // settles with the turn. Its motion is its own — the tail's spinner never + // migrates onto it — and both are gone once the turn is. + it('keeps the trailing run live while the turn tail spins, then settles both', () => { const workingSession: NativeChatLiveSession = { ...session, status: 'working', @@ -249,10 +253,11 @@ describe('NativeChatMessageList turn indicator', () => { /> ) - const settledTool = screen.getByText('pnpm test') - expect(settledTool).toHaveTextContent('pnpm test') - expect(settledTool.closest('button')?.querySelector('.animate-pulse')).toBeNull() - expect(settledTool.closest('button')?.querySelector('.lucide-check')).toBeInTheDocument() + const liveRun = screen.getByText('Running 1 command').closest('button') + expect(liveRun).toHaveTextContent('pnpm test') + expect(liveRun?.querySelector('.animate-pulse')).toBeInTheDocument() + expect(liveRun?.querySelector('.animate-spin')).toBeNull() + expect(liveRun?.querySelector('.lucide-check')).toBeNull() const activity = screen.getByText('Preparing the answer') expect(activity).not.toHaveClass('animate-pulse', 'animate-spin') expect(activity.closest('[data-native-chat-turn-activity]')?.querySelector('svg')).toHaveClass( @@ -272,6 +277,11 @@ describe('NativeChatMessageList turn indicator', () => { expect(container.querySelector('[data-native-chat-turn-activity]')).toBeNull() expect(container.querySelector('.animate-pulse')).toBeNull() expect(container.querySelector('.animate-spin')).toBeNull() + // Same element, now settled: the lone command names it, marked done. + expect(liveRun).toBeInTheDocument() + expect(liveRun).toHaveTextContent('pnpm test') + expect(liveRun).not.toHaveTextContent('Running') + expect(liveRun?.querySelector('.lucide-check')).toBeInTheDocument() }) it('keeps bridge chats on the legacy activity chrome', () => { diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx index 3388fa79082..3d82417e8ce 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx @@ -34,6 +34,7 @@ export const MessageRow = memo(function MessageRow({ revealedDiff, expandSignal, activeTurnIsWorking, + trailingRun, onScrollMessageToTop, onLinkClick, allowFileUriLinks = false, @@ -48,6 +49,8 @@ export const MessageRow = memo(function MessageRow({ revealedDiff?: NativeChatDiffReveal expandSignal: boolean activeTurnIsWorking?: boolean + /** This row's tool run is the turn's last, so it is the one still live. */ + trailingRun?: boolean /** Align this message's top to the top of the scroll viewport. */ onScrollMessageToTop: (el: HTMLElement) => void onLinkClick?: CommentMarkdownLinkClickHandler @@ -214,6 +217,7 @@ export const MessageRow = memo(function MessageRow({ backgroundTasks={backgroundTasks} expandSignal={expandSignal} activeTurnIsWorking={activeTurnIsWorking} + trailing={trailingRun} structuredActivityUi={structuredActivityUi} disclosureId={message.id} /> diff --git a/src/renderer/src/components/native-chat/NativeChatToolRun.ask-row.test.tsx b/src/renderer/src/components/native-chat/NativeChatToolRun.ask-row.test.tsx index 6efe920c20e..4de5a0d214b 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolRun.ask-row.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolRun.ask-row.test.tsx @@ -37,7 +37,8 @@ describe('NativeChatToolRun awaiting-input row', () => { /> ) expect(screen.getByText('Awaiting user input:')).toBeInTheDocument() - expect(screen.getByText(/Running Read/)).toBeInTheDocument() + expect(screen.getByText('Reading 1 file')).toBeInTheDocument() + expect(screen.getByText('Read a.ts')).toBeInTheDocument() }) it('preserves errors from failed question calls', () => { @@ -91,8 +92,10 @@ describe('NativeChatToolRun awaiting-input row', () => { render() expect(screen.getByText('Awaiting user input:')).toBeInTheDocument() - // One call ran; being asked a question is not work to summarize. + // One call ran; being asked a question is not work to summarize. The agent + // is blocked on the reader, so the run reads settled, not in progress. expect(screen.getByText('Read 1 file')).toBeInTheDocument() + expect(screen.queryByText('Reading 1 file')).toBeNull() }) it('draws the row from the tool name when the payload names no question', () => { diff --git a/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx b/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx index c491c3d6ad4..f40a4963f25 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx @@ -379,12 +379,18 @@ describe('NativeChatToolRun', () => { const { container } = render() - const activeLabel = screen.getByText('Running cat package.json') - expect(activeLabel).toBeInTheDocument() - expect(activeLabel).toHaveClass('animate-pulse', 'motion-reduce:animate-none') - expect(screen.queryByText('Running date')).toBeNull() - expect(screen.queryByText('Running pwd')).toBeNull() - expect(screen.queryByText('Ran 3 commands and used 1 tool')).toBeNull() + // The sentence counts the call in flight and speaks in the present; the + // latest call sits beside it. Earlier calls are one click away, not here. + const header = runHeader(container) + expect(header).toHaveTextContent('Running 3 commands') + expect(header).toHaveTextContent('cat package.json') + expect(header).not.toHaveTextContent('date') + expect(header).not.toHaveTextContent('pwd') + expect(screen.getByText('Running 3 commands')).toHaveClass( + 'animate-pulse', + 'motion-reduce:animate-none' + ) + expect(header.querySelector('.lucide-check')).toBeNull() expect(container.querySelector('.animate-spin')).toBeNull() }) @@ -397,7 +403,8 @@ describe('NativeChatToolRun', () => { /> ) - expect(screen.getByText('Running sleep 5')).toBeInTheDocument() + expect(screen.getByText('Running 1 command')).toBeInTheDocument() + expect(screen.getByText('sleep 5')).toBeInTheDocument() }) it('keeps a completed tool payload collapsed until the run is expanded', () => { @@ -415,28 +422,77 @@ describe('NativeChatToolRun', () => { expect(screen.queryByText('hello')).toBeNull() }) - it('replaces the live row with a compact result when the active call settles', () => { - const runningBlocks: NativeChatBlock[] = [ + // The reported defect: the header was two elements, one per state, chosen by + // whether a call was mid-flight. Every call start and end remounted it, the + // count vanished while a call ran, and a call that finished inside a frame + // still bought the whole swap. Live is the turn's state, and the header is one + // element from the first call to the turn's end. + it('keeps one header element from a call starting until its turn ends', () => { + const running: NativeChatBlock[] = [ { type: 'tool-call', name: 'shell', input: { command: 'sleep 1' }, state: 'running' } ] + const settled: NativeChatBlock[] = [ + { type: 'tool-call', name: 'shell', input: { command: 'sleep 1' }, state: 'completed' }, + { type: 'tool-result', output: 'done' } + ] const { rerender, container } = render( - + ) + const header = runHeader(container) + expect(header).toHaveAttribute('data-native-chat-tool-run-state', 'live') + expect(header).toHaveTextContent('Running 1 command') + expect(header).toHaveTextContent('sleep 1') - expect(screen.getByText('Running sleep 1')).toBeInTheDocument() + // The call settles; the turn has not. Same element, same words, no mark. + rerender() + expect(runHeader(container)).toBe(header) + expect(header).toHaveAttribute('data-native-chat-tool-run-state', 'live') + expect(header).toHaveTextContent('Running 1 command') + expect(header.querySelector('.lucide-check')).toBeNull() - rerender( + // The next call starts: still the same element, now counting it. + const next: NativeChatBlock[] = [ + ...settled, + { type: 'tool-call', name: 'shell', input: { command: 'sleep 2' }, state: 'running' } + ] + rerender() + expect(runHeader(container)).toBe(header) + expect(header).toHaveTextContent('Running 2 commands') + expect(header).toHaveTextContent('sleep 2') + + // The turn ends: the same element settles in place. + const done: NativeChatBlock[] = [ + ...settled, + { type: 'tool-call', name: 'shell', input: { command: 'sleep 2' }, state: 'completed' }, + { type: 'tool-result', output: 'done' } + ] + rerender() + expect(runHeader(container)).toBe(header) + expect(header).toHaveAttribute('data-native-chat-tool-run-state', 'settled') + expect(header).toHaveTextContent('Ran 2 commands') + expect(header).not.toHaveTextContent('Running') + expect(header.querySelector('.lucide-check')).toBeInTheDocument() + expect(header.querySelector('.animate-pulse')).toBeNull() + }) + + it('settles a run the agent has moved past even while its last call still reports', () => { + const { container } = render( ) - expect(screen.queryByText('Running sleep 1')).toBeNull() - expect(runHeader(container)).toHaveTextContent('sleep 1') + const header = runHeader(container) + expect(header).toHaveAttribute('data-native-chat-tool-run-state', 'settled') + expect(header).not.toHaveTextContent('Running') + expect(header.querySelector('.animate-pulse')).toBeNull() + // Still not a stated success: the call has not finished. + expect(header.querySelector('.lucide-check')).toBeNull() }) it('never animates a settled tool row with its completion check', () => { @@ -447,13 +503,15 @@ describe('NativeChatToolRun', () => { { type: 'tool-result', output: 'passed' } ]} expandSignal={false} - activeTurnIsWorking + activeTurnIsWorking={false} /> ) const settledRow = runHeader(container) expect(settledRow).toHaveTextContent('pnpm test') expect(settledRow.querySelector('.lucide-check')).toBeInTheDocument() + // Windowing remounts settled rows on scroll; a mark that faded in would replay. + expect(settledRow.querySelector('.lucide-check')).not.toHaveClass('animate-in') expect(settledRow.querySelector('.animate-pulse')).toBeNull() expect(container.querySelector('.animate-pulse')).toBeNull() }) @@ -789,18 +847,22 @@ describe('NativeChatToolRun', () => { expect(leadingGlyphs(container)).toEqual(['lucide-check', 'lucide-chevron-right']) }) - it('keeps naming the active call while the run is still running', () => { + it('holds the run glyph across live and settled', () => { const blocks: NativeChatBlock[] = [ call('read', { command: "sed -n '1,50p' a.ts", path: 'a.ts' }), { type: 'tool-call', name: 'shell', input: { command: 'npm test' }, state: 'running' } ] - const { container } = render( + const { container, rerender } = render( ) - // The running header names one call, so its glyph is that call's. - expect(leadingGlyphs(container)[0]).toBe('lucide-square-terminal') + // The header speaks for the whole run in both states, so its glyph is the + // run's, never the latest call's — a glyph that swapped as calls came and + // went read as a change of identity. + expect(leadingGlyphs(container)[0]).toBe('lucide-wrench') + rerender() + expect(leadingGlyphs(container)[0]).toBe('lucide-wrench') }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatToolRun.tsx b/src/renderer/src/components/native-chat/NativeChatToolRun.tsx index 7a050148064..bb8d9f96302 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolRun.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolRun.tsx @@ -23,6 +23,7 @@ import { pairNativeChatToolResults } from '../../../../shared/native-chat-tool-pairing' import { + describeLatestToolCall, NATIVE_CHAT_TOOL_ACTIVITY_COPY, selectActiveToolCall } from '../../../../shared/native-chat-tool-activity' @@ -37,8 +38,7 @@ import { NativeChatTaskList } from './NativeChatTaskList' import { buildNativeChatTaskListRows } from './native-chat-task-list-history' import { NativeChatBackgroundTaskRun } from './NativeChatBackgroundTaskRun' import { NativeChatSubagentRun } from './NativeChatSubagentRun' -import { NativeChatToolIcon, NativeChatToolRunIcon } from './NativeChatToolIcon' -import { nativeChatToolActivityLabel } from './native-chat-tool-activity-label' +import { NativeChatToolRunIcon } from './NativeChatToolIcon' /** Stable empty default: a fresh array literal per render breaks memoization. */ const NO_SUBAGENT_GROUPS: NativeChatSubagentGroupBlock[] = [] @@ -56,6 +56,7 @@ export function NativeChatToolRun({ backgroundTasks = NO_BACKGROUND_TASKS, expandSignal, activeTurnIsWorking, + trailing, expandOverride, structuredActivityUi = true, disclosureId, @@ -76,6 +77,10 @@ export function NativeChatToolRun({ expandOverride?: boolean /** Structured lifecycle state, when available, keeps orphaned running calls from spinning. */ activeTurnIsWorking?: boolean + /** Whether this run is the working turn's last. Only that run is live: a run + * the agent has already moved past reads as settled even mid-call. Left + * unset, a working turn's run is taken to be its last. */ + trailing?: boolean structuredActivityUi?: boolean /** Message this run belongs to. Windowing unmounts rows, so a run the reader * opened has to be remembered somewhere that outlives the row. */ @@ -117,14 +122,25 @@ export function NativeChatToolRun({ const askSubject = hasAskCall ? nativeChatAskRunSubject(asks) : null const showsHeader = !hasAskCall || countToolCalls(headerBlocks) > 0 const callCount = countToolCalls(headerBlocks) || headerBlocks.length + const askIsActive = selectActiveToolCall(unansweredAsks, { activeTurnIsWorking }) !== null + // Live is the turn's state, not a call's. Deriving it from "some call is + // running" flipped the header to settled and back around every call, and a + // call that finished inside a frame still bought the whole flip. The turn's + // trailing run stays live from its first call until the agent moves on; a + // caller with no turn state, or a turn blocked on the reader's answer, falls + // back to the calls themselves. + const live = + structuredActivityUi && + (activeTurnIsWorking === true && !askIsActive + ? trailing !== false + : selectActiveToolCall(headerBlocks, { activeTurnIsWorking }) !== null) // One sentence for the whole run, or the command itself when the run is one // call — the reader recognizes `git push` faster than "Ran 1 command". - const runSentence = nativeChatToolRunSentence(headerBlocks) - const headerActiveCall = structuredActivityUi - ? selectActiveToolCall(headerBlocks, { activeTurnIsWorking }) - : null - const isSettled = headerActiveCall == null - const askIsActive = selectActiveToolCall(unansweredAsks, { activeTurnIsWorking }) !== null + const runSentence = nativeChatToolRunSentence(headerBlocks, { live }) + // What the run is doing now, beside the sentence: the latest call, running or + // not, so a call that finished in a frame still leaves its name until the next. + const latestCall = live ? headerBlocks.findLast(isToolCallBlock) : undefined + const latestCallLabel = latestCall ? describeLatestToolCall(latestCall) : null const { succeeded: runSucceeded, failedCallCount } = nativeChatToolRunOutcome(headerBlocks, { activeTurnIsWorking }) @@ -186,7 +202,7 @@ export function NativeChatToolRun({ structuredActivityUi && expandOverride === false && !(revealedDiff && open) && - isSettled && + !live && activeTurnIsWorking === false ) { // The roster is not tool activity, so it survives this guard exactly as it @@ -203,37 +219,33 @@ export function NativeChatToolRun({ {hasAskCall ? ( ) : null} - {!showsHeader ? null : headerActiveCall ? ( + {!showsHeader ? null : ( + // One element for the run's whole life. Live and settled are states of + // this button, not two buttons: a header that remounted as a call started + // and again as it ended lost its hover, its mark, and its count each time. - ) : ( -