Siteplane UI

Trace

Show the steps an agent took, such as its reasoning, tool calls and sources, as one-line rows that expand to their details.

Markdown
Searched 214 accountsRead 38 conversationsSent 3 check-in emails
"use client";

import {
  BrainIcon,
  MailIcon,
  MessageSquareIcon,
  SearchIcon,
} from "lucide-react";
import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleContent,
  TraceCollapsibleTrigger,
  TraceDescription,
  TraceIcon,
  TraceLine,
  TraceTitle,
} from "@/siteplane/components/trace";

const reasoning =
  "Churn shows up as falling activity and questions nobody answered. I'll compare each account's weekly active seats over the last 12 weeks with its plan, then read the open support conversations of the accounts that dropped most.";

export default function Example() {
  return (
    <Trace className="max-w-md">
      <TraceCollapsible>
        <TraceCollapsibleTrigger>
          <TraceLine>
            <TraceIcon>
              <BrainIcon />
            </TraceIcon>
            <TraceTitle>Thought</TraceTitle>
            <TraceDescription>6s</TraceDescription>
          </TraceLine>
        </TraceCollapsibleTrigger>
        <TraceCollapsibleContent>{reasoning}</TraceCollapsibleContent>
      </TraceCollapsible>
      <TraceLine>
        <TraceIcon>
          <SearchIcon />
        </TraceIcon>
        <TraceTitle>Searched 214 accounts</TraceTitle>
      </TraceLine>
      <TraceLine>
        <TraceIcon>
          <MessageSquareIcon />
        </TraceIcon>
        <TraceTitle>Read 38 conversations</TraceTitle>
      </TraceLine>
      <TraceLine>
        <TraceIcon>
          <MailIcon />
        </TraceIcon>
        <TraceTitle>Sent 3 check-in emails</TraceTitle>
      </TraceLine>
    </Trace>
  );
}

Installation

pnpm dlx shadcn@4.18.0 add @siteplane/trace

Complete 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-card

Button 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
    └── TraceLine

A 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”.

Searched 214 accountsFailed to load the usage reportReading conversationsDraft 3 check-in emails
"use client";

import {
  CircleAlertIcon,
  CircleDashedIcon,
  MessageSquareIcon,
  SearchIcon,
} from "lucide-react";
import {
  Trace,
  TraceIcon,
  TraceLine,
  TraceTitle,
} from "@/siteplane/components/trace";

export default function Example() {
  return (
    <Trace className="max-w-md">
      <TraceLine>
        <TraceIcon>
          <SearchIcon />
        </TraceIcon>
        <TraceTitle>Searched 214 accounts</TraceTitle>
      </TraceLine>
      <TraceLine status="error">
        <TraceIcon>
          <CircleAlertIcon />
        </TraceIcon>
        <TraceTitle>Failed to load the usage report</TraceTitle>
      </TraceLine>
      <TraceLine status="running">
        <TraceIcon>
          <MessageSquareIcon />
        </TraceIcon>
        <TraceTitle>Reading conversations</TraceTitle>
      </TraceLine>
      <TraceLine status="pending">
        <TraceIcon>
          <CircleDashedIcon />
        </TraceIcon>
        <TraceTitle>Draft 3 check-in emails</TraceTitle>
      </TraceLine>
    </Trace>
  );
}

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.

Churn
"use client";

import { BrainIcon, RotateCcwIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { Button } from "@/siteplane/components/button";
import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleContent,
  TraceCollapsibleTrigger,
  TraceDescription,
  TraceIcon,
  TraceLine,
  TraceTitle,
} from "@/siteplane/components/trace";

const words =
  "Churn shows up as falling activity and questions nobody answered. I'll compare each account's weekly active seats over the last 12 weeks with its plan, then read the open support conversations of the accounts that dropped most.".split(
    " ",
  );

