refactor(desktop): flatten nested tool activity

This commit is contained in:
程序员阿江(Relakkes)
2026-08-07 23:18:37 +08:00
parent d3ba73af31
commit cfb7e224b4
5 changed files with 210 additions and 62 deletions
+37 -5
View File
@@ -3,12 +3,16 @@ import { Highlight, type PrismTheme } from 'prism-react-renderer'
import { CopyButton } from '@/components/ui/CopyButton'
import { useTranslation } from '../../i18n'
export type CodeViewerChrome = 'card' | 'embedded'
type Props = {
code: string
language?: string
maxLines?: number
showLineNumbers?: boolean
wrapLongLines?: boolean
chrome?: CodeViewerChrome
label?: string
}
const warmPrismTheme: PrismTheme = {
@@ -282,9 +286,18 @@ function CodeArea({
)
}
export function CodeViewer({ code, language, maxLines = 20, showLineNumbers = false, wrapLongLines = false }: Props) {
export function CodeViewer({
code,
language,
maxLines = 20,
showLineNumbers = false,
wrapLongLines = false,
chrome = 'card',
label,
}: Props) {
const t = useTranslation()
const [expanded, setExpanded] = useState(false)
const isEmbedded = chrome === 'embedded'
const allLines = code.split('\n')
const isTruncated = !expanded && allLines.length > maxLines
@@ -296,16 +309,35 @@ export function CodeViewer({ code, language, maxLines = 20, showLineNumbers = fa
const showExpandToggle = allLines.length > maxLines
return (
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-code-bg)]">
<div
data-code-viewer-chrome={chrome}
className={
isEmbedded
? 'overflow-hidden bg-[var(--color-code-bg)]'
: 'overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-code-bg)]'
}
>
{/* Header */}
<div className="flex items-center justify-between border-b border-[var(--color-border)] bg-[var(--color-surface-container)] px-3 py-1.5 text-[11px] text-[var(--color-text-tertiary)]">
<div className="flex items-center gap-3">
<span className="font-semibold uppercase tracking-[0.14em]">{languageLabel}</span>
<div className="flex items-center gap-2.5">
{label ? (
<>
<span className="font-semibold uppercase tracking-[0.14em]">{label}</span>
<span aria-hidden="true">·</span>
</>
) : null}
<span className={`${label ? 'font-medium' : 'font-semibold'} uppercase tracking-[0.14em]`}>
{languageLabel}
</span>
<span>{lineCountLabel}</span>
</div>
<CopyButton
text={code}
className="rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-2 py-1 text-[11px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-container-high)] hover:text-[var(--color-text-primary)]"
className={
isEmbedded
? 'rounded-[var(--radius-sm)] px-2 py-1 text-[11px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-container-high)] hover:text-[var(--color-text-primary)]'
: 'rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-2 py-1 text-[11px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-container-high)] hover:text-[var(--color-text-primary)]'
}
/>
</div>
@@ -1545,6 +1545,15 @@ describe('MessageList nested tool calls', () => {
expect(screen.queryByText(/Read .*example\.ts.*done/i)).toBeNull()
fireEvent.click(screen.getByRole('button', { name: /dispatched an agent/i }))
expect(screen.getByText(/Read .*example\.ts.*done/i)).toBeTruthy()
const agentRow = container.querySelector('[data-agent-call-layout="row"]')
expect(agentRow).toBeTruthy()
expect(agentRow?.className).not.toMatch(/\b(?:border|rounded)-/)
fireEvent.click(screen.getByRole('button', { name: 'Expand agent' }))
const nestedToolRow = container.querySelector('[data-tool-call-chrome="row"]')
expect(nestedToolRow).toBeTruthy()
expect(nestedToolRow?.textContent).toContain('Read')
expect(container.textContent).toContain('Agent')
})
+100 -28
View File
@@ -20,8 +20,8 @@ import {
/**
* `card` is the standalone bordered block. `row` strips the border, the ink icon
* square and the bold name so the call reads as one line inside an expanded
* activity group — the fix for "one card per tool call" (#1177). Its own output
* still gets a card, but only once the row is expanded.
* activity group. Expanded row details stay inline on a nested guide instead of
* rebuilding the card stack that the activity-group treatment removed (#1177).
*/
export type ToolCallChrome = 'card' | 'row'
@@ -156,8 +156,14 @@ export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, resu
)
const liveStatsSummary = liveStats ? formatContentStats(liveStats, t) : ''
const preview = useMemo(() => renderPreview(toolName, obj, result, t), [obj, result, toolName, t])
const details = useMemo(() => renderDetails(toolName, obj, t, isPending ? partialInput : undefined), [isPending, obj, partialInput, toolName, t])
const preview = useMemo(
() => renderPreview(toolName, obj, result, t, isRow),
[isRow, obj, result, toolName, t],
)
const details = useMemo(
() => renderDetails(toolName, obj, t, isPending ? partialInput : undefined, isRow),
[isPending, isRow, obj, partialInput, toolName, t],
)
const hasResultDetails = Boolean(result && extractTextContent(result.content))
const hasEditPreview = toolName === 'Edit' && typeof obj.old_string === 'string' && typeof obj.new_string === 'string'
const hasWritePreview = toolName === 'Write' && typeof obj.content === 'string'
@@ -211,13 +217,16 @@ export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, resu
}
return (
<div className={
isRow
? ''
: `overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] ${
compact ? 'mb-0' : 'mb-2'
}`
}>
<div
data-tool-call-chrome={chrome}
className={
isRow
? ''
: `overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] ${
compact ? 'mb-0' : 'mb-2'
}`
}
>
<button
type="button"
data-chat-disclosure="true"
@@ -315,13 +324,14 @@ export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, resu
{expandable && expanded && (
<div
data-tool-call-details={isRow ? 'inline' : 'panel'}
data-tool-output-error={result?.isError ? 'true' : undefined}
className={
isRow
? `mb-2 mt-1 space-y-2.5 rounded-[var(--radius-lg)] border px-3 py-2.5 ${
? `mb-2 ml-2 mt-1 space-y-2.5 border-l py-1 pl-3 ${
result?.isError
? 'border-[var(--color-error-soft-hover)] bg-[var(--color-error-soft)]'
: 'border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]'
? 'border-[var(--color-error-soft-hover)]'
: 'border-[var(--color-border)]'
}`
: `space-y-2.5 border-t px-4 py-3.5 ${
result?.isError
@@ -464,6 +474,7 @@ function renderPreview(
obj: Record<string, unknown>,
result?: { content: unknown; isError: boolean } | null,
t?: (key: TranslationKey, params?: Record<string, string | number>) => string,
embedded = false,
) {
const filePath = typeof obj.file_path === 'string' ? obj.file_path : 'file'
// Must match the terminal-card condition below exactly. When they diverged, a
@@ -473,7 +484,7 @@ function renderPreview(
const shellCommand = isShellTool(toolName) && typeof obj.command === 'string' ? obj.command : null
const echoesInTerminal = shellCommand !== null
const resultText = getVisibleResultText(toolName, result, echoesInTerminal)
const resultOutput = result && resultText ? renderResultOutput(result, resultText, t) : null
const resultOutput = result && resultText ? renderResultOutput(result, resultText, t, embedded) : null
if (toolName === 'Edit' && typeof obj.old_string === 'string' && typeof obj.new_string === 'string') {
return (
@@ -745,17 +756,49 @@ function renderResultOutput(
result: { content: unknown; isError: boolean },
text: string,
t?: (key: TranslationKey, params?: Record<string, string | number>) => string,
embedded = false,
) {
const label = result.isError
? t?.('tool.errorOutput') ?? 'Error Output'
: t?.('tool.toolOutput') ?? 'Tool Output'
if (embedded) {
return (
<>
<InlineImageGallery text={text} />
{result.isError ? (
<div data-tool-detail-surface="embedded" className="overflow-hidden bg-[var(--color-error-soft)]">
<div className="flex items-center justify-between border-b border-[var(--color-error-soft-hover)] px-3 py-2 text-[10px] uppercase tracking-[0.18em] text-[var(--color-error)]">
<span>{label}</span>
<CopyButton
text={text}
className="rounded-[var(--radius-sm)] px-2 py-1 text-[11px] normal-case tracking-normal text-[var(--color-error)] transition-colors hover:bg-[var(--color-error-soft-hover)]"
/>
</div>
<pre className="max-h-[420px] overflow-auto whitespace-pre-wrap break-words px-3 py-2 font-mono text-[12px] leading-[1.45] text-[var(--color-error)]">
{text}
</pre>
</div>
) : (
<CodeViewer code={text} language="plaintext" maxLines={18} chrome="embedded" label={label} />
)}
</>
)
}
return (
<>
<InlineImageGallery text={text} />
<div className={`overflow-hidden rounded-[var(--radius-lg)] border ${
result.isError
? 'border-[var(--color-error)] bg-[var(--color-error-container)]'
: 'border-[var(--color-border)] bg-[var(--color-surface)]'
}`}>
<div
data-tool-detail-surface="card"
className={`overflow-hidden rounded-[var(--radius-lg)] border ${
result.isError
? 'border-[var(--color-error)] bg-[var(--color-error-container)]'
: 'border-[var(--color-border)] bg-[var(--color-surface)]'
}`}
>
<div className="flex items-center justify-between border-b border-[var(--color-border)] 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>
<span>{label}</span>
<CopyButton
text={text}
className="rounded-[var(--radius-sm)] border border-[var(--color-border)] px-2 py-1 text-[11px] normal-case tracking-normal text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)]"
@@ -778,15 +821,16 @@ function renderDetails(
obj: Record<string, unknown>,
t?: (key: TranslationKey, params?: Record<string, string | number>) => string,
partialInput?: string,
embedded = false,
) {
if (partialInput) {
if (toolName === 'Write') {
const writerContent = extractPartialJsonStringField(partialInput, 'content')
if (writerContent !== null) {
return renderWriterPreview(writerContent, t)
return renderWriterPreview(writerContent, t, embedded)
}
}
return renderPartialInput(partialInput, t)
return renderPartialInput(partialInput, t, embedded)
}
if (toolName === 'Edit' || toolName === 'Write') {
@@ -804,10 +848,15 @@ function renderDetails(
}
const text = JSON.stringify(displayed, null, 2)
const label = t?.('tool.toolInput') ?? 'Tool Input'
if (embedded) {
return <CodeViewer code={text} language="json" maxLines={18} chrome="embedded" label={label} />
}
return (
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)]">
<div data-tool-detail-surface="card" className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)]">
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-3 py-2 text-[10px] uppercase tracking-[0.18em] text-[var(--color-outline)]">
<span>{t?.('tool.toolInput') ?? 'Tool Input'}</span>
<span>{label}</span>
<CopyButton
text={text}
className="rounded-[var(--radius-sm)] border border-[var(--color-border)] px-2 py-1 text-[11px] normal-case tracking-normal text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)]"
@@ -977,6 +1026,7 @@ function formatCount(count: number): string {
function renderWriterPreview(
content: string,
t?: (key: TranslationKey, params?: Record<string, string | number>) => string,
embedded = false,
) {
const contentStats = countContentStats(content)
const lines = content.length === 0 ? [] : content.split('\n')
@@ -1000,7 +1050,14 @@ function renderWriterPreview(
}, t)
return (
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)]">
<div
data-tool-detail-surface={embedded ? 'embedded' : 'card'}
className={
embedded
? 'overflow-hidden bg-[var(--color-surface)]'
: 'overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)]'
}
>
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-3 py-2 text-[10px] uppercase tracking-[0.18em] text-[var(--color-outline)]">
<span>{t?.('tool.writerPreview') ?? 'Writer'}</span>
<span className="font-mono normal-case tracking-normal tabular-nums">
@@ -1017,13 +1074,28 @@ function renderWriterPreview(
function renderPartialInput(
partialInput: string,
t?: (key: TranslationKey, params?: Record<string, string | number>) => string,
embedded = false,
) {
const formattedInput = formatPartialJsonInput(partialInput)
const label = t?.('tool.partialInput') ?? 'Partial input'
if (embedded) {
return (
<CodeViewer
code={formattedInput}
language="json"
maxLines={8}
wrapLongLines
chrome="embedded"
label={label}
/>
)
}
return (
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)]">
<div data-tool-detail-surface="card" className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)]">
<div className="border-b border-[var(--color-border)] px-3 py-2 text-[10px] uppercase tracking-[0.18em] text-[var(--color-outline)]">
{t?.('tool.partialInput') ?? 'Partial input'}
{label}
</div>
<CodeViewer code={formattedInput} language="json" maxLines={8} wrapLongLines />
</div>
+35 -29
View File
@@ -1,6 +1,6 @@
import { memo, useCallback, useMemo, useState } from 'react'
import { BookMarked, ChevronDown, ChevronRight, CircleCheck, Settings } from 'lucide-react'
import { ToolCallBlock } from './ToolCallBlock'
import { ToolCallBlock, type ToolCallChrome } from './ToolCallBlock'
import { ActivityGroup } from './ActivityGroup'
import { ThinkingBlock } from './ThinkingBlock'
import {
@@ -451,25 +451,19 @@ function AgentToolGroup({
</button>
{expanded && (
<div className="relative border-t border-[var(--color-border)] py-3 pl-5 pr-3.5">
<div className="absolute bottom-6 left-[11px] top-4 w-px rounded-full bg-[var(--color-border)]" />
<div className="space-y-2">
<div className="px-3.5 pb-2.5 pt-0.5">
<div className="ml-1.5 flex flex-col border-l border-[var(--color-border)] pl-4">
{toolCalls.map((toolCall) => (
<div key={toolCall.id} className="relative pl-7">
<div className="absolute left-0 top-1/2 -translate-y-1/2">
<div className="absolute left-[11px] top-1/2 h-px w-4 -translate-y-1/2 bg-[var(--color-border)]" />
<div className="absolute left-[8px] top-1/2 h-2.5 w-2.5 -translate-y-1/2 rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[0_0_0_2px_var(--color-surface)]" />
</div>
<AgentCallCard
sessionId={sessionId}
toolCall={toolCall}
resultMap={resultMap}
childToolCallsByParent={childToolCallsByParent}
agentTaskNotification={agentTaskNotifications[toolCall.toolUseId]}
agentTaskStatus={agentTaskStatuses?.[toolCall.toolUseId]}
showOpenRun={showOpenRun}
/>
</div>
<AgentCallCard
key={toolCall.id}
sessionId={sessionId}
toolCall={toolCall}
resultMap={resultMap}
childToolCallsByParent={childToolCallsByParent}
agentTaskNotification={agentTaskNotifications[toolCall.toolUseId]}
agentTaskStatus={agentTaskStatuses?.[toolCall.toolUseId]}
showOpenRun={showOpenRun}
/>
))}
</div>
</div>
@@ -535,8 +529,8 @@ function AgentCallCard({
const canOpenRun = showOpenRun && !!sessionId && !!toolCall.toolUseId
return (
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]">
<div className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-[var(--color-surface-hover)]">
<div data-agent-call-layout="row">
<div className="-mx-2 flex w-[calc(100%+1rem)] items-center gap-3 rounded-[var(--radius-md)] px-2 py-1.5 text-left transition-colors hover:bg-[var(--color-surface-hover)]">
<span className="material-symbols-outlined text-[18px] text-[var(--color-outline)]">smart_toy</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
@@ -616,14 +610,14 @@ function AgentCallCard({
</div>
{expanded && (
<div className="border-t border-[var(--color-border)] px-3 py-3">
<div className="mb-2 ml-2 mt-1 border-l border-[var(--color-border)] py-1 pl-3">
{errorText && (
<div className="mb-3 rounded-[var(--radius-lg)] border border-[var(--color-error)] bg-[var(--color-error-container)] px-3 py-2 text-[11px] text-[var(--color-on-error-container)]">
<div className="mb-2 bg-[var(--color-error-soft)] px-3 py-2 text-[11px] text-[var(--color-error)]">
{errorText}
</div>
)}
{childToolCalls.length > 0 ? (
<div className="space-y-1">
<div className="space-y-0.5">
{childToolCalls.map((childToolCall) => (
<ToolCallTree
key={childToolCall.id}
@@ -631,15 +625,16 @@ function AgentCallCard({
resultMap={resultMap}
childToolCallsByParent={childToolCallsByParent}
compact
chrome="row"
/>
))}
</div>
) : outputSummary ? (
<div className="text-[11px] text-[var(--color-text-tertiary)]">
<div className="px-2 py-1 text-[11px] text-[var(--color-text-tertiary)]">
{t('agentStatus.noActivity')}
</div>
) : (
<div className="text-[11px] text-[var(--color-text-tertiary)]">
<div className="px-2 py-1 text-[11px] text-[var(--color-text-tertiary)]">
{status === 'starting' ? t('agentStatus.starting') : t('agentStatus.noActivity')}
</div>
)}
@@ -664,30 +659,40 @@ function ToolCallTree({
resultMap,
childToolCallsByParent,
compact = false,
chrome = 'card',
}: {
toolCall: ToolCall
resultMap: Map<string, ToolResult>
childToolCallsByParent: Map<string, ToolCall[]>
compact?: boolean
chrome?: ToolCallChrome
}) {
const result = resultMap.get(toolCall.toolUseId)
const childToolCalls = childToolCallsByParent.get(toolCall.toolUseId) ?? []
const isRow = chrome === 'row'
return (
<div className={compact ? 'space-y-1' : ''}>
<div className={!isRow && compact ? 'space-y-1' : ''}>
<ToolCallBlock
toolName={toolCall.toolName}
input={toolCall.input}
result={result ? { content: result.content, isError: result.isError } : null}
compact={compact}
chrome={chrome}
isPending={toolCall.isPending}
status={toolCall.status}
partialInput={toolCall.partialInput}
durationMs={toolCallDurationMs(toolCall, result)}
/>
{childToolCalls.length > 0 && (
<div className={compact ? 'ml-4 border-l border-[var(--color-border)] pl-3' : 'mb-2 ml-16 border-l border-[var(--color-border)] pl-3'}>
<div className="space-y-1">
<div className={
isRow
? 'ml-2 border-l border-[var(--color-border)] pl-3'
: compact
? 'ml-4 border-l border-[var(--color-border)] pl-3'
: 'mb-2 ml-16 border-l border-[var(--color-border)] pl-3'
}>
<div className={isRow ? 'space-y-0.5' : 'space-y-1'}>
{childToolCalls.map((childToolCall) => (
<ToolCallTree
key={childToolCall.id}
@@ -695,6 +700,7 @@ function ToolCallTree({
resultMap={resultMap}
childToolCallsByParent={childToolCallsByParent}
compact
chrome={chrome}
/>
))}
</div>
@@ -349,6 +349,35 @@ describe('chat blocks', () => {
expect(container.textContent).not.toContain('const answer = 42')
})
it('keeps expanded row input and output inline instead of nesting detail cards', () => {
const { container } = render(
<ToolCallBlock
chrome="row"
toolName="TaskCreate"
input={{ subject: 'Inspect activity UI', description: 'Find the nested card treatment' }}
result={{ content: 'Task #1 created successfully', isError: false }}
/>,
)
const row = container.querySelector('[data-tool-call-chrome="row"]')
const disclosure = row?.querySelector<HTMLButtonElement>('[data-chat-disclosure="true"]')
expect(disclosure).toBeTruthy()
fireEvent.click(disclosure!)
const details = container.querySelector('[data-tool-call-details="inline"]')
expect(details).toBeTruthy()
expect(details?.className).not.toContain('rounded-')
const viewers = [...container.querySelectorAll('[data-code-viewer-chrome]')]
expect(viewers).toHaveLength(2)
expect(viewers.every((viewer) => viewer.getAttribute('data-code-viewer-chrome') === 'embedded')).toBe(true)
// Each section owns one compact header and one copy action. The previous
// nested Tool Output -> PLAINTEXT and Tool Input -> JSON cards doubled both.
expect(screen.getAllByRole('button', { name: 'Copy' })).toHaveLength(2)
expect((details?.textContent?.match(/Tool Output/g) ?? [])).toHaveLength(1)
expect((details?.textContent?.match(/Tool Input/g) ?? [])).toHaveLength(1)
})
// #1149: bash stdout used to be dropped entirely — the card showed the command
// three times and the result zero times. Output is now echoed into the terminal.
it('echoes bash stdout into the terminal card when expanded', () => {