import { memo, useState, type JSX } from "react"; import { AlertCircle, ChevronDown, ChevronRight } from "lucide-react"; import { AgentLinedIcon } from "../../../app/renderer/components/icons/AgentLinedIcon"; import { translate } from "../../../i18n/index"; import type { AgentTaskSubAgentVM } from "../contracts/agentTaskItemVM"; import type { AgentToolCallVM } from "../contracts/agentToolCallVM"; import { CollapsibleReveal } from "./CollapsibleReveal"; import { useAgentConversationNowUnixMs } from "./AgentConversationClock"; import { formatAgentToolDurationMs } from "./tool-renderers/render-data/agentToolRenderData"; import { AgentMessageMarkdown } from "../../AgentMessageMarkdown"; // A delegated sub-agent renders as a first-class row aligned with tool rows: // header = "Sub-agent · · · " with a chevron, body = // the indented detail panel (TASK / PROGRESS). Identity and status are the // sub-agent's own (child thread nickname + lifecycle), never the spawn tool's. export function AgentSubAgentCards({ call, onLinkClick }: { call: AgentToolCallVM; onLinkClick?: (href: string) => void; }): JSX.Element | null { "use memo"; const subAgents = call.task?.subAgents ?? []; if (subAgents.length === 0) { return null; } // No wrapper element: the transcript row's spacing-compression selectors // match direct .detail-tool-row children, keeping gaps identical to tools. return ( <> {subAgents.map((subAgent) => ( ))} ); } // Field-level memo: streaming updates elsewhere in the transcript must not // re-render settled cards; a collapsed card renders only its header row // (CollapsibleReveal lazy-mounts the body on first expand). export const AgentSubAgentCard = memo( AgentSubAgentCardImpl, (prev, next) => prev.onLinkClick === next.onLinkClick && subAgentVMEquals(prev.subAgent, next.subAgent) ); function subAgentVMEquals( left: AgentTaskSubAgentVM, right: AgentTaskSubAgentVM ): boolean { return ( left.childSessionId === right.childSessionId && left.parentToolCallId === right.parentToolCallId && left.status === right.status && left.name === right.name && left.task === right.task && left.laneIndex === right.laneIndex && left.laneCount === right.laneCount && left.latestActivity === right.latestActivity && left.assistantMarkdown === right.assistantMarkdown && left.failureDetail === right.failureDetail && left.queued === right.queued && left.startedAtUnixMs === right.startedAtUnixMs && left.latestActivityAtUnixMs === right.latestActivityAtUnixMs && left.terminalAtUnixMs === right.terminalAtUnixMs && left.childSessions === right.childSessions ); } function AgentSubAgentCardImpl({ subAgent, onLinkClick }: { subAgent: AgentTaskSubAgentVM; onLinkClick?: (href: string) => void; }): JSX.Element { "use memo"; // Expanded by default while running; always collapsible via the header. const [expanded, setExpanded] = useState(subAgent.status === "running"); return (
); } function SubAgentHeader({ subAgent, expanded }: { subAgent: AgentTaskSubAgentVM; expanded: boolean; }): JSX.Element { "use memo"; const running = subAgent.status === "running"; const statusLabel = subAgentStatusLabel(subAgent.status); const runningNowUnixMs = useRunningSubAgentNowUnixMs(subAgent); const elapsedText = subAgentElapsedText(subAgent, runningNowUnixMs); const nameText = subAgentNameText(subAgent); return (
{subAgent.status === "failed" ? (
{translate("agentHost.agentTool.details.subAgentFallbackName")} {nameText ? ( {nameText} ) : null} {elapsedText ? `${elapsedText} · ` : ""} {statusLabel}
{expanded ? (
); } function SubAgentBody({ subAgent, onLinkClick }: { subAgent: AgentTaskSubAgentVM; onLinkClick?: (href: string) => void; }): JSX.Element { "use memo"; // Bash-block layout: the task is the header strip, live progress renders // below it - no section labels. return (
{subAgent.task ? (
{subAgent.task}
) : null} {subAgent.childSessions.length > 0 ? (
{subAgent.childSessions.map((childSession) => ( ))}
) : null}
); } function SubAgentProgress({ subAgent, onLinkClick }: { subAgent: AgentTaskSubAgentVM; onLinkClick?: (href: string) => void; }): JSX.Element { "use memo"; if (subAgent.assistantMarkdown?.trim()) { return (
{subAgent.failureDetail ? (
{subAgent.failureDetail}
) : null}
); } // Tool/reasoning activity remains a short fallback until the child emits an // assistant message. Once it does, the complete Markdown above owns both the // streaming and settled presentation. const text = subAgent.failureDetail ?? (subAgent.status === "running" ? subAgent.latestActivity : null) ?? translate( subAgent.status === "completed" ? "agentHost.agentTool.statusCompleted" : subAgent.status === "canceled" ? "agentHost.agentTool.statusCanceled" : subAgent.status === "failed" ? "agentHost.agentTool.statusFailed" : subAgent.queued ? "agentHost.agentTool.details.subAgentQueued" : "agentHost.agentTool.details.subAgentStarting" ); return (
{text}
); } function subAgentNameText(subAgent: AgentTaskSubAgentVM): string | null { if (subAgent.name) { return subAgent.name; } return subAgent.laneCount > 1 ? `#${subAgent.laneIndex}` : null; } function subAgentAriaLabel(subAgent: AgentTaskSubAgentVM): string { return [ translate("agentHost.agentTool.details.subAgentFallbackName"), subAgentNameText(subAgent), subAgentStatusLabel(subAgent.status) ] .filter(Boolean) .join(" "); } function subAgentStatusLabel(status: AgentTaskSubAgentVM["status"]): string { switch (status) { case "completed": return translate("agentHost.agentTool.statusCompleted"); case "failed": return translate("agentHost.agentTool.statusFailed"); case "canceled": return translate("agentHost.agentTool.statusCanceled"); case "running": default: return translate("agentHost.agentTool.statusWorking"); } } // A sub-agent that is merely `queued` has been accepted but has not been // dispatched to a provider yet (codex caps concurrent sub-agents and queues // spawns beyond the cap - see AgentTaskSubAgentVM.queued). Its `status` still // reads "running" (there is no child lifecycle yet to say otherwise) and its // `startedAtUnixMs` is stamped from the spawn call itself, i.e. dispatch/ // queued time, not from when the sub-agent actually started running. Ticking // a live clock off that timestamp would count queued wait time as elapsed // run time, so the clock must stay off until the sub-agent is no longer // queued. function isSubAgentActivelyRunning(subAgent: AgentTaskSubAgentVM): boolean { return subAgent.status === "running" && !subAgent.queued; } function useRunningSubAgentNowUnixMs( subAgent: AgentTaskSubAgentVM ): number | null { const shouldTick = isSubAgentActivelyRunning(subAgent) && typeof subAgent.startedAtUnixMs === "number"; return useAgentConversationNowUnixMs(shouldTick); } function subAgentElapsedText( subAgent: AgentTaskSubAgentVM, runningNowUnixMs: number | null ): string | null { const started = subAgent.startedAtUnixMs; if (typeof started !== "number") { return null; } const terminal = subAgent.terminalAtUnixMs; const latest = subAgent.latestActivityAtUnixMs; const ended = typeof terminal === "number" ? terminal : isSubAgentActivelyRunning(subAgent) && typeof runningNowUnixMs === "number" ? Math.max(latest ?? started, runningNowUnixMs) : latest; if (typeof ended !== "number" || ended <= started) { return null; } return formatAgentToolDurationMs(ended - started); }