Files
orca/src/shared/native-chat-tool-activity.ts
T
Brennan Benson 641a7f36d9 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.
2026-09-23 10:49:04 -07:00

115 lines
4.2 KiB
TypeScript

// 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'
type NativeChatToolCallBlock = Extract<NativeChatBlock, { type: 'tool-call' }>
export const NATIVE_CHAT_TOOL_ACTIVITY_COPY = {
runningPreview: 'Running {{preview}}',
runningCommand: 'Running command',
runningNamedPreview: 'Running {{toolName}} {{preview}}',
runningNamed: 'Running {{toolName}}',
countOne: '1 tool call',
countN: '{{value0}} tool calls',
moreCalls: '+{{value0}} more',
/** Quiet decoration on a settled collapsed header; the run's lines carry the
* detail. Count-agnostic wording so one entry serves any number. */
failedCount: '{{value0}} failed',
/** Spoken form of the same mark — `1 failed` alone does not say failed what. */
failedCallsLabel: 'Failed tool calls: {{value0}}'
} as const
/** Tools whose call is a shell command, so the row reads as terminal activity
* (and takes the terminal glyph) rather than a named tool invocation. */
export const COMMAND_TOOL_NAMES: ReadonlySet<string> = new Set([
'bash',
'shell',
'powershell',
'terminal',
'execute',
'run_command',
'run_shell_command',
'shell_command',
'exec_command',
'run_terminal_cmd',
'run_terminal_command'
])
export function isCommandToolName(name: string): boolean {
return COMMAND_TOOL_NAMES.has(name.trim().toLowerCase())
}
export type NativeChatActiveToolDescriptor = {
key: 'runningPreview' | 'runningCommand' | 'runningNamedPreview' | 'runningNamed'
toolName: string
preview: string
isCommand: boolean
}
/** Which copy key and arguments the active-tool row renders for a running call. */
export function describeActiveToolCall(
call: NativeChatToolCallBlock
): NativeChatActiveToolDescriptor {
const preview = createToolInputDisplay(call.input).label
const isCommand = isCommandToolName(call.name)
const key = isCommand
? preview
? 'runningPreview'
: 'runningCommand'
: preview
? 'runningNamedPreview'
: 'runningNamed'
return { key, toolName: call.name, preview, isCommand }
}
/** Resolve the active-tool label in English. For platforms without i18n (mobile). */
export function formatActiveToolLabel(descriptor: NativeChatActiveToolDescriptor): string {
return NATIVE_CHAT_TOOL_ACTIVITY_COPY[descriptor.key]
.replaceAll('{{preview}}', descriptor.preview)
.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. */
export function selectActiveToolCall(
blocks: readonly NativeChatBlock[],
{ activeTurnIsWorking }: { activeTurnIsWorking?: boolean }
): NativeChatToolCallBlock | null {
if (activeTurnIsWorking === false) {
return null
}
const calls = blocks.filter(isToolCallBlock)
for (let index = calls.length - 1; index >= 0; index--) {
const call = calls[index]
if (
call &&
(call.state === 'running' || (call.state == null && activeTurnIsWorking === true))
) {
return call
}
}
return null
}
/** Fallback summary when no per-tool summary is available. */
export function formatToolCallCount(callCount: number): string {
return callCount === 1
? NATIVE_CHAT_TOOL_ACTIVITY_COPY.countOne
: NATIVE_CHAT_TOOL_ACTIVITY_COPY.countN.replaceAll('{{value0}}', String(callCount))
}