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 A todo moves to Completed when it finishes. Only the active todo shimmers while the stream runs. A todo with a description has its own disclosure.
Usage
Complete example
import { Message, MessageContent } from "@/components/ui/message";
import { Bubble, BubbleContent } from "@/components/ui/bubble";
import {
Trace,
TraceCollapsible,
TraceCollapsibleTrigger,
TraceLine,
TraceIcon,
TraceTitle,
TraceCollapsibleContent,
} from "@/components/ui/trace";
import { Icon } from "@/components/ui/icon";
import { Button } from "@/components/ui/button";
import * as React from "react";
const TODOS = [
"Add a dark variant to the theme tokens",
"Read the theme from a cookie",
"Add a toggle to the settings page",
"Check contrast on the charts",
];
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 AiQueueExample() {
const [current, setCurrent] = React.useState(TODOS.length);
const running = current < TODOS.length;
React.useEffect(() => {
if (!running) return;
const timer = setTimeout(() => setCurrent((value) => value + 1), 1400);
return () => clearTimeout(timer);
}, [current, running]);
const parts = [
{
type: "tool-writeTodos",
state: "input-available",
input: {
todos: TODOS.map((content, index) => ({
content,
status:
index < current
? "completed"
: index === current
? "in_progress"
: "pending",
})),
},
},
];
const todos =
parts
.filter(
(part) =>
part.type === "tool-writeTodos" && part.state !== "input-streaming",
)
.slice(-1)[0]?.input.todos ?? [];
const sections = [
{
id: "pending",
icon: "ListTodo",
todos: todos.filter((todo) => todo.status !== "completed"),
},
{
id: "completed",
icon: "ListChecks",
todos: todos.filter((todo) => todo.status === "completed"),
},
].filter((section) => section.todos.length > 0);
return (
<div style={canvasStyle}>
<Message align="end">
<MessageContent>
<Bubble align="end" variant="muted">
<BubbleContent>Add dark mode to the settings page.</BubbleContent>
</Bubble>
</MessageContent>
</Message>
<Message>
<MessageContent style={{ gap: 16 }}>
<Trace>
{sections.map((section) => (
<TraceCollapsible key={section.id} defaultOpen>
<TraceCollapsibleTrigger>
<TraceLine>
<TraceIcon>
<Icon name={section.icon} />
</TraceIcon>
<TraceTitle>
{section.todos.length}{" "}
{section.id === "pending" ? "to do" : "completed"}
</TraceTitle>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
<Trace>
{section.todos.map((todo) => (
<TraceLine
key={todo.content}
status={
running && todo.status === "in_progress"
? "running"
: undefined
}
>
<TraceIcon>
<Icon
name={
todo.status === "completed"
? "CircleCheck"
: todo.status === "in_progress"
? "CircleDot"
: "Circle"
}
/>
</TraceIcon>
<TraceTitle>{todo.content}</TraceTitle>
</TraceLine>
))}
</Trace>
</TraceCollapsibleContent>
</TraceCollapsible>
))}
</Trace>
{!running && (
<p>Dark mode is in. The toggle is under Settings, Appearance.</p>
)}
</MessageContent>
</Message>
<Button
variant="outline"
size="sm"
style={{ marginTop: "auto", alignSelf: "flex-start" }}
disabled={running}
onClick={() => setCurrent(0)}
>
Replay
</Button>
</div>
);
} Descriptions
import {
Trace,
TraceCollapsible,
TraceCollapsibleTrigger,
TraceCollapsibleContent,
TraceLine,
TraceIcon,
TraceTitle,
} from "@/components/ui/trace";
import { Icon } from "@/components/ui/icon";
import * as React from "react";
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>
);
}
export default function AiQueueExample() {
const [openSections, setOpenSections] = React.useState<
Record<string, boolean>
>({ pending: true });
const [openTodos, setOpenTodos] = React.useState<Record<string, boolean>>({});
const todos = [
{
title: "Write the migration guide",
description: "Cover the renamed props and the new data attributes.",
done: true,
},
{ title: "Add a changeset", done: true },
{
title: "Fix the focus ring on the select trigger",
description: "It is clipped by overflow-hidden on the field.",
done: false,
},
{ title: "Update the registry snapshots", done: false },
{
title: "Add tests for the combobox",
description: "Typing, clearing and keyboard selection.",
done: false,
},
];
const sections = [
{
id: "pending",
icon: "ListTodo",
todos: todos.filter((todo) => !todo.done),
},
{
id: "completed",
icon: "ListChecks",
todos: todos.filter((todo) => todo.done),
},
];
return (
<div
style={{
width: "100%",
maxWidth: 384,
marginInline: "auto",
paddingBlock: 24,
}}
>
<Trace>
{sections.map((section) => (
<TracePanel
key={section.id}
open={openSections[section.id] ?? false}
onOpenChange={(open) =>
setOpenSections((state) => ({ ...state, [section.id]: open }))
}
trigger={
<TraceLine>
<TraceIcon>
<Icon name={section.icon} />
</TraceIcon>
<TraceTitle>
{section.todos.length}{" "}
{section.id === "pending" ? "to do" : "completed"}
</TraceTitle>
</TraceLine>
}
>
<Trace>
{section.todos.map((todo) => {
const line = (
<TraceLine>
<TraceIcon>
<Icon name={todo.done ? "CircleCheck" : "Circle"} />
</TraceIcon>
<TraceTitle>{todo.title}</TraceTitle>
</TraceLine>
);
return todo.description ? (
<TracePanel
key={todo.title}
open={openTodos[todo.title] ?? false}
onOpenChange={(open) =>
setOpenTodos((state) => ({
...state,
[todo.title]: open,
}))
}
trigger={line}
>
{todo.description}
</TracePanel>
) : (
<React.Fragment key={todo.title}>{line}</React.Fragment>
);
})}
</Trace>
</TracePanel>
))}
</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 last tool-writeTodos call whose input is no longer streaming. Each call contains the complete todo list. Split it into pending and completed sections. Only show a running line while the chat streams.
import type { UIMessage } from "ai";
import {
Trace,
TraceCollapsible,
TraceCollapsibleTrigger,
TraceLine,
TraceIcon,
TraceTitle,
TraceCollapsibleContent,
} from "@/components/ui/trace";
import { Icon } from "@/components/ui/icon";
type Todo = {
content: string;
status: "pending" | "in_progress" | "completed";
};
type QueueMessage = UIMessage<
unknown,
never,
{
writeTodos: {
input: {
todos: Todo[];
};
output: {
saved: boolean;
};
};
}
>;
function QueueAdapter({
message,
streaming,
}: {
message: QueueMessage;
streaming: boolean;
}) {
const todos =
message.parts
.filter((part) => part.type === "tool-writeTodos")
.filter((part) => part.state !== "input-streaming")
.slice(-1)[0]?.input?.todos ?? [];
const running = streaming;
const sections = [
{
id: "pending",
icon: "ListTodo",
todos: todos.filter((todo) => todo.status !== "completed"),
},
{
id: "completed",
icon: "ListChecks",
todos: todos.filter((todo) => todo.status === "completed"),
},
].filter((section) => section.todos.length > 0);
return (
<Trace>
{sections.map((section) => (
<TraceCollapsible key={section.id} defaultOpen>
<TraceCollapsibleTrigger>
<TraceLine>
<TraceIcon>
<Icon name={section.icon} />
</TraceIcon>
<TraceTitle>
{section.todos.length}{" "}
{section.id === "pending" ? "to do" : "completed"}
</TraceTitle>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
<Trace>
{section.todos.map((todo) => (
<TraceLine
key={todo.content}
status={
running && todo.status === "in_progress"
? "running"
: undefined
}
>
<TraceIcon>
<Icon
name={
todo.status === "completed"
? "CircleCheck"
: todo.status === "in_progress"
? "CircleDot"
: "Circle"
}
/>
</TraceIcon>
<TraceTitle>{todo.content}</TraceTitle>
</TraceLine>
))}
</Trace>
</TraceCollapsibleContent>
</TraceCollapsible>
))}
</Trace>
);
}
const TODOS = [
"Add a dark variant to the theme tokens",
"Read the theme from a cookie",
"Add a toggle to the settings page",
"Check contrast on the charts",
];
export default function AiQueueExample() {
return (
<div
style={{
width: "100%",
maxWidth: 384,
marginInline: "auto",
paddingBlock: 24,
}}
>
<QueueAdapter
message={{
id: "answer",
role: "assistant",
parts: [
{
type: "tool-writeTodos",
toolCallId: "todos-1",
state: "input-available",
input: {
todos: TODOS.map((content, index) => ({
content,
status: index === 0 ? "completed" : "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 |