feat(desktop): improve tool call detail views (#69868)

* fix(desktop): improve fallback tool-call details

Show failed image-generation calls through the normal fallback row, remove duplicate normal-mode web-search JSON, and format Technical Mode payloads as readable JSON.

* feat(desktop): render terminal tool calls as transcripts

Show terminal commands with a prompt and exit status, then reveal ANSI-safe stdout and stderr in the expanded tool row.

* fix(desktop): reconcile tool calls by command

Match context-only tool starts with command-bearing completions when their IDs differ, preventing stale duplicate terminal rows. Show the web-search query above its result cards.
This commit is contained in:
ethernet 2026-07-23 01:27:38 -04:00 committed by GitHub
parent c1b0f6f3c1
commit 26f1f6a76b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 254 additions and 49 deletions

View File

@ -14,12 +14,21 @@ import { ActivityTimerText } from '@/components/chat/activity-timer-text'
import { DisclosureRow } from '@/components/chat/disclosure-row'
import { GeneratedImage } from '@/components/chat/generated-image-result'
import { useI18n } from '@/i18n'
import { generatedImageFromResult } from '@/lib/generated-images'
import { useEnterAnimation } from '@/lib/use-enter-animation'
import { cn } from '@/lib/utils'
const ImageGenerateTool: FC<ToolCallMessagePartProps> = ({ args, result }) => {
const ImageGenerateTool: FC<ToolCallMessagePartProps> = props => {
const { args, result } = props
const aspectRatio = typeof args?.aspect_ratio === 'string' ? args.aspect_ratio : undefined
// The image card owns successful generations. Failed or malformed results
// still need the normal tool row: it extracts the error text and gives the
// user an honest, expandable failure rather than silently dropping the call.
if (result !== undefined && !generatedImageFromResult(result)) {
return <ToolFallback {...props} />
}
return (
<div className="mt-1.5">
<GeneratedImage aspectRatio={aspectRatio} result={result} />

View File

@ -217,7 +217,10 @@ function assistantTodoMessage(
} as ThreadMessage
}
function assistantImageMessage(running = false): ThreadMessage {
function assistantImageMessage(
running = false,
result: unknown = { image: 'https://cdn.example/cat.png', success: true }
): ThreadMessage {
return {
id: `assistant-image-${running ? 'running' : 'done'}`,
role: 'assistant',
@ -228,7 +231,7 @@ function assistantImageMessage(running = false): ThreadMessage {
toolName: 'image_generate',
args: { prompt: 'draw a cat' },
argsText: JSON.stringify({ prompt: 'draw a cat' }),
...(running ? {} : { result: { image: 'https://cdn.example/cat.png', success: true } })
...(running ? {} : { result })
}
],
status: running ? { type: 'running' } : { type: 'complete', reason: 'stop' },
@ -243,6 +246,32 @@ function assistantImageMessage(running = false): ThreadMessage {
} as ThreadMessage
}
function assistantTerminalMessage(): ThreadMessage {
return {
id: 'assistant-terminal-1',
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: 'terminal-1',
toolName: 'terminal',
args: { command: 'npm run check --workspace=apps/desktop' },
argsText: JSON.stringify({ command: 'npm run check --workspace=apps/desktop' }),
result: { exit_code: 0, stdout: 'all checks passed' }
}
],
status: { type: 'complete', reason: 'stop' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
interface StreamingControls {
emitSecond: () => void
complete: () => void
@ -634,4 +663,32 @@ describe('assistant-ui streaming renderer', () => {
expect(container.querySelector('[data-slot="aui_generated-image"]')).toBeTruthy()
expect(screen.queryByRole('status', { name: /rendering image/i })).toBeNull()
})
it('uses the normal tool row for failed image generations instead of dropping their error payload', async () => {
const { container } = render(
<MessageHarness
message={assistantImageMessage(false, { error: 'FAL rejected the prompt', image: null, success: false })}
/>
)
fireEvent.click(container.querySelector('[data-tool-row] button')!)
await waitFor(() => {
expect(container.textContent).toContain('FAL rejected the prompt')
})
expect(container.querySelector('[data-slot="aui_generated-image"]')).toBeNull()
expect(container.textContent).not.toContain('"success":false')
})
it('shows the command prompt and exit code for terminal calls', async () => {
const { container } = render(<MessageHarness message={assistantTerminalMessage()} />)
fireEvent.click(container.querySelector('[data-tool-row] button')!)
await waitFor(() => {
expect(container.textContent).toContain('$ npm run check --workspace=apps/desktop')
expect(container.textContent).toContain('exit 0')
expect(container.textContent).toContain('all checks passed')
})
})
})

View File

@ -76,6 +76,38 @@ describe('buildToolView terminal exit-code status', () => {
'error'
)
})
it('keeps the command and exit code for the terminal transcript', () => {
const view = buildToolView(
part({
args: { command: 'npm run check --workspace=apps/desktop' },
result: { exit_code: 0, output: 'done' },
toolName: 'terminal'
}),
''
)
expect(view.terminalCommand).toBe('npm run check --workspace=apps/desktop')
expect(view.terminalExitCode).toBe(0)
})
})
describe('buildToolView web-search query', () => {
it('keeps the query separate from structured search results', () => {
const view = buildToolView(
part({
args: { query: 'Hermes Agent Desktop tool calls' },
result: { web: [{ snippet: 'Desktop docs', title: 'Hermes docs', url: 'https://example.com/docs' }] },
toolName: 'web_search'
}),
''
)
expect(view.searchQuery).toBe('Hermes Agent Desktop tool calls')
expect(view.searchHits).toEqual([
{ snippet: 'Desktop docs', title: 'Hermes docs', url: 'https://example.com/docs' }
])
})
})
describe('buildToolView browser_navigate title', () => {

View File

@ -128,6 +128,14 @@ function readFileDisplayTarget(args: Record<string, unknown>, result: Record<str
return [fileEditBasename(path), lineLabel].filter(Boolean).join(' ')
}
function shellCommand(args: Record<string, unknown>): string {
return (
firstStringField(args, ['context', 'preview']) ||
firstStringField(args, ['command', 'code']) ||
contextValue(args)
)
}
const TOOL_META: Record<ToolTitleKey, ToolMetaSpec> = {
browser_click: {
icon: 'globe',
@ -1315,10 +1323,7 @@ function dynamicTitle(
}
if (part.toolName === 'terminal' || part.toolName === 'execute_code') {
const command =
firstStringField(args, ['context', 'preview']) ||
firstStringField(args, ['command', 'code']) ||
contextValue(args)
const command = shellCommand(args)
if (command) {
const action =
@ -1384,6 +1389,8 @@ export function buildToolView(part: ToolPart, inlineDiff: string): ToolView {
const searchHits =
part.toolName === 'web_search' && status !== 'error' ? extractSearchResults(part.result) : undefined
const searchQuery =
part.toolName === 'web_search' ? firstStringField(argsRecord, ['search_term', 'query']) || contextValue(argsRecord) : ''
const resultCount = status === 'error' ? null : toolResultCount(part, argsRecord, resultRecord)
@ -1398,6 +1405,8 @@ export function buildToolView(part: ToolPart, inlineDiff: string): ToolView {
// field — otherwise the merged `detail` already covers it and double-
// rendering would duplicate output.
const hasSplitStreams = rendersAnsi && (Boolean(stdout) || Boolean(stderrRaw))
const terminalCommand = part.toolName === 'terminal' ? shellCommand(argsRecord) : undefined
const terminalExitCode = part.toolName === 'terminal' ? numericField(resultRecord, 'exit_code') : undefined
return {
countLabel: resultCount ? formatCountLabel(resultCount) : undefined,
@ -1409,8 +1418,11 @@ export function buildToolView(part: ToolPart, inlineDiff: string): ToolView {
inlineDiff,
previewTarget: toolPreviewTarget(part.toolName, argsRecord, resultRecord),
rendersAnsi: rendersAnsi || undefined,
searchQuery: searchQuery || undefined,
searchHits: searchHits?.length ? searchHits : undefined,
stderr: hasSplitStreams ? stderrRaw || undefined : undefined,
terminalCommand,
terminalExitCode,
stdout: hasSplitStreams ? stdout || undefined : undefined,
status,
subtitle,

View File

@ -40,11 +40,17 @@ export interface ToolView {
* (terminal/execute_code) so the renderer knows to run them through
* the ANSI parser instead of printing them as literals. */
rendersAnsi?: boolean
/** Original query, shown above structured web-search results. */
searchQuery?: string
searchHits?: SearchResultRow[]
/** When the backend reports stderr as a separate stream (terminal /
* execute_code), the renderer shows it as its own labeled, neutrally
* tinted block under stdout distinct from an error tone. */
stderr?: string
/** Terminal-only command shown as the prompt in the expanded transcript. */
terminalCommand?: string
/** Terminal-only process exit code, when the backend reported one. */
terminalExitCode?: number
/** When set, the renderer uses stdout+stderr as separate sections and
* ignores the merged `detail`. */
stdout?: string

View File

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { shouldBoundToolGroup, UNBOUNDABLE_TOOLS } from './fallback'
import { shouldBoundToolGroup, technicalTrace, UNBOUNDABLE_TOOLS } from './fallback'
describe('shouldBoundToolGroup', () => {
it('bounds long runs of ordinary tool calls', () => {
@ -22,3 +22,16 @@ describe('UNBOUNDABLE_TOOLS', () => {
expect(UNBOUNDABLE_TOOLS.has('image_generate')).toBe(true)
})
})
describe('technicalTrace', () => {
it('indents object payloads and persisted JSON strings', () => {
expect(technicalTrace({ offset: 2, path: '/tmp/demo.txt' }, '{"success":true,"lines":["a","b"]}')).toBe(
'Arguments:\n{\n "offset": 2,\n "path": "/tmp/demo.txt"\n}\n\nResult:\n{\n "success": true,\n "lines": [\n "a",\n "b"\n ]\n}'
)
})
it('leaves scalar strings untouched', () => {
expect(technicalTrace(undefined, 'plain text')).toBe('Result:\nplain text')
expect(technicalTrace(undefined, '"already quoted"')).toBe('Result:\n"already quoted"')
})
})

View File

@ -62,7 +62,6 @@ import {
type ToolStatus,
type ToolTitleAction
} from './fallback-model'
import { prettyJson } from './fallback-model/format'
// `true` when a ToolEntry is rendered inside an embedding wrapper that owns
// the per-row chrome (timer / preview). The flat ToolGroupSlot sets this
@ -104,23 +103,39 @@ interface ToolStatusCopy {
statusRunning: string
}
function rawTechnicalTrace(args: unknown, result: unknown): string {
const parts = [args, result]
.filter(value => value !== undefined && value !== null)
.map(value => {
if (typeof value === 'string') {
return value
}
function prettyTechnicalValue(value: unknown): string {
if (typeof value === 'string') {
const trimmed = value.trim()
try {
return JSON.stringify(value)
} catch {
return String(value)
}
})
.filter(Boolean)
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
return value
}
return clampForDisplay(parts.join('\n'))
try {
const parsed = JSON.parse(value)
return parsed && typeof parsed === 'object' ? JSON.stringify(parsed, null, 2) : value
} catch {
return value
}
}
try {
return JSON.stringify(value, null, 2)
} catch {
return String(value)
}
}
export function technicalTrace(args: unknown, result: unknown): string {
const parts = [
['Arguments', args],
['Result', result]
]
.filter(([, value]) => value !== undefined && value !== null)
.map(([label, value]) => `${label}:\n${prettyTechnicalValue(value)}`)
return clampForDisplay(parts.join('\n\n'))
}
function statusGlyph(status: ToolStatus, copy: ToolStatusCopy): ReactNode {
@ -363,7 +378,8 @@ function ToolEntry({ part }: ToolEntryProps) {
const showDetail =
!view.inlineDiff &&
((view.status === 'error' && Boolean(detailSections.summary || detailSections.body)) ||
(Boolean(view.stdout || view.stderr) ||
(view.status === 'error' && Boolean(detailSections.summary || detailSections.body)) ||
(view.status !== 'error' && Boolean(view.detail) && !detailMatchesTitle && !detailMatchesSubtitle))
const renderDetailAsCode =
@ -373,21 +389,16 @@ function ToolEntry({ part }: ToolEntryProps) {
const hasSearchHits = Boolean(view.searchHits?.length)
const searchResultsLabel = part.toolName === 'web_search' ? 'Search results' : view.detailLabel
// Only web_search renders the raw JSON drilldown, so serialize the result
// lazily here instead of prettyJson-ing every tool's result in buildToolView.
const rawResult = useMemo(
() => (part.toolName === 'web_search' && toolViewMode !== 'technical' ? prettyJson(part.result) : ''),
[part.toolName, part.result, toolViewMode]
)
const showRawSearchDrilldown =
part.toolName === 'web_search' &&
part.result !== undefined &&
toolViewMode !== 'technical' &&
Boolean(rawResult.trim())
const hasExpandableContent = Boolean(
view.imageUrl || view.inlineDiff || showDetail || hasSearchHits || toolViewMode === 'technical'
view.imageUrl ||
view.inlineDiff ||
showDetail ||
hasSearchHits ||
view.stdout ||
view.stderr ||
view.terminalCommand ||
view.terminalExitCode !== undefined ||
toolViewMode === 'technical'
)
// copyAction reads the uncapped view.detail; clampForDisplay below only bounds
@ -511,6 +522,9 @@ function ToolEntry({ part }: ToolEntryProps) {
text={copyAction.text}
/>
)}
{part.toolName === 'terminal' && toolViewMode !== 'technical' && (
<TerminalTranscript command={view.terminalCommand} exitCode={view.terminalExitCode} />
)}
{view.imageUrl && (
<div className="max-w-72 overflow-hidden rounded-[0.25rem] border border-(--ui-stroke-tertiary)">
<ZoomableImage alt={copy.outputAlt} className="h-auto w-full object-cover" src={view.imageUrl} />
@ -518,6 +532,12 @@ function ToolEntry({ part }: ToolEntryProps) {
)}
{hasSearchHits && view.searchHits && (
<div className="max-w-full text-xs leading-relaxed text-(--ui-text-secondary)">
{view.searchQuery && (
<p className="mb-1 flex min-w-0 gap-1.5 wrap-anywhere">
<span className="shrink-0 font-medium text-(--ui-text-tertiary)">Search</span>
<span>{view.searchQuery}</span>
</p>
)}
{searchResultsLabel && <p className={TOOL_SECTION_LABEL_CLASS}>{searchResultsLabel}</p>}
<SearchResultsList hits={view.searchHits} />
</div>
@ -596,22 +616,16 @@ function ToolEntry({ part }: ToolEntryProps) {
)}
</div>
))}
{showRawSearchDrilldown && (
<details className="max-w-full">
<summary className={cn(TOOL_SECTION_LABEL_CLASS, 'mb-0')}>{copy.rawResponse}</summary>
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'mt-1 whitespace-pre-wrap wrap-anywhere')}>{rawResult}</pre>
</details>
)}
{toolViewMode === 'technical' && !(isFileEdit && view.inlineDiff) && (
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'whitespace-pre-wrap wrap-anywhere')}>
{rawTechnicalTrace(part.args, part.result)}
{technicalTrace(part.args, part.result)}
</pre>
)}
{toolViewMode === 'technical' && isFileEdit && view.inlineDiff && (
<details className="max-w-full">
<summary className={cn(TOOL_SECTION_LABEL_CLASS, 'mb-0 cursor-pointer')}>Tool payload</summary>
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'mt-1 whitespace-pre-wrap wrap-anywhere')}>
{rawTechnicalTrace(part.args, part.result)}
{technicalTrace(part.args, part.result)}
</pre>
</details>
)}
@ -621,6 +635,38 @@ function ToolEntry({ part }: ToolEntryProps) {
)
}
interface TerminalTranscriptProps {
command?: string
exitCode?: number
}
function TerminalTranscript({ command, exitCode }: TerminalTranscriptProps) {
if (!command && exitCode === undefined) {
return null
}
return (
<div className="flex min-w-0 items-center gap-2 rounded-[0.25rem] border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-2 py-1.5 font-mono text-[0.7rem] leading-relaxed">
{command && (
<code className="min-w-0 flex-1 whitespace-pre-wrap wrap-anywhere text-(--ui-text-secondary)">
<span aria-hidden className="select-none text-(--ui-accent-secondary)">$ </span>
{command}
</code>
)}
{exitCode !== undefined && (
<span
className={cn(
'shrink-0 rounded bg-(--ui-bg-tertiary) px-1 py-px text-[0.6rem] tabular-nums',
exitCode === 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-amber-600 dark:text-amber-400'
)}
>
exit {exitCode}
</span>
)}
</div>
)
}
// A back-to-back run of this many tool calls collapses into the bounded,
// auto-scrolling window; fewer than this stays a plain inline stack.
const TOOL_GROUP_SCROLL_THRESHOLD = 3

View File

@ -538,6 +538,36 @@ describe('upsertToolPart', () => {
expect(summaries).toEqual(['Did 5 searches', 'Did 5 searches'])
})
it('pairs a terminal completion with its context-only start when event IDs differ', () => {
const started = upsertToolPart(
[],
{ context: 'echo "Hello from the terminal"', name: 'terminal', tool_id: 'terminal-start' },
'running'
)
const completed = upsertToolPart(
started,
{
args: { command: 'echo "Hello from the terminal"' },
name: 'terminal',
result: { exit_code: 0, stdout: 'Hello from the terminal' },
tool_id: 'terminal-complete'
},
'complete'
)
const terminalParts = completed.filter(
(part): part is Extract<ChatMessagePart, { type: 'tool-call' }> => part.type === 'tool-call' && part.toolName === 'terminal'
)
expect(terminalParts).toHaveLength(1)
expect(terminalParts[0]?.toolCallId).toBe('terminal-complete')
expect(terminalParts[0] && 'result' in terminalParts[0] ? terminalParts[0].result : undefined).toMatchObject({
exit_code: 0,
stdout: 'Hello from the terminal'
})
})
it('preserves query args when completion payload omits context', () => {
const started = upsertToolPart(
[],

View File

@ -360,7 +360,7 @@ function toolPayloadMatchValues(payload: GatewayEventPayload | undefined): strin
// `clarify.request` (a fresh request id) must correlate with the `tool.start`
// row (the model's tool_call_id) so the two ids don't produce a duplicate
// clarify card — same correlation ClarifyToolPending uses for request↔args.
const query = firstStringField(payloadArgs, ['search_term', 'query', 'question'])
const query = firstStringField(payloadArgs, ['search_term', 'query', 'question', 'command', 'code', 'path'])
const context = typeof payload?.context === 'string' ? payload.context.trim() : ''
const preview = typeof payload?.preview === 'string' ? payload.preview.trim() : ''
@ -373,7 +373,7 @@ function toolPartMatchValues(part: ChatMessagePart): string[] {
}
const args = part.args as Record<string, unknown>
const query = firstStringField(args, ['search_term', 'query', 'question'])
const query = firstStringField(args, ['search_term', 'query', 'question', 'command', 'code', 'path'])
const context = typeof args.context === 'string' ? args.context.trim() : ''
const preview = typeof args.preview === 'string' ? args.preview.trim() : ''