fix(desktop): show full tool error details (#625)

Surface expanded error output for tool cards whose previews previously returned before rendering the result body. Keep successful Bash/Read/Edit/Write outputs hidden as before while exposing error details with wrapping and copy support.

Tested: cd desktop && bun run test -- src/components/chat/chatBlocks.test.tsx
Tested: bun test src/server/__tests__/conversations.test.ts -t "should switch from bypass permissions back to default without restarting" --timeout=20000
Tested: bun run verify
Confidence: high
Scope-risk: narrow
This commit is contained in:
程序员阿江(Relakkes) 2026-06-09 21:07:32 +08:00
parent 385b996736
commit db631dfdfd
2 changed files with 147 additions and 32 deletions

View File

@ -131,57 +131,94 @@ function renderPreview(
t?: (key: TranslationKey, params?: Record<string, string | number>) => string,
) {
const filePath = typeof obj.file_path === 'string' ? obj.file_path : 'file'
const resultText = getVisibleResultText(toolName, result)
const resultOutput = result && resultText ? renderResultOutput(result, resultText, t) : null
if (toolName === 'Edit' && typeof obj.old_string === 'string' && typeof obj.new_string === 'string') {
return <DiffViewer filePath={filePath} oldString={obj.old_string} newString={obj.new_string} />
return (
<>
<DiffViewer filePath={filePath} oldString={obj.old_string} newString={obj.new_string} />
{resultOutput}
</>
)
}
if (toolName === 'Write' && typeof obj.content === 'string') {
return <DiffViewer filePath={filePath} oldString="" newString={obj.content} />
return (
<>
<DiffViewer filePath={filePath} oldString="" newString={obj.content} />
{resultOutput}
</>
)
}
if (toolName === 'Bash' && typeof obj.command === 'string') {
return (
<TerminalChrome title={typeof obj.description === 'string' ? obj.description : filePath}>
<div className="px-3 py-2.5 font-[var(--font-mono)] text-[11px] leading-[1.3] text-[var(--color-terminal-fg)]">
<span className="text-[var(--color-terminal-accent)]">$</span> {obj.command}
</div>
</TerminalChrome>
<>
<TerminalChrome title={typeof obj.description === 'string' ? obj.description : filePath}>
<div className="px-3 py-2.5 font-[var(--font-mono)] text-[11px] leading-[1.3] text-[var(--color-terminal-fg)]">
<span className="text-[var(--color-terminal-accent)]">$</span> {obj.command}
</div>
</TerminalChrome>
{resultOutput}
</>
)
}
if (toolName === 'Read') {
return null
return resultOutput
}
if (result) {
const text = extractTextContent(result.content)
if (text) {
return (
<>
<InlineImageGallery text={text} />
<div className={`overflow-hidden rounded-lg border ${
result.isError
? 'border-[var(--color-error)]/20 bg-[var(--color-error-container)]/60'
: 'border-[var(--color-border)] bg-[var(--color-surface)]'
}`}>
<div className="flex items-center justify-between border-b border-[var(--color-border)]/60 px-3 py-2 text-[10px] uppercase tracking-[0.18em] text-[var(--color-outline)]">
<span>{result.isError ? t?.('tool.errorOutput') ?? 'Error Output' : t?.('tool.toolOutput') ?? 'Tool Output'}</span>
<CopyButton
text={text}
className="rounded-md border border-[var(--color-border)] px-2 py-1 text-[10px] normal-case tracking-normal text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)]"
/>
</div>
<CodeViewer code={text} language="plaintext" maxLines={18} />
</div>
</>
)
}
}
if (resultOutput) return resultOutput
return null
}
function getVisibleResultText(
toolName: string,
result?: { content: unknown; isError: boolean } | null,
): string | null {
if (!result) return null
const text = extractTextContent(result.content)
if (!text) return null
if (result.isError) return text
if (toolName === 'Bash' || toolName === 'Read' || toolName === 'Edit' || toolName === 'Write') return null
return text
}
function renderResultOutput(
result: { content: unknown; isError: boolean },
text: string,
t?: (key: TranslationKey, params?: Record<string, string | number>) => string,
) {
return (
<>
<InlineImageGallery text={text} />
<div className={`overflow-hidden rounded-lg border ${
result.isError
? 'border-[var(--color-error)]/20 bg-[var(--color-error-container)]/60'
: 'border-[var(--color-border)] bg-[var(--color-surface)]'
}`}>
<div className="flex items-center justify-between border-b border-[var(--color-border)]/60 px-3 py-2 text-[10px] uppercase tracking-[0.18em] text-[var(--color-outline)]">
<span>{result.isError ? t?.('tool.errorOutput') ?? 'Error Output' : t?.('tool.toolOutput') ?? 'Tool Output'}</span>
<CopyButton
text={text}
className="rounded-md border border-[var(--color-border)] px-2 py-1 text-[10px] normal-case tracking-normal text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)]"
/>
</div>
{result.isError ? (
<pre className="max-h-[420px] overflow-auto whitespace-pre-wrap break-words bg-[var(--color-code-bg)] px-3 py-2 font-[var(--font-mono)] text-[12px] leading-[1.45] text-[var(--color-error)]">
{text}
</pre>
) : (
<CodeViewer code={text} language="plaintext" maxLines={18} />
)}
</div>
</>
)
}
function renderDetails(
toolName: string,
obj: Record<string, unknown>,

View File

@ -159,6 +159,84 @@ describe('chat blocks', () => {
expect(container.textContent).toContain('fatal: unrecognized argument: --no-stat')
})
it('shows full bash error output when the tool block is expanded', () => {
const lines = Array.from({ length: 8 }, (_, index) => `detail line ${index + 1}`)
const fullError = [
'<tool_use_error>InputValidationError: Bash failed due to the following issues: The required parameter `description` is missing.',
...lines,
'final remediation hint: provide a concise command description.',
].join('\n')
const { container } = render(
<ToolCallBlock
toolName="Bash"
input={{ command: 'bun run check:server' }}
result={{ content: fullError, isError: true }}
/>,
)
expect(container.textContent).toContain('InputValidationError')
expect(container.textContent).not.toContain('final remediation hint')
fireEvent.click(screen.getByRole('button'))
expect(container.textContent).toContain('Error Output')
expect(container.textContent).toContain('detail line 8')
expect(container.textContent).toContain('final remediation hint')
})
it('shows read tool validation errors when the tool block is expanded', () => {
const fullError = [
'<tool_use_error>InputValidationError: Read failed due to the following issues:',
'The required parameter `file_path` is missing.',
'The provided limit must be greater than 0.',
].join('\n')
const { container } = render(
<ToolCallBlock
toolName="Read"
input={{}}
result={{ content: fullError, isError: true }}
/>,
)
expect(container.textContent).toContain('Read')
expect(container.textContent).not.toContain('file_path')
fireEvent.click(screen.getByRole('button'))
expect(container.textContent).toContain('Error Output')
expect(container.textContent).toContain('file_path')
expect(container.textContent).toContain('limit must be greater than 0')
})
it('keeps edit previews while showing edit tool error output when expanded', () => {
const { container } = render(
<ToolCallBlock
toolName="Edit"
input={{
file_path: '/tmp/example.ts',
old_string: 'const enabled = false',
new_string: 'const enabled = true',
}}
result={{
content: [
'InputValidationError: Edit failed due to the following issues:',
'The provided old_string was not found in the file.',
].join('\n'),
isError: true,
}}
/>,
)
expect(container.textContent).toContain('Edit')
expect(container.textContent).not.toContain('old_string was not found')
fireEvent.click(screen.getByRole('button'))
expect(container.textContent).toContain('example.ts')
expect(container.textContent).toContain('Error Output')
expect(container.textContent).toContain('old_string was not found')
})
it('expands tool errors so full Computer Use gate messages are readable', () => {
const { container } = render(
<ToolCallBlock