Installation
Complete project setup with react/base, then install these components:
pnpm exec frontfriend download trace collapsible message bubble button icon -f react --primitive base Use Task when each step becomes known during the work. A running task opens, then closes when complete, unless the user chooses another state. A failed or interrupted step makes its task an error. Plan is for steps known in advance.
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 TASKS = [
{
id: "find",
title: ["Finding the project files", "Found the project files"],
steps: [
["Searching for generateMetadata", "Searched for generateMetadata"],
["Reading app/layout.tsx", "Read app/layout.tsx"],
["Reading app/page.tsx", "Read app/page.tsx"],
],
},
{
id: "update",
title: ["Updating the page metadata", "Updated the page metadata"],
steps: [
["Editing app/layout.tsx", "Edited app/layout.tsx"],
["Editing app/page.tsx", "Edited app/page.tsx"],
],
},
];
type TaskData = {
id: string;
title: string;
steps: {
title: string;
status: "done" | "running" | "error";
}[];
};
function taskAt(task: (typeof TASKS)[number], done: number): TaskData {
return {
id: task.id,
title: task.title[done < task.steps.length ? 0 : 1],
steps: task.steps.slice(0, done + 1).map(([running, finished], index) => ({
title: index < done ? finished : running,
status: index < done ? "done" : "running",
})),
};
}
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,
};
function TaskRow({ task, streaming }: { task: TaskData; streaming: boolean }) {
const [open, setOpen] = React.useState<boolean>();
const steps = task.steps.map((step) => ({
title: step.title,
status:
step.status === "done"
? undefined
: step.status === "running" && streaming
? ("running" as const)
: ("error" as const),
}));
const status = steps.some((step) => step.status === "error")
? "error"
: steps.some((step) => step.status === "running")
? "running"
: undefined;
return (
<TraceCollapsible
open={open ?? status === "running"}
onOpenChange={setOpen}
>
<TraceCollapsibleTrigger>
<TraceLine status={status}>
<TraceIcon>
<Icon
name={
status === "running"
? "CircleDot"
: status === "error"
? "CircleAlert"
: "CircleCheck"
}
/>
</TraceIcon>
<TraceTitle>{task.title}</TraceTitle>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent keepMounted>
<Trace>
{steps.map((step) => (
<TraceLine key={step.title} status={step.status}>
<TraceTitle>
{step.status === "error"
? `Interrupted: ${step.title}`
: step.title}
</TraceTitle>
</TraceLine>
))}
</Trace>
</TraceCollapsibleContent>
</TraceCollapsible>
);
}
export default function AiTaskExample() {
const [run, setRun] = React.useState(0);
const [current, setCurrent] = React.useState(7);
const running = current < 7;
React.useEffect(() => {
if (!running) return;
const timer = setTimeout(() => setCurrent((value) => value + 1), 900);
return () => clearTimeout(timer);
}, [current, running]);
const tasks =
current <= 3
? [taskAt(TASKS[0], current)]
: [taskAt(TASKS[0], 3), taskAt(TASKS[1], current - 4)];
return (
<div style={canvasStyle}>
<Message align="end">
<MessageContent>
<Bubble align="end" variant="muted">
<BubbleContent>Add Open Graph metadata to the home page.</BubbleContent>
</Bubble>
</MessageContent>
</Message>
<Message>
<MessageContent style={{ gap: 16 }}>
<Trace>
{tasks.map((task) => (
<TaskRow
key={`${run}-${task.id}`}
task={task}
streaming={running}
/>
))}
</Trace>
{!running && (
<p>
Added Open Graph defaults to app/layout.tsx and a title,
description and image for the home page in app/page.tsx.
</p>
)}
</MessageContent>
</Message>
<Button
variant="outline"
size="sm"
style={{ marginTop: "auto", alignSelf: "flex-start" }}
disabled={running}
onClick={() => {
setRun((value) => value + 1);
setCurrent(0);
}}
>
Replay
</Button>
</div>
);
} Interrupted
import {
Trace,
TraceCollapsible,
TraceCollapsibleTrigger,
TraceLine,
TraceIcon,
TraceTitle,
TraceCollapsibleContent,
} from "@/components/ui/trace";
import { Icon } from "@/components/ui/icon";
import * as React from "react";
type TaskData = {
id: string;
title: string;
steps: {
title: string;
status: "done" | "running" | "error";
}[];
};
function TaskRow({ task, streaming }: { task: TaskData; streaming: boolean }) {
const [open, setOpen] = React.useState<boolean>();
const steps = task.steps.map((step) => ({
title: step.title,
status:
step.status === "done"
? undefined
: step.status === "running" && streaming
? ("running" as const)
: ("error" as const),
}));
const status = steps.some((step) => step.status === "error")
? "error"
: steps.some((step) => step.status === "running")
? "running"
: undefined;
return (
<TraceCollapsible
open={open ?? status === "running"}
onOpenChange={setOpen}
>
<TraceCollapsibleTrigger>
<TraceLine status={status}>
<TraceIcon>
<Icon
name={
status === "running"
? "CircleDot"
: status === "error"
? "CircleAlert"
: "CircleCheck"
}
/>
</TraceIcon>
<TraceTitle>{task.title}</TraceTitle>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent keepMounted>
<Trace>
{steps.map((step) => (
<TraceLine key={step.title} status={step.status}>
<TraceTitle>
{step.status === "error"
? `Interrupted: ${step.title}`
: step.title}
</TraceTitle>
</TraceLine>
))}
</Trace>
</TraceCollapsibleContent>
</TraceCollapsible>
);
}
export default function AiTaskExample() {
const task: TaskData = {
id: "metadata",
title: "Failed to update the page metadata",
steps: [
{ title: "Read app/page.tsx", status: "done" },
{ title: "Save app/page.tsx", status: "running" },
],
};
return (
<div
style={{
width: "100%",
maxWidth: 384,
marginInline: "auto",
paddingBlock: 24,
}}
>
<Trace>
<TaskRow task={task} streaming={false} />
</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 data-task parts and render each task by its part id. On the server, write updates with the same task id to replace the data part in place. Titles and step errors come from the task data. A running step becomes an error when the chat stops before it completes.
Server (app/api/chat/route.ts):
import { createUIMessageStream } from "ai";
const title = "Updated the page metadata";
const steps = [{ title: "Saved app/page.tsx", status: "done" }];
const stream = createUIMessageStream({
execute: async ({ writer }) => {
writer.write({ type: "data-task", id: "update", data: { title, steps } });
},
});
Pass the resulting message to the client adapter below.
import type { UIMessage } from "ai";
import {
Trace,
TraceCollapsible,
TraceCollapsibleTrigger,
TraceLine,
TraceIcon,
TraceTitle,
TraceCollapsibleContent,
} from "@/components/ui/trace";
import { Icon } from "@/components/ui/icon";
import * as React from "react";
type TaskData = {
id: string;
title: string;
steps: {
title: string;
status: "done" | "running" | "error";
}[];
};
type TaskMessage = UIMessage<
unknown,
{
task: Omit<TaskData, "id">;
}
>;
function TaskRow({ task, streaming }: { task: TaskData; streaming: boolean }) {
const [open, setOpen] = React.useState<boolean>();
const steps = task.steps.map((step) => ({
title: step.title,
status:
step.status === "done"
? undefined
: step.status === "running" && streaming
? ("running" as const)
: ("error" as const),
}));
const status = steps.some((step) => step.status === "error")
? "error"
: steps.some((step) => step.status === "running")
? "running"
: undefined;
return (
<TraceCollapsible
open={open ?? status === "running"}
onOpenChange={setOpen}
>
<TraceCollapsibleTrigger>
<TraceLine status={status}>
<TraceIcon>
<Icon
name={
status === "running"
? "CircleDot"
: status === "error"
? "CircleAlert"
: "CircleCheck"
}
/>
</TraceIcon>
<TraceTitle>{task.title}</TraceTitle>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent keepMounted>
<Trace>
{steps.map((step) => (
<TraceLine key={step.title} status={step.status}>
<TraceTitle>
{step.status === "error"
? `Interrupted: ${step.title}`
: step.title}
</TraceTitle>
</TraceLine>
))}
</Trace>
</TraceCollapsibleContent>
</TraceCollapsible>
);
}
function TaskAdapter({
message,
streaming,
}: {
message: TaskMessage;
streaming: boolean;
}) {
return (
<Trace>
{message.parts
.filter((part) => part.type === "data-task")
.map((part, index) => (
<TaskRow
key={part.id ?? index}
task={{ ...part.data, id: part.id ?? String(index) }}
streaming={streaming}
/>
))}
</Trace>
);
}
export default function AiTaskExample() {
return (
<div
style={{
width: "100%",
maxWidth: 384,
marginInline: "auto",
paddingBlock: 24,
}}
>
<TaskAdapter
message={{
id: "answer",
role: "assistant",
parts: [
{
type: "data-task",
id: "find",
data: {
title: "Could not find the project files",
steps: [
{ title: "Search the project directory", status: "error" },
],
},
},
{
type: "data-task",
id: "update",
data: {
title: "Updated the page metadata",
steps: [{ title: "Saved app/page.tsx", status: "done" }],
},
},
],
}}
streaming={false}
/>
</div>
);
} Components and API
This composition has no separate component API. Use the APIs for its installed components.
| Component | Target reference |
|---|---|
trace | Base UI API |
collapsible | Base UI API |
message | Base UI API |
bubble | Base UI API |
button | Base UI API |
icon | Base UI API |