mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -188,6 +188,8 @@ export default {
|
||||
'tool.exitPlan': '退出计划模式',
|
||||
'tool.patch': '补丁',
|
||||
'tool.input': '输入',
|
||||
'tool.trace': '追踪',
|
||||
'tool.trace.callsSuffix': '次调用',
|
||||
'tool.result': '结果',
|
||||
'tool.questionsAnswers': '问答',
|
||||
'tool.submit': '提交',
|
||||
|
||||
Reference in New Issue
Block a user