feat(ui): add dedicated Agent subagent rendering with nested tool call display
Render Agent/Explore/Plan tool calls in a visually distinct collapsible
container with colored left border, replacing the generic tool card. Parse
subagent JSONL transcripts from {sessionId}/subagents/ to extract and
display the actual tool calls (Bash, Read, Grep, etc.) the subagent
executed, reusing the existing ToolCallPart for consistent appearance.
- Add AgentToolCallPart component with collapsible body, prompt section,
execution stats, and nested tool call list via render prop injection
- Add AgentExecutionStats and AgentToolCall types (Rust + TypeScript)
- Parse toolUseResult.agentId to locate and read subagent JSONL files
- Validate agentId against path traversal before filesystem access
- Pass agentStats through adapter for both ID-matched and positional
tool result pairing
- Strip agentStats in nested render to prevent recursive Agent expansion
- Add i18n keys for agent UI labels across all 10 languages
This commit is contained in:
269
src/components/message/agent-tool-call.tsx
Normal file
269
src/components/message/agent-tool-call.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
import { memo, useMemo, useState, type ReactNode } from "react"
|
||||
import type { AdaptedContentPart } from "@/lib/adapters/ai-elements-adapter"
|
||||
import type { AgentToolCall } from "@/lib/types"
|
||||
import { tryParseJson, extractJsonField } from "./content-parts-renderer"
|
||||
import { MessageResponse } from "@/components/ai-elements/message"
|
||||
import { Shimmer } from "@/components/ai-elements/shimmer"
|
||||
import { getStatusBadge } from "@/components/ai-elements/tool"
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
CompassIcon,
|
||||
MapIcon,
|
||||
TerminalIcon,
|
||||
WrenchIcon,
|
||||
} from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
const ICON_CLASS = "size-4 text-muted-foreground"
|
||||
|
||||
function getAgentIcon(subagentType: string | null) {
|
||||
const t = subagentType?.toLowerCase() ?? ""
|
||||
if (t.includes("explore")) return <CompassIcon className={ICON_CLASS} />
|
||||
if (t.includes("plan")) return <MapIcon className={ICON_CLASS} />
|
||||
if (t.includes("bash")) return <TerminalIcon className={ICON_CLASS} />
|
||||
return <WrenchIcon className={ICON_CLASS} />
|
||||
}
|
||||
|
||||
function getAccentColor(subagentType: string | null): string {
|
||||
const t = subagentType?.toLowerCase() ?? ""
|
||||
if (t.includes("explore"))
|
||||
return "border-l-blue-500/50 dark:border-l-blue-400/40"
|
||||
if (t.includes("plan"))
|
||||
return "border-l-amber-500/50 dark:border-l-amber-400/40"
|
||||
if (t.includes("bash"))
|
||||
return "border-l-green-500/50 dark:border-l-green-400/40"
|
||||
return "border-l-purple-500/50 dark:border-l-purple-400/40"
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
const sec = ms / 1000
|
||||
if (sec < 60) return `${sec.toFixed(1)}s`
|
||||
return `${(sec / 60).toFixed(1)}m`
|
||||
}
|
||||
|
||||
/** Convert AgentToolCall[] to AdaptedContentPart[] for reuse with ToolCallPart */
|
||||
function adaptToolCalls(
|
||||
calls: AgentToolCall[],
|
||||
parentId: string
|
||||
): AdaptedContentPart[] {
|
||||
return calls.map(
|
||||
(call, i): Extract<AdaptedContentPart, { type: "tool-call" }> => ({
|
||||
type: "tool-call",
|
||||
toolCallId: `${parentId}-sub-${i}`,
|
||||
toolName: call.tool_name,
|
||||
input: call.input_preview ?? null,
|
||||
state: call.is_error ? "output-error" : "output-available",
|
||||
output: call.output_preview ?? null,
|
||||
errorText: call.is_error ? (call.output_preview ?? undefined) : undefined,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// ── main component ────────────────────────────────────────────────────
|
||||
|
||||
export const AgentToolCallPart = memo(function AgentToolCallPart({
|
||||
part,
|
||||
renderToolCall,
|
||||
}: {
|
||||
part: Extract<AdaptedContentPart, { type: "tool-call" }>
|
||||
/** Render a single tool-call part — injected by the parent to avoid
|
||||
* circular imports (content-parts-renderer → agent-tool-call → renderer). */
|
||||
renderToolCall: (
|
||||
part: Extract<AdaptedContentPart, { type: "tool-call" }>,
|
||||
key: string
|
||||
) => ReactNode
|
||||
}) {
|
||||
const t = useTranslations("Folder.chat.contentParts")
|
||||
const tTool = useTranslations("Folder.chat.tool")
|
||||
|
||||
const isRunning =
|
||||
part.state === "input-available" || part.state === "input-streaming"
|
||||
const isError = part.state === "output-error"
|
||||
|
||||
const [bodyOpen, setBodyOpen] = useState(isRunning || isError)
|
||||
const [promptOpen, setPromptOpen] = useState(false)
|
||||
|
||||
const parsed = useMemo(
|
||||
() => (part.input ? tryParseJson(part.input) : null),
|
||||
[part.input]
|
||||
)
|
||||
|
||||
const subagentType = useMemo(
|
||||
() =>
|
||||
(parsed?.subagent_type as string | undefined) ??
|
||||
(part.input ? extractJsonField(part.input, "subagent_type") : null),
|
||||
[parsed, part.input]
|
||||
)
|
||||
|
||||
const description = useMemo(
|
||||
() =>
|
||||
(parsed?.description as string | undefined) ??
|
||||
(part.input ? extractJsonField(part.input, "description") : null),
|
||||
[parsed, part.input]
|
||||
)
|
||||
|
||||
const prompt = useMemo(
|
||||
() =>
|
||||
(parsed?.prompt as string | undefined) ??
|
||||
(part.input ? extractJsonField(part.input, "prompt") : null),
|
||||
[parsed, part.input]
|
||||
)
|
||||
|
||||
const model = useMemo(
|
||||
() =>
|
||||
(parsed?.model as string | undefined) ??
|
||||
(part.input ? extractJsonField(part.input, "model") : null),
|
||||
[parsed, part.input]
|
||||
)
|
||||
|
||||
const icon = useMemo(() => getAgentIcon(subagentType), [subagentType])
|
||||
const accentColor = useMemo(
|
||||
() => getAccentColor(subagentType),
|
||||
[subagentType]
|
||||
)
|
||||
|
||||
const title = useMemo(() => {
|
||||
const prefix = subagentType ?? "Agent"
|
||||
return description ? `${prefix}: ${description}` : prefix
|
||||
}, [subagentType, description])
|
||||
|
||||
const statusLabel =
|
||||
part.state === "input-available"
|
||||
? tTool("status.inputAvailable")
|
||||
: part.state === "input-streaming"
|
||||
? tTool("status.inputStreaming")
|
||||
: part.state === "output-available"
|
||||
? tTool("status.outputAvailable")
|
||||
: tTool("status.outputError")
|
||||
|
||||
const agentStats = part.agentStats ?? null
|
||||
const adaptedToolCalls = useMemo(
|
||||
() => adaptToolCalls(agentStats?.tool_calls ?? [], part.toolCallId),
|
||||
[agentStats?.tool_calls, part.toolCallId]
|
||||
)
|
||||
|
||||
const durationSuffix = useMemo(() => {
|
||||
if (!agentStats?.total_duration_ms) return null
|
||||
return formatDuration(agentStats.total_duration_ms)
|
||||
}, [agentStats])
|
||||
|
||||
return (
|
||||
<Collapsible open={bodyOpen} onOpenChange={setBodyOpen}>
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-md border border-border/60 bg-muted/20 overflow-hidden",
|
||||
"border-l-[3px]",
|
||||
accentColor
|
||||
)}
|
||||
>
|
||||
{/* Header — clickable to toggle body */}
|
||||
<CollapsibleTrigger className="flex w-full min-w-0 items-center justify-between gap-3 px-4 py-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="shrink-0">{icon}</span>
|
||||
<span className="min-w-0 flex-1 truncate whitespace-nowrap text-sm font-medium text-left">
|
||||
{title}
|
||||
</span>
|
||||
{!bodyOpen && durationSuffix && (
|
||||
<span className="shrink-0 text-xs text-muted-foreground/60">
|
||||
{durationSuffix}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{getStatusBadge(part.state, statusLabel)}
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-4 text-muted-foreground transition-transform",
|
||||
!bodyOpen && "-rotate-90"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
{/* Collapsible body */}
|
||||
<CollapsibleContent>
|
||||
<div className="space-y-3 px-4 pb-4">
|
||||
{/* Model + duration summary */}
|
||||
{(model || durationSuffix) && (
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
{model && (
|
||||
<span>
|
||||
{t("agentModelLabel")}:{" "}
|
||||
<span className="font-mono">{model}</span>
|
||||
</span>
|
||||
)}
|
||||
{durationSuffix && <span>{durationSuffix}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collapsible prompt */}
|
||||
{prompt && (
|
||||
<Collapsible open={promptOpen} onOpenChange={setPromptOpen}>
|
||||
<CollapsibleTrigger className="flex items-center gap-1 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors">
|
||||
<ChevronRightIcon
|
||||
className={cn(
|
||||
"size-3.5 transition-transform",
|
||||
promptOpen && "rotate-90"
|
||||
)}
|
||||
/>
|
||||
{t("agentPromptLabel")}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<pre className="mt-2 max-h-64 overflow-y-auto whitespace-pre-wrap break-words rounded-md bg-muted/50 p-3 text-xs text-muted-foreground">
|
||||
{prompt}
|
||||
</pre>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
{/* Subagent tool calls — rendered with the same ToolCallPart
|
||||
as the outer conversation for consistent appearance */}
|
||||
{adaptedToolCalls.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{adaptedToolCalls.map((tc, i) =>
|
||||
renderToolCall(
|
||||
tc as Extract<AdaptedContentPart, { type: "tool-call" }>,
|
||||
`subagent-tc-${i}`
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Running indicator */}
|
||||
{isRunning && !part.output && (
|
||||
<Shimmer className="text-sm" duration={2}>
|
||||
Running...
|
||||
</Shimmer>
|
||||
)}
|
||||
|
||||
{/* Error output */}
|
||||
{isError && part.errorText && (
|
||||
<div className="rounded-md bg-destructive/10 p-3">
|
||||
<pre className="whitespace-pre-wrap break-words text-xs text-destructive">
|
||||
{part.errorText}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Final output */}
|
||||
{part.output && (
|
||||
<div className="text-sm prose prose-sm dark:prose-invert max-w-none [&_ul]:list-inside [&_ol]:list-inside">
|
||||
<MessageResponse>{part.output}</MessageResponse>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
)
|
||||
})
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
ReasoningTrigger,
|
||||
ReasoningContent,
|
||||
} from "@/components/ai-elements/reasoning"
|
||||
import { AgentToolCallPart } from "./agent-tool-call"
|
||||
import {
|
||||
FileTextIcon,
|
||||
FilePenLineIcon,
|
||||
@@ -45,7 +46,7 @@ import {
|
||||
// ── helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Try JSON.parse; return null on failure. */
|
||||
function tryParseJson(s: string): Record<string, unknown> | null {
|
||||
export function tryParseJson(s: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const v = JSON.parse(s)
|
||||
return typeof v === "object" && v !== null && !Array.isArray(v) ? v : null
|
||||
@@ -55,7 +56,7 @@ function tryParseJson(s: string): Record<string, unknown> | null {
|
||||
}
|
||||
|
||||
/** Regex-extract a JSON string value for a given key (works on truncated JSON). */
|
||||
function extractJsonField(input: string, key: string): string | null {
|
||||
export function extractJsonField(input: string, key: string): string | null {
|
||||
const re = new RegExp(`"${key}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"`)
|
||||
const m = input.match(re)
|
||||
return m?.[1]?.replace(/\\"/g, '"').replace(/\\\\/g, "\\") ?? null
|
||||
@@ -191,7 +192,7 @@ function shortPath(p: string): string {
|
||||
}
|
||||
|
||||
/** Truncate text to maxLen, appending "…" if truncated. */
|
||||
function ellipsis(s: string, maxLen: number): string {
|
||||
export function ellipsis(s: string, maxLen: number): string {
|
||||
return s.length > maxLen ? s.slice(0, maxLen - 1) + "…" : s
|
||||
}
|
||||
|
||||
@@ -2240,6 +2241,19 @@ const ToolCallPart = memo(function ToolCallPart({
|
||||
toolNameLower === "exitplanmode" ||
|
||||
isFileTool) &&
|
||||
!part.errorText
|
||||
// Agent/subagent tools get a dedicated container rendering
|
||||
if (toolNameLower === "agent") {
|
||||
return (
|
||||
<AgentToolCallPart
|
||||
part={part}
|
||||
renderToolCall={(p, key) => (
|
||||
// Strip agentStats to prevent recursive Agent nesting
|
||||
<ToolCallPart key={key} part={{ ...p, agentStats: undefined }} />
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Cline: attempt_completion — render as an expanded card with result + progress
|
||||
if (toolNameLower === "attempt_completion") {
|
||||
const parsedCompletion = tryParseJson(part.input ?? "")
|
||||
@@ -2297,8 +2311,7 @@ const ToolCallPart = memo(function ToolCallPart({
|
||||
output={part.output}
|
||||
/>
|
||||
)}
|
||||
{(toolNameLower === "task" || toolNameLower === "agent") &&
|
||||
part.output ? (
|
||||
{toolNameLower === "task" && part.output ? (
|
||||
<div className="text-sm prose prose-sm dark:prose-invert max-w-none [&_ul]:list-inside [&_ol]:list-inside">
|
||||
<MessageResponse>{part.output}</MessageResponse>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user