diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.test.tsx index cb9ad6932f0..bdfc80c5f77 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.test.tsx @@ -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') diff --git a/src/renderer/src/components/native-chat/NativeChatSubagentRun.test.tsx b/src/renderer/src/components/native-chat/NativeChatSubagentRun.test.tsx index 925f0cd583a..61fc599f132 100644 --- a/src/renderer/src/components/native-chat/NativeChatSubagentRun.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatSubagentRun.test.tsx @@ -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') }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatToolRun.identity.test.tsx b/src/renderer/src/components/native-chat/NativeChatToolRun.identity.test.tsx index 386ef47af00..2b83f86d14a 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolRun.identity.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolRun.identity.test.tsx @@ -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) } ) diff --git a/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx b/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx index da9202254c0..bba523b40fd 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx @@ -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() + + 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() + + 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() + + 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() + + 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() + + expect(runHeader(container)).not.toHaveTextContent('more') + }) + + it('indents opened members so the run has a visible end', () => { + const { container } = render() + + 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( + + ) + + 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() + const { rerender, container } = render( + + ) 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( { /> ) - 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', () => { diff --git a/src/renderer/src/components/native-chat/NativeChatToolRun.tsx b/src/renderer/src/components/native-chat/NativeChatToolRun.tsx index 5935add6932..c50fb9838b5 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolRun.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolRun.tsx @@ -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) => ) 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() + 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({ {callCount}× - - {summary || fallbackLabel} - + {summaryMembers.length > 0 ? ( + /* Wraps rather than truncates: a clipped member name is the one thing + the header cannot afford to lose. */ + + {keyedSummaryMembers.map((member) => ( + + + {/* Name and argument share one text run so the pill still reads + as `name arg` to a screen reader and to a text selection. */} + + {member.name} + {member.arg ? ( + {` ${member.arg}`} + ) : null} + + + ))} + {hiddenCallCount > 0 ? ( + + {translate( + 'components.native-chat.tool.moreCalls', + NATIVE_CHAT_TOOL_ACTIVITY_COPY.moreCalls, + { value0: hiddenCallCount } + )} + + ) : null} + + ) : ( + + {fallbackLabel} + + )} {/* Completion reads as a trailing mark so the leading glyph can stay fixed. */} {structuredActivityUi ? ( @@ -346,7 +398,10 @@ export function NativeChatToolRun({ )} {open ? ( -
+ // 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. +
{(() => { const seen = new Map() return blocks.map((block) => { diff --git a/src/renderer/src/components/native-chat/native-chat-tool-summary.ts b/src/renderer/src/components/native-chat/native-chat-tool-summary.ts index f786de33fff..02f5f7451ec 100644 --- a/src/renderer/src/components/native-chat/native-chat-tool-summary.ts +++ b/src/renderer/src/components/native-chat/native-chat-tool-summary.ts @@ -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' diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 7a205ba816b..c533c1fde13 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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}}", diff --git a/src/shared/native-chat-tool-activity.ts b/src/shared/native-chat-tool-activity.ts index 533dd75aecf..58a1179e9ac 100644 --- a/src/shared/native-chat-tool-activity.ts +++ b/src/shared/native-chat-tool-activity.ts @@ -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 diff --git a/src/shared/native-chat-tool-summary.test.ts b/src/shared/native-chat-tool-summary.test.ts index f11481b1136..d22f5031eea 100644 --- a/src/shared/native-chat-tool-summary.test.ts +++ b/src/shared/native-chat-tool-summary.test.ts @@ -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[] = [ diff --git a/src/shared/native-chat-tool-summary.ts b/src/shared/native-chat-tool-summary.ts index 57b42607569..60cd4ba9d53 100644 --- a/src/shared/native-chat-tool-summary.ts +++ b/src/shared/native-chat-tool-summary.ts @@ -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 {