Trace
Show the steps an agent took, such as its reasoning, tool calls and sources, as one-line rows that expand to their details.
Installation
pnpm dlx shadcn@4.18.0 add @siteplane/traceComplete the one-time setup first. Registry JSON lists source files and dependencies.
For this page’s examples, also install:
pnpm dlx shadcn@4.18.0 add @siteplane/alert @siteplane/badge @siteplane/code @siteplane/code-highlight @siteplane/preview-cardButton and Collapsible are installed with Trace. code-highlight is the optional highlighter of Code; the tool example uses it for its JSON output.
Usage
import { SearchIcon } from "lucide-react";
import {
Trace,
TraceIcon,
TraceLine,
TraceTitle,
} from "@/siteplane/components/trace";
<Trace>
<TraceLine>
<TraceIcon>
<SearchIcon />
</TraceIcon>
<TraceTitle>Searched 214 accounts</TraceTitle>
</TraceLine>
</Trace>;Composition
Trace
├── TraceLine
│ ├── TraceIcon
│ ├── TraceTitle
│ ├── TraceDescription (optional)
│ └── TraceIcon (optional, after the title)
├── TraceCollapsible
│ ├── TraceCollapsibleTrigger
│ │ └── TraceLine
│ └── TraceCollapsibleContent
└── TraceLink
└── TraceLineA line is text. Put it in a TraceCollapsibleTrigger to make it a button that opens its details, or in a TraceLink to make it a link.
Status
A line without status reports work that is done. running is work in progress, and its title shimmers. error is work that failed, and the line takes the error color. pending is work that has not started, such as the next step of a plan, and the line is dimmed.
Screen readers hear the title, not the color or the shimmer, so the status belongs in its words. Write a running step in the present tense, as in “Reading conversations”, and a finished one in the past tense; the title of a failed step starts with “Failed to”.
Reasoning
A reasoning step is open while the model thinks and closed when it is done. Its title reads “Thinking” with a shimmer, then “Thought” with the time it took as its description. Keep the reader’s choice in state and pass open ?? running to the TraceCollapsible: the step follows the model until the reader opens or closes it, and after that the reader’s click wins.
Tool calls
A tool call is a line whose title says what it does: “Searching accounts” while it runs and “Searched 214 accounts” when it is done. Its output goes in a Code Block in the details. A call that failed has status="error", a title that starts with “Failed to” and the error in its details. A call the person denied is a line without details, such as “Denied sending 3 emails”.
Result{
"plans": ["Growth", "Scale"],
"activeSeats": "down 40% or more in 12 weeks",
"matches": 9
}Approval
When a call waits for the person’s approval, ask in an Alert after the Trace. It is a question for the person, not a step the agent took, so it is not a line. The Alert shows an icon, the question as its title and what will happen as its description. Its buttons are the last row of the description and start where the text starts, Approve first.
Once the person answers, the call is a line in the Trace again: a running line such as “Sending 3 emails” after an approval, and “Denied sending 3 emails” after a denial.
To Halden Logistics, Brightwave Studio and Ortega Foods, from your address. You can edit the drafts first.
Sources
List the pages an answer used in a collapsible line that counts them, with a TraceLink for each. A TraceLink is the same row as a collapsible step, with the same colors, focus outline and touch target. With target="_blank" it adds “(opens in a new tab)” after its title for screen readers; show the same with an arrow icon after the title, and set rel="noreferrer".
Inline citations
A citation in the text of an answer is a small Badge that links to the source. A Preview Card on it shows the source’s title, two lines of its text and its address on hover and keyboard focus. The link opens in a new tab, and visually hidden text after the hostname says so to screen readers.
Accounts that lose more than 40% of their active seats within twelve weeks cancel far more often than steady ones. analytics.example.com (opens in a new tab)
A conversation that waits longer than a week for a reply makes it worse, and Ortega Foods has one open for 15 days. support.example.com (opens in a new tab)
Chain of thought
A summary line can expand into the steps behind it: put a second Trace in its details. Pass keepMounted to the summary’s TraceCollapsibleContent, so the steps inside keep their open state while the summary is closed. While the steps run, the summary follows the reasoning rule, open ?? running. The results of a search can follow their step as hostname Badges.
AI SDK
The parts use the AI SDK’s words for statuses and states, so the parts of a message from useChat map to them one by one. The examples on this page run on local state; the code below maps the AI SDK’s message parts instead. Set sendReasoning and sendSources on toUIMessageStreamResponse in your route, so that reasoning and source parts reach the client.
Only the last message streams, and only while the chat’s status is streaming. Pass that to the steps as live: a part that was still streaming when the reply stopped will not finish, so its line stops running. While the chat is submitted, before the first part arrives, the turn shows a Marker that says “Thinking”.
import { useChat } from "@ai-sdk/react";
import { isToolUIPart, type UIMessage } from "ai";
import { Trace } from "@/siteplane/components/trace";
// The reasoning and tool parts of an assistant message, before its answer.
export function Steps({
live,
message,
}: {
live: boolean;
message: UIMessage;
}) {
const steps = message.parts.filter(
(part) => part.type === "reasoning" || isToolUIPart(part),
);
// No empty Trace: it would leave a gap in the turn.
if (steps.length === 0) return null;
return (
<Trace>
{steps.map((part, index) =>
part.type === "reasoning" ? (
<ReasoningStep key={index} live={live} part={part} />
) : (
<ToolStep key={part.toolCallId} live={live} part={part} />
),
)}
</Trace>
);
}
// In the thread:
const { messages, status } = useChat();
messages.map((message, index) => (
<Steps
key={message.id}
live={index === messages.length - 1 && status === "streaming"}
message={message}
/>
));Reasoning parts
A reasoning part runs while its state is streaming and is done at done. The AI SDK sends no duration: measure it in the app, from the part’s first text to its done state, or send it with the message’s metadata.
import type { ReasoningUIPart } from "ai";
import { BrainIcon } from "lucide-react";
import { useState } from "react";
import {
TraceCollapsible,
TraceCollapsibleContent,
TraceCollapsibleTrigger,
TraceDescription,
TraceIcon,
TraceLine,
TraceTitle,
} from "@/siteplane/components/trace";
export function ReasoningStep({
live,
part,
seconds,
}: {
live: boolean;
part: ReasoningUIPart;
seconds?: number;
}) {
// The reader's choice wins over the model's state once they click.
const [open, setOpen] = useState<boolean>();
const running = live && part.state === "streaming";
return (
<TraceCollapsible onOpenChange={setOpen} open={open ?? running}>
<TraceCollapsibleTrigger>
<TraceLine status={running ? "running" : undefined}>
<TraceIcon>
<BrainIcon />
</TraceIcon>
<TraceTitle>{running ? "Thinking" : "Thought"}</TraceTitle>
{running || seconds === undefined ? null : (
<TraceDescription>{seconds}s</TraceDescription>
)}
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>{part.text}</TraceCollapsibleContent>
</TraceCollapsible>
);
}Tool parts
A tool part becomes a line by its state. The words of each title are yours, since the title is what screen readers hear; a count such as “214 accounts” comes from the call’s input or output.
Tool part state | Line | Title |
|---|---|---|
input-streaming, input-available | status="running"; status="error" once not live | Present tense, “Searching accounts” |
approval-requested | No line: the Alert after the Trace asks | The question, with Approve and Deny |
approval-responded | status="running" if approved, done if denied | “Sending 3 emails” or “Denied sending 3 emails” |
output-available | Done, with output in a Code Block | Past tense, “Searched 214 accounts” |
output-error | status="error", with errorText in the details | “Failed to read conversations” |
output-denied | Done, without details | “Denied sending 3 emails” |
import { getToolName, type DynamicToolUIPart, type ToolUIPart } from "ai";
import { BanIcon, CircleAlertIcon, SearchIcon, WrenchIcon } from "lucide-react";
import type { ReactNode } from "react";
import { CodeBlock } from "@/siteplane/components/code";
import {
TraceCollapsible,
TraceCollapsibleContent,
TraceCollapsibleTrigger,
TraceIcon,
TraceLine,
TraceTitle,
} from "@/siteplane/components/trace";
type Words = {
icon: ReactNode;
running: string;
done: string;
failed: string;
denied: string;
};
// What the line of each tool says in each state.
const tools: Record<string, Words> = {
searchAccounts: {
icon: <SearchIcon />,
running: "Searching accounts",
done: "Searched accounts",
failed: "Failed to search accounts",
denied: "Denied searching accounts",
},
};
function wordsFor(name: string): Words {
return (
tools[name] ?? {
icon: <WrenchIcon />,
running: `Running ${name}`,
done: `Ran ${name}`,
failed: `Failed to run ${name}`,
denied: `Denied running ${name}`,
}
);
}
export function ToolStep({
live,
part,
}: {
live: boolean;
part: ToolUIPart | DynamicToolUIPart;
}) {
const words = wordsFor(getToolName(part));
switch (part.state) {
case "input-streaming":
case "input-available":
// A call that is still open when the reply has stopped did not finish.
return live ? (
<Step icon={words.icon} status="running" title={words.running} />
) : (
<Step status="error" title={words.failed} />
);
case "approval-requested":
// The Alert after the Trace asks the person.
return null;
case "approval-responded":
return part.approval.approved ? (
<Step icon={words.icon} status="running" title={words.running} />
) : (
<Step icon={<BanIcon />} title={words.denied} />
);
case "output-available":
return (
<Step icon={words.icon} title={words.done}>
<CodeBlock
language="json"
size="sm"
title="Result"
value={JSON.stringify(part.output, null, 2)}
/>
</Step>
);
case "output-error":
return (
<Step status="error" title={words.failed}>
<CodeBlock size="sm" value={part.errorText} />
</Step>
);
case "output-denied":
return <Step icon={<BanIcon />} title={words.denied} />;
}
}
/** A line, which expands when the call has details. */
function Step({
children,
icon,
status,
title,
}: {
children?: ReactNode;
icon?: ReactNode;
status?: "running" | "error";
title: string;
}) {
const line = (
<TraceLine status={status}>
<TraceIcon>{status === "error" ? <CircleAlertIcon /> : icon}</TraceIcon>
<TraceTitle>{title}</TraceTitle>
</TraceLine>
);
if (!children) return line;
return (
<TraceCollapsible>
<TraceCollapsibleTrigger>{line}</TraceCollapsibleTrigger>
<TraceCollapsibleContent>{children}</TraceCollapsibleContent>
</TraceCollapsible>
);
}Approvals
A call that needs the person’s approval stops at approval-requested. Ask with the Alert after the turn’s Trace and answer with addToolApprovalResponse; with sendAutomaticallyWhen, the reply continues once every approval has an answer. An approval that the server decides on its own has approval.isAutomatic and needs no question. Build the question from the call’s input, such as the number of emails it sends.
import { useChat } from "@ai-sdk/react";
import {
lastAssistantMessageIsCompleteWithApprovalResponses,
type ToolUIPart,
} from "ai";
import { MailIcon } from "lucide-react";
import {
Alert,
AlertDescription,
AlertTitle,
} from "@/siteplane/components/alert";
import { Button } from "@/siteplane/components/button";
export function Approval({
onAnswer,
part,
}: {
onAnswer: (response: { id: string; approved: boolean }) => void;
part: ToolUIPart;
}) {
if (part.state !== "approval-requested" || part.approval.isAutomatic)
return null;
const { id } = part.approval;
return (
<Alert>
<MailIcon aria-hidden="true" />
<AlertTitle>Send 3 check-in emails?</AlertTitle>
<AlertDescription>
<p>To Halden Logistics, Brightwave Studio and Ortega Foods.</p>
<div className="flex flex-wrap gap-2">
<Button
onClick={() => onAnswer({ id, approved: true })}
size="sm"
variant="accent"
>
Approve
</Button>
<Button
onClick={() => onAnswer({ id, approved: false })}
size="sm"
variant="outline"
>
Deny
</Button>
</div>
</AlertDescription>
</Alert>
);
}
// In the thread:
const { addToolApprovalResponse } = useChat({
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
});
<Approval onAnswer={addToolApprovalResponse} part={part} />;Source parts
Each source-url part is a link: url, and title when the source has one. Count them in one line after the answer. A source-document part has no address; show it as a line with its title, without a link. The same parts back inline citations: where the answer cites a source, render its Badge with the part’s title and URL.
import type { UIMessage } from "ai";
import { ArrowUpRightIcon, FileTextIcon, GlobeIcon } from "lucide-react";
import {
Trace,
TraceCollapsible,
TraceCollapsibleContent,
TraceCollapsibleTrigger,
TraceDescription,
TraceIcon,
TraceLine,
TraceLink,
TraceTitle,
} from "@/siteplane/components/trace";
export function Sources({ message }: { message: UIMessage }) {
const sources = message.parts.filter((part) => part.type === "source-url");
if (sources.length === 0) return null;
return (
<Trace>
<TraceCollapsible>
<TraceCollapsibleTrigger>
<TraceLine>
<TraceIcon>
<FileTextIcon />
</TraceIcon>
<TraceTitle>
{sources.length === 1
? "Used 1 source"
: `Used ${sources.length} sources`}
</TraceTitle>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
<Trace>
{sources.map((source) => {
const host = new URL(source.url).hostname;
return (
<TraceLink
href={source.url}
key={source.sourceId}
rel="noreferrer"
target="_blank"
>
<TraceLine>
<TraceIcon>
<GlobeIcon />
</TraceIcon>
<TraceTitle>{source.title ?? host}</TraceTitle>
<TraceDescription>{host}</TraceDescription>
<TraceIcon>
<ArrowUpRightIcon />
</TraceIcon>
</TraceLine>
</TraceLink>
);
})}
</Trace>
</TraceCollapsibleContent>
</TraceCollapsible>
</Trace>
);
}API reference
| Prop | Type | Default | Description |
|---|---|---|---|
Trace | TraceStatus: running | error | pending | unset | The state of the work. Without it the line reports work that is done; running shimmers the title, error takes the error color and pending dims the line. |
Trace | string | its text | The full text, shown when an ellipsis cuts it. It is taken from text children; pass it when the children hold elements. |
Trace | boolean | false | Controlled or initial open state of a step. Steps start closed. |
Trace | (open, eventDetails) => void | unset | Called when the reader opens or closes the step. |
Trace | boolean | false | Keeps the details mounted while closed, so nested steps keep their open state. |
Trace | Partial<TraceLinkLabels> | English | Replaces the words screen readers hear after the title of a link with target _blank: newTab, “(opens in a new tab)” by default. |
Trace renders a div that stacks its rows 8 px apart, in the 14 px body text
and the tertiary text color. TraceLine, TraceIcon, TraceTitle and
TraceDescription render spans, so a line is valid inside the button or the
link that makes it act; on its own a line is text and not a tab stop.
TraceIcon is hidden from assistive technology and sizes an icon to 16 px.
TraceTitle and TraceDescription stay on one line. A short description stays
whole; a long one, such as a long hostname, ends with an ellipsis like the title
and carries its full text in title, so a row never widens the thread.
TraceCollapsible takes the props of Collapsible.
TraceCollapsibleTrigger is its trigger, rendered as the muted inline Button,
with the chevron after the line and aria-expanded. TraceCollapsibleContent
is its panel, which opens and closes with the kit’s expand motion; its
className styles the details, and its other props go to the panel.
TraceLink is the same Button rendered as an a and takes the attributes of
an anchor. The other parts take the props of their elements and string
className. TraceStatus, TraceLineProps, TraceLinkProps and
TraceLinkLabels are exported. The shimmer stands still and the details open
at once when the system asks for reduced motion, when html carries
data-motion-reduce="true", or inside SiteplaneMotionProvider preset="reduced".
Usage notes
Trace presents steps and keeps no state of its own beyond the open state of a step that is not controlled. The app decides which steps show, in which order, and how long each took; a duration such as “6s” is text you pass.
Details take the Trace’s quieter text color. Output that must be read in full, such as a result or a log, belongs in a component with colors of its own, such as a Code Block.