/** * @file AgentDetails.tsx * @description What the chat shows of a subagent, like Claude Desktop: an * Agent/Task call's facts (type, model, status, time, tokens, tool calls), * the task it was given and the report it returned — read off the call, its * result (`tool_result.agent`) and, for a background agent, the * `` that closed it — and, one click further, the * subagent's own conversation as a small chat right there in the step, every * step of it. The chat's owner (ConversationView) supplies the links through * `AgentContext`: which agent transcript a call launched, the notifications * seen, and how to render one agent's conversation (a nested * ConversationView — passed in, so this module never imports it back). * @author Nguyễn Ngọc Trí Vĩ */ import { createContext, useContext, useState } from "react"; import { useTranslation } from "react-i18next"; import { CheckCircle2, ChevronRight, CircleSlash, Loader2, XCircle } from "lucide-react"; import type { TranscriptContent, TranscriptMessage } from "../../lib/types"; import { fmt, formatModelName, formatMs } from "../../lib/format"; import { MarkdownContent } from "./MarkdownContent"; export type AgentStatus = "running" | "done" | "error" | "stopped"; /** One subagent as the chat knows it from the call that launched it. */ export interface AgentRef { /** The Agent/Task `tool_use` id. */ toolUseId: string; /** Its own transcript's `agent_id`; null while nothing names it yet. */ agentId: string | null; type: string; description: string; model: string | null; status: AgentStatus; background: boolean; prompt: string | null; report: string | null; durationMs: number | null; tokens: number | null; toolUses: number | null; } /** What a `` said about a background agent. */ export interface AgentNotice { status: string; result: string | null; durationMs: number | null; tokens: number | null; toolUses: number | null; } export interface AgentLinks { /** tool_use id → agent transcript id, from the session's transcript list. */ ids: Map; /** tool_use id → the notification that closed that background agent. */ notices: Map; /** The session runs in a pane: a call with no result yet still works. */ live: boolean; /** One agent's own conversation, as a chat that fills its box. */ render?: (agent: AgentRef) => React.ReactNode; } export const AgentContext = createContext({ ids: new Map(), notices: new Map(), live: false, }); const AGENT_TOOLS = new Set(["Agent", "Task"]); export const isAgentCall = (b: TranscriptContent) => b.type === "tool_use" && AGENT_TOOLS.has(b.name ?? ""); // The result names the agent's own transcript (`agent-.jsonl`). const AGENT_ID = /agentId:\s*([\w-]+)/; const USAGE = /([\s\S]*?)<\/usage>/; const tag = (text: string, name: string) => text.match(new RegExp(`<${name}>([\\s\\S]*?)`))?.[1]?.trim() ?? null; /** The time, tokens and tool calls of a `` block, in either form * Claude Code writes it: `N` tags or `total_tokens: N`. */ export function parseUsage(text: string): Pick { const body = text.match(USAGE)?.[1] ?? ""; const num = (...names: string[]) => { for (const n of names) { const v = body.match(new RegExp(`${n}(?:>|:\\s*)(\\d+)`))?.[1]; if (v) return Number(v); } return null; }; return { durationMs: num("duration_ms"), tokens: num("subagent_tokens", "total_tokens"), toolUses: num("tool_uses"), }; } /** Each `` in the transcript, by the call it closes. */ export function agentNotices(messages: TranscriptMessage[]): Map { const out = new Map(); for (const msg of messages) { if (msg.type !== "user") continue; for (const c of msg.content) { if (c.type !== "text" || !c.text?.includes("")) continue; for (const part of c.text.split("").slice(1)) { const id = tag(part, "tool-use-id"); if (!id) continue; out.set(id, { status: tag(part, "status") ?? "completed", result: tag(part, "result"), ...parseUsage(part), }); } } } return out; } /** A report without the footer Claude Code appends for the orchestrator. */ function stripFooter(text: string): string { return text .replace(USAGE, "") .replace(/^agentId:.*$/m, "") .trim(); } /** Everything the chat can say about the subagent `block` launched. */ export function agentRef( block: TranscriptContent, result: TranscriptContent | null | undefined, links: AgentLinks ): AgentRef { const raw = block.input as Record | undefined; const input = raw && !("_truncated" in raw) ? raw : {}; const str = (v: unknown) => (typeof v === "string" && v ? v : null); const id = block.id ?? ""; const facts = result?.agent; const output = result?.output ?? ""; const background = facts?.status === "async_launched" || output.includes("Async agent launched"); const notice = background ? links.notices.get(id) : undefined; const usage = !background && output ? parseUsage(output) : null; let status: AgentStatus; if (result?.is_error) status = "error"; else if (!result || (background && !notice)) status = links.live ? "running" : "stopped"; else if (notice && notice.status !== "completed") status = notice.status === "failed" ? "error" : "stopped"; else status = "done"; return { toolUseId: id, agentId: facts?.id ?? output.match(AGENT_ID)?.[1] ?? links.ids.get(id) ?? null, type: str(input.subagent_type) ?? "Agent", description: str(input.description) ?? "", model: facts?.model ?? str(input.model), status, background, prompt: str(input.prompt), report: background ? (notice?.result ?? null) : output ? stripFooter(output) || null : null, durationMs: facts?.duration_ms ?? notice?.durationMs ?? usage?.durationMs ?? null, tokens: facts?.tokens ?? notice?.tokens ?? usage?.tokens ?? null, toolUses: facts?.tool_uses ?? notice?.toolUses ?? usage?.toolUses ?? null, }; } const STATUS_ICON: Record = { running: , done: , error: , stopped: , }; /** One muted line: its status as an icon, then type · model · time · * tokens · tool calls, as far as they are known. */ export function AgentFacts({ agent }: { agent: AgentRef }) { const { t } = useTranslation("sessions"); const parts = [ agent.type, agent.model && formatModelName(agent.model), agent.background && t("conversation.agent.background"), agent.durationMs != null && formatMs(agent.durationMs), agent.tokens != null && t("conversation.agentContext", { tokens: fmt(agent.tokens) }), agent.toolUses != null && t("conversation.agent.toolUses", { count: agent.toolUses }), ].filter(Boolean); return (
{STATUS_ICON[agent.status]} {t(`conversation.agent.status.${agent.status}`)} {parts.join(" · ")}
); } const linkClass = "inline-flex items-center gap-0.5 text-xs text-fg-muted transition-colors hover:text-fg-secondary"; /** The subagent's own conversation, a thread hung under its step by a line * on the left — as tall as it is, up to 24rem, then it scrolls. */ export function AgentChatBox({ agent }: { agent: AgentRef }) { const links = useContext(AgentContext); return (
{links.render?.(agent)}
); } /** An Agent/Task step opened in the chat: one line of facts, its report as * prose, and "View conversation ›", which puts the agent's own thread (its * task, every step, its reply) in the report's place. With no transcript * to show, the task it was given opens on its own instead. */ export function AgentStepBody({ block, result, }: { block: TranscriptContent; result: TranscriptContent | null; }) { const { t } = useTranslation("sessions"); const links = useContext(AgentContext); const agent = agentRef(block, result, links); const canOpen = !!agent.agentId && !!links.render; const [thread, setThread] = useState(false); const [task, setTask] = useState(false); return (
{thread ? ( ) : ( <> {!canOpen && agent.prompt && task && (
)} {agent.report && (
)} )} {canOpen ? ( ) : ( agent.prompt && ( ) )}
); }