fix(web): align Agent tool dialog with TUI ctrl+o expand (#585)

This commit is contained in:
Junmo Kim
2026-05-07 08:28:03 +08:00
committed by GitHub
parent 08d3d9e111
commit e17d7e5995
15 changed files with 325 additions and 78 deletions
@@ -2,6 +2,7 @@ import type { ToolCallMessagePartProps } from '@assistant-ui/react'
import type { ChatBlock } from '@/chat/types'
import type { ToolCallBlock } from '@/chat/types'
import { isObject, safeStringify } from '@hapi/protocol'
import { isSubagentToolName } from '@/chat/subagentTool'
import { getEventPresentation } from '@/chat/presentation'
import { CodeBlock } from '@/components/CodeBlock'
import { MarkdownRenderer } from '@/components/MarkdownRenderer'
@@ -109,7 +110,7 @@ function HappyNestedBlockList(props: {
}
if (block.kind === 'tool-call') {
const isTask = block.tool.name === 'Task'
const isTask = isSubagentToolName(block.tool.name)
const taskChildren = isTask ? splitTaskChildren(block) : null
return (
@@ -199,7 +200,7 @@ export function HappyToolMessage(props: ToolCallMessagePartProps) {
}
const block = artifact
const isTask = block.tool.name === 'Task'
const isTask = isSubagentToolName(block.tool.name)
const taskChildren = isTask ? splitTaskChildren(block) : null
return (
+4 -3
View File
@@ -22,6 +22,7 @@ import { getInputString, getInputStringAny, truncate } from '@/lib/toolInputUtil
import { cn } from '@/lib/utils'
import { useTranslation } from '@/lib/use-translation'
import { TraceSection } from '@/components/ToolCard/trace'
import { isSubagentToolName } from '@/chat/subagentTool'
const ELAPSED_INTERVAL_MS = 1000
@@ -47,7 +48,7 @@ function ElapsedView(props: { from: number; active: boolean }) {
}
function getTaskSummaryChildren(block: ToolCallBlock): { visible: ToolCallBlock[]; remaining: number } | null {
if (block.tool.name !== 'Task') return null
if (!isSubagentToolName(block.tool.name)) return null
const children = block.children
.filter((child): child is ToolCallBlock => child.kind === 'tool-call')
@@ -126,7 +127,7 @@ function renderToolInput(block: ToolCallBlock, surface: 'inline' | 'dialog' = 'i
const toolName = block.tool.name
const input = block.tool.input
if (toolName === 'Task' && isObject(input) && typeof input.prompt === 'string') {
if (isSubagentToolName(toolName) && isObject(input) && typeof input.prompt === 'string') {
return <MarkdownRenderer content={input.prompt} />
}
@@ -287,7 +288,7 @@ function ToolCardInner(props: ToolCardProps) {
const subtitle = presentation.subtitle ?? props.block.tool.description
const taskSummary = renderTaskSummary(props.block, props.metadata, t)
const runningFrom = props.block.tool.startedAt ?? props.block.tool.createdAt
const showInline = !presentation.minimal && toolName !== 'Task'
const showInline = !presentation.minimal && !isSubagentToolName(toolName)
const CompactToolView = showInline ? getToolViewComponent(toolName) : null
const FullToolView = getToolFullViewComponent(toolName)
const ResultToolView = getToolResultViewComponent(toolName)
+5 -2
View File
@@ -294,13 +294,16 @@ export const knownTools: Record<string, {
icon: () => <RocketIcon className={DEFAULT_ICON_CLASS} />,
title: (opts) => {
const description = getInputStringAny(opts.input, ['description'])
return description ?? 'Agent'
return description ?? 'Launch Agent'
},
subtitle: (opts) => {
// Subagent invocation: show prompt preview (same as Task)
const prompt = getInputStringAny(opts.input, ['prompt'])
if (prompt) return truncate(prompt, 120)
const model = getInputStringAny(opts.input, ['subagent_type'])
return model ?? null
},
minimal: true
minimal: (opts) => opts.childrenCount === 0
},
CodexReasoning: {
icon: () => <BulbIcon className={DEFAULT_ICON_CLASS} />,
@@ -95,6 +95,31 @@ function makeTaskBlock(
}
}
function makeAgentBlock(
children: ToolCallBlock[],
state: ToolCallBlock['tool']['state'] = 'completed',
result: unknown = null,
): ToolCallBlock {
return {
kind: 'tool-call',
id: 'agent-1',
localId: null,
createdAt: 1000,
tool: {
id: 'agent-1',
name: 'Agent',
state,
input: { prompt: 'do stuff', subagent_type: 'general-purpose' },
createdAt: 1000,
startedAt: 1000,
completedAt: 2000,
description: null,
result,
},
children,
}
}
// ---------------------------------------------------------------------------
// getTaskTraceChildren
// ---------------------------------------------------------------------------
@@ -139,6 +164,22 @@ describe('getTaskTraceChildren', () => {
}
expect(getTaskTraceChildren(block)).toBeNull()
})
// Agent tool name (new SDK name for subagent invocations)
it('returns children for Agent blocks (same as Task)', () => {
const block = makeAgentBlock([
makeChild('c1', 'Glob'),
makeChild('c2', 'Grep'),
])
const result = getTaskTraceChildren(block)
expect(result).not.toBeNull()
expect(result!.length).toBe(2)
})
it('returns null for Agent block with no tool-call children', () => {
const block = makeAgentBlock([])
expect(getTaskTraceChildren(block)).toBeNull()
})
})
// ---------------------------------------------------------------------------
@@ -267,4 +308,29 @@ describe('TraceSection', () => {
// Result section label must also be present
expect(screen.getByText('Result')).toBeInTheDocument()
})
// Agent tool name — same Trace UX as Task
it('renders Trace header for Agent blocks', () => {
const block = makeAgentBlock([makeChild('c1', 'Glob'), makeChild('c2', 'Grep')])
const { container } = render(<TraceSection block={block} metadata={null} />)
// TraceSection must mount (non-null) for Agent blocks
expect(container.firstChild).not.toBeNull()
// header button with aria-expanded attribute must exist
const btn = container.querySelector('button[aria-expanded]')
expect(btn).not.toBeNull()
})
it('renders nothing for Agent block with no children', () => {
const block = makeAgentBlock([])
const { container } = render(<TraceSection block={block} metadata={null} />)
expect(container.firstChild).toBeNull()
})
it('expands Agent trace by default when running', () => {
const block = makeAgentBlock([makeChild('c1', 'Read')], 'running')
const { container } = render(<TraceSection block={block} metadata={null} />)
const headerBtn = container.querySelector('button[aria-expanded="true"]')
expect(headerBtn).not.toBeNull()
expect(container.querySelector('.border-l')).not.toBeNull()
})
})
+4 -3
View File
@@ -1,5 +1,5 @@
/**
* TraceSection — shows child tool calls inside a Task tool dialog.
* TraceSection — shows child tool calls inside a Task/Agent tool dialog.
* Placed between Input and Result sections.
*/
import { useState } from 'react'
@@ -11,6 +11,7 @@ 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'
import { isSubagentToolName } from '@/chat/subagentTool'
// ---------------------------------------------------------------------------
// Result type narrowing (trace.tsx-internal; do NOT move to shared protocol)
@@ -46,10 +47,10 @@ type _TaskToolResultSummary = TaskToolResultSummary
// ---------------------------------------------------------------------------
/**
* Returns tool-call children of the given Task block, or null if none exist.
* Returns tool-call children of the given Task/Agent block, or null if none exist.
*/
export function getTaskTraceChildren(block: ToolCallBlock): ToolCallBlock[] | null {
if (block.tool.name !== 'Task') return null
if (!isSubagentToolName(block.tool.name)) return null
const children = block.children.filter(
(c): c is ToolCallBlock => c.kind === 'tool-call',
)
@@ -11,7 +11,6 @@ import { MultiEditFullView, MultiEditView } from '@/components/ToolCard/views/Mu
import { TodoWriteView } from '@/components/ToolCard/views/TodoWriteView'
import { UpdatePlanView } from '@/components/ToolCard/views/UpdatePlanView'
import { WriteView } from '@/components/ToolCard/views/WriteView'
import { isObject } from '@hapi/protocol'
import { getInputStringAny } from '@/lib/toolInputUtils'
export type ToolViewProps = {
@@ -31,25 +30,6 @@ const SkillFullView: ToolViewComponent = ({ block }: ToolViewProps) => {
)
}
const AgentFullView: ToolViewComponent = ({ block }: ToolViewProps) => {
const input = block.tool.input
const description = getInputStringAny(input, ['description'])
const subagentType = getInputStringAny(input, ['subagent_type'])
const runInBackground = isObject(input) && input.run_in_background === true
return (
<div className="flex flex-col gap-1 text-sm">
{description && (
<div className="text-[var(--app-fg)]">{description}</div>
)}
<div className="flex gap-3 text-[var(--app-hint)]">
{subagentType && <span>Type: {subagentType}</span>}
{runInBackground && <span>Background</span>}
</div>
</div>
)
}
export const toolViewRegistry: Record<string, ToolViewComponent> = {
Edit: EditView,
MultiEdit: MultiEditView,
@@ -71,7 +51,6 @@ export const toolFullViewRegistry: Record<string, ToolViewComponent> = {
CodexDiff: CodexDiffFullView,
CodexPatch: CodexPatchView,
Skill: SkillFullView,
Agent: AgentFullView,
AskUserQuestion: AskUserQuestionView,
ExitPlanMode: ExitPlanModeView,
ask_user_question: AskUserQuestionView,
@@ -138,6 +138,12 @@ describe('getToolResultViewComponent registry', () => {
it('uses a dedicated result view for CodexBash', () => {
expect(getToolResultViewComponent('CodexBash')).not.toBe(getToolResultViewComponent('SomeUnknownTool'))
})
it('Agent falls back to GenericResultView (no dedicated view — view layer must not filter content)', () => {
const agentView = getToolResultViewComponent('Agent')
const genericView = getToolResultViewComponent('SomeUnknownTool')
expect(agentView).toBe(genericView)
})
})
describe('dialog result formatting', () => {
@@ -751,46 +751,6 @@ const TodoWriteResultView: ToolViewComponent = (props: ToolViewProps) => {
return <ChecklistList items={todos} />
}
const AgentResultView: ToolViewComponent = (props: ToolViewProps) => {
const { state, result } = props.block.tool
if (result === undefined || result === null) {
return <ResultStatusPill text={placeholderForState(state)} />
}
// For errors, show the error text
if (state === 'error') {
const text = extractTextFromResult(result)
return (
<div className="text-sm text-red-600">
{text?.trim() ? text : 'Agent failed'}
</div>
)
}
const text = extractTextFromResult(result)
if (!text) {
return <ResultStatusPill text={state === 'completed' ? 'Done' : placeholderForState(state)} />
}
// Detect internal launch metadata. Check structurally first (result object
// may carry agentId/output_file keys), then fall back to a strict text
// pattern that is unlikely to appear in normal agent prose.
const isInternalMeta = isObject(result) && ('agentId' in result || 'output_file' in result)
|| (text.startsWith('Async agent launched successfully.') && text.includes('agentId:'))
if (isInternalMeta) {
return <ResultStatusPill text="Agent launched" />
}
return (
<>
{renderResultBody(renderMarkdown(text, props.surface), props.surface)}
<RawJsonDevOnly value={result} surface={props.surface} />
</>
)
}
const SkillResultView: ToolViewComponent = (props: ToolViewProps) => {
const { state, result, input } = props.block.tool
@@ -889,7 +849,6 @@ export const toolResultViewRegistry: Record<string, ToolViewComponent> = {
CodexPatch: CodexPatchResultView,
CodexDiff: CodexDiffResultView,
Skill: SkillResultView,
Agent: AgentResultView,
AskUserQuestion: AskUserQuestionResultView,
ExitPlanMode: MarkdownResultView,
ask_user_question: AskUserQuestionResultView,