# Trace

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

Steps of a finished reply

[Complete TSX file](/examples/trace/default.tsx)

```tsx
"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

Complete [one-time setup](/docs/installation/) first.

```bash
pnpm dlx shadcn@4.18.0 add @siteplane/trace
```

With npm: `npx shadcn@4.18.0 add @siteplane/trace`. [Registry JSON](/registry/trace.json) lists source files and dependencies.

For this page’s examples, also install:

```bash
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](/docs/components/code/#syntax-highlighting); the tool example uses it for its JSON output.

## Usage

```tsx
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

```text
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”.

Steps in each status

[Complete TSX file](/examples/trace/status.tsx)

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

Reasoning that streams, then closes

[Complete TSX file](/examples/trace/reasoning.tsx)

```tsx
"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](/docs/components/code/) 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”.

Tool calls with their output, a failure and a denial

[Complete TSX file](/examples/trace/tool.tsx)

```tsx
"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](/docs/components/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.

Approval of a tool call

[Complete TSX file](/examples/trace/confirmation.tsx)

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

Sources with links that open in a new tab

[Complete TSX file](/examples/trace/sources.tsx)

```tsx
"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](/docs/components/badge/) that links to the source. A [Preview Card](/docs/components/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.

Inline citations with a preview

[Complete TSX file](/examples/trace/citation.tsx)

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

Research summary with its steps

[Complete TSX file](/examples/trace/chain-of-thought.tsx)

```tsx
"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](/docs/components/marker/) that says “Thinking”.

```tsx
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.

```tsx
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”                       |

```tsx
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.

```tsx
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.

```tsx
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 |
| --- | --- | --- | --- |
| TraceLine.status | 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. |
| TraceTitle.title / TraceDescription.title | 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. |
| TraceCollapsible.open / defaultOpen | boolean | false | Controlled or initial open state of a step. Steps start closed. |
| TraceCollapsible.onOpenChange | (open, eventDetails) => void | unset | Called when the reader opens or closes the step. |
| TraceCollapsibleContent.keepMounted | boolean | false | Keeps the details mounted while closed, so nested steps keep their open state. |
| TraceLink.labels | 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](/docs/components/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](/source/trace).

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