feat(native-chat): read a tool batch as a group

A run of several tool calls collapsed to one joined string: names and
arguments run together, separated by a middle dot that also occurs inside
`browser.open` and `tools/read`, with the overflow cut mid-token. Opened,
the member rows sat flush with the header and with the message content
around them, so the batch had no visible end.

Two presentation changes, no new derivation:

- Each member gets its own bounded pill in the collapsed header, carrying
  its own category glyph, so the boundary between calls is a shape rather
  than a character. Pills wrap instead of truncating, and members past the
  summary cap are counted in `+N more` rather than dropped silently.
- Opened members are indented under the header, which is what marks where
  the run ends.

`toolRunSummaryMembers` keeps the run's leading calls apart instead of
pre-joining them; `summarizeToolRun` now derives its string from it, so
mobile's header is byte-identical and the two cannot disagree about which
calls speak for a run.

Two existing behaviours are pinned by test rather than changed, both being
naming decisions rather than layout ones: the header still prints the raw
`mcp__linear__list_issues` while the row beneath prints the split name, and
a call carrying only a `url` still falls through to a JSON preview clipped
at 28 characters.
This commit is contained in:
Merge Sim
2026-09-07 15:17:03 -07:00
parent 2265fce591
commit 14bd382b97
10 changed files with 279 additions and 29 deletions
@@ -200,7 +200,7 @@ describe('NativeChatMessageList assistant messages', () => {
/>
)
const settledTool = screen.getByText('shell pnpm test')
const settledTool = screen.getByText('shell')
const activity = screen.getByText('Working…')
expect(activity.textContent).not.toBe(settledTool.textContent)
expect(activity).not.toHaveTextContent('shell')
@@ -243,7 +243,8 @@ describe('NativeChatMessageList assistant messages', () => {
/>
)
const settledTool = screen.getByText('shell pnpm test')
const settledTool = screen.getByText('shell')
expect(settledTool).toHaveTextContent('shell pnpm test')
expect(settledTool.closest('button')?.querySelector('.animate-pulse')).toBeNull()
expect(settledTool.closest('button')?.querySelector('.lucide-check')).toBeInTheDocument()
const activity = screen.getByText('Preparing the answer')
@@ -299,7 +299,7 @@ describe('NativeChatToolRun with a spawn group', () => {
)
expect(screen.getByText('Ran 1 subagent')).toBeInTheDocument()
expect(screen.queryByText('shell ls')).toBeNull()
expect(screen.queryByText('shell')).toBeNull()
})
it('renders the roster alongside the tool activity of its turn', () => {
@@ -313,6 +313,6 @@ describe('NativeChatToolRun with a spawn group', () => {
)
expect(screen.getByText('Ran 1 subagent')).toBeInTheDocument()
expect(screen.getByText('shell ls')).toBeInTheDocument()
expect(screen.getByText('shell').closest('button')).toHaveTextContent('shell ls')
})
})
@@ -70,7 +70,7 @@ describe('inline tool annotations', () => {
/>
)
expect(screen.queryByRole('link')).toBeNull()
fireEvent.click(screen.getByText('web_search').closest('button')!)
fireEvent.click(screen.getByText('web_search', { selector: 'code' }).closest('button')!)
const link = screen.getByRole('link', { name: /Reference docs/ })
expect(link.getAttribute('href')).toBe('https://example.com/docs')
expect(link.closest('button')).toBeNull()
@@ -112,7 +112,10 @@ it.each(['running', 'completed'] as const)(
expect(screen.getByText('My server')).toBeTruthy()
expect(screen.getByText('ns.tool')).toBeTruthy()
expect(screen.getByTitle(name)).toBeTruthy()
expect(document.querySelectorAll('.lucide-plug')).toHaveLength(2)
// Header glyph plus the row's. A settled header also names each member in a
// pill, which carries that member's own glyph — so three, all plug: the
// identity holds wherever it is drawn.
expect(document.querySelectorAll('.lucide-plug')).toHaveLength(state === 'completed' ? 3 : 2)
}
)
@@ -23,6 +23,16 @@ function leadingGlyphs(container: HTMLElement): (string | null)[] {
)
}
/** The run header — the first button in a run, above its member rows. Its
* members render as separate pills, so it has no single joined summary node. */
function runHeader(container: HTMLElement): HTMLElement {
const header = container.querySelector('button')
if (!header) {
throw new Error('run header did not render')
}
return header
}
describe('NativeChatToolRun', () => {
it('uses the shared clean label for a desktop tool row', () => {
const blocks: NativeChatBlock[] = [
@@ -244,6 +254,94 @@ describe('NativeChatToolRun', () => {
expect(writeClipboardText).toHaveBeenCalledWith(' ctx\n-was\n+now\n+tail')
})
describe('reading a batch as a group', () => {
const batch: NativeChatBlock[] = [
{
type: 'tool-call',
name: 'mcp__linear__list_issues',
input: { query: 'todo' },
state: 'completed',
mcpIdentity: { server: 'linear', tool: 'list_issues' }
},
{ type: 'tool-call', name: 'Bash', input: { command: 'ls -la' }, state: 'completed' },
{
type: 'tool-call',
name: 'tools/read',
input: { file_path: 'README.md' },
state: 'completed'
}
]
it('gives each member its own bounded pill instead of one joined string', () => {
const { container } = render(<NativeChatToolRun blocks={batch} expandSignal={false} />)
const pills = runHeader(container).querySelectorAll('.bg-accent')
expect([...pills].map((pill) => pill.textContent)).toEqual([
'mcp__linear__list_issues todo',
'Bash ls -la',
'tools/read README.md'
])
})
// The header still prints the raw identifier while the row beneath it prints
// the split MCP name. Pinned, not endorsed: reconciling the two changes what
// a tool is called, which is a naming decision rather than a layout one.
it('leaves the header naming a member differently from the row below it', () => {
const { container } = render(<NativeChatToolRun blocks={batch} expandSignal />)
expect(runHeader(container)).toHaveTextContent('mcp__linear__list_issues')
expect(screen.getByText('Linear')).toBeInTheDocument()
})
it('names each member with its own glyph, not the run-wide fallback', () => {
const { container } = render(<NativeChatToolRun blocks={batch} expandSignal={false} />)
const header = runHeader(container)
expect(header.querySelector('.bg-accent .lucide-plug')).toBeInTheDocument()
expect(header.querySelector('.bg-accent .lucide-square-terminal')).toBeInTheDocument()
// The run-wide glyph still reads generic, the categories being mixed.
expect(header.firstElementChild?.querySelector('.lucide-wrench')).toBeInTheDocument()
})
it('counts the members it could not show rather than ending mid-name', () => {
const wide: NativeChatBlock[] = [
...batch,
{ type: 'tool-call', name: 'Grep', input: { pattern: 'todo' }, state: 'completed' },
{ type: 'tool-call', name: 'Write', input: { file_path: 'a.ts' }, state: 'completed' }
]
const { container } = render(<NativeChatToolRun blocks={wide} expandSignal={false} />)
expect(runHeader(container)).toHaveTextContent('+2 more')
expect(runHeader(container).querySelectorAll('.bg-accent')).toHaveLength(3)
})
it('leaves no remainder marker when every member is shown', () => {
const { container } = render(<NativeChatToolRun blocks={batch} expandSignal={false} />)
expect(runHeader(container)).not.toHaveTextContent('more')
})
it('indents opened members so the run has a visible end', () => {
const { container } = render(<NativeChatToolRun blocks={batch} expandSignal />)
const members = runHeader(container).parentElement?.querySelector('.pl-4')
expect(members).toBeInTheDocument()
expect(members?.querySelectorAll('button').length).toBe(batch.length)
})
it('falls back to the call count when a run names no tool', () => {
const { container } = render(
<NativeChatToolRun
blocks={[{ type: 'tool-call', name: ' ', input: {}, state: 'completed' }]}
expandSignal={false}
/>
)
expect(runHeader(container)).toHaveTextContent('1 tool call')
})
})
it('keeps a grouped active run to one stable row showing only the latest tool', () => {
const blocks: NativeChatBlock[] = [
{ type: 'tool-call', name: 'shell', input: { command: 'date' }, state: 'completed' },
@@ -293,7 +391,9 @@ describe('NativeChatToolRun', () => {
const runningBlocks: NativeChatBlock[] = [
{ type: 'tool-call', name: 'shell', input: { command: 'sleep 1' }, state: 'running' }
]
const { rerender } = render(<NativeChatToolRun blocks={runningBlocks} expandSignal={false} />)
const { rerender, container } = render(
<NativeChatToolRun blocks={runningBlocks} expandSignal={false} />
)
expect(screen.getByText('Running sleep 1')).toBeInTheDocument()
@@ -308,7 +408,7 @@ describe('NativeChatToolRun', () => {
)
expect(screen.queryByText('Running sleep 1')).toBeNull()
expect(screen.getByText('shell sleep 1')).toBeInTheDocument()
expect(runHeader(container)).toHaveTextContent('shell sleep 1')
})
it('never animates a settled tool row with its completion check', () => {
@@ -323,9 +423,10 @@ describe('NativeChatToolRun', () => {
/>
)
const settledRow = screen.getByText('shell pnpm test').closest('button')
expect(settledRow?.querySelector('.lucide-check')).toBeInTheDocument()
expect(settledRow?.querySelector('.animate-pulse')).toBeNull()
const settledRow = runHeader(container)
expect(settledRow).toHaveTextContent('shell pnpm test')
expect(settledRow.querySelector('.lucide-check')).toBeInTheDocument()
expect(settledRow.querySelector('.animate-pulse')).toBeNull()
expect(container.querySelector('.animate-pulse')).toBeNull()
})
@@ -348,7 +449,7 @@ describe('NativeChatToolRun', () => {
{ type: 'tool-result', output: 'exit 128', isError: true }
]
const { rerender } = render(
const { rerender, container } = render(
<NativeChatToolRun
blocks={blocks}
expandSignal={false}
@@ -369,7 +470,7 @@ describe('NativeChatToolRun', () => {
/>
)
expect(screen.getByText('shell git log -1')).toBeInTheDocument()
expect(runHeader(container)).toHaveTextContent('shell git log -1')
})
it('settles an orphaned running call when its turn lifecycle has ended', () => {
@@ -401,7 +502,7 @@ describe('NativeChatToolRun', () => {
const glyph = container.querySelector('.lucide-eye')
expect(glyph).toBeInTheDocument()
expect(glyph).toHaveAttribute('aria-hidden')
expect(screen.getByText('read')).toBeInTheDocument()
expect(screen.getByText('read', { selector: 'code' })).toBeInTheDocument()
})
it('holds one glyph for a category across running, completed, and failed', () => {
@@ -26,8 +26,9 @@ import type { NativeChatEditFile } from '../../../../shared/native-chat-edit-mod
import {
countToolCalls,
createToolInputDisplay,
summarizeToolRun,
truncateToolDetail
toolRunSummaryMembers,
truncateToolDetail,
type ToolRunMember
} from './native-chat-tool-summary'
import {
NATIVE_CHAT_TOOL_ACTIVITY_COPY,
@@ -237,7 +238,22 @@ export function NativeChatToolRun({
.filter(isRenderableSubagentGroup)
.map((group) => <NativeChatSubagentRun key={group.groupId} block={group} />)
const callCount = countToolCalls(blocks) || blocks.length
const summary = summarizeToolRun(blocks)
// Members stay separate all the way to the markup: joining them into one
// string is what made a run read as a single call, because the separator also
// occurs inside tool names like `browser.open` and `tools/read`.
const summaryMembers = toolRunSummaryMembers(blocks)
const hiddenCallCount = Math.max(0, callCount - summaryMembers.length)
// Same content-signature keying the member rows below use: two identical calls
// in one run are distinguished by occurrence, never by list position.
const keyedSummaryMembers = ((): (ToolRunMember & { key: string })[] => {
const seen = new Map<string, number>()
return summaryMembers.map((member) => {
const signature = `${member.name}:${member.arg}`
const occurrence = seen.get(signature) ?? 0
seen.set(signature, occurrence + 1)
return { ...member, key: `${signature}:${occurrence}` }
})
})()
const latestActiveCall = structuredActivityUi
? selectActiveToolCall(blocks, { activeTurnIsWorking })
: null
@@ -251,7 +267,7 @@ export function NativeChatToolRun({
() => (open ? buildEditCards(blocks) : NO_EDIT_CARDS),
[open, blocks]
)
// Only the settled header reads this. It stands over `summary`, which speaks
// Only the settled header reads this. It stands over `summaryMembers`, which speaks
// for the run's first calls rather than its last, so a glyph taken from one
// call would assert a category the text beside it doesn't describe. A run that
// spans categories therefore heads with the generic tool glyph. The glyph is
@@ -329,9 +345,45 @@ export function NativeChatToolRun({
<span className="shrink-0 font-mono text-[11px] font-bold text-muted-foreground transition-colors group-hover:text-foreground/80">
{callCount}×
</span>
<span className="min-w-0 truncate font-mono text-[11px] text-muted-foreground transition-colors group-hover:text-foreground/80">
{summary || fallbackLabel}
</span>
{summaryMembers.length > 0 ? (
/* Wraps rather than truncates: a clipped member name is the one thing
the header cannot afford to lose. */
<span className="flex min-w-0 flex-1 flex-wrap items-center gap-1">
{keyedSummaryMembers.map((member) => (
<span
key={member.key}
className="flex min-w-0 max-w-72 items-center gap-1 rounded-sm bg-accent px-1.5 py-px font-mono text-[11px] text-foreground/80"
>
<NativeChatToolIcon
rowWord={member.name}
mcpIdentity={member.mcpIdentity}
className="size-3.5 text-muted-foreground"
/>
{/* Name and argument share one text run so the pill still reads
as `name arg` to a screen reader and to a text selection. */}
<span className="truncate">
{member.name}
{member.arg ? (
<span className="text-muted-foreground">{` ${member.arg}`}</span>
) : null}
</span>
</span>
))}
{hiddenCallCount > 0 ? (
<span className="shrink-0 font-mono text-[11px] text-muted-foreground">
{translate(
'components.native-chat.tool.moreCalls',
NATIVE_CHAT_TOOL_ACTIVITY_COPY.moreCalls,
{ value0: hiddenCallCount }
)}
</span>
) : null}
</span>
) : (
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground transition-colors group-hover:text-foreground/80">
{fallbackLabel}
</span>
)}
{/* Completion reads as a trailing mark so the leading glyph can stay fixed. */}
{structuredActivityUi ? (
<Check aria-hidden className="size-3 shrink-0 text-muted-foreground" />
@@ -346,7 +398,10 @@ export function NativeChatToolRun({
</button>
)}
{open ? (
<div className="mt-1">
// Members are indented under the header because nothing else marks the
// run's extent — flush rows are indistinguishable from the blocks after
// them, so the batch has no visible end.
<div className="mt-1 pl-4">
{(() => {
const seen = new Map<string, number>()
return blocks.map((block) => {
@@ -6,5 +6,7 @@ export {
summarizeToolInput,
summarizeToolRun,
toolFilePath,
toolRunSummaryMembers,
truncateToolDetail
} from '../../../../shared/native-chat-tool-summary'
export type { ToolRunMember } from '../../../../shared/native-chat-tool-summary'
+1
View File
@@ -17039,6 +17039,7 @@
"result": "Result",
"countOne": "1 tool call",
"countN": "{{value0}} tool calls",
"moreCalls": "+{{value0}} more",
"runningPreview": "Running {{preview}}",
"runningCommand": "Running command",
"runningNamedPreview": "Running {{toolName}} {{preview}}",
+2 -1
View File
@@ -13,7 +13,8 @@ export const NATIVE_CHAT_TOOL_ACTIVITY_COPY = {
runningNamedPreview: 'Running {{toolName}} {{preview}}',
runningNamed: 'Running {{toolName}}',
countOne: '1 tool call',
countN: '{{value0}} tool calls'
countN: '{{value0}} tool calls',
moreCalls: '+{{value0}} more'
} as const
/** Tools whose call is a shell command, so the row reads as terminal activity
@@ -9,6 +9,7 @@ import {
MAX_TOOL_DETAIL_LENGTH,
summarizeToolInput,
summarizeToolRun,
toolRunSummaryMembers,
toolFilePath,
truncateToolDetail
} from './native-chat-tool-summary'
@@ -254,6 +255,73 @@ describe('briefToolArg', () => {
})
})
describe('toolRunSummaryMembers', () => {
it('keeps each call separate so a boundary can be drawn between them', () => {
const blocks: NativeChatBlock[] = [
{ type: 'tool-call', name: 'Bash', input: { command: 'ls -la' } },
{ type: 'tool-call', name: 'tools/read', input: { file_path: 'README.md' } }
]
expect(toolRunSummaryMembers(blocks)).toEqual([
{ name: 'Bash', arg: 'ls -la', mcpIdentity: undefined },
{ name: 'tools/read', arg: 'README.md', mcpIdentity: undefined }
])
})
// `url` is a PRIMARY_ARG_KEY but not a BRIEF_ARG_KEY, so a call carrying only
// a url falls through to the bounded JSON preview and is cut at 28 chars —
// mid-token, brace unbalanced. Pinned as-is: the header renders whatever this
// returns, and changing the key list would move mobile's summary too.
it('still falls through to a clipped JSON preview for a url-only call', () => {
const blocks: NativeChatBlock[] = [
{ type: 'tool-call', name: 'browser.open', input: { url: 'https://example.com' } }
]
expect(toolRunSummaryMembers(blocks)[0]?.arg).toBe('{"url":"https://example.com"')
})
it('caps at the summary limit and skips nameless calls, as the joined string does', () => {
const blocks: NativeChatBlock[] = [
{ type: 'tool-call', name: ' ', input: {} },
{ type: 'tool-call', name: 'Bash', input: { command: 'ls' } },
{ type: 'tool-call', name: 'Read', input: { file_path: 'a.ts' } },
{ type: 'tool-call', name: 'Edit', input: { file_path: 'b.ts' } },
{ type: 'tool-call', name: 'Write', input: { file_path: 'c.ts' } }
]
const members = toolRunSummaryMembers(blocks)
expect(members.map((member) => member.name)).toEqual(['Bash', 'Read', 'Edit'])
// The joined string is derived from these, so the two can never disagree.
expect(summarizeToolRun(blocks)).toBe(
members.map((member) => `${member.name} ${member.arg}`).join(' · ')
)
})
it('carries provider MCP identity through, so a pill can draw the server glyph', () => {
const blocks: NativeChatBlock[] = [
{
type: 'tool-call',
name: 'mcp__linear__list_issues',
input: {},
mcpIdentity: { server: 'linear', tool: 'list_issues' }
}
]
expect(toolRunSummaryMembers(blocks)[0]?.mcpIdentity).toEqual({
server: 'linear',
tool: 'list_issues'
})
})
it('reports a blank argument rather than standing raw JSON in for one', () => {
const blocks: NativeChatBlock[] = [{ type: 'tool-call', name: 'Bash', input: { command: '' } }]
expect(toolRunSummaryMembers(blocks)).toEqual([
{ name: 'Bash', arg: '', mcpIdentity: undefined }
])
})
})
describe('summarizeToolRun', () => {
it('caps the run summary and skips nameless calls', () => {
const blocks: NativeChatBlock[] = [
+24 -6
View File
@@ -1,3 +1,4 @@
import type { NativeChatMcpIdentity } from './native-chat-tool-identity'
import { isToolCallBlock, type NativeChatBlock } from './native-chat-types'
const MAX_PREVIEW_LENGTH = 80
@@ -260,8 +261,20 @@ function summarizePrimaryToolArg(input: unknown): string | null {
return null
}
export function summarizeToolRun(blocks: readonly NativeChatBlock[]): string {
const parts: string[] = []
/** One named call in a run header, kept apart rather than pre-joined so a
* surface can draw the boundary between members itself. */
export type ToolRunMember = {
name: string
/** Brief argument, or '' when the call has none worth showing. */
arg: string
mcpIdentity?: NativeChatMcpIdentity
}
/** The run header's leading calls. Capped at the same limit the joined string
* has always used, so the two can never disagree about which calls speak for
* a run. */
export function toolRunSummaryMembers(blocks: readonly NativeChatBlock[]): ToolRunMember[] {
const members: ToolRunMember[] = []
for (const block of blocks) {
if (!isToolCallBlock(block)) {
continue
@@ -270,13 +283,18 @@ export function summarizeToolRun(blocks: readonly NativeChatBlock[]): string {
if (!name) {
continue
}
const detail = briefToolArg(block.input)
parts.push(detail ? `${name} ${detail}` : name)
if (parts.length >= MAX_TOOL_RUN_SUMMARY_PARTS) {
members.push({ name, arg: briefToolArg(block.input), mcpIdentity: block.mcpIdentity })
if (members.length >= MAX_TOOL_RUN_SUMMARY_PARTS) {
break
}
}
return parts.join(' · ')
return members
}
export function summarizeToolRun(blocks: readonly NativeChatBlock[]): string {
return toolRunSummaryMembers(blocks)
.map((member) => (member.arg ? `${member.name} ${member.arg}` : member.name))
.join(' · ')
}
export function countToolCalls(blocks: readonly NativeChatBlock[]): number {