mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
fix(web): align Agent tool dialog with TUI ctrl+o expand (#585)
This commit is contained in:
@@ -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 (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user