Installation
Complete project setup with react/radix, then install these components:
pnpm exec frontfriend download trace code-block badge icon button message bubble -f react --primitive radix Keep preliminary output in the running state until the subagent returns its final result. Nested steps use stable IDs and controlled disclosures. The task and model badges describe the run, and the duration appears after its title.
Usage
Complete example
import { Message, MessageContent } from "@/components/ui/message";
import { Bubble, BubbleContent } from "@/components/ui/bubble";
import {
Trace,
TraceLine,
TraceIcon,
TraceTitle,
TraceDescription,
TraceCollapsible,
TraceCollapsibleTrigger,
TraceCollapsibleContent,
} from "@/components/ui/trace";
import { Icon } from "@/components/ui/icon";
import { Badge } from "@/components/ui/badge";
import { CodeBlock, CodeBlockContent } from "@/components/ui/code-block";
import { Button } from "@/components/ui/button";
import * as React from "react";
type Phase = "complete" | "running" | "error" | "pending";
function useRun(initial: Phase) {
const [phase, setPhase] = React.useState(initial);
const timer = React.useRef<ReturnType<typeof setTimeout> | undefined>(
undefined,
);
React.useEffect(() => {
clearTimeout(timer.current);
setPhase(initial);
}, [initial]);
React.useEffect(() => () => clearTimeout(timer.current), []);
function replay() {
clearTimeout(timer.current);
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
setPhase("complete");
return;
}
setPhase("running");
timer.current = setTimeout(() => setPhase("complete"), 3600);
}
return { phase, replay };
}
type AgentStep = {
id: string;
title: string;
status?: "running" | "error" | "pending";
output?: string;
children?: AgentStep[];
report?: string;
task?: string;
duration?: string;
model?: string;
tool?: string;
};
const frame: React.CSSProperties = {
width: "100%",
maxWidth: 384,
minWidth: 0,
minHeight: 288,
marginInline: "auto",
display: "flex",
flexDirection: "column",
gap: 16,
paddingBlock: 24,
fontSize: "var(--text-sm)",
lineHeight: 1.5,
};
function TracePanel({
trigger,
children,
open: controlledOpen,
defaultOpen = false,
onOpenChange,
...props
}: React.ComponentProps<typeof TraceCollapsible> & {
trigger: React.ReactNode;
}) {
const contentRef = React.useRef<HTMLDivElement>(null);
const [height, setHeight] = React.useState<number>();
const [localOpen, setLocalOpen] = React.useState(defaultOpen);
const expanded = controlledOpen ?? localOpen;
const [hasOpened, setHasOpened] = React.useState(expanded);
React.useEffect(() => {
if (expanded) setHasOpened(true);
}, [expanded]);
React.useLayoutEffect(() => {
const content = contentRef.current;
const inner = content?.firstElementChild;
if (!expanded || !content || !inner) return;
const measure = () => setHeight(content.scrollHeight);
measure();
const observer = new ResizeObserver(measure);
observer.observe(inner);
return () => observer.disconnect();
}, [expanded]);
return (
<TraceCollapsible
{...props}
open={expanded}
onOpenChange={(value) => {
if (!value && contentRef.current) {
setHeight(contentRef.current.getBoundingClientRect().height);
}
setLocalOpen(value);
onOpenChange?.(value);
}}
>
<TraceCollapsibleTrigger>{trigger}</TraceCollapsibleTrigger>
<TraceCollapsibleContent
ref={contentRef}
style={
height === undefined
? undefined
: ({
"--radix-collapsible-content-height": `${height}px`,
} as React.CSSProperties)
}
forceMount={hasOpened ? true : undefined}
inert={!expanded}
>
{children}
</TraceCollapsibleContent>
</TraceCollapsible>
);
}
function AgentRow({
step,
open,
onOpen,
}: {
step: AgentStep;
open: Record<string, boolean>;
onOpen: (id: string, value: boolean) => void;
}) {
const line = (
<TraceLine status={step.status}>
<TraceIcon>
<Icon
name={
step.status === "running"
? "LoaderCircle"
: step.status === "error"
? "CircleAlert"
: step.status === "pending"
? "CircleDashed"
: "CircleCheck"
}
/>
</TraceIcon>
<TraceTitle>{step.title}</TraceTitle>
{step.duration && <TraceDescription>{step.duration}</TraceDescription>}
</TraceLine>
);
if (!step.children && !step.output) return line;
return (
<TracePanel
open={open[step.id] ?? Boolean(step.children)}
onOpenChange={(value) => onOpen(step.id, value)}
trigger={line}
>
<div style={{ display: "grid", gap: 12 }}>
{step.task && (
<>
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
<Badge variant="secondary">{step.model}</Badge>
<Badge variant="outline">{step.tool}</Badge>
</div>
<p>{step.task}</p>
</>
)}
{step.children && (
<Trace>
{step.children.map((child) => (
<AgentRow
key={child.id}
step={child}
open={open}
onOpen={onOpen}
/>
))}
</Trace>
)}
{step.output && (
<CodeBlock
size="sm"
variant={step.status === "error" ? "destructive" : "default"}
>
<CodeBlockContent wrap style={{ maxHeight: 256 }}>
{step.output}
</CodeBlockContent>
</CodeBlock>
)}
{step.report && <p>{step.report}</p>}
</div>
</TracePanel>
);
}
export default function AiSubagentExample() {
const { phase, replay: run } = useRun("complete");
const [open, setOpen] = React.useState<Record<string, boolean>>({});
function onOpen(id: string, value: boolean) {
setOpen((current) => ({ ...current, [id]: value }));
}
function replay() {
setOpen({});
run();
}
const step: AgentStep = {
id: "research",
title:
phase === "complete"
? "Ran the research subagent"
: phase === "running"
? "Running the research subagent"
: phase === "pending"
? "Research subagent queued"
: "Research subagent failed",
status: phase === "complete" ? undefined : phase,
task: "Find out why image optimization reruns on every deploy.",
duration: phase === "complete" ? "48s" : undefined,
model: "Research model",
tool: "runCommand",
children: [
{
id: "find",
title:
phase === "pending"
? "Run rg cacheKey scripts"
: "Ran rg cacheKey scripts",
status: phase === "pending" ? "pending" : undefined,
output:
phase === "pending"
? undefined
: "scripts/optimize-images.ts:14: const cacheKey = process.env.COMMIT_SHA",
},
{
id: "read",
title:
phase === "complete"
? "Read scripts/optimize-images.ts"
: phase === "error"
? "Failed to read scripts/optimize-images.ts"
: phase === "pending"
? "Read scripts/optimize-images.ts"
: "Reading scripts/optimize-images.ts",
status: phase === "complete" ? undefined : phase,
output:
phase === "complete"
? 'const cacheDir = ".next/cache/images"\nconst cacheKey = process.env.COMMIT_SHA\nconst cached = await restoreCache(cacheDir, cacheKey)'
: phase === "error"
? "The file could not be read."
: undefined,
},
{
id: "count",
title:
phase === "complete"
? "Counted public/images"
: "Count public/images",
status: phase === "complete" ? undefined : "pending",
output: phase === "complete" ? "1240" : undefined,
},
],
report:
phase === "complete"
? "The cache is keyed on the commit SHA, so each deployment optimizes all 1,240 images again."
: undefined,
};
return (
<div style={frame}>
<Message align="end">
<MessageContent>
<Bubble align="end" variant="muted">
<BubbleContent>
Why does image optimization rerun on every deploy?
</BubbleContent>
</Bubble>
</MessageContent>
</Message>
<Message>
<MessageContent style={{ gap: 16, minWidth: 0 }}>
<Trace>
<AgentRow step={step} open={open} onOpen={onOpen} />
</Trace>
{phase === "complete" && (
<p>
Key the cache on a hash of public/images, so unchanged images will
be skipped.
</p>
)}
</MessageContent>
</Message>
<div>
<Button
size="sm"
variant="outline"
onClick={replay}
disabled={phase === "running"}
>
Replay
</Button>
</div>
</div>
);
} AI SDK
The research tool returns a nested UIMessage. Its output.parts render recursively, including nested tool calls and text. An output-available part with preliminary: true still runs while the chat streams. It becomes a failure if streaming stops before final output. Final output replaces the preliminary message.
The basic example runs without a provider. For the AI SDK adapter example only, install the SDK types:
pnpm add ai
Pass a message from your chat client to the adapter. The supplied message does not call a provider.
import type { UIMessage } from "ai";
import {
Trace,
TraceCollapsible,
TraceCollapsibleTrigger,
TraceCollapsibleContent,
TraceLine,
TraceIcon,
TraceTitle,
} from "@/components/ui/trace";
import { Icon } from "@/components/ui/icon";
import { CodeBlock, CodeBlockContent } from "@/components/ui/code-block";
import { Badge } from "@/components/ui/badge";
import * as React from "react";
const frame: React.CSSProperties = {
width: "100%",
maxWidth: 384,
minWidth: 0,
minHeight: 288,
marginInline: "auto",
display: "flex",
flexDirection: "column",
gap: 16,
paddingBlock: 24,
fontSize: "var(--text-sm)",
lineHeight: 1.5,
};
type SdkAgentMessage = UIMessage<
unknown,
{},
{
runCommand: {
input: {
command: string;
};
output: {
stdout: string;
exitCode: number;
};
};
research: {
input: {
task: string;
model?: string;
};
output: SdkAgentMessage;
};
}
>;
type SdkCommandMessage = UIMessage<
unknown,
{},
{
runCommand: {
input: {
command: string;
};
output: {
stdout: string;
exitCode: number;
};
};
}
>;
type SdkCommandPart = Extract<
SdkCommandMessage["parts"][number],
{
type: "tool-runCommand";
}
>;
function sdkCommandState(part: SdkCommandPart, streaming: boolean) {
const command = part.input?.command ?? "command";
const denied =
part.state === "output-denied" ||
(part.state === "approval-responded" && !part.approval.approved);
const finished = part.state === "output-available" && !part.preliminary;
const waiting = part.state === "approval-requested";
const approved =
part.state === "approval-responded" && part.approval.approved;
const failed =
part.state === "output-error" ||
(finished && part.output.exitCode !== 0) ||
(!denied && !finished && !waiting && !approved && !streaming);
const status = denied
? undefined
: waiting
? "pending"
: failed
? "error"
: finished
? undefined
: "running";
const title = `${denied ? "Denied" : waiting ? "Waiting to run" : failed ? "Failed to run" : finished ? "Ran" : "Running"} ${command}`;
const output =
part.state === "output-error"
? part.errorText
: part.state === "output-available"
? part.output.stdout
: undefined;
return { status, title, output } as const;
}
function TracePanel({
trigger,
children,
open: controlledOpen,
defaultOpen = false,
onOpenChange,
...props
}: React.ComponentProps<typeof TraceCollapsible> & {
trigger: React.ReactNode;
}) {
const contentRef = React.useRef<HTMLDivElement>(null);
const [height, setHeight] = React.useState<number>();
const [localOpen, setLocalOpen] = React.useState(defaultOpen);
const expanded = controlledOpen ?? localOpen;
const [hasOpened, setHasOpened] = React.useState(expanded);
React.useEffect(() => {
if (expanded) setHasOpened(true);
}, [expanded]);
React.useLayoutEffect(() => {
const content = contentRef.current;
const inner = content?.firstElementChild;
if (!expanded || !content || !inner) return;
const measure = () => setHeight(content.scrollHeight);
measure();
const observer = new ResizeObserver(measure);
observer.observe(inner);
return () => observer.disconnect();
}, [expanded]);
return (
<TraceCollapsible
{...props}
open={expanded}
onOpenChange={(value) => {
if (!value && contentRef.current) {
setHeight(contentRef.current.getBoundingClientRect().height);
}
setLocalOpen(value);
onOpenChange?.(value);
}}
>
<TraceCollapsibleTrigger>{trigger}</TraceCollapsibleTrigger>
<TraceCollapsibleContent
ref={contentRef}
style={
height === undefined
? undefined
: ({
"--radix-collapsible-content-height": `${height}px`,
} as React.CSSProperties)
}
forceMount={hasOpened ? true : undefined}
inert={!expanded}
>
{children}
</TraceCollapsibleContent>
</TraceCollapsible>
);
}
function SdkPanel({
title,
status,
children,
initialOpen = false,
}: {
title: string;
status?: "running" | "pending" | "error";
children: React.ReactNode;
initialOpen?: boolean;
}) {
const [open, setOpen] = React.useState<boolean>();
const expanded = open ?? initialOpen;
return (
<TracePanel
open={expanded}
onOpenChange={setOpen}
trigger={
<TraceLine status={status}>
<TraceIcon>
<Icon name={status === "error" ? "CircleAlert" : "Terminal"} />
</TraceIcon>
<TraceTitle>{title}</TraceTitle>
</TraceLine>
}
>
{children}
</TracePanel>
);
}
function SdkCommandRow({
part,
streaming,
}: {
part: SdkCommandPart;
streaming: boolean;
}) {
const view = sdkCommandState(part, streaming);
const output = view.output;
if (output !== undefined)
return (
<SdkPanel
title={view.title}
status={view.status}
initialOpen={view.status === "error" || view.status === "running"}
>
<CodeBlock
size="sm"
variant={view.status === "error" ? "destructive" : "default"}
>
<CodeBlockContent wrap style={{ maxHeight: 256 }}>
{output}
</CodeBlockContent>
</CodeBlock>
</SdkPanel>
);
return (
<TraceLine status={view.status}>
<TraceIcon>
<Icon name="Terminal" />
</TraceIcon>
<TraceTitle>{view.title}</TraceTitle>
</TraceLine>
);
}
function SubagentSdkAdapter({
message,
streaming,
}: {
message: SdkAgentMessage;
streaming: boolean;
}) {
return (
<Trace>
{message.parts.map((part, index) => {
if (part.type === "text") return <p key={index}>{part.text}</p>;
if (part.type === "tool-runCommand")
return (
<SdkCommandRow
key={part.toolCallId}
part={part}
streaming={streaming}
/>
);
if (part.type !== "tool-research") return null;
const finished = part.state === "output-available" && !part.preliminary;
const pending = part.state === "approval-requested";
const denied =
part.state === "output-denied" ||
(part.state === "approval-responded" && !part.approval.approved);
const failed =
part.state === "output-error" ||
(!finished && !pending && !denied && !streaming);
const title = `${denied ? "Denied" : pending ? "Waiting for" : failed ? "Failed to run" : finished ? "Ran" : "Running"} research subagent`;
return (
<SdkPanel
key={part.toolCallId}
title={title}
status={
pending
? "pending"
: failed
? "error"
: !finished && !denied
? "running"
: undefined
}
initialOpen
>
<div style={{ display: "grid", gap: 12 }}>
{part.input?.model && (
<div>
<Badge variant="secondary">{part.input.model}</Badge>
</div>
)}
<p>{part.input?.task}</p>
{part.state === "output-error" && (
<CodeBlock size="sm" variant="destructive">
<CodeBlockContent wrap>{part.errorText}</CodeBlockContent>
</CodeBlock>
)}
{part.state === "output-available" && (
<SubagentSdkAdapter
message={part.output}
streaming={streaming && part.preliminary === true}
/>
)}
</div>
</SdkPanel>
);
})}
</Trace>
);
}
const sdkMessage: SdkAgentMessage = {
id: "research",
role: "assistant",
parts: [
{
type: "tool-research",
toolCallId: "research-agent",
state: "output-available",
preliminary: true,
input: {
task: "Find why image optimization runs on every deploy.",
model: "Research model",
},
output: {
id: "child",
role: "assistant",
parts: [
{
type: "tool-runCommand",
toolCallId: "find",
state: "output-available",
input: { command: "rg cacheKey scripts" },
output: {
stdout: "scripts/optimize-images.ts:14: process.env.COMMIT_SHA",
exitCode: 0,
},
},
{
type: "tool-runCommand",
toolCallId: "read",
state: "input-available",
input: { command: "cat scripts/optimize-images.ts" },
},
],
},
},
],
};
export default function AiSubagentExample() {
return (
<div style={frame}>
<SubagentSdkAdapter message={sdkMessage} streaming />
</div>
);
} Running
import {
Trace,
TraceCollapsible,
TraceCollapsibleTrigger,
TraceCollapsibleContent,
TraceLine,
TraceIcon,
TraceTitle,
} from "@/components/ui/trace";
import { Icon } from "@/components/ui/icon";
import * as React from "react";
const frame: React.CSSProperties = {
width: "100%",
maxWidth: 384,
minWidth: 0,
minHeight: 288,
marginInline: "auto",
display: "flex",
flexDirection: "column",
gap: 16,
paddingBlock: 24,
fontSize: "var(--text-sm)",
lineHeight: 1.5,
};
function TracePanel({
trigger,
children,
open: controlledOpen,
defaultOpen = false,
onOpenChange,
...props
}: React.ComponentProps<typeof TraceCollapsible> & {
trigger: React.ReactNode;
}) {
const contentRef = React.useRef<HTMLDivElement>(null);
const [height, setHeight] = React.useState<number>();
const [localOpen, setLocalOpen] = React.useState(defaultOpen);
const expanded = controlledOpen ?? localOpen;
const [hasOpened, setHasOpened] = React.useState(expanded);
React.useEffect(() => {
if (expanded) setHasOpened(true);
}, [expanded]);
React.useLayoutEffect(() => {
const content = contentRef.current;
const inner = content?.firstElementChild;
if (!expanded || !content || !inner) return;
const measure = () => setHeight(content.scrollHeight);
measure();
const observer = new ResizeObserver(measure);
observer.observe(inner);
return () => observer.disconnect();
}, [expanded]);
return (
<TraceCollapsible
{...props}
open={expanded}
onOpenChange={(value) => {
if (!value && contentRef.current) {
setHeight(contentRef.current.getBoundingClientRect().height);
}
setLocalOpen(value);
onOpenChange?.(value);
}}
>
<TraceCollapsibleTrigger>{trigger}</TraceCollapsibleTrigger>
<TraceCollapsibleContent
ref={contentRef}
style={
height === undefined
? undefined
: ({
"--radix-collapsible-content-height": `${height}px`,
} as React.CSSProperties)
}
forceMount={hasOpened ? true : undefined}
inert={!expanded}
>
{children}
</TraceCollapsibleContent>
</TraceCollapsible>
);
}
function SubagentRunningExample() {
return (
<Trace>
<TracePanel
defaultOpen
trigger={
<TraceLine status="running">
<TraceIcon>
<Icon name="LoaderCircle" />
</TraceIcon>
<TraceTitle>Running the research agent</TraceTitle>
</TraceLine>
}
>
<Trace>
<TraceLine>
<TraceIcon>
<Icon name="CircleCheck" />
</TraceIcon>
<TraceTitle>Ran grep -rn cacheKey scripts</TraceTitle>
</TraceLine>
<TraceLine status="running">
<TraceIcon>
<Icon name="LoaderCircle" />
</TraceIcon>
<TraceTitle>
Running sed -n 13,15p scripts/optimize-images.ts
</TraceTitle>
</TraceLine>
</Trace>
</TracePanel>
</Trace>
);
}
export default function AiSubagentExample() {
return (
<div style={frame}>
<SubagentRunningExample />
</div>
);
} Details
import {
Trace,
TraceCollapsible,
TraceCollapsibleTrigger,
TraceCollapsibleContent,
TraceLine,
TraceIcon,
TraceTitle,
TraceDescription,
} from "@/components/ui/trace";
import { Icon } from "@/components/ui/icon";
import { Badge } from "@/components/ui/badge";
import * as React from "react";
const frame: React.CSSProperties = {
width: "100%",
maxWidth: 384,
minWidth: 0,
minHeight: 288,
marginInline: "auto",
display: "flex",
flexDirection: "column",
gap: 16,
paddingBlock: 24,
fontSize: "var(--text-sm)",
lineHeight: 1.5,
};
function TracePanel({
trigger,
children,
open: controlledOpen,
defaultOpen = false,
onOpenChange,
...props
}: React.ComponentProps<typeof TraceCollapsible> & {
trigger: React.ReactNode;
}) {
const contentRef = React.useRef<HTMLDivElement>(null);
const [height, setHeight] = React.useState<number>();
const [localOpen, setLocalOpen] = React.useState(defaultOpen);
const expanded = controlledOpen ?? localOpen;
const [hasOpened, setHasOpened] = React.useState(expanded);
React.useEffect(() => {
if (expanded) setHasOpened(true);
}, [expanded]);
React.useLayoutEffect(() => {
const content = contentRef.current;
const inner = content?.firstElementChild;
if (!expanded || !content || !inner) return;
const measure = () => setHeight(content.scrollHeight);
measure();
const observer = new ResizeObserver(measure);
observer.observe(inner);
return () => observer.disconnect();
}, [expanded]);
return (
<TraceCollapsible
{...props}
open={expanded}
onOpenChange={(value) => {
if (!value && contentRef.current) {
setHeight(contentRef.current.getBoundingClientRect().height);
}
setLocalOpen(value);
onOpenChange?.(value);
}}
>
<TraceCollapsibleTrigger>{trigger}</TraceCollapsibleTrigger>
<TraceCollapsibleContent
ref={contentRef}
style={
height === undefined
? undefined
: ({
"--radix-collapsible-content-height": `${height}px`,
} as React.CSSProperties)
}
forceMount={hasOpened ? true : undefined}
inert={!expanded}
>
{children}
</TraceCollapsibleContent>
</TraceCollapsible>
);
}
function SubagentDetailsExample() {
return (
<Trace>
<TracePanel
defaultOpen
trigger={
<TraceLine>
<TraceIcon>
<Icon name="CircleCheck" />
</TraceIcon>
<TraceTitle>Ran the research agent</TraceTitle>
<TraceDescription>48s</TraceDescription>
</TraceLine>
}
>
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: 6,
}}
>
<Badge variant="secondary">anthropic/claude-sonnet-4.5</Badge>
<Badge variant="outline">runCommand</Badge>
</div>
<p>Find out why image optimization reruns on every deploy.</p>
<Trace>
<TraceLine>
<TraceIcon>
<Icon name="CircleCheck" />
</TraceIcon>
<TraceTitle>Ran grep -rn cacheKey scripts</TraceTitle>
</TraceLine>
<TraceLine>
<TraceIcon>
<Icon name="CircleCheck" />
</TraceIcon>
<TraceTitle>
Ran sed -n 13,15p scripts/optimize-images.ts
</TraceTitle>
</TraceLine>
</Trace>
<p>The cache is keyed on the commit SHA.</p>
</TracePanel>
</Trace>
);
}
export default function AiSubagentExample() {
return (
<div style={frame}>
<SubagentDetailsExample />
</div>
);
} Components and API
This composition has no separate component API. Use the APIs for its installed components.
| Component | Target reference |
|---|---|
trace | Radix UI API |
code-block | Radix UI API |
badge | Radix UI API |
icon | Radix UI API |
button | Radix UI API |
message | Radix UI API |
bubble | Radix UI API |