Displays the steps an agent takes, such as reasoning, tool calls, and sources, as a list of rows.
10.18.0 package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)ERR_PNPM_OUTDATED_LOCKFILE Cannot install with "frozen-lockfile" because pnpm-lock.yaml is not up to date with package.jsonThe last commit bumped a version in package.json without updating pnpm-lock.yaml, so the frozen install fails. Run pnpm install locally and commit the lockfile.
"use client";
import { useChat } from "@ai-sdk/react";import type { UIDataTypes, UIMessage } from "ai";import { ArrowUpRightIcon, CircleAlertIcon, CircleCheckIcon, GlobeIcon, LoaderCircleIcon,} from "lucide-react";import * as React from "react";import { createChat, getMessageText } from "@/lib/ai";import { Bubble, BubbleContent } from "@/components/ui/bubble";import { Button } from "@/components/ui/button";import { CodeBlock, CodeBlockContent,} from "@/components/ui/code-block";import { Message, MessageContent } from "@/components/ui/message";import { Trace, TraceCollapsible, TraceCollapsibleContent, TraceCollapsibleTrigger, TraceIcon, TraceLine, TraceTitle,} from "@/components/ui/trace";
type ChatMessage = UIMessage< unknown, UIDataTypes, { runCommand: { input: { command: string }; output: { stdout: string }; }; }>;
type Part = ChatMessage["parts"][number];
const chat = createChat< unknown, UIDataTypes, { runCommand: { input: { command: string }; output: { stdout: string }; }; }>() .user("Why does pnpm install fail in CI?") .assistant(({ writer }) => { writer.reasoning( "CI installs with a frozen lockfile, so the install stops when package.json and pnpm-lock.yaml disagree. I should check what the last commit changed, then reproduce the install.", { delayMs: 40 }, ); writer .tool("runCommand", { input: { command: "pnpm --version" } }) .sleep(700) .output({ stdout: "10.18.0" }); writer .tool("runCommand", { input: { command: "git diff --stat HEAD~1 -- package.json pnpm-lock.yaml", }, }) .sleep(900) .output({ stdout: " package.json | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-)", }); writer .tool("runCommand", { input: { command: "pnpm install --frozen-lockfile" }, }) .sleep(1200) .error( 'ERR_PNPM_OUTDATED_LOCKFILE Cannot install with "frozen-lockfile" because pnpm-lock.yaml is not up to date with package.json', ); writer.sourceUrl({ url: "https://pnpm.io/cli/install#--frozen-lockfile", title: "pnpm install | pnpm.io", }); writer.sourceUrl({ url: "https://pnpm.io/continuous-integration", title: "Continuous Integration | pnpm.io", }); writer.sleep(1000); writer.text( "The last commit bumped a version in package.json without updating pnpm-lock.yaml, so the frozen install fails. Run pnpm install locally and commit the lockfile.", ); });
const transcript = chat.get();const [question] = chat.get({ count: 1 });const transport = chat.transport({ chunkDelayMs: 20 });
function ThinkingRow({ text, running }: { text: string; running: boolean }) { const [open, setOpen] = React.useState<boolean>();
const line = ( <TraceLine status={running ? "running" : undefined}> <TraceIcon> {running ? ( <LoaderCircleIcon className="animate-spin" /> ) : ( <CircleCheckIcon /> )} </TraceIcon> <TraceTitle>{running ? "Thinking" : "Thought for 5s"}</TraceTitle> </TraceLine> );
if (text.length === 0) { return line; }
return ( <TraceCollapsible open={open ?? running} onOpenChange={setOpen}> <TraceCollapsibleTrigger>{line}</TraceCollapsibleTrigger> <TraceCollapsibleContent className="whitespace-pre-wrap"> {text} </TraceCollapsibleContent> </TraceCollapsible> );}
function ToolRow({ part, streaming, defaultOpen,}: { part: Extract<Part, { type: "tool-runCommand" }>; streaming: boolean; defaultOpen?: boolean;}) { const command = part.input?.command;
if (part.state === "output-available" || part.state === "output-error") { const failed = part.state === "output-error";
return ( <TraceCollapsible defaultOpen={defaultOpen}> <TraceCollapsibleTrigger> <TraceLine status={failed ? "error" : undefined}> <TraceIcon> {failed ? <CircleAlertIcon /> : <CircleCheckIcon />} </TraceIcon> <TraceTitle> {failed ? "Failed to run" : "Ran"} {command} </TraceTitle> </TraceLine> </TraceCollapsibleTrigger> <TraceCollapsibleContent> {failed ? ( <CodeBlock size="sm" variant="destructive"> <CodeBlockContent wrap className="max-h-64"> {part.errorText} </CodeBlockContent> </CodeBlock> ) : ( <CodeBlock size="sm"> <CodeBlockContent wrap className="max-h-64"> {part.output.stdout} </CodeBlockContent> </CodeBlock> )} </TraceCollapsibleContent> </TraceCollapsible> ); }
return ( <TraceLine status={streaming ? "running" : "error"}> <TraceIcon> {streaming ? ( <LoaderCircleIcon className="animate-spin" /> ) : ( <CircleAlertIcon /> )} </TraceIcon> <TraceTitle> {streaming ? "Running" : "Failed to run"} {command} </TraceTitle> </TraceLine> );}
function SourcesRow({ sources, running,}: { sources: Extract<Part, { type: "source-url" }>[]; running: boolean;}) { return ( <TraceCollapsible> <TraceCollapsibleTrigger> <TraceLine status={running ? "running" : undefined}> <TraceIcon> {running ? ( <LoaderCircleIcon className="animate-spin" /> ) : ( <GlobeIcon /> )} </TraceIcon> <TraceTitle> {running ? "Using" : "Used"} {sources.length} sources </TraceTitle> </TraceLine> </TraceCollapsibleTrigger> <TraceCollapsibleContent> <Trace> {sources.map((source) => ( <a key={source.url} href={source.url} target="_blank" rel="noreferrer" > <TraceLine> <TraceIcon> <GlobeIcon /> </TraceIcon> <TraceTitle> {source.title ?? new URL(source.url).hostname} <span className="sr-only"> (opens in a new tab)</span> </TraceTitle> <TraceIcon> <ArrowUpRightIcon /> </TraceIcon> </TraceLine> </a> ))} </Trace> </TraceCollapsibleContent> </TraceCollapsible> );}
export function TraceDemo() { const { messages, sendMessage, setMessages, status } = useChat({ messages: transcript, transport, }); const busy = status === "submitted" || status === "streaming";
return ( <div className="flex min-h-72 w-full max-w-sm flex-col gap-4 py-6 text-sm"> {messages.map((message) => { if (message.role === "user") { return ( <Message key={message.id} align="end"> <MessageContent> <Bubble align="end" variant="muted"> <BubbleContent>{getMessageText(message)}</BubbleContent> </Bubble> </MessageContent> </Message> ); }
const streaming = busy && message === messages.at(-1); const rows = message.parts.filter( (part) => part.type === "reasoning" || part.type === "tool-runCommand", ); const sources = message.parts.filter( (part) => part.type === "source-url", ); const text = getMessageText(message);
return ( <Message key={message.id}> <MessageContent className="gap-4"> {rows.length + sources.length > 0 && ( <Trace> {rows.map((part, index) => part.type === "reasoning" ? ( <ThinkingRow key={index} text={part.text} running={streaming && part.state === "streaming"} /> ) : part.type === "tool-runCommand" ? ( <ToolRow key={part.toolCallId} part={part} streaming={streaming} defaultOpen={index === 2} /> ) : null, )} {sources.length > 0 && ( <SourcesRow sources={sources} running={streaming && text.length === 0} /> )} </Trace> )} {text && <p>{text}</p>} </MessageContent> </Message> ); })} {!busy && ( <Button variant="outline" size="sm" className="mt-auto self-start" onClick={() => { if (question) { setMessages([]); sendMessage(question); } }} > Replay </Button> )} </div> );}Trace is a list of rows for the steps an agent takes, like calling a tool, running a command, or reading a file. Every row has the same shape, so reasoning, tool calls, sources, and plans look and behave the same.
Use Trace for the steps an agent takes. Use Message for its reply, and Marker for notes and dividers between turns.
pnpm dlx shadcn@latest add @herocn/traceimport { Trace, TraceIcon, TraceLine, TraceTitle } from "@/components/ui/trace"<Trace> <TraceLine> <TraceIcon> <SearchIcon /> </TraceIcon> <TraceTitle>Searched for minimumReleaseAge</TraceTitle> </TraceLine></Trace>Wrap the row in a TraceCollapsible to show more detail when it expands:
import { Trace, TraceCollapsible, TraceCollapsibleContent, TraceCollapsibleTrigger, TraceIcon, TraceLine, TraceTitle,} from "@/components/ui/trace"<Trace> <TraceCollapsible> <TraceCollapsibleTrigger> <TraceLine> <TraceIcon> <TerminalIcon /> </TraceIcon> <TraceTitle>Ran node --version</TraceTitle> </TraceLine> </TraceCollapsibleTrigger> <TraceCollapsibleContent>v24.18.0</TraceCollapsibleContent> </TraceCollapsible></Trace>Use the following composition to build a trace row:
Trace└── TraceLine ├── TraceIcon ├── TraceTitle ├── TraceDescription └── TraceIconWrap the row in a TraceCollapsible to show more detail when it expands:
Trace└── TraceCollapsible ├── TraceCollapsibleTrigger │ └── TraceLine └── TraceCollapsibleContentTraceLine is always the row. To make a row do something, wrap it in that thing's trigger, such as a link or a TraceCollapsibleTrigger.
Use a TraceLine on its own for a step with no detail to show. It reads as plain text.
import { GitBranchIcon } from "lucide-react";
import { Trace, TraceIcon, TraceLine, TraceTitle,} from "@/components/ui/trace";
export function TraceStatic() { return ( <Trace className="max-w-sm"> <TraceLine> <TraceIcon> <GitBranchIcon /> </TraceIcon> <TraceTitle>Created branch fix/frozen-lockfile</TraceTitle> </TraceLine> </Trace> );}Set status on TraceLine when a step is running, has failed, or hasn't started yet. Write the title to match the status: present tense while running ("Running pnpm build") and past tense when done ("Ran pnpm install").
import { CircleAlertIcon, CircleCheckIcon, CircleIcon, LoaderCircleIcon,} from "lucide-react";
import { Trace, TraceIcon, TraceLine, TraceTitle,} from "@/components/ui/trace";
export function TraceStatus() { return ( <Trace className="max-w-sm"> <TraceLine> <TraceIcon> <CircleCheckIcon /> </TraceIcon> <TraceTitle>Ran pnpm install</TraceTitle> </TraceLine> <TraceLine status="error"> <TraceIcon> <CircleAlertIcon /> </TraceIcon> <TraceTitle>Failed to run pnpm lint</TraceTitle> </TraceLine> <TraceLine status="running"> <TraceIcon> <LoaderCircleIcon className="animate-spin" /> </TraceIcon> <TraceTitle>Running pnpm build</TraceTitle> </TraceLine> <TraceLine status="pending"> <TraceIcon> <CircleIcon /> </TraceIcon> <TraceTitle>Run pnpm test</TraceTitle> </TraceLine> </Trace> );}| Status | Use for |
|---|---|
| none | Work that is done: "Ran pnpm install". |
"error" | Work that failed. Say so in the title: "Failed to run pnpm lint". |
"running" | Work in progress. Write the title in the present tense: "Running pnpm build". |
"pending" | Work that has not started, such as a plan step: "Run pnpm test". |
Put secondary text such as a diff stat, a duration, or a result count in a TraceDescription after the TraceTitle. It does not truncate.
import { FilePenIcon, SearchIcon, TerminalIcon } from "lucide-react";
import { Trace, TraceDescription, TraceIcon, TraceLine, TraceTitle,} from "@/components/ui/trace";
export function TraceDescriptionDemo() { return ( <Trace className="max-w-sm"> <TraceLine> <TraceIcon> <FilePenIcon /> </TraceIcon> <TraceTitle>Edited notification-preferences-form.tsx</TraceTitle> <TraceDescription className="font-mono">+48 −12</TraceDescription> </TraceLine> <TraceLine> <TraceIcon> <TerminalIcon /> </TraceIcon> <TraceTitle>Ran pnpm test</TraceTitle> <TraceDescription>14s</TraceDescription> </TraceLine> <TraceLine> <TraceIcon> <SearchIcon /> </TraceIcon> <TraceTitle>Searched for useChat</TraceTitle> <TraceDescription>8 results</TraceDescription> </TraceLine> </Trace> );}Wrap the row in a TraceCollapsible to show more detail when it expands. Put the TraceLine inside TraceCollapsibleTrigger and the detail in TraceCollapsibleContent.
$ node --version
v24.18.0import { TerminalIcon } from "lucide-react";import { CodeBlock, CodeBlockContent,} from "@/components/ui/code-block";import { TraceCollapsible, TraceCollapsibleContent, TraceCollapsibleTrigger, TraceIcon, TraceLine, TraceTitle,} from "@/components/ui/trace";
export function TraceCollapsibleDemo() { return ( <div className="w-full max-w-sm"> <TraceCollapsible> <TraceCollapsibleTrigger> <TraceLine> <TraceIcon> <TerminalIcon /> </TraceIcon> <TraceTitle>Ran node --version</TraceTitle> </TraceLine> </TraceCollapsibleTrigger> <TraceCollapsibleContent> <CodeBlock size="sm"> <CodeBlockContent wrap className="max-h-64"> {"$ node --version\nv24.18.0"} </CodeBlockContent> </CodeBlock> </TraceCollapsibleContent> </TraceCollapsible> </div> );}Rows start closed. Use defaultOpen on TraceCollapsible to start open, or open with onOpenChange to control a row.
$ node --version
v24.18.0$ pnpm --version
10.33.4"use client";
import { TerminalIcon } from "lucide-react";import * as React from "react";import { Button } from "@/components/ui/button";import { CodeBlock, CodeBlockContent,} from "@/components/ui/code-block";import { TraceCollapsible, TraceCollapsibleContent, TraceCollapsibleTrigger, TraceIcon, TraceLine, TraceTitle,} from "@/components/ui/trace";
export function TraceOpen() { const [open, setOpen] = React.useState(false);
return ( <div className="flex w-full max-w-sm flex-col items-start gap-4"> <TraceCollapsible defaultOpen> <TraceCollapsibleTrigger> <TraceLine> <TraceIcon> <TerminalIcon /> </TraceIcon> <TraceTitle>Ran node --version</TraceTitle> </TraceLine> </TraceCollapsibleTrigger> <TraceCollapsibleContent> <CodeBlock size="sm"> <CodeBlockContent wrap className="max-h-64"> {"$ node --version\nv24.18.0"} </CodeBlockContent> </CodeBlock> </TraceCollapsibleContent> </TraceCollapsible> <TraceCollapsible open={open} onOpenChange={setOpen}> <TraceCollapsibleTrigger> <TraceLine> <TraceIcon> <TerminalIcon /> </TraceIcon> <TraceTitle>Ran pnpm --version</TraceTitle> </TraceLine> </TraceCollapsibleTrigger> <TraceCollapsibleContent> <CodeBlock size="sm"> <CodeBlockContent wrap className="max-h-64"> {"$ pnpm --version\n10.33.4"} </CodeBlockContent> </CodeBlock> </TraceCollapsibleContent> </TraceCollapsible> <Button variant="outline" size="sm" onClick={() => setOpen(!open)}> {open ? "Hide" : "Show"} the pnpm output </Button> </div> );}To open a row while it streams and let the reader's click win, pass open ?? running.
const [open, setOpen] = React.useState<boolean>()
<TraceCollapsible open={open ?? running} onOpenChange={setOpen}> <TraceCollapsibleTrigger> <TraceLine status={running ? "running" : undefined}>…</TraceLine> </TraceCollapsibleTrigger> <TraceCollapsibleContent>{text}</TraceCollapsibleContent></TraceCollapsible>Wrap the TraceLine in an anchor to make the row navigate. For a link that opens a new tab, use an arrow-up-right icon and add "(opens in a new tab)" to the title with sr-only.
import { ArrowUpRightIcon, ChevronRightIcon, FileTextIcon, GlobeIcon,} from "lucide-react";
import { Trace, TraceIcon, TraceLine, TraceTitle,} from "@/components/ui/trace";
export function TraceLink() { return ( <Trace className="max-w-sm"> <a href="#trace-link"> <TraceLine> <TraceIcon> <FileTextIcon /> </TraceIcon> <TraceTitle>Read apps/docs/lib/docs.ts</TraceTitle> <TraceIcon> <ChevronRightIcon /> </TraceIcon> </TraceLine> </a> <a href="https://docs.astro.build/en/install-and-setup/" target="_blank" rel="noreferrer" > <TraceLine> <TraceIcon> <GlobeIcon /> </TraceIcon> <TraceTitle> Install Astro | docs.astro.build <span className="sr-only"> (opens in a new tab)</span> </TraceTitle> <TraceIcon> <ArrowUpRightIcon /> </TraceIcon> </TraceLine> </a> </Trace> );}<a href="https://docs.astro.build" target="_blank" rel="noreferrer"> <TraceLine> <TraceTitle> Install Astro <span className="sr-only"> (opens in a new tab)</span> </TraceTitle> <TraceIcon> <ArrowUpRightIcon /> </TraceIcon> </TraceLine></a>Wrap the TraceLine in the dialog's trigger. The dialog owns its open state, Escape, and focus return.
import { ArrowUpRightIcon, ChevronRightIcon, GlobeIcon } from "lucide-react";import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger,} from "@/components/ui/dialog";import { Trace, TraceIcon, TraceLine, TraceTitle,} from "@/components/ui/trace";
const results = [ { title: "Caching build outputs", url: "https://nextjs.org/docs/app/guides/caching", }, { title: "Image optimization reference", url: "https://nextjs.org/docs/app/api-reference/components/image", }, { title: "Deploy hooks and cache keys", url: "https://vercel.com/docs/deploy-hooks", },];
export function TraceDialog() { return ( <Trace className="max-w-sm"> <Dialog> <DialogTrigger> <TraceLine> <TraceIcon> <GlobeIcon /> </TraceIcon> <TraceTitle>Searched the web for cache keys</TraceTitle> <TraceIcon> <ChevronRightIcon /> </TraceIcon> </TraceLine> </DialogTrigger> <DialogContent> <DialogHeader> <DialogTitle>Searched the web for cache keys</DialogTitle> <DialogDescription> Three results were used in the response. </DialogDescription> </DialogHeader> <Trace> {results.map((result) => ( <a key={result.url} href={result.url} target="_blank" rel="noreferrer" > <TraceLine> <TraceIcon> <GlobeIcon /> </TraceIcon> <TraceTitle> {result.title} <span className="sr-only"> (opens in a new tab)</span> </TraceTitle> <TraceIcon> <ArrowUpRightIcon /> </TraceIcon> </TraceLine> </a> ))} </Trace> </DialogContent> </Dialog> </Trace> );}<Dialog> <DialogTrigger> <TraceLine> <TraceTitle>Searched the web for cache keys</TraceTitle> <TraceIcon> <ChevronRightIcon /> </TraceIcon> </TraceLine> </DialogTrigger> <DialogContent>…</DialogContent></Dialog>The same shape as the dialog. Drawer and Popover work the same way.
import { ChevronRightIcon, FolderSearchIcon } from "lucide-react";import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger,} from "@/components/ui/sheet";import { Trace, TraceIcon, TraceLine, TraceTitle,} from "@/components/ui/trace";
export function TraceSheet() { return ( <Trace className="max-w-sm"> <Sheet> <SheetTrigger> <TraceLine> <TraceIcon> <FolderSearchIcon /> </TraceIcon> <TraceTitle>Explored 4 files</TraceTitle> <TraceIcon> <ChevronRightIcon /> </TraceIcon> </TraceLine> </SheetTrigger> <SheetContent> <SheetHeader> <SheetTitle>Explored 4 files</SheetTitle> <SheetDescription> Files read while preparing the response. </SheetDescription> </SheetHeader> <ul className="flex flex-col gap-2 px-6 font-mono text-xs"> <li>apps/v4/lib/docs.ts</li> <li>apps/v4/lib/components.ts</li> <li>apps/v4/next.config.mjs</li> <li>apps/v4/package.json</li> </ul> </SheetContent> </Sheet> </Trace> );}Put rows that sit next to each other in a Trace. It also styles any trigger or link that wraps a TraceLine.
import { FileTextIcon, SearchIcon, TerminalIcon } from "lucide-react";
import { Trace, TraceIcon, TraceLine, TraceTitle,} from "@/components/ui/trace";
export function TraceGroupDemo() { return ( <Trace className="max-w-sm"> <TraceLine> <TraceIcon> <SearchIcon /> </TraceIcon> <TraceTitle>Searched for minimumReleaseAge</TraceTitle> </TraceLine> <TraceLine> <TraceIcon> <FileTextIcon /> </TraceIcon> <TraceTitle>Read pnpm-workspace.yaml</TraceTitle> </TraceLine> <TraceLine> <TraceIcon> <TerminalIcon /> </TraceIcon> <TraceTitle>Ran pnpm install</TraceTitle> </TraceLine> </Trace> );}A collapsible row whose detail is a Trace of link rows, for example the sources used for a reply.
import { ArrowUpRightIcon, GlobeIcon } from "lucide-react";
import { Trace, TraceCollapsible, TraceCollapsibleContent, TraceCollapsibleTrigger, TraceIcon, TraceLine, TraceTitle,} from "@/components/ui/trace";
const sources = [ { title: "Install Astro | docs.astro.build", href: "https://docs.astro.build/en/install-and-setup/", }, { title: "create-astro | npmjs.com", href: "https://www.npmjs.com/package/create-astro", }, { title: "Settings: minimumReleaseAge | pnpm.io", href: "https://pnpm.io/settings#minimumreleaseage", },];
export function TraceSources() { return ( <div className="w-full max-w-sm"> <TraceCollapsible defaultOpen> <TraceCollapsibleTrigger> <TraceLine> <TraceIcon> <GlobeIcon /> </TraceIcon> <TraceTitle>Used {sources.length} sources</TraceTitle> </TraceLine> </TraceCollapsibleTrigger> <TraceCollapsibleContent> <Trace> {sources.map((source) => ( <a key={source.href} href={source.href} target="_blank" rel="noreferrer" > <TraceLine> <TraceIcon> <GlobeIcon /> </TraceIcon> <TraceTitle> {source.title} <span className="sr-only"> (opens in a new tab)</span> </TraceTitle> <TraceIcon> <ArrowUpRightIcon /> </TraceIcon> </TraceLine> </a> ))} </Trace> </TraceCollapsibleContent> </TraceCollapsible> </div> );}A chain of thought is a collapsible row with step rows inside. Set the parent's status and title from its children.
import { ChevronRightIcon, FileTextIcon, SearchIcon } from "lucide-react";import { Badge } from "@/components/ui/badge";import { CodeBlock, CodeBlockContent,} from "@/components/ui/code-block";import { Trace, TraceCollapsible, TraceCollapsibleContent, TraceCollapsibleTrigger, TraceIcon, TraceLine, TraceTitle,} from "@/components/ui/trace";
export function TraceSteps() { return ( <div className="w-full max-w-sm"> <TraceCollapsible defaultOpen> <TraceCollapsibleTrigger> <TraceLine status="running"> <TraceTitle>Looking into the slow deploy</TraceTitle> </TraceLine> </TraceCollapsibleTrigger> <TraceCollapsibleContent keepMounted> <Trace> <TraceCollapsible> <TraceCollapsibleTrigger> <TraceLine> <TraceIcon> <SearchIcon /> </TraceIcon> <TraceTitle>Searched for the checkout build log</TraceTitle> </TraceLine> </TraceCollapsibleTrigger> <TraceCollapsibleContent className="flex flex-wrap gap-1.5"> <Badge>vercel.com</Badge> <Badge>github.com</Badge> </TraceCollapsibleContent> </TraceCollapsible> <a href="#trace-steps"> <TraceLine> <TraceIcon> <FileTextIcon /> </TraceIcon> <TraceTitle>Read the last twelve deploys</TraceTitle> <TraceIcon> <ChevronRightIcon /> </TraceIcon> </TraceLine> </a> <TraceCollapsible> <TraceCollapsibleTrigger> <TraceLine status="running"> <TraceIcon> <SearchIcon /> </TraceIcon> <TraceTitle>Searching for the cache key</TraceTitle> </TraceLine> </TraceCollapsibleTrigger> <TraceCollapsibleContent> <CodeBlock size="sm"> <CodeBlockContent wrap className="max-h-64"> cache key image optimization </CodeBlockContent> </CodeBlock> </TraceCollapsibleContent> </TraceCollapsible> </Trace> </TraceCollapsibleContent> </TraceCollapsible> </div> );}A commit is a collapsible row with a row for each changed file. Say what changed in the title, Added, Edited, or Deleted, and put the line counts in the description. Each file opens its diff in a Sheet.
import { ChevronRightIcon, FilePenIcon, FilePlusIcon, GitCommitHorizontalIcon,} from "lucide-react";import { CodeBlock, CodeBlockContent, CodeBlockLine,} from "@/components/ui/code-block";import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger,} from "@/components/ui/sheet";import { Trace, TraceCollapsible, TraceCollapsibleContent, TraceCollapsibleTrigger, TraceDescription, TraceIcon, TraceLine, TraceTitle,} from "@/components/ui/trace";
const files = [ { path: "app/login/page.tsx", added: false, additions: 2, deletions: 2, diff: `@@ -14,7 +14,7 @@ export async function LoginPage({ searchParams, }) { const { next } = await searchParams- const redirectTo =- next && next.startsWith("/") ? next : "/"+ const redirectTo = safeRedirect(next)+ return <LoginForm redirectTo={redirectTo} /> }`, }, { path: "lib/auth/redirect.ts", added: true, additions: 7, deletions: 0, diff: `@@ -0,0 +1,7 @@+export function safeRedirect(next: string | undefined) {+ if (!next || !next.startsWith("/") || next.startsWith("//")) {+ return "/"+ }++ return next+}`, },];
function DiffLines({ diff }: { diff: string }) { return diff.split("\n").map((line, index) => { if (line.startsWith("@@")) { return ( <CodeBlockLine key={index} className="text-muted-foreground"> {line} </CodeBlockLine> ); }
return ( <CodeBlockLine key={index} variant={ line.startsWith("+") ? "added" : line.startsWith("-") ? "removed" : "default" } > {line.slice(1)} </CodeBlockLine> ); });}
export function TraceCommit() { return ( <Trace className="max-w-sm"> <TraceCollapsible defaultOpen> <TraceCollapsibleTrigger> <TraceLine> <TraceIcon> <GitCommitHorizontalIcon /> </TraceIcon> <TraceTitle>Committed a1b2c3: Fix the login redirect</TraceTitle> </TraceLine> </TraceCollapsibleTrigger> <TraceCollapsibleContent> <Trace> {files.map((file) => ( <Sheet key={file.path}> <SheetTrigger> <TraceLine> <TraceIcon> {file.added ? <FilePlusIcon /> : <FilePenIcon />} </TraceIcon> <TraceTitle> {file.added ? "Added" : "Edited"} {file.path} </TraceTitle> <TraceDescription className="font-mono"> +{file.additions} −{file.deletions} </TraceDescription> <TraceIcon> <ChevronRightIcon /> </TraceIcon> </TraceLine> </SheetTrigger> <SheetContent> <SheetHeader> <SheetTitle className="font-mono">{file.path}</SheetTitle> <SheetDescription> {file.additions} additions, {file.deletions} deletions </SheetDescription> </SheetHeader> <CodeBlock className="mx-6 mb-6 w-auto"> <CodeBlockContent> <DiffLines diff={file.diff} /> </CodeBlockContent> </CodeBlock> </SheetContent> </Sheet> ))} </Trace> </TraceCollapsibleContent> </TraceCollapsible> </Trace> );}Three levels: the turn, a summary, and the rows. Text between steps sits between two Trace lists.
command not found: pnpmNode is installed and pnpm is not. Reading the docs config.
import { BrainIcon, ChevronRightIcon, FileTextIcon, TerminalIcon,} from "lucide-react";import { CodeBlock, CodeBlockContent,} from "@/components/ui/code-block";import { Trace, TraceCollapsible, TraceCollapsibleContent, TraceCollapsibleTrigger, TraceDescription, TraceIcon, TraceLine, TraceTitle,} from "@/components/ui/trace";
export function TraceTurn() { return ( <div className="w-full max-w-sm"> <TraceCollapsible defaultOpen> <TraceCollapsibleTrigger> <TraceLine status="error"> <TraceTitle>Worked for 2m 4s, 1 command failed</TraceTitle> </TraceLine> </TraceCollapsibleTrigger> <TraceCollapsibleContent keepMounted className="flex flex-col gap-2"> <Trace> <TraceCollapsible> <TraceCollapsibleTrigger> <TraceLine> <TraceIcon> <BrainIcon /> </TraceIcon> <TraceTitle>Thought</TraceTitle> <TraceDescription>12s</TraceDescription> </TraceLine> </TraceCollapsibleTrigger> <TraceCollapsibleContent className="whitespace-pre-wrap"> I should check which package managers are installed before I scaffold anything. </TraceCollapsibleContent> </TraceCollapsible> <TraceCollapsible defaultOpen> <TraceCollapsibleTrigger> <TraceLine status="error"> <TraceIcon> <TerminalIcon /> </TraceIcon> <TraceTitle>Ran 2 commands, 1 failed</TraceTitle> </TraceLine> </TraceCollapsibleTrigger> <TraceCollapsibleContent keepMounted> <Trace> <TraceLine> <TraceIcon> <TerminalIcon /> </TraceIcon> <TraceTitle>Ran node --version</TraceTitle> </TraceLine> <TraceCollapsible> <TraceCollapsibleTrigger> <TraceLine status="error"> <TraceIcon> <TerminalIcon /> </TraceIcon> <TraceTitle>Failed to run pnpm --version</TraceTitle> </TraceLine> </TraceCollapsibleTrigger> <TraceCollapsibleContent> <CodeBlock size="sm" variant="destructive"> <CodeBlockContent wrap className="max-h-64"> command not found: pnpm </CodeBlockContent> </CodeBlock> </TraceCollapsibleContent> </TraceCollapsible> </Trace> </TraceCollapsibleContent> </TraceCollapsible> </Trace> <p>Node is installed and pnpm is not. Reading the docs config.</p> <Trace> <a href="#trace-turn"> <TraceLine> <TraceIcon> <FileTextIcon /> </TraceIcon> <TraceTitle>Read apps/v4/lib/docs.ts</TraceTitle> <TraceIcon> <ChevronRightIcon /> </TraceIcon> </TraceLine> </a> </Trace> </TraceCollapsibleContent> </TraceCollapsible> </div> );}Render a reasoning part from the AI SDK. The row is a TraceLine until the first text arrives. See Open State.
"use client";
import { useChat } from "@ai-sdk/react";import type { UIMessage } from "ai";import { BrainIcon } from "lucide-react";import * as React from "react";import { createChat, getMessageText } from "@/lib/ai";import { Bubble, BubbleContent } from "@/components/ui/bubble";import { Button } from "@/components/ui/button";import { Message, MessageContent } from "@/components/ui/message";import { Trace, TraceCollapsible, TraceCollapsibleContent, TraceCollapsibleTrigger, TraceIcon, TraceLine, TraceTitle,} from "@/components/ui/trace";
const chat = createChat() .user("Why is the checkout deploy so slow?") .assistant(({ writer }) => { writer.reasoning( "The build log shows install and compile finish in under a minute. Image optimization takes almost four, and it reruns on every deploy because the cache key includes the commit hash.", { delayMs: 60 }, ); writer.sleep(400); writer.text( "Image optimization is the bottleneck. Key its cache on the lockfile and the image directory instead of the commit hash.", ); });
const initialMessages = chat.get({ count: 0 });const transport = chat.transport({ chunkDelayMs: 20 });
function ThinkingTrace({ text, running }: { text: string; running: boolean }) { const [open, setOpen] = React.useState<boolean>();
const line = ( <TraceLine status={running ? "running" : undefined}> <TraceIcon> <BrainIcon /> </TraceIcon> <TraceTitle>{running ? "Thinking" : "Thought"}</TraceTitle> </TraceLine> );
if (text.length === 0) { return line; }
return ( <TraceCollapsible open={open ?? running} onOpenChange={setOpen}> <TraceCollapsibleTrigger>{line}</TraceCollapsibleTrigger> <TraceCollapsibleContent className="whitespace-pre-wrap"> {text} </TraceCollapsibleContent> </TraceCollapsible> );}
function groupParts(parts: UIMessage["parts"]) { const runs: { trace: boolean; parts: UIMessage["parts"] }[] = [];
for (const part of parts) { const trace = part.type === "reasoning";
if (!trace && part.type !== "text") { continue; }
const last = runs.at(-1);
if (last?.trace === trace) { last.parts.push(part); } else { runs.push({ trace, parts: [part] }); } }
return runs;}
export function TraceThinking() { const { messages, sendMessage, setMessages, status } = useChat({ messages: initialMessages, transport, }); const nextMessage = chat.next({ after: messages }); const busy = status === "submitted" || status === "streaming";
return ( <div className="flex min-h-72 w-full max-w-sm flex-col gap-4 py-6 text-sm"> {messages.map((message) => { if (message.role === "user") { return ( <Message key={message.id} align="end"> <MessageContent> <Bubble align="end" variant="muted"> <BubbleContent>{getMessageText(message)}</BubbleContent> </Bubble> </MessageContent> </Message> ); }
const live = status === "streaming" && message === messages.at(-1);
return ( <Message key={message.id}> <MessageContent className="gap-4"> {groupParts(message.parts).map((run, runIndex) => run.trace ? ( <Trace key={runIndex}> {run.parts.map((part, index) => part.type === "reasoning" ? ( <ThinkingTrace key={index} text={part.text} running={live && part.state === "streaming"} /> ) : null, )} </Trace> ) : ( run.parts.map((part, index) => part.type === "text" ? ( <p key={`${runIndex}-${index}`}>{part.text}</p> ) : null, ) ), )} </MessageContent> </Message> ); })} {!busy && ( <Button variant="outline" size="sm" className="mt-auto self-start" onClick={() => nextMessage ? sendMessage(nextMessage) : setMessages(initialMessages) } > {nextMessage ? "Send message" : "Reset"} </Button> )} </div> );}Render tool parts from the AI SDK.
"use client";
import { useChat } from "@ai-sdk/react";import type { DynamicToolUIPart, UIMessage } from "ai";import { TerminalIcon } from "lucide-react";import { createChat, getMessageText } from "@/lib/ai";import { Bubble, BubbleContent } from "@/components/ui/bubble";import { Button } from "@/components/ui/button";import { CodeBlock, CodeBlockContent,} from "@/components/ui/code-block";import { Message, MessageContent } from "@/components/ui/message";import { Trace, TraceCollapsible, TraceCollapsibleContent, TraceCollapsibleTrigger, TraceIcon, TraceLine, TraceTitle,} from "@/components/ui/trace";
const chat = createChat() .user("Which package managers are installed?") .assistant(({ writer }) => { writer .tool("runCommand", { dynamic: true, input: { command: "node --version" }, }) .sleep(900) .output({ stdout: "v24.18.0" }); writer .tool("runCommand", { dynamic: true, input: { command: "npm --version" }, }) .sleep(900) .output({ stdout: "11.6.2" }); writer .tool("runCommand", { dynamic: true, input: { command: "pnpm --version" }, }) .sleep(900) .error("command not found: pnpm"); writer.text("Node 24 and npm 11 are installed. pnpm is not."); });
const initialMessages = chat.get({ count: 0 });const transport = chat.transport({ chunkDelayMs: 20 });
function ToolTrace({ part, live }: { part: DynamicToolUIPart; live: boolean }) { const command = (part.input as { command?: string } | undefined)?.command;
if (part.state === "output-available" || part.state === "output-error") { const failed = part.state === "output-error";
return ( <TraceCollapsible> <TraceCollapsibleTrigger> <TraceLine status={failed ? "error" : undefined}> <TraceIcon> <TerminalIcon /> </TraceIcon> <TraceTitle> {failed ? "Failed to run" : "Ran"} {command} </TraceTitle> </TraceLine> </TraceCollapsibleTrigger> <TraceCollapsibleContent> <CodeBlock size="sm"> <CodeBlockContent wrap className="max-h-64"> {failed ? part.errorText : (part.output as { stdout: string }).stdout} </CodeBlockContent> </CodeBlock> </TraceCollapsibleContent> </TraceCollapsible> ); }
if (part.state === "output-denied") { return ( <TraceLine> <TraceIcon> <TerminalIcon /> </TraceIcon> <TraceTitle>Denied {command}</TraceTitle> </TraceLine> ); }
return ( <TraceLine status={live ? "running" : "error"}> <TraceIcon> <TerminalIcon /> </TraceIcon> <TraceTitle> {live ? "Running" : "Failed to run"} {command} </TraceTitle> </TraceLine> );}
function groupParts(parts: UIMessage["parts"]) { const runs: { trace: boolean; parts: UIMessage["parts"] }[] = [];
for (const part of parts) { const trace = part.type === "dynamic-tool";
if (!trace && part.type !== "text") { continue; }
const last = runs.at(-1);
if (last?.trace === trace) { last.parts.push(part); } else { runs.push({ trace, parts: [part] }); } }
return runs;}
export function TraceToolCalls() { const { messages, sendMessage, setMessages, status } = useChat({ messages: initialMessages, transport, }); const nextMessage = chat.next({ after: messages }); const busy = status === "submitted" || status === "streaming";
return ( <div className="flex min-h-72 w-full max-w-sm flex-col gap-4 py-6 text-sm"> {messages.map((message) => { if (message.role === "user") { return ( <Message key={message.id} align="end"> <MessageContent> <Bubble align="end" variant="muted"> <BubbleContent>{getMessageText(message)}</BubbleContent> </Bubble> </MessageContent> </Message> ); }
const live = status === "streaming" && message === messages.at(-1);
return ( <Message key={message.id}> <MessageContent className="gap-4"> {groupParts(message.parts).map((run, runIndex) => run.trace ? ( <Trace key={runIndex}> {run.parts.map((part) => part.type === "dynamic-tool" ? ( <ToolTrace key={part.toolCallId} part={part} live={live} /> ) : null, )} </Trace> ) : ( run.parts.map((part, index) => part.type === "text" ? ( <p key={`${runIndex}-${index}`}>{part.text}</p> ) : null, ) ), )} </MessageContent> </Message> ); })} {!busy && ( <Button variant="outline" size="sm" className="mt-auto self-start" onClick={() => nextMessage ? sendMessage(nextMessage) : setMessages(initialMessages) } > {nextMessage ? "Send message" : "Reset"} </Button> )} </div> );}Trace adds no roles of its own. Each row gets its semantics from the element that wraps it.
TraceLine on its own is a span. It is read as text and is not a tab stop.TraceCollapsibleTrigger and it becomes a button with aria-expanded. Wrap it in a link and it takes the keyboard and focus behavior of that element.TraceIcon is hidden from assistive tech, so TraceTitle is the accessible name. Write it as a sentence that says what happened, like "Edited trace.tsx", not just "trace.tsx".sr-only.The list wrapper for trace rows.
| Prop | Type | Default |
|---|---|---|
| className | string | — |
Additional classes to apply to the list. | ||
A single step row. Render it on its own for a step with no detail, or wrap it in a link or a TraceCollapsibleTrigger to make it interactive.
| Prop | Type | Default |
|---|---|---|
| status | "running" | "error" | "pending" | — |
The step state. Omit for done. "running" shimmers the title, "error" uses the destructive color, and "pending" dims the row. | ||
| className | string | — |
Additional classes to apply to the row. | ||
A decorative icon slot. Hidden from assistive tech with aria-hidden. Render a trailing icon, such as a chevron or arrow, after TraceTitle to signal that the row navigates.
| Prop | Type | Default |
|---|---|---|
| className | string | — |
Additional classes to apply to the icon slot. | ||
The row title. Truncates when the row is too narrow.
| Prop | Type | Default |
|---|---|---|
| className | string | — |
Additional classes to apply to the title. | ||
Secondary text after the title, such as a diff stat, a duration, or a result count. Does not truncate.
| Prop | Type | Default |
|---|---|---|
| className | string | — |
Additional classes to apply to the description. | ||
The collapsible wrapper for an expandable row. Uncontrolled by default; pass open with onOpenChange to control it.
| Prop | Type | Default |
|---|---|---|
| defaultOpen | boolean | — |
Whether the row starts open when uncontrolled. | ||
| open | boolean | — |
The controlled open state. Requires onOpenChange. | ||
| onOpenChange | (open: boolean) => void | — |
Callback fired when the open state changes. Required when open is controlled. | ||
| className | string | — |
Additional classes to apply to the collapsible. | ||
The trigger that toggles the row. Renders the TraceLine plus a chevron that rotates when open.
| Prop | Type | Default |
|---|---|---|
| className | string | — |
Additional classes to apply to the trigger. | ||
The detail revealed when the row expands, indented with a border.
| Prop | Type | Default |
|---|---|---|
| keepMounted | boolean | — |
Keep the detail mounted when the row is closed. | ||
| className | string | — |
Additional classes to apply to the content. | ||
On This Page
InstallationUsageCompositionExamplesTrace LineStatusDescriptionCollapsibleOpen StateLinkDialogSheetGroupSourcesChain of ThoughtCommitWhole TurnReasoningToolAccessibilityAPI ReferenceTraceTraceLineTraceIconTraceTitleTraceDescriptionTraceCollapsibleTraceCollapsibleTriggerTraceCollapsibleContent