export default function Example() {
  // The reasoning arrives a word at a time, as a reasoning part streams.
  const [count, setCount] = useState(1);
  // The reader's choice, set once they open or close the step.
  const [open, setOpen] = useState<boolean>();
  const running = count < words.length;

  useEffect(() => {
    if (!running) return;
    const timer = setTimeout(() => setCount((current) => current + 1), 80);
    return () => clearTimeout(timer);
  }, [count, running]);

  return (
    <div className="grid w-full max-w-md justify-items-start gap-3">
      <Trace>
        {/* Open while the model thinks and closed when it is done, unless
            the reader has opened or closed it. */}
        <TraceCollapsible onOpenChange={setOpen} open={open ?? running}>
          <TraceCollapsibleTrigger>
            <TraceLine status={running ? "running" : undefined}>
              <TraceIcon>
                <BrainIcon />
              </TraceIcon>
              <TraceTitle>{running ? "Thinking" : "Thought"}</TraceTitle>
              {running ? null : <TraceDescription>3s</TraceDescription>}
            </TraceLine>
          </TraceCollapsibleTrigger>
          <TraceCollapsibleContent>
            {words.slice(0, count).join(" ")}
          </TraceCollapsibleContent>
        </TraceCollapsible>
      </Trace>
      <Button
        disabled={running}
        onClick={() => {
          setOpen(undefined);
          setCount(1);
        }}
        size="sm"
        variant="outline"
      >
        <RotateCcwIcon />
        Replay
      </Button>
    </div>
  );
}

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
}
Denied sending 3 emails
"use client";

import { BanIcon, CircleAlertIcon, SearchIcon } from "lucide-react";
import { CodeBlock } from "@/siteplane/components/code";
import { enableCodeHighlighting } from "@/siteplane/components/code/code-highlight";
import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleContent,
  TraceCollapsibleTrigger,
  TraceIcon,
  TraceLine,
  TraceTitle,
} from "@/siteplane/components/trace";

// Highlighting is optional. An app turns it on once, before the first Code
// Block renders.
enableCodeHighlighting();

const output = `{
  "plans": ["Growth", "Scale"],
  "activeSeats": "down 40% or more in 12 weeks",
  "matches": 9
}`;

const failure =
  "The conversations service did not answer within 30 seconds. Nothing was read.";

export default function Example() {
  return (
    <Trace className="max-w-md">
      <TraceCollapsible defaultOpen>
        <TraceCollapsibleTrigger>
          <TraceLine>
            <TraceIcon>
              <SearchIcon />
            </TraceIcon>
            <TraceTitle>Searched 214 accounts</TraceTitle>
          </TraceLine>
        </TraceCollapsibleTrigger>
        <TraceCollapsibleContent>
          <CodeBlock language="json" size="sm" title="Result" value={output} />
        </TraceCollapsibleContent>
      </TraceCollapsible>
      <TraceCollapsible>
        <TraceCollapsibleTrigger>
          <TraceLine status="error">
            <TraceIcon>
              <CircleAlertIcon />
            </TraceIcon>
            <TraceTitle>Failed to read conversations</TraceTitle>
          </TraceLine>
        </TraceCollapsibleTrigger>
        <TraceCollapsibleContent>
          <CodeBlock size="sm" value={failure} />
        </TraceCollapsibleContent>
      </TraceCollapsible>
      <TraceLine>
        <TraceIcon>
          <BanIcon />
        </TraceIcon>
        <TraceTitle>Denied sending 3 emails</TraceTitle>
      </TraceLine>
    </Trace>
  );
}

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.

Searched 214 accountsRead 38 conversations
Send 3 check-in emails?

To Halden Logistics, Brightwave Studio and Ortega Foods, from your address. You can edit the drafts first.

"use client";

import { MailIcon, MessageSquareIcon, SearchIcon } from "lucide-react";
import {
  Alert,
  AlertDescription,
  AlertTitle,
} from "@/siteplane/components/alert";
import { Button } from "@/siteplane/components/button";
import {
  Trace,
  TraceIcon,
  TraceLine,
  TraceTitle,
} from "@/siteplane/components/trace";

export default function Example() {
  return (
    <div className="grid w-full max-w-md gap-4">
      <Trace>
        <TraceLine>
          <TraceIcon>
            <SearchIcon />
          </TraceIcon>
          <TraceTitle>Searched 214 accounts</TraceTitle>
        </TraceLine>
        <TraceLine>
          <TraceIcon>
            <MessageSquareIcon />
          </TraceIcon>
          <TraceTitle>Read 38 conversations</TraceTitle>
        </TraceLine>
      </Trace>
      {/* A question for the person, not a step the agent took, so it sits
          outside the Trace. */}
      <Alert>
        <MailIcon aria-hidden="true" />
        <AlertTitle>Send 3 check-in emails?</AlertTitle>
        <AlertDescription>
          <p>
            To Halden Logistics, Brightwave Studio and Ortega Foods, from your
            address. You can edit the drafts first.
          </p>
          <div className="flex flex-wrap gap-2">
            <Button size="sm" variant="accent">
              Approve
            </Button>
            <Button size="sm" variant="outline">
              Deny
            </Button>
          </div>
        </AlertDescription>
      </Alert>
    </div>
  );
}

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".

