feat(web): show subagent task trace in tool dialog (#539)

* refactor(web): extract shared task tool helpers

* feat(web): show subagent task trace in tool dialog

Task tool modals previously showed only Input and Result. This adds a
Trace section between them that surfaces the child tool calls already
wired through the reducer into block.children.

- TraceSection collapses by default when completed, expands when
  running or error so the relevant state is visible on open
- Each child row toggles an inline expand (Input/Result) to avoid
  nested Dialogs
- Header summarises call count, token total and duration via
  readSummaryFields() typed parser, falling back gracefully when any
  value is absent
- formatTaskChildLabel / TaskStateIcon imported from shared helpers.tsx
  (extracted in prior refactor commit) — no local duplicates
- Task name guard: getTaskTraceChildren returns null for non-Task blocks
- children prop renamed to items in TraceSectionInner / TraceChildList
  (react/no-children-prop anti-pattern removed)
- i18n: tool.trace and tool.trace.callsSuffix keys added for en and
  zh-CN; useTranslation hooked up to header label and calls suffix
- 15 unit tests: getTaskTraceChildren (guard, filter, non-Task null),
  getTraceSummaryText (3 branches), TraceSection (open/close/toggle/
  summary/empty)

* feat(web): include input view in trace row expand

Expanded child rows in the Task trace section now render both an Input
section and a Result section, matching the pattern used in the parent
ToolCard dialog. Tools with a registered FullInputView use it; all
others fall back to a JSON CodeBlock. Closes bot review on PR #539.
This commit is contained in:
Junmo Kim
2026-04-28 10:55:10 +08:00
committed by GitHub
parent d66547ff46
commit 52ec08b6cb
6 changed files with 555 additions and 30 deletions
+3 -30
View File
@@ -16,10 +16,12 @@ import { isRequestUserInputToolName } from '@/components/ToolCard/requestUserInp
import { getToolPresentation } from '@/components/ToolCard/knownTools'
import { getToolFullViewComponent, getToolViewComponent } from '@/components/ToolCard/views/_all'
import { getToolResultViewComponent } from '@/components/ToolCard/views/_results'
import { formatTaskChildLabel, TaskStateIcon } from '@/components/ToolCard/helpers'
import { usePointerFocusRing } from '@/hooks/usePointerFocusRing'
import { getInputString, getInputStringAny, truncate } from '@/lib/toolInputUtils'
import { cn } from '@/lib/utils'
import { useTranslation } from '@/lib/use-translation'
import { TraceSection } from '@/components/ToolCard/trace'
const ELAPSED_INTERVAL_MS = 1000
@@ -44,36 +46,6 @@ function ElapsedView(props: { from: number; active: boolean }) {
)
}
function formatTaskChildLabel(child: ToolCallBlock, metadata: SessionMetadataSummary | null): string {
const presentation = getToolPresentation({
toolName: child.tool.name,
input: child.tool.input,
result: child.tool.result,
childrenCount: child.children.length,
description: child.tool.description,
metadata
})
if (presentation.subtitle) {
return truncate(`${presentation.title}: ${presentation.subtitle}`, 140)
}
return presentation.title
}
function TaskStateIcon(props: { state: ToolCallBlock['tool']['state'] }) {
if (props.state === 'completed') {
return <span className="text-emerald-600"></span>
}
if (props.state === 'error') {
return <span className="text-red-600"></span>
}
if (props.state === 'pending') {
return <span className="text-amber-600">🔐</span>
}
return <span className="text-amber-600 animate-pulse"></span>
}
function getTaskSummaryChildren(block: ToolCallBlock): { visible: ToolCallBlock[]; remaining: number } | null {
if (block.tool.name !== 'Task') return null
@@ -392,6 +364,7 @@ function ToolCardInner(props: ToolCardProps) {
renderToolInput(props.block)
)}
</div>
<TraceSection block={props.block} metadata={props.metadata} />
{!isQuestionToolWithAnswers && (
<div>
<div className="mb-1 text-xs font-medium text-[var(--app-hint)]">{t('tool.result')}</div>
+42
View File
@@ -0,0 +1,42 @@
/**
* Shared helpers for Task tool child rendering.
* Used by both ToolCard.tsx (summary) and trace.tsx (trace section).
*/
import React from 'react'
import type { ToolCallBlock } from '@/chat/types'
import type { SessionMetadataSummary } from '@/types/api'
import { getToolPresentation } from '@/components/ToolCard/knownTools'
import { truncate } from '@/lib/toolInputUtils'
export function formatTaskChildLabel(
child: ToolCallBlock,
metadata: SessionMetadataSummary | null,
): string {
const presentation = getToolPresentation({
toolName: child.tool.name,
input: child.tool.input,
result: child.tool.result,
childrenCount: child.children.length,
description: child.tool.description,
metadata,
})
if (presentation.subtitle) {
return truncate(`${presentation.title}: ${presentation.subtitle}`, 140)
}
return presentation.title
}
export function TaskStateIcon(props: { state: ToolCallBlock['tool']['state'] }): React.JSX.Element {
if (props.state === 'completed') {
return <span className="text-emerald-600"></span>
}
if (props.state === 'error') {
return <span className="text-red-600"></span>
}
if (props.state === 'pending') {
return <span className="text-amber-600">🔐</span>
}
return <span className="text-amber-600 animate-pulse"></span>
}
+270
View File
@@ -0,0 +1,270 @@
/**
* Tests for Trace section in ToolCard dialog.
* Verifies that Task tool modals expose child tool call traces.
*/
import { describe, expect, it, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import type { ToolCallBlock } from '@/chat/types'
import { TraceSection, getTaskTraceChildren, getTraceSummaryText } from '@/components/ToolCard/trace'
// useTranslation returns a simple key-passthrough stub for tests
vi.mock('@/lib/use-translation', () => ({
useTranslation: () => ({
t: (key: string) => {
const map: Record<string, string> = {
'tool.trace': 'Trace',
'tool.trace.callsSuffix': 'calls',
'tool.input': 'Input',
'tool.result': 'Result',
}
return map[key] ?? key
},
}),
}))
// getToolFullViewComponent returns null by default (no special view for generic tools)
vi.mock('@/components/ToolCard/views/_all', () => ({
getToolFullViewComponent: () => null,
}))
// CodeBlock renders a simple pre element
vi.mock('@/components/CodeBlock', () => ({
CodeBlock: ({ code }: { code: string }) => <pre data-testid="code-block">{code}</pre>,
}))
// safeStringify from @hapi/protocol
vi.mock('@hapi/protocol', async (importOriginal) => {
const actual = await importOriginal<typeof import('@hapi/protocol')>()
return {
...actual,
safeStringify: (v: unknown) => JSON.stringify(v),
}
})
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeChild(
id: string,
name: string,
state: ToolCallBlock['tool']['state'] = 'completed',
): ToolCallBlock {
return {
kind: 'tool-call',
id,
localId: null,
createdAt: 1000,
tool: {
id,
name,
state,
input: { path: `file-${id}.ts` },
createdAt: 1000,
startedAt: 1000,
completedAt: 2000,
description: null,
result: null,
},
children: [],
}
}
function makeTaskBlock(
children: ToolCallBlock[],
state: ToolCallBlock['tool']['state'] = 'completed',
result: unknown = null,
): ToolCallBlock {
return {
kind: 'tool-call',
id: 'task-1',
localId: null,
createdAt: 1000,
tool: {
id: 'task-1',
name: 'Task',
state,
input: { prompt: 'do stuff', subagent_type: 'Explore' },
createdAt: 1000,
startedAt: 1000,
completedAt: 2000,
description: null,
result,
},
children,
}
}
// ---------------------------------------------------------------------------
// getTaskTraceChildren
// ---------------------------------------------------------------------------
describe('getTaskTraceChildren', () => {
it('returns null when there are no tool-call children', () => {
const block = makeTaskBlock([])
expect(getTaskTraceChildren(block)).toBeNull()
})
it('returns all tool-call children', () => {
const block = makeTaskBlock([
makeChild('c1', 'Glob'),
makeChild('c2', 'Grep'),
makeChild('c3', 'Read'),
])
const result = getTaskTraceChildren(block)
expect(result).not.toBeNull()
expect(result!.length).toBe(3)
})
it('filters out non-tool-call children', () => {
const block: ToolCallBlock = {
...makeTaskBlock([makeChild('c1', 'Glob')]),
children: [
makeChild('c1', 'Glob'),
{ kind: 'agent-text', id: 'txt-1', localId: null, createdAt: 0, text: 'hi' },
],
}
const result = getTaskTraceChildren(block)
expect(result!.length).toBe(1)
})
// Fix #2: non-Task blocks must return null
it('returns null for non-Task blocks', () => {
const block: ToolCallBlock = {
...makeTaskBlock([makeChild('c1', 'Glob')]),
tool: {
...makeTaskBlock([makeChild('c1', 'Glob')]).tool,
name: 'Bash',
},
}
expect(getTaskTraceChildren(block)).toBeNull()
})
})
// ---------------------------------------------------------------------------
// getTraceSummaryText
// ---------------------------------------------------------------------------
describe('getTraceSummaryText', () => {
it('shows calls + tok + seconds when all data available', () => {
const text = getTraceSummaryText(5, 25054, 11784, 'calls')
expect(text).toBe('5 calls · 25.1k tok · 11.8s')
})
it('shows calls + seconds when tokens unavailable', () => {
const text = getTraceSummaryText(3, null, 4200, 'calls')
expect(text).toBe('3 calls · 4.2s')
})
it('shows only calls when both unavailable', () => {
const text = getTraceSummaryText(2, null, null, 'calls')
expect(text).toBe('2 calls')
})
})
// ---------------------------------------------------------------------------
// TraceSection component
// ---------------------------------------------------------------------------
describe('TraceSection', () => {
it('renders nothing when children is empty', () => {
const block = makeTaskBlock([])
const { container } = render(
<TraceSection block={block} metadata={null} />
)
expect(container.firstChild).toBeNull()
})
it('renders Trace header when children exist', () => {
const block = makeTaskBlock([makeChild('c1', 'Glob'), makeChild('c2', 'Grep')])
render(<TraceSection block={block} metadata={null} />)
expect(screen.getByText(/Trace/i)).toBeInTheDocument()
})
it('shows child rows when expanded (running)', () => {
const block = makeTaskBlock([makeChild('c1', 'Glob'), makeChild('c2', 'Grep')], 'running')
const { container } = render(<TraceSection block={block} metadata={null} />)
// running → default open; child list rendered
const childList = container.querySelector('.border-l')
expect(childList).not.toBeNull()
// 2 child toggle buttons present (data-testid free — query by aria-expanded absence)
const allBtns = container.querySelectorAll('button')
expect(allBtns.length).toBeGreaterThanOrEqual(3) // header + 2 children
})
it('is collapsed by default when task is completed', () => {
const block = makeTaskBlock([makeChild('c1', 'Glob'), makeChild('c2', 'Grep')], 'completed')
const { container } = render(<TraceSection block={block} metadata={null} />)
// header button with aria-expanded=false
const headerBtn = container.querySelector('button[aria-expanded="false"]')
expect(headerBtn).not.toBeNull()
// child list NOT rendered
const childList = container.querySelector('.border-l')
expect(childList).toBeNull()
})
it('is expanded by default when task is running', () => {
const block = makeTaskBlock([makeChild('c1', 'Read')], 'running')
const { container } = render(<TraceSection block={block} metadata={null} />)
// header aria-expanded=true
const headerBtn = container.querySelector('button[aria-expanded="true"]')
expect(headerBtn).not.toBeNull()
// child list rendered
expect(container.querySelector('.border-l')).not.toBeNull()
})
it('is expanded by default when task is error', () => {
const block = makeTaskBlock([makeChild('c1', 'Read', 'error')], 'error')
const { container } = render(<TraceSection block={block} metadata={null} />)
const headerBtn = container.querySelector('button[aria-expanded="true"]')
expect(headerBtn).not.toBeNull()
})
it('toggles open/close on header click', () => {
const block = makeTaskBlock([makeChild('c1', 'Glob')], 'completed')
const { container } = render(<TraceSection block={block} metadata={null} />)
// initially collapsed
expect(container.querySelector('.border-l')).toBeNull()
expect(container.querySelector('button[aria-expanded="false"]')).not.toBeNull()
// click header to open
const btn = container.querySelector('button[aria-expanded="false"]') as HTMLButtonElement
fireEvent.click(btn)
expect(container.querySelector('.border-l')).not.toBeNull()
expect(container.querySelector('button[aria-expanded="true"]')).not.toBeNull()
// click again to close
const btn2 = container.querySelector('button[aria-expanded="true"]') as HTMLButtonElement
fireEvent.click(btn2)
expect(container.querySelector('.border-l')).toBeNull()
})
it('displays summary text with call count', () => {
const result = { totalToolUseCount: 3, totalTokens: 12400, totalDurationMs: 4200 }
const block = makeTaskBlock([
makeChild('c1', 'Glob'),
makeChild('c2', 'Grep'),
makeChild('c3', 'Read'),
], 'completed', result)
render(<TraceSection block={block} metadata={null} />)
// summary shown in header
expect(screen.getByText(/3 calls/)).toBeInTheDocument()
})
it('shows Input section when a child row is expanded', () => {
const block = makeTaskBlock([makeChild('c1', 'Bash')], 'running')
const { container } = render(<TraceSection block={block} metadata={null} />)
// child list visible (running → default open)
const childBtns = container.querySelectorAll('.border-l button')
expect(childBtns.length).toBeGreaterThanOrEqual(1)
// click the child row to expand it
fireEvent.click(childBtns[0])
// Input section label must be present in the expanded box
expect(screen.getByText('Input')).toBeInTheDocument()
// Result section label must also be present
expect(screen.getByText('Result')).toBeInTheDocument()
})
})
+236
View File
@@ -0,0 +1,236 @@
/**
* TraceSection — shows child tool calls inside a Task tool dialog.
* Placed between Input and Result sections.
*/
import { useState } from 'react'
import { isObject, safeStringify } from '@hapi/protocol'
import type { ToolCallBlock } from '@/chat/types'
import type { SessionMetadataSummary } from '@/types/api'
import { getToolFullViewComponent } from '@/components/ToolCard/views/_all'
import { getToolResultViewComponent } from '@/components/ToolCard/views/_results'
import { formatTaskChildLabel, TaskStateIcon } from '@/components/ToolCard/helpers'
import { CodeBlock } from '@/components/CodeBlock'
import { useTranslation } from '@/lib/use-translation'
// ---------------------------------------------------------------------------
// Result type narrowing (trace.tsx-internal; do NOT move to shared protocol)
// ---------------------------------------------------------------------------
type TaskToolResultSummary = {
totalTokens?: number
totalDurationMs?: number
totalToolUseCount?: number
}
function readSummaryFields(result: unknown): {
totalTokens: number | null
totalDurationMs: number | null
totalToolUseCount: number | null
} {
if (!isObject(result)) {
return { totalTokens: null, totalDurationMs: null, totalToolUseCount: null }
}
const r = result as Record<string, unknown>
return {
totalTokens: typeof r.totalTokens === 'number' ? r.totalTokens : null,
totalDurationMs: typeof r.totalDurationMs === 'number' ? r.totalDurationMs : null,
totalToolUseCount: typeof r.totalToolUseCount === 'number' ? r.totalToolUseCount : null,
}
}
// Keep the type alias visible for documentation purposes
type _TaskToolResultSummary = TaskToolResultSummary
// ---------------------------------------------------------------------------
// Pure helpers (exported for unit tests)
// ---------------------------------------------------------------------------
/**
* Returns tool-call children of the given Task block, or null if none exist.
*/
export function getTaskTraceChildren(block: ToolCallBlock): ToolCallBlock[] | null {
if (block.tool.name !== 'Task') return null
const children = block.children.filter(
(c): c is ToolCallBlock => c.kind === 'tool-call',
)
return children.length === 0 ? null : children
}
/**
* Formats the summary line shown in the Trace header.
* Falls back gracefully when token / duration data is unavailable.
*/
export function getTraceSummaryText(
calls: number,
totalTokens: number | null,
totalDurationMs: number | null,
callsSuffix: string,
): string {
const parts: string[] = [`${calls} ${callsSuffix}`]
if (totalTokens !== null) {
const k = totalTokens / 1000
parts.push(`${k.toFixed(1)}k tok`)
}
if (totalDurationMs !== null) {
const s = totalDurationMs / 1000
parts.push(`${s.toFixed(1)}s`)
}
return parts.join(' · ')
}
// ---------------------------------------------------------------------------
// TraceSection component
// ---------------------------------------------------------------------------
type TraceSectionProps = {
block: ToolCallBlock
metadata: SessionMetadataSummary | null
}
export function TraceSection({ block, metadata }: TraceSectionProps) {
const { t } = useTranslation()
const children = getTaskTraceChildren(block)
if (!children) return null
const state = block.tool.state
const defaultOpen = state === 'running' || state === 'error' || state === 'pending'
// Extract summary metadata from result using typed helper
const { totalTokens, totalDurationMs, totalToolUseCount } = readSummaryFields(block.tool.result)
const callCount = totalToolUseCount !== null ? totalToolUseCount : children.length
const summaryText = getTraceSummaryText(callCount, totalTokens, totalDurationMs, t('tool.trace.callsSuffix'))
return (
<TraceSectionInner
items={children}
metadata={metadata}
defaultOpen={defaultOpen}
summaryText={summaryText}
/>
)
}
// ---------------------------------------------------------------------------
// Inner component (holds open/close state)
// ---------------------------------------------------------------------------
type TraceSectionInnerProps = {
items: ToolCallBlock[]
metadata: SessionMetadataSummary | null
defaultOpen: boolean
summaryText: string
}
function TraceSectionInner({
items,
metadata,
defaultOpen,
summaryText,
}: TraceSectionInnerProps) {
const { t } = useTranslation()
const [open, setOpen] = useState(defaultOpen)
return (
<div className="flex flex-col gap-1">
{/* Header row — clickable to toggle */}
<button
type="button"
className="flex items-center gap-1 text-left text-xs font-medium text-[var(--app-hint)] hover:text-[var(--app-fg)] transition-colors"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
>
<span className="w-3 text-center select-none">{open ? '▾' : '▸'}</span>
<span>{t('tool.trace')}</span>
<span className="font-mono font-normal opacity-70">({summaryText})</span>
</button>
{open && (
<TraceChildList items={items} metadata={metadata} />
)}
</div>
)
}
// ---------------------------------------------------------------------------
// Child list
// ---------------------------------------------------------------------------
type TraceChildListProps = {
items: ToolCallBlock[]
metadata: SessionMetadataSummary | null
}
function TraceChildList({ items, metadata }: TraceChildListProps) {
const [expandedId, setExpandedId] = useState<string | null>(null)
return (
<div className="flex flex-col gap-1 pl-4 border-l border-[var(--app-border)]">
{items.map((child) => (
<TraceChildRow
key={child.id}
child={child}
metadata={metadata}
expanded={expandedId === child.id}
onToggle={() =>
setExpandedId((prev) => (prev === child.id ? null : child.id))
}
/>
))}
</div>
)
}
// ---------------------------------------------------------------------------
// Individual child row
// ---------------------------------------------------------------------------
type TraceChildRowProps = {
child: ToolCallBlock
metadata: SessionMetadataSummary | null
expanded: boolean
onToggle: () => void
}
function TraceChildRow({ child, metadata, expanded, onToggle }: TraceChildRowProps) {
const { t } = useTranslation()
const label = formatTaskChildLabel(child, metadata)
const FullInputView = getToolFullViewComponent(child.tool.name)
const ResultView = getToolResultViewComponent(child.tool.name)
return (
<div className="flex flex-col gap-1">
<button
type="button"
className="flex items-center gap-2 text-left text-xs text-[var(--app-hint)] hover:text-[var(--app-fg)] transition-colors"
onClick={onToggle}
>
<span className="w-3 text-center select-none">{expanded ? '▾' : '▸'}</span>
<span className="w-4 text-center shrink-0">
<TaskStateIcon state={child.tool.state} />
</span>
<span className="font-mono break-all">{label}</span>
</button>
{expanded && (
<div className="ml-8 flex flex-col gap-2 rounded border border-[var(--app-border)] p-2">
<div>
<div className="mb-1 text-xs font-medium text-[var(--app-hint)]">{t('tool.input')}</div>
{FullInputView ? (
<FullInputView block={child} metadata={metadata} />
) : (
<CodeBlock code={safeStringify(child.tool.input)} language="json" />
)}
</div>
<div>
<div className="mb-1 text-xs font-medium text-[var(--app-hint)]">{t('tool.result')}</div>
<ResultView block={child} metadata={metadata} />
</div>
</div>
)}
</div>
)
}
+2
View File
@@ -186,6 +186,8 @@ export default {
'tool.exitPlan': 'Exit Plan Mode',
'tool.patch': 'Patch',
'tool.input': 'Input',
'tool.trace': 'Trace',
'tool.trace.callsSuffix': 'calls',
'tool.result': 'Result',
'tool.questionsAnswers': 'Questions & Answers',
'tool.submit': 'Submit',
+2
View File
@@ -188,6 +188,8 @@ export default {
'tool.exitPlan': '退出计划模式',
'tool.patch': '补丁',
'tool.input': '输入',
'tool.trace': '追踪',
'tool.trace.callsSuffix': '次调用',
'tool.result': '结果',
'tool.questionsAnswers': '问答',
'tool.submit': '提交',