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