"use client";

import { ArrowUpRightIcon, FileTextIcon, GlobeIcon } from "lucide-react";
import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleContent,
  TraceCollapsibleTrigger,
  TraceDescription,
  TraceIcon,
  TraceLine,
  TraceLink,
  TraceTitle,
} from "@/siteplane/components/trace";

const sources = [
  {
    host: "support.example.com",
    href: "https://support.example.com/conversations/4821",
    title: "Halden Logistics: Renewal questions",
  },
  {
    host: "analytics.example.com",
    href: "https://analytics.example.com/reports/usage-w43",
    title: "Usage report, week 43",
  },
  {
    host: "support.example.com",
    href: "https://support.example.com/conversations/4790",
    title: "Ortega Foods: Seats after the reorganization",
  },
];

export default function Example() {
  return (
    <Trace className="max-w-md">
      <TraceCollapsible defaultOpen>
        <TraceCollapsibleTrigger>
          <TraceLine>
            <TraceIcon>
              <FileTextIcon />
            </TraceIcon>
            <TraceTitle>Used {sources.length} sources</TraceTitle>
          </TraceLine>
        </TraceCollapsibleTrigger>
        <TraceCollapsibleContent>
          <Trace>
            {sources.map((source) => (
              // A link with target="_blank" tells screen readers that it
              // opens in a new tab; the arrow shows it.
              <TraceLink
                href={source.href}
                key={source.href}
                rel="noreferrer"
                target="_blank"
              >
                <TraceLine>
                  <TraceIcon>
                    <GlobeIcon />
                  </TraceIcon>
                  <TraceTitle>{source.title}</TraceTitle>
                  <TraceDescription>{source.host}</TraceDescription>
                  <TraceIcon>
                    <ArrowUpRightIcon />
                  </TraceIcon>
                </TraceLine>
              </TraceLink>
            ))}
          </Trace>
        </TraceCollapsibleContent>
      </TraceCollapsible>
    </Trace>
  );
}

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)

"use client";

import { Badge } from "@/siteplane/components/badge";
import {
  PreviewCard,
  PreviewCardPopup,
  PreviewCardTrigger,
} from "@/siteplane/components/preview-card";

type Source = {
  host: string;
  snippet: string;
  title: string;
  url: string;
};

const usageReport: Source = {
  host: "analytics.example.com",
  snippet:
    "Weekly active seats per account over the last 12 weeks, compared with the seats on the plan, for every Growth and Scale account.",
  title: "Usage report, week 43",
  url: "https://analytics.example.com/reports/usage-w43",
};

const supportThread: Source = {
  host: "support.example.com",
  snippet:
    "The customer asked on 14 October how seats are counted after a reorganization and has not had a reply since.",
  title: "Ortega Foods: Seats after the reorganization",
  url: "https://support.example.com/conversations/4790",
};

/** A source in the sentence it supports, previewed on hover and focus. */
function Citation({ source }: { source: Source }) {
  return (
    <PreviewCard>
      <PreviewCardTrigger
        href={source.url}
        render={
          <Badge render={<a />} shape="pill" size="sm" variant="secondary" />
        }
        rel="noreferrer"
        target="_blank"
      >
        {source.host}
        <span className="sr-only"> (opens in a new tab)</span>
      </PreviewCardTrigger>
      <PreviewCardPopup>
        <div className="grid min-w-0 gap-1">
          <p className="font-medium leading-5">{source.title}</p>
          <p className="line-clamp-2 text-muted-foreground text-xs leading-5">
            {source.snippet}
          </p>
          <p
            className="truncate text-muted-foreground text-xs leading-5"
            title={source.url}
          >
            {source.url}
          </p>
        </div>
      </PreviewCardPopup>
    </PreviewCard>
  );
}

