traces
Multi-Agent Trace
A tree of parallel sub-agent tasks, each with its own status and step list.
4 agents working
"use client";
import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
import { Check, ChevronDown, Loader2, X } from "lucide-react";
import { cn } from "@/lib/utils";
export interface AgentStep {
label: string;
meta?: string;
}
export type AgentStatus = "queued" | "running" | "done" | "error";
export interface Agent {
id: string;
name: string;
icon?: React.ComponentType<{ className?: string }>;
status: AgentStatus;
currentStep?: string;
elapsedSeconds?: number;
steps: AgentStep[];
subAgents?: Agent[];
}
export interface MultiAgentTraceProps {
agents: Agent[];
className?: string;
}
function countActive(agents: Agent[]): number {
return agents.reduce((n, a) => {
const self = a.status === "running" || a.status === "queued" ? 1 : 0;
return n + self + (a.subAgents ? countActive(a.subAgents) : 0);
}, 0);
}
function countFailed(agents: Agent[]): number {
return agents.reduce((n, a) => {
return n + (a.status === "error" ? 1 : 0) + (a.subAgents ? countFailed(a.subAgents) : 0);
}, 0);
}
function countTotal(agents: Agent[]): number {
return agents.reduce((n, a) => n + 1 + (a.subAgents ? countTotal(a.subAgents) : 0), 0);
}
export function MultiAgentTrace({ agents, className }: MultiAgentTraceProps) {
const active = countActive(agents);
const failed = countFailed(agents);
const total = countTotal(agents);
const allResolved = active === 0;
return (
<div className={cn("w-full overflow-hidden rounded-2xl border bg-card", className)}>
<div aria-live="polite" className="flex items-center gap-2 border-b px-4 py-3 text-xs font-medium">
{!allResolved ? (
<Loader2 className="size-3.5 animate-spin text-muted-foreground" aria-hidden />
) : failed > 0 ? (
<X className="size-3.5 text-destructive" aria-hidden />
) : (
<Check className="size-3.5 text-emerald-600 dark:text-emerald-400" aria-hidden />
)}
<span>
{!allResolved
? `${active} agent${active === 1 ? "" : "s"} working`
: `Done — ${total - failed}/${total} complete`}
</span>
</div>
<ul className="divide-y">
{agents.map((agent) => (
<AgentRow key={agent.id} agent={agent} />
))}
</ul>
</div>
);
}
function AgentRow({ agent, depth = 0 }: { agent: Agent; depth?: number }) {
const [open, setOpen] = React.useState(false);
const hasContent = agent.steps.length > 0 || (agent.subAgents?.length ?? 0) > 0;
return (
<li>
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
disabled={!hasContent}
className="flex w-full items-center gap-3 px-4 py-3 text-left disabled:cursor-default"
style={{ paddingLeft: depth > 0 ? `${1 + depth * 1.5}rem` : undefined }}
>
<StatusIcon status={agent.status} icon={agent.icon} />
<div className="min-w-0 flex-1">
<p className="text-xs font-medium">{agent.name}</p>
{agent.currentStep && (
<p className="truncate text-[11px] text-muted-foreground">{agent.currentStep}</p>
)}
</div>
{typeof agent.elapsedSeconds === "number" && (
<span className="shrink-0 font-mono text-xs text-muted-foreground">
{agent.elapsedSeconds}s
</span>
)}
{hasContent && (
<ChevronDown
className={cn(
"size-4 shrink-0 text-muted-foreground transition-transform",
open && "rotate-180"
)}
/>
)}
</button>
<AnimatePresence initial={false}>
{open && hasContent && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: "easeInOut" }}
className="overflow-hidden"
>
{agent.steps.length > 0 && (
<ul
className="px-4 pb-3 pl-11"
style={{ paddingLeft: depth > 0 ? `${2.75 + depth * 1.5}rem` : undefined }}
>
{agent.steps.map((step, i) => (
<li key={i} className="flex gap-3">
<div className="flex flex-col items-center">
<span className="flex size-4 shrink-0 items-center justify-center rounded-full bg-muted">
<Check className="size-2.5 text-muted-foreground" />
</span>
{(i < agent.steps.length - 1 || (agent.subAgents?.length ?? 0) > 0) && (
<span className="my-1 w-px flex-1 bg-border" />
)}
</div>
<p className="pb-3 text-xs text-foreground/90">
{step.label}
{step.meta && (
<span className="ml-1.5 text-muted-foreground">{step.meta}</span>
)}
</p>
</li>
))}
</ul>
)}
{agent.subAgents && agent.subAgents.length > 0 && (
<ul className="border-t divide-y">
{agent.subAgents.map((sub) => (
<AgentRow key={sub.id} agent={sub} depth={depth + 1} />
))}
</ul>
)}
</motion.div>
)}
</AnimatePresence>
</li>
);
}
function StatusIcon({
status,
icon: Icon,
}: {
status: AgentStatus;
icon?: React.ComponentType<{ className?: string }>;
}) {
if (status === "running") {
return (
<span className="relative flex size-6 shrink-0 items-center justify-center rounded-full bg-muted">
{Icon ? (
<>
<Icon className="size-3.5 text-muted-foreground" aria-label="Running" />
<span className="absolute -bottom-0.5 -right-0.5 flex size-2.5 items-center justify-center rounded-full bg-card ring-1 ring-border">
<Loader2 className="size-1.5 animate-spin text-muted-foreground" />
</span>
</>
) : (
<Loader2 className="size-3.5 animate-spin text-muted-foreground" aria-label="Running" />
)}
</span>
);
}
if (status === "error") {
return (
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-destructive/10 text-destructive">
{Icon ? <Icon className="size-3.5" aria-label="Error" /> : <X className="size-3.5" aria-label="Failed" />}
</span>
);
}
if (status === "done") {
return (
<span className="relative flex size-6 shrink-0 items-center justify-center rounded-full bg-emerald-500/15 text-emerald-600 dark:text-emerald-400">
{Icon ? (
<>
<Icon className="size-3.5" aria-label="Done" />
<span className="absolute -bottom-0.5 -right-0.5 flex size-2.5 items-center justify-center rounded-full bg-card ring-1 ring-border">
<Check className="size-1.5 text-emerald-600 dark:text-emerald-400" />
</span>
</>
) : (
<Check className="size-3.5" aria-label="Done" />
)}
</span>
);
}
return (
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-muted" aria-label="Queued">
{Icon ? (
<Icon className="size-3.5 text-muted-foreground/50" />
) : (
<span className="size-1.5 rounded-full bg-muted-foreground" />
)}
</span>
);
}
A UX spec for this pattern — written for agents implementing or reusing it, not the code.
# Multi-Agent Trace
## Summary
A tree/list view of an orchestrator's parallel sub-agent tasks — each with its own name, live status, current-step label, and collapsible step list — so the user can watch several agents work at once instead of inferring parallel progress from one linear trace. The multi-agent counterpart to Expandable Trace.
## When to use
- An orchestrating agent has dispatched more than one sub-agent or task concurrently, and the user benefits from seeing each one's independent progress and outcome.
- Any workflow where sub-agents can finish in a different order than they started, or one can fail while others keep going — a single linear trace can't represent that.
## When not to use
- For a single agent working through one sequence of steps — use Expandable Trace; wrapping one linear sequence in this pattern adds structure with nothing to show for it.
- When "sub-agents" are actually just sequential steps of one process dressed up as separate agents — if they run one after another, they read better as a single trace.
## Anatomy
- Root header: an aggregate status line ("3 agents working" while running, "Done — 3/3 complete" once finished) with a count.
- Per-agent row: a status icon (queued/running/done/error), the agent's name or role, a short current-step label, elapsed time, and an expand chevron.
- Expanded body: that agent's own step list, in the same step-row language as Expandable Trace.
## Behavior
- Each row updates independently — one agent completing, failing, or still running has no effect on any other row's state or label.
- The root header aggregates live: it reflects "N agents working" while any are active, and collapses to a static summary once all have resolved.
- Expanding one row never collapses another — several can be open simultaneously, since the agents themselves are independent.
- A failed sub-agent's row gets a distinct error tint and icon and stays visible rather than disappearing, so the user knows it needs attention.
## Content guidelines
- Agent names are short role labels ("Research agent", "Code agent"), not full descriptions of what they're doing — the current-step label carries that.
- Current-step labels follow the same present-tense, short-phrase convention as Thinking Loader and Tool Call Chip ("Reading docs", not "It is currently reading the documentation").
## Accessibility
- Each row's expand toggle exposes `aria-expanded`.
- Status changes are announced through a single `aria-live="polite"` region on the root header, not per row, so simultaneous updates from several agents don't spam assistive tech.
- Status is conveyed by icon and text together, never color alone, since red/green distinctions must survive a color-blind or grayscale viewing.
## Related patterns
- Expandable Trace is the single-agent, linear counterpart this pattern extends to parallel work.
- Tool Call Chip is what an individual step inside one agent's row typically looks like when that step is a specific tool call.