mirror of
https://github.com/stablyai/orca.git
synced 2026-09-27 00:02:37 +00:00
fix(native-chat): keep one live tool-run header from a call's start to the turn's end (#22432)
* 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.
This commit is contained in:
@@ -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(
|
||||
<NativeChatMessageList
|
||||
session={{ ...session, status: 'working', messages: [run, after('reasoning')] }}
|
||||
isWorking
|
||||
expandSignal={false}
|
||||
fontScale={1}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Running 1 command')).toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<NativeChatMessageList
|
||||
session={{ ...session, status: 'working', messages: [run, after('assistant')] }}
|
||||
isWorking
|
||||
expandSignal={false}
|
||||
fontScale={1}
|
||||
/>
|
||||
)
|
||||
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(
|
||||
<NativeChatMessageList
|
||||
@@ -125,7 +173,8 @@ describe('NativeChatMessageList assistant messages', () => {
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Running sleep 5')).toBeInTheDocument()
|
||||
expect(screen.getByText('Running 1 command')).toBeInTheDocument()
|
||||
expect(screen.getByText('sleep 5')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+18
-8
@@ -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', () => {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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(<NativeChatToolRun blocks={blocks} expandSignal activeTurnIsWorking />)
|
||||
|
||||
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', () => {
|
||||
|
||||
@@ -379,12 +379,18 @@ describe('NativeChatToolRun', () => {
|
||||
|
||||
const { container } = render(<NativeChatToolRun blocks={blocks} expandSignal={false} />)
|
||||
|
||||
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(
|
||||
<NativeChatToolRun blocks={runningBlocks} expandSignal={false} />
|
||||
<NativeChatToolRun blocks={running} expandSignal={false} activeTurnIsWorking />
|
||||
)
|
||||
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(<NativeChatToolRun blocks={settled} expandSignal={false} activeTurnIsWorking />)
|
||||
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(<NativeChatToolRun blocks={next} expandSignal={false} activeTurnIsWorking />)
|
||||
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(<NativeChatToolRun blocks={done} expandSignal={false} activeTurnIsWorking={false} />)
|
||||
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(
|
||||
<NativeChatToolRun
|
||||
blocks={[
|
||||
{ type: 'tool-call', name: 'shell', input: { command: 'sleep 1' }, state: 'completed' },
|
||||
{ type: 'tool-result', output: 'done' }
|
||||
{ type: 'tool-call', name: 'shell', input: { command: 'sleep 1' }, state: 'running' }
|
||||
]}
|
||||
expandSignal={false}
|
||||
activeTurnIsWorking
|
||||
trailing={false}
|
||||
/>
|
||||
)
|
||||
|
||||
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(
|
||||
<NativeChatToolRun blocks={blocks} expandSignal activeTurnIsWorking />
|
||||
)
|
||||
|
||||
// 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(<NativeChatToolRun blocks={blocks} expandSignal activeTurnIsWorking={false} />)
|
||||
expect(leadingGlyphs(container)[0]).toBe('lucide-wrench')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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 ? (
|
||||
<NativeChatAwaitingInputRow subject={askSubject} pending={askIsActive} />
|
||||
) : 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.
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className="group/tool-run flex min-h-6 w-full items-center gap-1.5 rounded-md py-0.5 text-left text-sm leading-relaxed text-muted-foreground hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
|
||||
className="group/tool-run flex min-h-6 w-full items-center gap-1.5 rounded-md py-0.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
|
||||
aria-expanded={open}
|
||||
aria-live="polite"
|
||||
>
|
||||
<NativeChatToolIcon
|
||||
mcpIdentity={headerActiveCall.mcpIdentity}
|
||||
rowWord={headerActiveCall.name}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<span className="min-w-0 animate-pulse truncate text-foreground/85 motion-reduce:animate-none">
|
||||
{nativeChatToolActivityLabel(headerActiveCall)}
|
||||
</span>
|
||||
{open ? <ChevronRight className="size-3.5 rotate-90 text-muted-foreground" /> : null}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className="group/tool-run flex min-h-6 w-full items-center gap-1.5 py-0.5 text-left"
|
||||
aria-expanded={open}
|
||||
data-native-chat-tool-run-state={live ? 'live' : 'settled'}
|
||||
>
|
||||
{structuredActivityUi && settledHeaderIcon ? (
|
||||
<NativeChatToolRunIcon iconName={settledHeaderIcon} className="text-muted-foreground" />
|
||||
) : null}
|
||||
{/* The run in words, in the transcript's own type. The calls it counts
|
||||
are one click away, so the header does not have to list them. */}
|
||||
<span className="min-w-0 truncate text-sm leading-relaxed text-muted-foreground transition-colors group-hover/tool-run:text-foreground/80">
|
||||
{/* The run in words, in the transcript's own type. Present tense while
|
||||
live, past once settled; the text changes in place and nothing
|
||||
around it moves. While live it keeps its width and the preview
|
||||
beside it is what gives way. */}
|
||||
<span
|
||||
className={cn(
|
||||
'truncate text-sm leading-relaxed transition-colors',
|
||||
live
|
||||
? 'max-w-[72%] shrink-0 animate-pulse text-foreground/85 motion-reduce:animate-none'
|
||||
: 'min-w-0 text-muted-foreground group-hover/tool-run:text-foreground/80'
|
||||
)}
|
||||
>
|
||||
{runSentence ?? fallbackLabel}
|
||||
</span>
|
||||
{failedCallCount > 0 ? (
|
||||
@@ -257,10 +269,17 @@ export function NativeChatToolRun({
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
{/* Only a stated success is marked done — see nativeChatToolRunOutcome. */}
|
||||
{structuredActivityUi && runSucceeded ? (
|
||||
{/* Only a stated success is marked done — see nativeChatToolRunOutcome —
|
||||
and never while live: between two calls nothing is running, and a
|
||||
mark that appeared then would flash on every call. */}
|
||||
{structuredActivityUi && !live && runSucceeded ? (
|
||||
<Check aria-hidden className="size-3 shrink-0 text-muted-foreground" />
|
||||
) : null}
|
||||
{latestCallLabel ? (
|
||||
<span className="min-w-0 truncate font-mono text-[11px] text-muted-foreground">
|
||||
{latestCallLabel}
|
||||
</span>
|
||||
) : null}
|
||||
{/* Revealed on hover of this header alone — see NativeChatToolLine on
|
||||
why the group is named — and points down when open. */}
|
||||
<ChevronRight
|
||||
|
||||
@@ -56,6 +56,7 @@ export const NativeChatTranscriptRow = memo(function NativeChatTranscriptRow({
|
||||
}
|
||||
expandSignal={context.expandSignal}
|
||||
activeTurnIsWorking={slot.activeTurnIsWorking}
|
||||
trailingRun={slot.trailingRun}
|
||||
onScrollMessageToTop={context.onScrollMessageToTop}
|
||||
onLinkClick={context.onLinkClick}
|
||||
allowFileUriLinks={context.allowFileUriLinks}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
describeActiveToolCall,
|
||||
NATIVE_CHAT_TOOL_ACTIVITY_COPY
|
||||
} from '../../../../shared/native-chat-tool-activity'
|
||||
import type { NativeChatBlock } from '../../../../shared/native-chat-types'
|
||||
|
||||
type ToolCall = Extract<NativeChatBlock, { type: 'tool-call' }>
|
||||
|
||||
export function nativeChatToolActivityLabel(call: ToolCall): string {
|
||||
const { key, toolName, preview } = describeActiveToolCall(call)
|
||||
const copy = NATIVE_CHAT_TOOL_ACTIVITY_COPY[key]
|
||||
return key === 'runningPreview'
|
||||
? translate('components.native-chat.tool.runningPreview', copy, { preview })
|
||||
: key === 'runningCommand'
|
||||
? translate('components.native-chat.tool.runningCommand', copy)
|
||||
: key === 'runningNamedPreview'
|
||||
? translate('components.native-chat.tool.runningNamedPreview', copy, { toolName, preview })
|
||||
: translate('components.native-chat.tool.runningNamed', copy, { toolName })
|
||||
}
|
||||
@@ -15,9 +15,12 @@ import { toolInputCommand } from '../../../../shared/native-chat-tool-summary'
|
||||
* sentence would stay English in every locale while the row above it translated.
|
||||
* `native-chat-shared-copy-matches-catalog` pins these against the record mobile
|
||||
* renders from, which is what keeps the duplication honest. */
|
||||
function clause(category: NativeChatToolCategory, count: number): string {
|
||||
function clause(category: NativeChatToolCategory, count: number, live: boolean): string {
|
||||
const one = count === 1
|
||||
const value0 = { value0: count }
|
||||
if (live) {
|
||||
return liveClause(category, one, value0)
|
||||
}
|
||||
switch (category) {
|
||||
case 'read':
|
||||
return one
|
||||
@@ -86,8 +89,99 @@ function clause(category: NativeChatToolCategory, count: number): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** The one line a settled run reads as, localized. */
|
||||
export function nativeChatToolRunSentence(blocks: readonly NativeChatBlock[]): string | null {
|
||||
/** The present-tense clause, while the run is still live. Same shape as the
|
||||
* settled one so the header's text changes in place and nothing else does. */
|
||||
function liveClause(
|
||||
category: NativeChatToolCategory,
|
||||
one: boolean,
|
||||
value0: { value0: number }
|
||||
): string {
|
||||
switch (category) {
|
||||
case 'read':
|
||||
return one
|
||||
? translate('components.native-chat.tool.runLiveReadOne', 'Reading 1 file')
|
||||
: translate(
|
||||
'components.native-chat.tool.runLiveReadMany',
|
||||
'Reading {{value0}} files',
|
||||
value0
|
||||
)
|
||||
case 'search':
|
||||
return one
|
||||
? translate('components.native-chat.tool.runLiveSearchOne', 'Searching 1 time')
|
||||
: translate(
|
||||
'components.native-chat.tool.runLiveSearchMany',
|
||||
'Searching {{value0}} times',
|
||||
value0
|
||||
)
|
||||
case 'listFiles':
|
||||
return one
|
||||
? translate('components.native-chat.tool.runLiveListFilesOne', 'Listing 1 directory')
|
||||
: translate(
|
||||
'components.native-chat.tool.runLiveListFilesMany',
|
||||
'Listing {{value0}} directories',
|
||||
value0
|
||||
)
|
||||
case 'unknown':
|
||||
return one
|
||||
? translate('components.native-chat.tool.runLiveCommandOne', 'Running 1 command')
|
||||
: translate(
|
||||
'components.native-chat.tool.runLiveCommandMany',
|
||||
'Running {{value0}} commands',
|
||||
value0
|
||||
)
|
||||
case 'fileChange':
|
||||
return one
|
||||
? translate('components.native-chat.tool.runLiveFileChangeOne', 'Editing 1 file')
|
||||
: translate(
|
||||
'components.native-chat.tool.runLiveFileChangeMany',
|
||||
'Editing {{value0}} files',
|
||||
value0
|
||||
)
|
||||
case 'webSearch':
|
||||
return one
|
||||
? translate('components.native-chat.tool.runLiveWebSearchOne', 'Searching the web 1 time')
|
||||
: translate(
|
||||
'components.native-chat.tool.runLiveWebSearchMany',
|
||||
'Searching the web {{value0}} times',
|
||||
value0
|
||||
)
|
||||
case 'mcpToolCall':
|
||||
return one
|
||||
? translate('components.native-chat.tool.runLiveIntegrationOne', 'Using 1 integration')
|
||||
: translate(
|
||||
'components.native-chat.tool.runLiveIntegrationMany',
|
||||
'Using {{value0}} integrations',
|
||||
value0
|
||||
)
|
||||
case 'subAgentActivity':
|
||||
return one
|
||||
? translate('components.native-chat.tool.runLiveAgentOne', 'Running 1 agent')
|
||||
: translate(
|
||||
'components.native-chat.tool.runLiveAgentMany',
|
||||
'Running {{value0}} agents',
|
||||
value0
|
||||
)
|
||||
case 'todoList':
|
||||
return one
|
||||
? translate('components.native-chat.tool.runLivePlanOne', 'Updating the plan')
|
||||
: translate(
|
||||
'components.native-chat.tool.runLivePlanMany',
|
||||
'Updating the plan {{value0}} times',
|
||||
value0
|
||||
)
|
||||
case 'other':
|
||||
return one
|
||||
? translate('components.native-chat.tool.runLiveToolOne', 'Using 1 tool')
|
||||
: translate('components.native-chat.tool.runLiveToolMany', 'Using {{value0}} tools', value0)
|
||||
}
|
||||
}
|
||||
|
||||
/** The one line a run reads as, localized: past tense once settled, present
|
||||
* while live. */
|
||||
export function nativeChatToolRunSentence(
|
||||
blocks: readonly NativeChatBlock[],
|
||||
{ live = false }: { live?: boolean } = {}
|
||||
): string | null {
|
||||
const calls = blocks.filter(isToolCallBlock)
|
||||
if (calls.length === 0) {
|
||||
return null
|
||||
@@ -95,14 +189,17 @@ export function nativeChatToolRunSentence(blocks: readonly NativeChatBlock[]): s
|
||||
// A lone shell call keeps its own command: `git push` identifies the work
|
||||
// better than "Ran 1 command" can, and it is the row the reader would open
|
||||
// anyway. A call carrying no command has nothing better than its category.
|
||||
if (calls.length === 1) {
|
||||
// Not while live: the command is then the preview beside the sentence, and a
|
||||
// header that swapped shape when the second call arrived is the swap this row
|
||||
// exists to avoid.
|
||||
if (calls.length === 1 && !live) {
|
||||
const command = toolInputCommand(calls[0].input)
|
||||
if (command !== null && command.length > 0) {
|
||||
return command
|
||||
}
|
||||
}
|
||||
return joinNativeChatToolRunClauses(
|
||||
nativeChatToolRunClauses(calls).map(({ category, count }) => clause(category, count)),
|
||||
nativeChatToolRunClauses(calls).map(({ category, count }) => clause(category, count, live)),
|
||||
{
|
||||
pair: (first, second) =>
|
||||
translate('components.native-chat.tool.runPair', '{{value0}} and {{value1}}', {
|
||||
|
||||
@@ -47,7 +47,57 @@ function build(
|
||||
})
|
||||
}
|
||||
|
||||
function toolRun(id: string): NativeChatMessage {
|
||||
return {
|
||||
id,
|
||||
role: 'assistant',
|
||||
blocks: [{ type: 'tool-call', name: 'shell', input: { command: 'ls' }, state: 'completed' }],
|
||||
timestamp: 1,
|
||||
source: 'transcript'
|
||||
}
|
||||
}
|
||||
|
||||
describe('transcript slots', () => {
|
||||
// The trailing run is the one still live while the turn works. Prose or a
|
||||
// further run after it settles it; a reasoning aside leaves it live.
|
||||
it('marks the last row that speaks or acts as the trailing run', () => {
|
||||
const trailing = (messages: NativeChatMessage[]) =>
|
||||
build(messages)
|
||||
.filter((slot) => slot.trailingRun)
|
||||
.map((slot) => slot.message.id)
|
||||
|
||||
expect(trailing([text('u', 'go', 'user'), toolRun('a'), text('b', 'Done.')])).toEqual(['b'])
|
||||
expect(trailing([text('u', 'go', 'user'), toolRun('a'), toolRun('b')])).toEqual(['b'])
|
||||
expect(
|
||||
trailing([text('u', 'go', 'user'), toolRun('a'), text('r', 'hmm', 'reasoning')])
|
||||
).toEqual(['a'])
|
||||
expect(trailing([toolRun('a'), text('u', 'again', 'user')])).toEqual(['a'])
|
||||
})
|
||||
|
||||
// Approving a call lets that call run, and it sits in the run above the
|
||||
// receipt. A question's receipt blocks the agent on the reader, so it does not.
|
||||
it('keeps the run above an approval receipt trailing, but not above a question', () => {
|
||||
const resolution = {
|
||||
state: 'resolved' as const,
|
||||
selectedOptionId: 'yes',
|
||||
resolvedBy: 'desktop',
|
||||
resolvedAt: 1
|
||||
}
|
||||
const receipts = new Map<string, NativeChatResolvedPrompt>([
|
||||
['approval', { kind: 'approval', title: 'Run?', detail: 'ls', options: [], resolution }],
|
||||
['question', { kind: 'question', question: 'Which?', options: [], resolution }]
|
||||
])
|
||||
const trailing = (receiptId: string) =>
|
||||
build([text('u', 'go', 'user'), toolRun('a'), text(receiptId, 'Run?', 'system')], {
|
||||
receipts
|
||||
})
|
||||
.filter((slot) => slot.trailingRun)
|
||||
.map((slot) => slot.message.id)
|
||||
|
||||
expect(trailing('approval')).toEqual(['a'])
|
||||
expect(trailing('question')).toEqual(['question'])
|
||||
})
|
||||
|
||||
// A counted row that draws nothing is a gap in the transcript: it reserves
|
||||
// estimated height for a bubble that never appears.
|
||||
it('gives no slot to a message with nothing to draw', () => {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import {
|
||||
isBackgroundTaskBlock,
|
||||
isSubagentGroupBlock,
|
||||
isToolCallBlock,
|
||||
type NativeChatMessage
|
||||
} from '../../../../shared/native-chat-types'
|
||||
import type { NativeChatTurnStatus } from '../../../../shared/native-chat-turn-status'
|
||||
@@ -30,6 +31,10 @@ export type NativeChatTranscriptSlot = {
|
||||
turnKey: string | undefined
|
||||
/** The row's own turn is the one still running, so its tools stay live. */
|
||||
activeTurnIsWorking: boolean
|
||||
/** Nothing the agent said or did comes after this row, so its tool run is
|
||||
* the one still live while the turn works. A later run or answer settles it;
|
||||
* a reasoning aside does not, the agent is still inside the same batch. */
|
||||
trailingRun: boolean
|
||||
/** Resolved approval/question stands in for the message it answered. */
|
||||
receipt: NativeChatResolvedPrompt | undefined
|
||||
/** Turn timing shown under this row, already filtered to "should render". */
|
||||
@@ -94,6 +99,17 @@ export function buildNativeChatTranscriptSlots(
|
||||
)
|
||||
}
|
||||
})
|
||||
// Liveness is the turn's, not any one call's: the run at the frontier stays
|
||||
// live between its calls, and a run the agent has moved past is settled even
|
||||
// while its last call is still reporting. An approval's receipt decides a call
|
||||
// of the run above it, which then runs, so it does not move past that run.
|
||||
const trailingRunIndex = foldRows.findLastIndex(
|
||||
(row, index) =>
|
||||
row.role !== 'user' &&
|
||||
row.role !== 'reasoning' &&
|
||||
receipts.get(messages[index].id)?.kind !== 'approval' &&
|
||||
(row.rendersProse || messages[index].blocks.some(isToolCallBlock))
|
||||
)
|
||||
const settledTurnKeys = new Set(
|
||||
showTurnStatus
|
||||
? Object.entries(turnStatuses.completedByTurn)
|
||||
@@ -134,6 +150,7 @@ export function buildNativeChatTranscriptSlots(
|
||||
activeTurnIsWorking:
|
||||
(currentTurnKey ? turnKey === currentTurnKey : turnKey === undefined) &&
|
||||
(isWorking || lifecycleWorking),
|
||||
trailingRun: index === trailingRunIndex,
|
||||
receipt,
|
||||
status: status ?? undefined,
|
||||
folded,
|
||||
|
||||
+1
@@ -52,6 +52,7 @@ function slot(id: string): NativeChatTranscriptSlot {
|
||||
},
|
||||
turnKey: undefined,
|
||||
activeTurnIsWorking: false,
|
||||
trailingRun: false,
|
||||
receipt: undefined,
|
||||
status: undefined,
|
||||
folded: false,
|
||||
|
||||
@@ -17399,7 +17399,27 @@
|
||||
"runToolOne": "Used 1 tool",
|
||||
"runToolMany": "Used {{value0}} tools",
|
||||
"runPair": "{{value0}} and {{value1}}",
|
||||
"runList": "{{value0}}, and {{value1}}"
|
||||
"runList": "{{value0}}, and {{value1}}",
|
||||
"runLiveReadOne": "Reading 1 file",
|
||||
"runLiveReadMany": "Reading {{value0}} files",
|
||||
"runLiveSearchOne": "Searching 1 time",
|
||||
"runLiveSearchMany": "Searching {{value0}} times",
|
||||
"runLiveListFilesOne": "Listing 1 directory",
|
||||
"runLiveListFilesMany": "Listing {{value0}} directories",
|
||||
"runLiveCommandOne": "Running 1 command",
|
||||
"runLiveCommandMany": "Running {{value0}} commands",
|
||||
"runLiveFileChangeOne": "Editing 1 file",
|
||||
"runLiveFileChangeMany": "Editing {{value0}} files",
|
||||
"runLiveWebSearchOne": "Searching the web 1 time",
|
||||
"runLiveWebSearchMany": "Searching the web {{value0}} times",
|
||||
"runLiveIntegrationOne": "Using 1 integration",
|
||||
"runLiveIntegrationMany": "Using {{value0}} integrations",
|
||||
"runLiveAgentOne": "Running 1 agent",
|
||||
"runLiveAgentMany": "Running {{value0}} agents",
|
||||
"runLivePlanOne": "Updating the plan",
|
||||
"runLivePlanMany": "Updating the plan {{value0}} times",
|
||||
"runLiveToolOne": "Using 1 tool",
|
||||
"runLiveToolMany": "Using {{value0}} tools"
|
||||
},
|
||||
"providerFrame": {
|
||||
"byteLength": "{{value0}} bytes"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Live tool-activity derivation and copy for the native-chat "Running …" row,
|
||||
// shared by the desktop renderer (as its i18n fallback strings) and the mobile
|
||||
// app (used directly — mobile ships English only) so the two surfaces never drift.
|
||||
// Live tool-activity derivation and copy for native chat's tool runs. Mobile's
|
||||
// "Running …" row renders this copy directly (it ships English only); desktop
|
||||
// uses the count and failure strings as i18n fallbacks, so the two never drift.
|
||||
|
||||
import { createToolInputDisplay } from './native-chat-tool-summary'
|
||||
import { isToolCallBlock, type NativeChatBlock } from './native-chat-types'
|
||||
@@ -72,6 +72,17 @@ export function formatActiveToolLabel(descriptor: NativeChatActiveToolDescriptor
|
||||
.replaceAll('{{toolName}}', descriptor.toolName)
|
||||
}
|
||||
|
||||
/** What a live run names beside its sentence: the command itself, or the tool's
|
||||
* word and argument. Tense-free on purpose — the sentence carries the state, so
|
||||
* this never claims a call that just finished is still running. */
|
||||
export function describeLatestToolCall(call: NativeChatToolCallBlock): string {
|
||||
const { toolName, preview, isCommand } = describeActiveToolCall(call)
|
||||
if (isCommand) {
|
||||
return preview || toolName
|
||||
}
|
||||
return preview ? `${toolName} ${preview}` : toolName
|
||||
}
|
||||
|
||||
/** The most recent still-running call in a run, or null once the run is settled.
|
||||
* A block without lifecycle `state` only counts while the turn is known to be
|
||||
* working, so a restored transcript never spins on an orphaned call. */
|
||||
|
||||
Reference in New Issue
Block a user