export default function Example() {
  return (
    <div className="grid w-full max-w-md gap-3 text-sm leading-relaxed">
      <p>
        Accounts that lose more than 40% of their active seats within twelve
        weeks cancel far more often than steady ones.{" "}
        <Citation source={usageReport} />
      </p>
      <p>
        A conversation that waits longer than a week for a reply makes it worse,
        and Ortega Foods has one open for 15 days.{" "}
        <Citation source={supportThread} />
      </p>
    </div>
  );
}

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.

Read the usage report, week 43
"use client";

import {
  ChartLineIcon,
  ListChecksIcon,
  SearchIcon,
  TableIcon,
} from "lucide-react";
import { Badge } from "@/siteplane/components/badge";
import { CodeBlock } from "@/siteplane/components/code";
import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleContent,
  TraceCollapsibleTrigger,
  TraceIcon,
  TraceLine,
  TraceTitle,
} from "@/siteplane/components/trace";

const comparison = `Halden Logistics    41 → 17 seats
Brightwave Studio   18 →  8 seats
Ortega Foods        26 → 14 seats`;

export default function Example() {
  return (
    <Trace className="max-w-md">
      <TraceCollapsible defaultOpen>
        <TraceCollapsibleTrigger>
          <TraceLine>
            <TraceIcon>
              <ListChecksIcon />
            </TraceIcon>
            <TraceTitle>Researched in 3 steps</TraceTitle>
          </TraceLine>
        </TraceCollapsibleTrigger>
        {/* The steps keep their open state while the summary is closed. */}
        <TraceCollapsibleContent keepMounted>
          <Trace>
            <TraceCollapsible>
              <TraceCollapsibleTrigger>
                <TraceLine>
                  <TraceIcon>
                    <SearchIcon />
                  </TraceIcon>
                  <TraceTitle>Searched the web for churn benchmarks</TraceTitle>
                </TraceLine>
              </TraceCollapsibleTrigger>
              <TraceCollapsibleContent className="flex flex-wrap gap-1.5">
                <Badge size="sm" variant="secondary">
                  benchmarks.example.org
                </Badge>
                <Badge size="sm" variant="secondary">
                  research.example.com
                </Badge>
                <Badge size="sm" variant="secondary">
                  community.example.net
                </Badge>
              </TraceCollapsibleContent>
            </TraceCollapsible>
            <TraceLine>
              <TraceIcon>
                <TableIcon />
              </TraceIcon>
              <TraceTitle>Read the usage report, week 43</TraceTitle>
            </TraceLine>
            <TraceCollapsible>
              <TraceCollapsibleTrigger>
                <TraceLine>
                  <TraceIcon>
                    <ChartLineIcon />
                  </TraceIcon>
                  <TraceTitle>Compared seats by plan</TraceTitle>
                </TraceLine>
              </TraceCollapsibleTrigger>
              <TraceCollapsibleContent>
                <CodeBlock size="sm" value={comparison} />
              </TraceCollapsibleContent>
            </TraceCollapsible>
          </Trace>
        </TraceCollapsibleContent>
      </TraceCollapsible>
    </Trace>
  );
}

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 stateLineTitle
input-streaming, input-availablestatus="running"; status="error" once not livePresent tense, “Searching accounts”
approval-requestedNo line: the Alert after the Trace asksThe question, with Approve and Deny
approval-respondedstatus="running" if approved, done if denied“Sending 3 emails” or “Denied sending 3 emails”
output-availableDone, with output in a Code BlockPast tense, “Searched 214 accounts”
output-errorstatus="error", with errorText in the details“Failed to read conversations”
output-deniedDone, 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

Component properties
PropTypeDefaultDescription
TraceLine.statusTraceStatus: running | error | pendingunsetThe 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.
TraceTitle.title / TraceDescription.titlestringits textThe full text, shown when an ellipsis cuts it. It is taken from text children; pass it when the children hold elements.
TraceCollapsible.open / defaultOpenbooleanfalseControlled or initial open state of a step. Steps start closed.
TraceCollapsible.onOpenChange(open, eventDetails) => voidunsetCalled when the reader opens or closes the step.
TraceCollapsibleContent.keepMountedbooleanfalseKeeps the details mounted while closed, so nested steps keep their open state.
TraceLink.labelsPartial<TraceLinkLabels>EnglishReplaces 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".

Read the installed source.

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.