Installation
Complete project setup with react/radix, then install these components:
pnpm exec frontfriend download trace collapsible message bubble button icon -f react --primitive radix Use Plan when the steps are known before work starts. Completed, running, and pending steps remain in order. A task adds steps as work occurs.
Usage
Complete example
import { Message, MessageContent } from "@/components/ui/message";
import { Bubble, BubbleContent } from "@/components/ui/bubble";
import {
Trace,
TraceCollapsible,
TraceCollapsibleTrigger,
TraceLine,
TraceIcon,
TraceTitle,
TraceDescription,
TraceCollapsibleContent,
} from "@/components/ui/trace";
import { Icon } from "@/components/ui/icon";
import { Button } from "@/components/ui/button";
import * as React from "react";
const STEPS = [
"Install the design system components",
"Replace the dialog imports",
"Connect the dialog state",
"Run the type checker",
];
const canvasStyle: 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,
};
export default function AiPlanExample() {
const [current, setCurrent] = React.useState(STEPS.length);
const running = current < STEPS.length;
React.useEffect(() => {
if (!running) return;
const timer = setTimeout(() => setCurrent((value) => value + 1), 1200);
return () => clearTimeout(timer);
}, [current, running]);
const part = {
type: "data-plan",
id: "plan",
data: {
title: "Plan: migrate the dialogs",
steps: STEPS.map((title, index) => ({
title,
status:
index < current ? "done" : index === current ? "running" : "pending",
})),
},
};
const done = part.data.steps.filter((step) => step.status === "done").length;
return (
<div style={canvasStyle}>
<Message align="end">
<MessageContent>
<Bubble align="end" variant="muted">
<BubbleContent>Migrate the dialog components to the new design system.</BubbleContent>
</Bubble>
</MessageContent>
</Message>
<Message>
<MessageContent style={{ gap: 16 }}>
<Trace>
<TraceCollapsible defaultOpen>
<TraceCollapsibleTrigger>
<TraceLine status={running ? "running" : undefined}>
<TraceIcon>
<Icon name="Route" />
</TraceIcon>
<TraceTitle>{part.data.title}</TraceTitle>
<TraceDescription>
{done} of {part.data.steps.length} done
</TraceDescription>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
<Trace>
{part.data.steps.map((step) => (
<TraceLine
key={step.title}
status={
step.status === "pending"
? "pending"
: running && step.status === "running"
? "running"
: undefined
}
>
<TraceIcon>
<Icon
name={
step.status === "done"
? "CircleCheck"
: step.status === "running"
? "CircleDot"
: "Circle"
}
/>
</TraceIcon>
<TraceTitle>{step.title}</TraceTitle>
</TraceLine>
))}
</Trace>
</TraceCollapsibleContent>
</TraceCollapsible>
</Trace>
{!running && (
<p>Migrated Dialog and AlertDialog. The type checker passes.</p>
)}
</MessageContent>
</Message>
<Button
variant="outline"
size="sm"
style={{ marginTop: "auto", alignSelf: "flex-start" }}
disabled={running}
onClick={() => setCurrent(0)}
>
Replay
</Button>
</div>
);
} Details
import {
Trace,
TraceCollapsible,
TraceCollapsibleTrigger,
TraceLine,
TraceIcon,
TraceTitle,
TraceDescription,
TraceCollapsibleContent,
} from "@/components/ui/trace";
import { Icon } from "@/components/ui/icon";
export default function AiPlanExample() {
const steps = [
"Add a dark variant to the theme tokens",
"Read the theme from a cookie on the server",
"Add a toggle to the settings page",
"Check contrast on the charts",
];
return (
<div
style={{
width: "100%",
maxWidth: 384,
marginInline: "auto",
paddingBlock: 24,
}}
>
<Trace>
<TraceCollapsible defaultOpen>
<TraceCollapsibleTrigger>
<TraceLine>
<TraceIcon>
<Icon name="Route" />
</TraceIcon>
<TraceTitle>Plan: add dark mode</TraceTitle>
<TraceDescription>1 of {steps.length} done</TraceDescription>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
<p style={{ marginBottom: 12 }}>
Store the theme in a cookie so the server renders the right one
and the page does not flash on load.
</p>
<Trace>
{steps.map((step, index) => (
<TraceLine
key={step}
status={index > 0 ? "pending" : undefined}
>
<TraceIcon>
<Icon name={index === 0 ? "CircleCheck" : "Circle"} />
</TraceIcon>
<TraceTitle>{step}</TraceTitle>
</TraceLine>
))}
</Trace>
</TraceCollapsibleContent>
</TraceCollapsible>
</Trace>
</div>
);
} AI SDK
This optional adapter accepts a UIMessage from your chat client. The complete example needs no AI SDK or provider. Install this extra package only for the adapter:
pnpm add ai
Read the data-plan part. On the server, write each update with the same part id, such as plan. The AI SDK replaces that data part in place. The adapter renders its title, steps, and completed count.
Server (app/api/chat/route.ts):
import { createUIMessageStream } from "ai";
const title = "Plan: migrate the dialogs";
const steps = [{ title: "Update the dialog imports", status: "pending" }];
const stream = createUIMessageStream({
execute: async ({ writer }) => {
writer.write({ type: "data-plan", id: "plan", data: { title, steps } });
},
});
Pass the resulting message to the client adapter below.
import type { UIMessage } from "ai";
import {
Trace,
TraceCollapsible,
TraceCollapsibleTrigger,
TraceLine,
TraceIcon,
TraceTitle,
TraceDescription,
TraceCollapsibleContent,
} from "@/components/ui/trace";
import { Icon } from "@/components/ui/icon";
type PlanData = {
title: string;
steps: {
title: string;
status: "done" | "running" | "pending";
}[];
};
type PlanMessage = UIMessage<
unknown,
{
plan: PlanData;
}
>;
function PlanAdapter({
message,
streaming,
}: {
message: PlanMessage;
streaming: boolean;
}) {
const part = message.parts.find((part) => part.type === "data-plan");
if (!part) return null;
const done = part.data.steps.filter((step) => step.status === "done").length;
const running = streaming && done < part.data.steps.length;
return (
<Trace>
<TraceCollapsible defaultOpen>
<TraceCollapsibleTrigger>
<TraceLine status={running ? "running" : undefined}>
<TraceIcon>
<Icon name="Route" />
</TraceIcon>
<TraceTitle>{part.data.title}</TraceTitle>
<TraceDescription>
{done} of {part.data.steps.length} done
</TraceDescription>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
<Trace>
{part.data.steps.map((step) => (
<TraceLine
key={step.title}
status={
step.status === "pending"
? "pending"
: running && step.status === "running"
? "running"
: undefined
}
>
<TraceIcon>
<Icon
name={
step.status === "done"
? "CircleCheck"
: step.status === "running"
? "CircleDot"
: "Circle"
}
/>
</TraceIcon>
<TraceTitle>{step.title}</TraceTitle>
</TraceLine>
))}
</Trace>
</TraceCollapsibleContent>
</TraceCollapsible>
</Trace>
);
}
const STEPS = [
"Install the design system components",
"Replace the dialog imports",
"Connect the dialog state",
"Run the type checker",
];
export default function AiPlanExample() {
return (
<div
style={{
width: "100%",
maxWidth: 384,
marginInline: "auto",
paddingBlock: 24,
}}
>
<PlanAdapter
message={{
id: "answer",
role: "assistant",
parts: [
{
type: "data-plan",
id: "plan",
data: {
title: "Plan: migrate the dialogs",
steps: STEPS.map((title, index) => ({
title,
status: index === 0 ? "done" : "pending",
})),
},
},
],
}}
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 |
collapsible | Radix UI API |
message | Radix UI API |
bubble | Radix UI API |
button | Radix UI API |
icon | Radix UI API |