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. - ) : ( -