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 Use a nested Trace for each group of steps. A running step uses the present tense; a complete step uses the past tense. Keep disclosure choices in application state so nested steps keep their open state when a parent closes. Map tool output to these records; no AI provider is required.
Usage
Complete example
import { Message, MessageContent } from "@/components/ui/message";
import { Bubble, BubbleContent } from "@/components/ui/bubble";
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 { 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 Step = {
id: string;
title: string;
status?: "running" | "error" | "pending";
output?: string;
files?: string[];
href?: string;
children?: Step[];
defaultOpen?: boolean;
};
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 StepRow({
step,
open,
onOpen,
}: {
step: Step;
open: Record<string, boolean>;
onOpen: (id: string, value: boolean) => void;
}) {
const line = (
<TraceLine status={step.status}>
<TraceIcon>
<Icon
name={
step.href
? "Globe"
: step.status === "error"
? "CircleAlert"
: step.status === "running"
? "LoaderCircle"
: step.status === "pending"
? "CircleDashed"
: "CircleCheck"
}
/>
</TraceIcon>
<TraceTitle>{step.title}</TraceTitle>
</TraceLine>
);
if (step.href)
return (
<a
href={step.href}
target="_blank"
rel="noreferrer"
aria-label={step.title + " (opens in a new tab)"}
>
{line}
</a>
);
if (!step.output && !step.files && !step.children) return line;
return (
<TracePanel
open={open[step.id] ?? step.defaultOpen === true}
onOpenChange={(value) => onOpen(step.id, value)}
trigger={line}
>
{step.output && (
<CodeBlock
size="sm"
variant={step.status === "error" ? "destructive" : "default"}
>
<CodeBlockContent wrap style={{ maxHeight: 256 }}>
{step.output}
</CodeBlockContent>
</CodeBlock>
)}
{step.files && (
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
{step.files.map((file) => (
<Badge key={file} variant="secondary">
{file}
</Badge>
))}
</div>
)}
{step.children && (
<Trace>
{step.children.map((child) => (
<StepRow key={child.id} step={child} open={open} onOpen={onOpen} />
))}
</Trace>
)}
</TracePanel>
);
}
export default function AiChainOfThoughtExample() {
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 steps: Step[] =
phase === "pending"
? [
{
id: "waiting",
title: "Waiting to research the deployment",
status: "pending",
},
]
: [
{
id: "log",
defaultOpen: true,
title: "Read the build log for dpl_8Kx2",
output: "install 41s\ncompile 38s\nimages 3m 52s",
},
{
id: "web",
title:
phase === "running"
? "Searching the web for image cache keys"
: phase === "error"
? "Failed to search the web"
: "Searched the web for image cache keys",
status:
phase === "running"
? "running"
: phase === "error"
? "error"
: undefined,
output:
phase === "error" ? "Search timed out. Try again." : undefined,
children:
phase === "complete"
? [
{
id: "image-docs",
title: "Image component · nextjs.org",
href: "https://nextjs.org/docs/app/api-reference/components/image",
},
{
id: "cache-docs",
title: "Build cache · vercel.com",
href: "https://vercel.com/docs/deployments/troubleshoot-a-build",
},
]
: undefined,
},
{
id: "code",
title:
phase === "complete"
? "Searched the code for cacheKey"
: "Search the code for cacheKey",
status: phase === "complete" ? undefined : "pending",
files:
phase === "complete"
? ["next.config.ts", "scripts/optimize-images.ts"]
: undefined,
},
];
return (
<div style={frame}>
<Message align="end">
<MessageContent>
<Bubble align="end" variant="muted">
<BubbleContent>Why is the checkout deploy so slow?</BubbleContent>
</Bubble>
</MessageContent>
</Message>
<Message>
<MessageContent style={{ gap: 16, minWidth: 0 }}>
<Trace>
<TracePanel
open={open.summary ?? phase !== "complete"}
onOpenChange={(value) => onOpen("summary", value)}
trigger={
<TraceLine status={phase === "complete" ? undefined : phase}>
<TraceIcon>
<Icon
name={
phase === "error"
? "CircleAlert"
: phase === "running"
? "LoaderCircle"
: "CircleCheck"
}
/>
</TraceIcon>
<TraceTitle>
{phase === "complete"
? "Researched in 3 steps"
: phase === "running"
? "Researching"
: phase === "pending"
? "Research queued"
: "Research interrupted"}
</TraceTitle>
</TraceLine>
}
>
<Trace>
{steps.map((step) => (
<StepRow
key={step.id}
step={step}
open={open}
onOpen={onOpen}
/>
))}
</Trace>
</TracePanel>
</Trace>
{phase === "complete" && (
<p>
Image optimization takes almost four minutes because its cache key
includes the commit hash. Key it on the image directory instead.
</p>
)}
</MessageContent>
</Message>
<div>
<Button
size="sm"
variant="outline"
onClick={replay}
disabled={phase === "running"}
>
Replay
</Button>
</div>
</div>
);
} AI SDK
The adapter reads searchDocs, readFile, and runCommand tool parts from a typed UIMessage. It opens the summary while tool parts stream and before a text answer begins. Failed steps determine the summary status. Each call keeps its toolCallId and its own disclosure state.
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 SdkChainMessage = UIMessage<
unknown,
{},
{
runCommand: {
input: {
command: string;
};
output: {
stdout: string;
exitCode: number;
};
};
searchDocs: {
input: {
query: string;
};
output: {
summary: string;
};
};
readFile: {
input: {
path: string;
};
output: {
content: string;
};
};
}
>;
type SdkStepPart = Extract<
SdkChainMessage["parts"][number],
{
type: `tool-${string}`;
}
>;
type Step = {
id: string;
title: string;
status?: "running" | "error" | "pending";
output?: string;
files?: string[];
href?: string;
children?: Step[];
defaultOpen?: boolean;
};
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 researchStep(part: SdkStepPart, streaming: boolean): Step {
if (part.type === "tool-runCommand")
return { id: part.toolCallId, ...sdkCommandState(part, streaming) };
const finished = part.state === "output-available" && !part.preliminary;
const denied =
part.state === "output-denied" ||
(part.state === "approval-responded" && !part.approval.approved);
const pending = part.state === "approval-requested";
const failed =
part.state === "output-error" ||
(!finished && !pending && !denied && !streaming);
const verb =
part.type === "tool-searchDocs"
? finished
? "Searched"
: "Searching"
: finished
? "Read"
: "Reading";
const action =
part.type === "tool-searchDocs"
? (part.input?.query ?? "documentation")
: (part.input?.path ?? "file");
const output =
part.state === "output-error"
? part.errorText
: part.state === "output-available"
? part.type === "tool-searchDocs"
? part.output.summary
: part.output.content
: undefined;
return {
id: part.toolCallId,
title: `${denied ? "Denied" : pending ? "Waiting to" : failed ? "Failed to" : verb} ${denied || pending || failed ? (part.type === "tool-searchDocs" ? "search " : "read ") : ""}${action}`,
status: denied
? undefined
: pending
? "pending"
: failed
? "error"
: finished
? undefined
: "running",
output,
};
}
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 StepRow({
step,
open,
onOpen,
}: {
step: Step;
open: Record<string, boolean>;
onOpen: (id: string, value: boolean) => void;
}) {
const line = (
<TraceLine status={step.status}>
<TraceIcon>
<Icon
name={
step.href
? "Globe"
: step.status === "error"
? "CircleAlert"
: step.status === "running"
? "LoaderCircle"
: step.status === "pending"
? "CircleDashed"
: "CircleCheck"
}
/>
</TraceIcon>
<TraceTitle>{step.title}</TraceTitle>
</TraceLine>
);
if (step.href)
return (
<a
href={step.href}
target="_blank"
rel="noreferrer"
aria-label={step.title + " (opens in a new tab)"}
>
{line}
</a>
);
if (!step.output && !step.files && !step.children) return line;
return (
<TracePanel
open={open[step.id] ?? step.defaultOpen === true}
onOpenChange={(value) => onOpen(step.id, value)}
trigger={line}
>
{step.output && (
<CodeBlock
size="sm"
variant={step.status === "error" ? "destructive" : "default"}
>
<CodeBlockContent wrap style={{ maxHeight: 256 }}>
{step.output}
</CodeBlockContent>
</CodeBlock>
)}
{step.files && (
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
{step.files.map((file) => (
<Badge key={file} variant="secondary">
{file}
</Badge>
))}
</div>
)}
{step.children && (
<Trace>
{step.children.map((child) => (
<StepRow key={child.id} step={child} open={open} onOpen={onOpen} />
))}
</Trace>
)}
</TracePanel>
);
}
function ChainOfThoughtSdkAdapter({
message,
streaming,
}: {
message: SdkChainMessage;
streaming: boolean;
}) {
const [open, setOpen] = React.useState<Record<string, boolean>>({});
const steps = message.parts
.filter(
(part): part is SdkStepPart =>
part.type === "tool-runCommand" ||
part.type === "tool-searchDocs" ||
part.type === "tool-readFile",
)
.map((part) => researchStep(part, streaming));
const answering = message.parts.some(
(part) => part.type === "text" && part.text.trim().length > 0,
);
const running = streaming && !answering;
const failed = steps.filter((step) => step.status === "error").length;
return (
<>
<Trace>
<SdkPanel
title={
running
? "Researching"
: `Researched in ${steps.length} steps${failed ? `, ${failed} failed` : ""}`
}
status={running ? "running" : failed ? "error" : undefined}
initialOpen={running}
>
<Trace>
{steps.map((step) => (
<StepRow
key={step.id}
step={step}
open={open}
onOpen={(id, value) =>
setOpen((current) => ({ ...current, [id]: value }))
}
/>
))}
</Trace>
</SdkPanel>
</Trace>
{message.parts.map((part, index) =>
part.type === "text" ? <p key={index}>{part.text}</p> : null,
)}
</>
);
}
const sdkMessage: SdkChainMessage = {
id: "research",
role: "assistant",
parts: [
{
type: "tool-searchDocs",
toolCallId: "docs",
state: "output-available",
input: { query: "image cache" },
output: {
summary: "Reuse a cache key based on the image directory.",
},
},
{
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-readFile",
toolCallId: "read",
state: "output-available",
input: { path: "scripts/optimize-images.ts" },
output: { content: "const cacheKey = process.env.COMMIT_SHA" },
},
{
type: "text",
text: "Key the image cache on the image directory so unchanged files can be reused.",
},
],
};
export default function AiChainOfThoughtExample() {
return (
<div style={frame}>
<ChainOfThoughtSdkAdapter message={sdkMessage} streaming={false} />
</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 |