10.7k

Trace

PreviousNext

The building block for reasoning, tool calls, sources and more.

Why does pnpm install fail in CI?
 package.json | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

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.

<script setup lang="ts">
import {
  ArrowUpRightIcon,
  CircleAlertIcon,
  CircleCheckIcon,
  GlobeIcon,
  LoaderCircleIcon,
} from '@lucide/vue'
import { createReusableTemplate, promiseTimeout, tryOnScopeDispose } from '@vueuse/core'
import { computed, ref } from 'vue'
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'

interface TextPart {
  type: 'text'
  text: string
}

interface ReasoningPart {
  type: 'reasoning'
  text: string
  state: 'streaming' | 'done'
}

interface ToolPart {
  type: 'tool-runCommand'
  toolCallId: string
  input: { command: string }
  state: 'input-available' | 'output-available' | 'output-error'
  output?: { stdout: string }
  errorText?: string
}

interface SourcePart {
  type: 'source-url'
  url: string
  title?: string
}

type Part = TextPart | ReasoningPart | ToolPart | SourcePart

interface ChatMessage {
  id: string
  role: 'user' | 'assistant'
  parts: Part[]
}

const question = 'Why does pnpm install fail in CI?'
const 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.'
const tools = [
  { command: 'pnpm --version', delayMs: 700, stdout: '10.18.0' },
  {
    command: 'git diff --stat HEAD~1 -- package.json pnpm-lock.yaml',
    delayMs: 900,
    stdout: ' package.json | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-)',
  },
  {
    command: 'pnpm install --frozen-lockfile',
    delayMs: 1200,
    error: 'ERR_PNPM_OUTDATED_LOCKFILE  Cannot install with "frozen-lockfile" because pnpm-lock.yaml is not up to date with package.json',
  },
]
const sources = [
  {
    url: 'https://pnpm.io/cli/install#--frozen-lockfile',
    title: 'pnpm install | pnpm.io',
  },
  {
    url: 'https://pnpm.io/continuous-integration',
    title: 'Continuous Integration | pnpm.io',
  },
]
const answer = '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.'

// The finished conversation, shown before the first replay.
const transcript: ChatMessage[] = [
  { id: 'user-0', role: 'user', parts: [{ type: 'text', text: question }] },
  {
    id: 'assistant-0',
    role: 'assistant',
    parts: [
      { type: 'reasoning', text: reasoning, state: 'done' },
      ...tools.map((tool, index): ToolPart => ({
        type: 'tool-runCommand',
        toolCallId: `call-0-${index}`,
        input: { command: tool.command },
        ...(tool.error
          ? { state: 'output-error' as const, errorText: tool.error }
          : { state: 'output-available' as const, output: { stdout: tool.stdout! } }),
      })),
      ...sources.map((source): SourcePart => ({ type: 'source-url', ...source })),
      { type: 'text', text: answer },
    ],
  },
]

const messages = ref<ChatMessage[]>(transcript)
const status = ref<'ready' | 'streaming'>('ready')
const open = ref<Record<string, boolean | undefined>>({})

const [DefineThinkingLine, ThinkingLine] = createReusableTemplate<{ running: boolean }>()

const busy = computed(() => status.value === 'streaming')

// Simulated streaming. Swap for `new Chat()` from `@ai-sdk/vue` in your app.
let runId = 0

// Invalidate the running script on unmount so pending steps bail out.
tryOnScopeDispose(() => {
  runId++
})

async function stream(part: TextPart | ReasoningPart, text: string, delayMs: number, id: number) {
  for (const chunk of text.split(/(?<= )/)) {
    await promiseTimeout(delayMs)
    if (id !== runId)
      return false
    part.text += chunk
  }
  return true
}

async function replay() {
  const id = ++runId
  open.value = {}
  messages.value = [
    { id: `user-${id}`, role: 'user', parts: [{ type: 'text', text: question }] },
    { id: `assistant-${id}`, role: 'assistant', parts: [] },
  ]
  const message = messages.value.at(-1)!
  status.value = 'streaming'

  message.parts.push({ type: 'reasoning', text: '', state: 'streaming' })
  const thinking = message.parts.at(-1) as ReasoningPart
  if (!await stream(thinking, reasoning, 40, id))
    return
  thinking.state = 'done'

  for (const [index, tool] of tools.entries()) {
    message.parts.push({
      type: 'tool-runCommand',
      toolCallId: `call-${id}-${index}`,
      input: { command: tool.command },
      state: 'input-available',
    })
    const part = message.parts.at(-1) as ToolPart
    await promiseTimeout(tool.delayMs)
    if (id !== runId)
      return
    if (tool.error) {
      part.state = 'output-error'
      part.errorText = tool.error
    }
    else {
      part.state = 'output-available'
      part.output = { stdout: tool.stdout! }
    }
  }

  for (const source of sources)
    message.parts.push({ type: 'source-url', ...source })

  await promiseTimeout(1000)
  if (id !== runId)
    return

  message.parts.push({ type: 'text', text: '' })
  if (!await stream(message.parts.at(-1) as TextPart, answer, 20, id))
    return

  status.value = 'ready'
}

function getMessageText(message: ChatMessage) {
  return message.parts
    .map(part => (part.type === 'text' ? part.text : ''))
    .join('')
}

function getRows(message: ChatMessage) {
  return message.parts.filter(
    (part): part is ReasoningPart | ToolPart =>
      part.type === 'reasoning' || part.type === 'tool-runCommand',
  )
}

function getSources(message: ChatMessage) {
  return message.parts.filter(
    (part): part is SourcePart => part.type === 'source-url',
  )
}

function getHostname(url: string) {
  return new URL(url).hostname
}

function isStreaming(message: ChatMessage) {
  return busy.value && message === messages.value.at(-1)
}
</script>

<template>
  <DefineThinkingLine v-slot="{ running }">
    <TraceLine :status="running ? 'running' : undefined">
      <TraceIcon>
        <LoaderCircleIcon v-if="running" class="animate-spin" />
        <CircleCheckIcon v-else />
      </TraceIcon>
      <TraceTitle>
        {{ running ? 'Thinking' : 'Thought for 5s' }}
      </TraceTitle>
    </TraceLine>
  </DefineThinkingLine>
  <div class="flex min-h-72 w-full max-w-sm flex-col gap-4 py-6 text-sm">
    <template v-for="message in messages" :key="message.id">
      <Message v-if="message.role === 'user'" align="end">
        <MessageContent>
          <Bubble align="end" variant="muted">
            <BubbleContent>{{ getMessageText(message) }}</BubbleContent>
          </Bubble>
        </MessageContent>
      </Message>
      <Message v-else>
        <MessageContent class="gap-4">
          <Trace v-if="getRows(message).length + getSources(message).length > 0">
            <template
              v-for="(part, index) in getRows(message)"
              :key="part.type === 'tool-runCommand' ? part.toolCallId : index"
            >
              <!-- Thinking row -->
              <template v-if="part.type === 'reasoning'">
                <ThinkingLine
                  v-if="part.text.length === 0"
                  :running="isStreaming(message) && part.state === 'streaming'"
                />
                <TraceCollapsible
                  v-else
                  :open="open[`${message.id}-${index}`] ?? (isStreaming(message) && part.state === 'streaming')"
                  @update:open="open[`${message.id}-${index}`] = $event"
                >
                  <TraceCollapsibleTrigger>
                    <ThinkingLine :running="isStreaming(message) && part.state === 'streaming'" />
                  </TraceCollapsibleTrigger>
                  <TraceCollapsibleContent class="whitespace-pre-wrap">
                    {{ part.text }}
                  </TraceCollapsibleContent>
                </TraceCollapsible>
              </template>

              <!-- Tool row -->
              <template v-else-if="part.type === 'tool-runCommand'">
                <TraceCollapsible
                  v-if="part.state === 'output-available' || part.state === 'output-error'"
                  :default-open="index === 2"
                >
                  <TraceCollapsibleTrigger>
                    <TraceLine :status="part.state === 'output-error' ? 'error' : undefined">
                      <TraceIcon>
                        <CircleAlertIcon v-if="part.state === 'output-error'" />
                        <CircleCheckIcon v-else />
                      </TraceIcon>
                      <TraceTitle>
                        {{ part.state === 'output-error' ? 'Failed to run' : 'Ran' }} {{ part.input.command }}
                      </TraceTitle>
                    </TraceLine>
                  </TraceCollapsibleTrigger>
                  <TraceCollapsibleContent>
                    <CodeBlock v-if="part.state === 'output-error'" size="sm" variant="destructive">
                      <CodeBlockContent wrap class="max-h-64">
                        {{ part.errorText }}
                      </CodeBlockContent>
                    </CodeBlock>
                    <CodeBlock v-else size="sm">
                      <CodeBlockContent wrap class="max-h-64">
                        {{ part.output?.stdout }}
                      </CodeBlockContent>
                    </CodeBlock>
                  </TraceCollapsibleContent>
                </TraceCollapsible>
                <TraceLine v-else :status="isStreaming(message) ? 'running' : 'error'">
                  <TraceIcon>
                    <LoaderCircleIcon v-if="isStreaming(message)" class="animate-spin" />
                    <CircleAlertIcon v-else />
                  </TraceIcon>
                  <TraceTitle>
                    {{ isStreaming(message) ? 'Running' : 'Failed to run' }} {{ part.input.command }}
                  </TraceTitle>
                </TraceLine>
              </template>
            </template>

            <!-- Sources row -->
            <TraceCollapsible v-if="getSources(message).length > 0">
              <TraceCollapsibleTrigger>
                <TraceLine :status="isStreaming(message) && getMessageText(message).length === 0 ? 'running' : undefined">
                  <TraceIcon>
                    <LoaderCircleIcon v-if="isStreaming(message) && getMessageText(message).length === 0" class="animate-spin" />
                    <GlobeIcon v-else />
                  </TraceIcon>
                  <TraceTitle>
                    {{ isStreaming(message) && getMessageText(message).length === 0 ? 'Using' : 'Used' }} {{ getSources(message).length }} sources
                  </TraceTitle>
                </TraceLine>
              </TraceCollapsibleTrigger>
              <TraceCollapsibleContent>
                <Trace>
                  <a
                    v-for="source in getSources(message)"
                    :key="source.url"
                    :href="source.url"
                    target="_blank"
                    rel="noreferrer"
                  >
                    <TraceLine>
                      <TraceIcon>
                        <GlobeIcon />
                      </TraceIcon>
                      <TraceTitle>
                        {{ source.title ?? getHostname(source.url) }}
                        <span class="sr-only"> (opens in a new tab)</span>
                      </TraceTitle>
                      <TraceIcon>
                        <ArrowUpRightIcon />
                      </TraceIcon>
                    </TraceLine>
                  </a>
                </Trace>
              </TraceCollapsibleContent>
            </TraceCollapsible>
          </Trace>
          <p v-if="getMessageText(message)">
            {{ getMessageText(message) }}
          </p>
        </MessageContent>
      </Message>
    </template>
    <Button
      v-if="!busy"
      variant="outline"
      size="sm"
      class="mt-auto self-start"
      @click="replay"
    >
      Replay
    </Button>
  </div>
</template>

Trace is a list of lines. Each line has an icon, a title, and an optional description and status. A line can expand to show more detail, link to a page, or open a dialog or sheet.

Every line has the same shape, so reasoning, tool calls, sources and plans built from Trace look and behave the same.

Use Trace for the steps an agent takes, like calling a tool, running a subagent or editing a file. Use Message for its reply, and Marker for notes and dividers between turns.

Built with Trace

Every pattern below is a composition of Trace parts. See AI Elements for examples and code.

PatternShows
Chain of ThoughtA summary that expands into the steps behind it.
PlanThe steps the agent plans to take, and how far it has got.
QueueThe agent's todo list, split into what is left and what is done.
ReasoningThe agent's thinking as it streams.
SourcesThe links the agent used for a reply.
Stack TraceA failed tool call and its stack frames.
SubagentA subagent run, with its tool calls and result.
TaskThe steps the agent takes, as it takes them.
TerminalA command the agent runs, with its output as it streams.
Test ResultsA test run by the agent, with its suites and failures.
ToolA tool call the agent makes, from input to output.

Installation

pnpm dlx shadcn-vue@latest add trace

Usage

Use a TraceLine for a single step:

<script setup lang="ts">
import { SearchIcon } from '@lucide/vue'
import { Trace, TraceIcon, TraceLine, TraceTitle } from '@/components/ui/trace'
</script>

<template>
  <Trace>
    <TraceLine>
      <TraceIcon>
        <SearchIcon />
      </TraceIcon>
      <TraceTitle>Searched for minimumReleaseAge</TraceTitle>
    </TraceLine>
  </Trace>
</template>

Wrap the line in a TraceCollapsible to show more detail when it expands:

<script setup lang="ts">
import { TerminalIcon } from '@lucide/vue'
import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleContent,
  TraceCollapsibleTrigger,
  TraceIcon,
  TraceLine,
  TraceTitle,
} from '@/components/ui/trace'
</script>

<template>
  <Trace>
    <TraceCollapsible>
      <TraceCollapsibleTrigger>
        <TraceLine>
          <TraceIcon>
            <TerminalIcon />
          </TraceIcon>
          <TraceTitle>Ran node --version</TraceTitle>
        </TraceLine>
      </TraceCollapsibleTrigger>
      <TraceCollapsibleContent>v24.18.0</TraceCollapsibleContent>
    </TraceCollapsible>
  </Trace>
</template>

Composition

Use the following composition to build a trace line:

Trace
└── TraceLine
    ├── TraceIcon
    ├── TraceTitle
    ├── TraceDescription
    └── TraceIcon

Wrap the line in a TraceCollapsible to show more detail when it expands:

Trace
└── TraceCollapsible
    ├── TraceCollapsibleTrigger
    │   └── TraceLine
    └── TraceCollapsibleContent

TraceLine is always the line. To make a line do something, wrap it in that thing's trigger.

Features

  • One row for reasoning, tool calls, sources, plans and tasks
  • Rows that expand, link out, or open a dialog, sheet, drawer or popover
  • running, error and pending states, with a shimmer while running
  • Nested rows, so a summary expands into steps and a step into its output
  • Works with reasoning, tool and source parts from the AI SDK
  • Keyboard and screen reader support from the trigger that wraps each row
  • Customizable styling through the class prop on every part

Trace Line

Use a TraceLine on its own for a step with no detail to show. It reads as plain text.

Created branch fix/frozen-lockfile
<script setup lang="ts">
import { GitBranchIcon } from '@lucide/vue'
import {
  Trace,
  TraceIcon,
  TraceLine,
  TraceTitle,
} from '@/components/ui/trace'
</script>

<template>
  <Trace class="max-w-sm">
    <TraceLine>
      <TraceIcon>
        <GitBranchIcon />
      </TraceIcon>
      <TraceTitle>Created branch fix/frozen-lockfile</TraceTitle>
    </TraceLine>
  </Trace>
</template>

Status

Set status on TraceLine when a step is running, has failed or hasn't started yet.

Ran pnpm installFailed to run pnpm lintRunning pnpm buildRun pnpm test
<script setup lang="ts">
import {
  CircleAlertIcon,
  CircleCheckIcon,
  CircleIcon,
  LoaderCircleIcon,
} from '@lucide/vue'
import {
  Trace,
  TraceIcon,
  TraceLine,
  TraceTitle,
} from '@/components/ui/trace'
</script>

<template>
  <Trace class="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 class="animate-spin" />
      </TraceIcon>
      <TraceTitle>Running pnpm build</TraceTitle>
    </TraceLine>
    <TraceLine status="pending">
      <TraceIcon>
        <CircleIcon />
      </TraceIcon>
      <TraceTitle>Run pnpm test</TraceTitle>
    </TraceLine>
  </Trace>
</template>
StatusUse for
noneWork 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".

Description

Put secondary text such as a diff stat, a duration or a count in a TraceDescription after the TraceTitle. It does not truncate.

Edited components/settings/NotificationPreferencesForm.vue +48 −12 Ran pnpm test14sSearched for useChat8 results
<script setup lang="ts">
import { FilePenIcon, SearchIcon, TerminalIcon } from '@lucide/vue'
import {
  Trace,
  TraceDescription,
  TraceIcon,
  TraceLine,
  TraceTitle,
} from '@/components/ui/trace'
</script>

<template>
  <Trace class="max-w-sm">
    <TraceLine>
      <TraceIcon>
        <FilePenIcon />
      </TraceIcon>
      <TraceTitle>
        Edited components/settings/NotificationPreferencesForm.vue
      </TraceTitle>
      <TraceDescription class="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>
</template>

Collapsible

Wrap the line in a TraceCollapsible to show more detail when it expands. Put the TraceLine inside TraceCollapsibleTrigger and the detail in TraceCollapsibleContent.

<script setup lang="ts">
import { TerminalIcon } from '@lucide/vue'
import { CodeBlock, CodeBlockContent } from '@/components/ui/code-block'
import {
  TraceCollapsible,
  TraceCollapsibleContent,
  TraceCollapsibleTrigger,
  TraceIcon,
  TraceLine,
  TraceTitle,
} from '@/components/ui/trace'
</script>

<template>
  <div class="w-full max-w-sm">
    <TraceCollapsible>
      <TraceCollapsibleTrigger>
        <TraceLine>
          <TraceIcon>
            <TerminalIcon />
          </TraceIcon>
          <TraceTitle>Ran node --version</TraceTitle>
        </TraceLine>
      </TraceCollapsibleTrigger>
      <TraceCollapsibleContent>
        <CodeBlock size="sm">
          <CodeBlockContent wrap class="max-h-64">
            {{ '$ node --version\nv24.18.0' }}
          </CodeBlockContent>
        </CodeBlock>
      </TraceCollapsibleContent>
    </TraceCollapsible>
  </div>
</template>

Open State

Rows start closed. Use default-open on TraceCollapsible to start open, or v-model:open to control a row.

$ node --version
v24.18.0
<script setup lang="ts">
import { TerminalIcon } from '@lucide/vue'
import { useToggle } from '@vueuse/core'
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'

const [open, toggleOpen] = useToggle(false)
</script>

<template>
  <div class="flex w-full max-w-sm flex-col items-start gap-4">
    <TraceCollapsible default-open>
      <TraceCollapsibleTrigger>
        <TraceLine>
          <TraceIcon>
            <TerminalIcon />
          </TraceIcon>
          <TraceTitle>Ran node --version</TraceTitle>
        </TraceLine>
      </TraceCollapsibleTrigger>
      <TraceCollapsibleContent>
        <CodeBlock size="sm">
          <CodeBlockContent wrap class="max-h-64">
            {{ '$ node --version\nv24.18.0' }}
          </CodeBlockContent>
        </CodeBlock>
      </TraceCollapsibleContent>
    </TraceCollapsible>
    <TraceCollapsible v-model:open="open">
      <TraceCollapsibleTrigger>
        <TraceLine>
          <TraceIcon>
            <TerminalIcon />
          </TraceIcon>
          <TraceTitle>Ran pnpm --version</TraceTitle>
        </TraceLine>
      </TraceCollapsibleTrigger>
      <TraceCollapsibleContent>
        <CodeBlock size="sm">
          <CodeBlockContent wrap class="max-h-64">
            {{ '$ pnpm --version\n10.33.4' }}
          </CodeBlockContent>
        </CodeBlock>
      </TraceCollapsibleContent>
    </TraceCollapsible>
    <Button variant="outline" size="sm" @click="toggleOpen()">
      {{ open ? 'Hide' : 'Show' }} the pnpm output
    </Button>
  </div>
</template>

To open a row while it streams and let the reader's click win, pass open ?? running.

<script setup lang="ts">
import { ref } from 'vue'

const props = defineProps<{ text: string, running: boolean }>()

const open = ref<boolean>()
</script>

<template>
  <TraceCollapsible
    :open="open ?? props.running"
    @update:open="open = $event"
  >
    <TraceCollapsibleTrigger>
      <TraceLine :status="props.running ? 'running' : undefined">
        …
      </TraceLine>
    </TraceCollapsibleTrigger>
    <TraceCollapsibleContent>{{ props.text }}</TraceCollapsibleContent>
  </TraceCollapsible>
</template>

Wrap the TraceLine in an anchor. For a new tab, use an arrow-up-right icon and sr-only "(opens in a new tab)".

<script setup lang="ts">
import {
  ArrowUpRightIcon,
  ChevronRightIcon,
  FileTextIcon,
  GlobeIcon,
} from '@lucide/vue'
import {
  Trace,
  TraceIcon,
  TraceLine,
  TraceTitle,
} from '@/components/ui/trace'
</script>

<template>
  <Trace class="max-w-sm">
    <a href="#link">
      <TraceLine>
        <TraceIcon>
          <FileTextIcon />
        </TraceIcon>
        <TraceTitle>Read apps/v4/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 class="sr-only"> (opens in a new tab)</span>
        </TraceTitle>
        <TraceIcon>
          <ArrowUpRightIcon />
        </TraceIcon>
      </TraceLine>
    </a>
  </Trace>
</template>
<template>
  <a href="https://docs.astro.build" target="_blank" rel="noreferrer">
    <TraceLine>
      <TraceTitle>
        Install Astro
        <span class="sr-only"> (opens in a new tab)</span>
      </TraceTitle>
      <TraceIcon>
        <ArrowUpRightIcon />
      </TraceIcon>
    </TraceLine>
  </a>
</template>

Dialog

Wrap the TraceLine in the dialog's trigger. The dialog owns its open state, Escape and focus return.

<script setup lang="ts">
import { ArrowUpRightIcon, ChevronRightIcon, GlobeIcon } from '@lucide/vue'
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',
  },
]
</script>

<template>
  <Trace class="max-w-sm">
    <Dialog>
      <DialogTrigger as-child>
        <button type="button">
          <TraceLine>
            <TraceIcon>
              <GlobeIcon />
            </TraceIcon>
            <TraceTitle>Searched the web for cache keys</TraceTitle>
            <TraceIcon>
              <ChevronRightIcon />
            </TraceIcon>
          </TraceLine>
        </button>
      </DialogTrigger>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>Searched the web for cache keys</DialogTitle>
          <DialogDescription>
            Three results were used in the response.
          </DialogDescription>
        </DialogHeader>
        <Trace>
          <a
            v-for="result in results"
            :key="result.url"
            :href="result.url"
            target="_blank"
            rel="noreferrer"
          >
            <TraceLine>
              <TraceIcon>
                <GlobeIcon />
              </TraceIcon>
              <TraceTitle>
                {{ result.title }}
                <span class="sr-only"> (opens in a new tab)</span>
              </TraceTitle>
              <TraceIcon>
                <ArrowUpRightIcon />
              </TraceIcon>
            </TraceLine>
          </a>
        </Trace>
      </DialogContent>
    </Dialog>
  </Trace>
</template>
<template>
  <Dialog>
    <DialogTrigger as-child>
      <button type="button">
        <TraceLine>
          <TraceTitle>Searched the web for cache keys</TraceTitle>
          <TraceIcon>
            <ChevronRightIcon />
          </TraceIcon>
        </TraceLine>
      </button>
    </DialogTrigger>
    <DialogContent>…</DialogContent>
  </Dialog>
</template>

Sheet

The same shape as the dialog. Drawer and Popover work the same way.

<script setup lang="ts">
import { ChevronRightIcon, FolderSearchIcon } from '@lucide/vue'
import {
  Sheet,
  SheetContent,
  SheetDescription,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from '@/components/ui/sheet'
import {
  Trace,
  TraceIcon,
  TraceLine,
  TraceTitle,
} from '@/components/ui/trace'
</script>

<template>
  <Trace class="max-w-sm">
    <Sheet>
      <SheetTrigger as-child>
        <button type="button">
          <TraceLine>
            <TraceIcon>
              <FolderSearchIcon />
            </TraceIcon>
            <TraceTitle>Explored 4 files</TraceTitle>
            <TraceIcon>
              <ChevronRightIcon />
            </TraceIcon>
          </TraceLine>
        </button>
      </SheetTrigger>
      <SheetContent>
        <SheetHeader>
          <SheetTitle>Explored 4 files</SheetTitle>
          <SheetDescription>
            Files read while preparing the response.
          </SheetDescription>
        </SheetHeader>
        <ul class="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/nuxt.config.ts</li>
          <li>apps/v4/package.json</li>
        </ul>
      </SheetContent>
    </Sheet>
  </Trace>
</template>

Group

Put rows that sit next to each other in a Trace. It also styles any trigger or link that wraps a TraceLine.

Searched for minimumReleaseAgeRead pnpm-workspace.yamlRan pnpm install
<script setup lang="ts">
import { FileTextIcon, SearchIcon, TerminalIcon } from '@lucide/vue'
import {
  Trace,
  TraceIcon,
  TraceLine,
  TraceTitle,
} from '@/components/ui/trace'
</script>

<template>
  <Trace class="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>
</template>

Sources

A collapsible row whose detail is a Trace of link rows.

<script setup lang="ts">
import { ArrowUpRightIcon, GlobeIcon } from '@lucide/vue'
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',
  },
]
</script>

<template>
  <div class="w-full max-w-sm">
    <TraceCollapsible default-open>
      <TraceCollapsibleTrigger>
        <TraceLine>
          <TraceIcon>
            <GlobeIcon />
          </TraceIcon>
          <TraceTitle>Used {{ sources.length }} sources</TraceTitle>
        </TraceLine>
      </TraceCollapsibleTrigger>
      <TraceCollapsibleContent>
        <Trace>
          <a
            v-for="source in sources"
            :key="source.href"
            :href="source.href"
            target="_blank"
            rel="noreferrer"
          >
            <TraceLine>
              <TraceIcon>
                <GlobeIcon />
              </TraceIcon>
              <TraceTitle>
                {{ source.title }}
                <span class="sr-only"> (opens in a new tab)</span>
              </TraceTitle>
              <TraceIcon>
                <ArrowUpRightIcon />
              </TraceIcon>
            </TraceLine>
          </a>
        </Trace>
      </TraceCollapsibleContent>
    </TraceCollapsible>
  </div>
</template>

See Sources for more examples.

Chain of Thought

A chain of thought is a collapsible row with step rows inside. Set the parent's status and title from its children.

Read the last twelve deploys
<script setup lang="ts">
import { ChevronRightIcon, FileTextIcon, SearchIcon } from '@lucide/vue'
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'
</script>

<template>
  <div class="w-full max-w-sm">
    <TraceCollapsible default-open :unmount-on-hide="false">
      <TraceCollapsibleTrigger>
        <TraceLine status="running">
          <TraceTitle>Looking into the slow deploy</TraceTitle>
        </TraceLine>
      </TraceCollapsibleTrigger>
      <TraceCollapsibleContent>
        <Trace>
          <TraceCollapsible>
            <TraceCollapsibleTrigger>
              <TraceLine>
                <TraceIcon>
                  <SearchIcon />
                </TraceIcon>
                <TraceTitle>Searched for the checkout build log</TraceTitle>
              </TraceLine>
            </TraceCollapsibleTrigger>
            <TraceCollapsibleContent class="flex flex-wrap gap-1.5">
              <Badge variant="secondary">
                vercel.com
              </Badge>
              <Badge variant="secondary">
                github.com
              </Badge>
            </TraceCollapsibleContent>
          </TraceCollapsible>
          <a href="#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 class="max-h-64">
                  cache key image optimization
                </CodeBlockContent>
              </CodeBlock>
            </TraceCollapsibleContent>
          </TraceCollapsible>
        </Trace>
      </TraceCollapsibleContent>
    </TraceCollapsible>
  </div>
</template>

See Chain of Thought and Task for more examples.

Commit

A commit is a collapsible row with a line 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.

<script setup lang="ts">
import {
  ChevronRightIcon,
  FilePenIcon,
  FilePlusIcon,
  GitCommitHorizontalIcon,
} from '@lucide/vue'
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: 'pages/login.vue',
    added: false,
    additions: 2,
    deletions: 2,
    diff: `@@ -1,6 +1,6 @@
 <script setup lang="ts">
 const route = useRoute()
 const next = route.query.next as string | undefined
-const redirectTo =
-  next && next.startsWith('/') ? next : '/'
+const redirectTo = safeRedirect(next)
+
 <\/script>`,
  },
  {
    path: 'utils/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: string) {
  return diff.split('\n').map((line) => {
    if (line.startsWith('@@'))
      return { hunk: true, variant: 'default' as const, text: line }

    return {
      hunk: false,
      variant: line.startsWith('+')
        ? 'added' as const
        : line.startsWith('-')
          ? 'removed' as const
          : 'default' as const,
      text: line.slice(1),
    }
  })
}
</script>

<template>
  <Trace class="max-w-sm">
    <TraceCollapsible default-open>
      <TraceCollapsibleTrigger>
        <TraceLine>
          <TraceIcon>
            <GitCommitHorizontalIcon />
          </TraceIcon>
          <TraceTitle>Committed a1b2c3: Fix the login redirect</TraceTitle>
        </TraceLine>
      </TraceCollapsibleTrigger>
      <TraceCollapsibleContent>
        <Trace>
          <Sheet v-for="file in files" :key="file.path">
            <SheetTrigger as-child>
              <button type="button">
                <TraceLine>
                  <TraceIcon>
                    <FilePlusIcon v-if="file.added" />
                    <FilePenIcon v-else />
                  </TraceIcon>
                  <TraceTitle>
                    {{ file.added ? 'Added' : 'Edited' }} {{ file.path }}
                  </TraceTitle>
                  <TraceDescription class="font-mono">
                    +{{ file.additions }} −{{ file.deletions }}
                  </TraceDescription>
                  <TraceIcon>
                    <ChevronRightIcon />
                  </TraceIcon>
                </TraceLine>
              </button>
            </SheetTrigger>
            <SheetContent>
              <SheetHeader>
                <SheetTitle class="font-mono">
                  {{ file.path }}
                </SheetTitle>
                <SheetDescription>
                  {{ file.additions }} additions, {{ file.deletions }} deletions
                </SheetDescription>
              </SheetHeader>
              <CodeBlock class="mx-6 mb-6 w-auto">
                <CodeBlockContent>
                  <CodeBlockLine
                    v-for="(line, index) in diffLines(file.diff)"
                    :key="index"
                    :variant="line.variant"
                    :class="line.hunk ? 'text-muted-foreground' : undefined"
                  >
                    {{ line.text }}
                  </CodeBlockLine>
                </CodeBlockContent>
              </CodeBlock>
            </SheetContent>
          </Sheet>
        </Trace>
      </TraceCollapsibleContent>
    </TraceCollapsible>
  </Trace>
</template>

Whole Turn

Three levels: the turn, a summary, and the rows. Text between steps sits between two Trace lists.

Ran node --version

Node is installed and pnpm is not. Reading the docs config.

<script setup lang="ts">
import {
  BrainIcon,
  ChevronRightIcon,
  FileTextIcon,
  TerminalIcon,
} from '@lucide/vue'
import { CodeBlock, CodeBlockContent } from '@/components/ui/code-block'
import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleContent,
  TraceCollapsibleTrigger,
  TraceDescription,
  TraceIcon,
  TraceLine,
  TraceTitle,
} from '@/components/ui/trace'
</script>

<template>
  <div class="w-full max-w-sm">
    <TraceCollapsible default-open :unmount-on-hide="false">
      <TraceCollapsibleTrigger>
        <TraceLine status="error">
          <TraceTitle>Worked for 2m 4s, 1 command failed</TraceTitle>
        </TraceLine>
      </TraceCollapsibleTrigger>
      <TraceCollapsibleContent class="flex flex-col gap-2">
        <Trace>
          <TraceCollapsible>
            <TraceCollapsibleTrigger>
              <TraceLine>
                <TraceIcon>
                  <BrainIcon />
                </TraceIcon>
                <TraceTitle>Thought</TraceTitle>
                <TraceDescription>12s</TraceDescription>
              </TraceLine>
            </TraceCollapsibleTrigger>
            <TraceCollapsibleContent class="whitespace-pre-wrap">
              I should check which package managers are installed before I
              scaffold anything.
            </TraceCollapsibleContent>
          </TraceCollapsible>
          <TraceCollapsible default-open :unmount-on-hide="false">
            <TraceCollapsibleTrigger>
              <TraceLine status="error">
                <TraceIcon>
                  <TerminalIcon />
                </TraceIcon>
                <TraceTitle>Ran 2 commands, 1 failed</TraceTitle>
              </TraceLine>
            </TraceCollapsibleTrigger>
            <TraceCollapsibleContent>
              <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 class="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="#turn">
            <TraceLine>
              <TraceIcon>
                <FileTextIcon />
              </TraceIcon>
              <TraceTitle>Read apps/v4/lib/docs.ts</TraceTitle>
              <TraceIcon>
                <ChevronRightIcon />
              </TraceIcon>
            </TraceLine>
          </a>
        </Trace>
      </TraceCollapsibleContent>
    </TraceCollapsible>
  </div>
</template>

Reasoning

Render a reasoning part from the AI SDK. The row is a TraceLine until the first text arrives. See Open State.

<script setup lang="ts">
import { BrainIcon } from '@lucide/vue'
import { createReusableTemplate, promiseTimeout, tryOnScopeDispose } from '@vueuse/core'
import { computed, ref } from 'vue'
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'

interface Part {
  type: 'reasoning' | 'text'
  text: string
  state: 'streaming' | 'done'
}

interface ChatMessage {
  id: string
  role: 'user' | 'assistant'
  parts: Part[]
}

const question = 'Why is the checkout deploy so slow?'
const 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.'
const answer = 'Image optimization is the bottleneck. Key its cache on the lockfile and the image directory instead of the commit hash.'

const messages = ref<ChatMessage[]>([])
const status = ref<'ready' | 'streaming'>('ready')
const open = ref<Record<string, boolean | undefined>>({})

const [DefineThinkingLine, ThinkingLine] = createReusableTemplate<{ running: boolean }>()

const busy = computed(() => status.value === 'streaming')
const nextMessage = computed(() => messages.value.length === 0 ? question : undefined)

// Simulated streaming. Swap for `new Chat()` from `@ai-sdk/vue` in your app.
let runId = 0

// Invalidate the running script on unmount so pending steps bail out.
tryOnScopeDispose(() => {
  runId++
})

async function stream(part: Part, text: string, delayMs: number, id: number) {
  for (const chunk of text.split(/(?<= )/)) {
    await promiseTimeout(delayMs)
    if (id !== runId)
      return false
    part.text += chunk
  }
  part.state = 'done'
  return true
}

async function sendMessage(text: string) {
  const id = ++runId
  messages.value.push({
    id: `user-${id}`,
    role: 'user',
    parts: [{ type: 'text', text, state: 'done' }],
  })
  messages.value.push({ id: `assistant-${id}`, role: 'assistant', parts: [] })
  const message = messages.value.at(-1)!
  status.value = 'streaming'

  message.parts.push({ type: 'reasoning', text: '', state: 'streaming' })
  if (!await stream(message.parts.at(-1)!, reasoning, 60, id))
    return
  await promiseTimeout(400)
  if (id !== runId)
    return
  message.parts.push({ type: 'text', text: '', state: 'streaming' })
  if (!await stream(message.parts.at(-1)!, answer, 20, id))
    return

  status.value = 'ready'
}

function reset() {
  runId++
  messages.value = []
  open.value = {}
  status.value = 'ready'
}

function getMessageText(message: ChatMessage) {
  return message.parts
    .filter(part => part.type === 'text')
    .map(part => part.text)
    .join('')
}

function groupParts(parts: Part[]) {
  const runs: { trace: boolean, parts: Part[] }[] = []

  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
}

function isLive(message: ChatMessage) {
  return status.value === 'streaming' && message === messages.value.at(-1)
}
</script>

<template>
  <DefineThinkingLine v-slot="{ running }">
    <TraceLine :status="running ? 'running' : undefined">
      <TraceIcon>
        <BrainIcon />
      </TraceIcon>
      <TraceTitle>
        {{ running ? 'Thinking' : 'Thought' }}
      </TraceTitle>
    </TraceLine>
  </DefineThinkingLine>
  <div class="flex min-h-72 w-full max-w-sm flex-col gap-4 py-6 text-sm">
    <template v-for="message in messages" :key="message.id">
      <Message v-if="message.role === 'user'" align="end">
        <MessageContent>
          <Bubble align="end" variant="muted">
            <BubbleContent>{{ getMessageText(message) }}</BubbleContent>
          </Bubble>
        </MessageContent>
      </Message>
      <Message v-else>
        <MessageContent class="gap-4">
          <template v-for="(run, runIndex) in groupParts(message.parts)" :key="runIndex">
            <Trace v-if="run.trace">
              <template v-for="(part, index) in run.parts" :key="index">
                <template v-if="part.type === 'reasoning'">
                  <ThinkingLine
                    v-if="part.text.length === 0"
                    :running="isLive(message) && part.state === 'streaming'"
                  />
                  <TraceCollapsible
                    v-else
                    :open="open[`${message.id}-${runIndex}-${index}`] ?? (isLive(message) && part.state === 'streaming')"
                    @update:open="open[`${message.id}-${runIndex}-${index}`] = $event"
                  >
                    <TraceCollapsibleTrigger>
                      <ThinkingLine :running="isLive(message) && part.state === 'streaming'" />
                    </TraceCollapsibleTrigger>
                    <TraceCollapsibleContent class="whitespace-pre-wrap">
                      {{ part.text }}
                    </TraceCollapsibleContent>
                  </TraceCollapsible>
                </template>
              </template>
            </Trace>
            <template v-else>
              <p v-for="(part, index) in run.parts" :key="`${runIndex}-${index}`">
                {{ part.text }}
              </p>
            </template>
          </template>
        </MessageContent>
      </Message>
    </template>
    <Button
      v-if="!busy"
      variant="outline"
      size="sm"
      class="mt-auto self-start"
      @click="nextMessage ? sendMessage(nextMessage) : reset()"
    >
      {{ nextMessage ? 'Send message' : 'Reset' }}
    </Button>
  </div>
</template>

See Reasoning for more examples.

Tool

Render tool parts from the AI SDK. Map the tool part as in Tool.

<script setup lang="ts">
import { TerminalIcon } from '@lucide/vue'
import { promiseTimeout, tryOnScopeDispose } from '@vueuse/core'
import { computed, ref } from 'vue'
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'

interface TextPart {
  type: 'text'
  text: string
}

interface ToolPart {
  type: 'dynamic-tool'
  toolCallId: string
  input: { command: string }
  state: 'input-available' | 'output-available' | 'output-error' | 'output-denied'
  output?: { stdout: string }
  errorText?: string
}

type Part = TextPart | ToolPart

interface ChatMessage {
  id: string
  role: 'user' | 'assistant'
  parts: Part[]
}

const question = 'Which package managers are installed?'
const tools = [
  { command: 'node --version', stdout: 'v24.18.0' },
  { command: 'npm --version', stdout: '11.6.2' },
  { command: 'pnpm --version', error: 'command not found: pnpm' },
]
const answer = 'Node 24 and npm 11 are installed. pnpm is not.'

const messages = ref<ChatMessage[]>([])
const status = ref<'ready' | 'streaming'>('ready')

const busy = computed(() => status.value === 'streaming')
const nextMessage = computed(() => messages.value.length === 0 ? question : undefined)

// Simulated streaming. Swap for `new Chat()` from `@ai-sdk/vue` in your app.
let runId = 0

// Invalidate the running script on unmount so pending steps bail out.
tryOnScopeDispose(() => {
  runId++
})

async function sendMessage(text: string) {
  const id = ++runId
  messages.value.push({
    id: `user-${id}`,
    role: 'user',
    parts: [{ type: 'text', text }],
  })
  messages.value.push({ id: `assistant-${id}`, role: 'assistant', parts: [] })
  const message = messages.value.at(-1)!
  status.value = 'streaming'

  for (const [index, tool] of tools.entries()) {
    message.parts.push({
      type: 'dynamic-tool',
      toolCallId: `call-${id}-${index}`,
      input: { command: tool.command },
      state: 'input-available',
    })
    const part = message.parts.at(-1) as ToolPart
    await promiseTimeout(900)
    if (id !== runId)
      return
    if (tool.error) {
      part.state = 'output-error'
      part.errorText = tool.error
    }
    else {
      part.state = 'output-available'
      part.output = { stdout: tool.stdout! }
    }
  }

  message.parts.push({ type: 'text', text: '' })
  const part = message.parts.at(-1) as TextPart
  for (const chunk of answer.split(/(?<= )/)) {
    await promiseTimeout(20)
    if (id !== runId)
      return
    part.text += chunk
  }

  status.value = 'ready'
}

function reset() {
  runId++
  messages.value = []
  status.value = 'ready'
}

function getMessageText(message: ChatMessage) {
  return message.parts
    .map(part => (part.type === 'text' ? part.text : ''))
    .join('')
}

function groupParts(parts: Part[]) {
  const runs: { trace: boolean, parts: Part[] }[] = []

  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
}

function isLive(message: ChatMessage) {
  return status.value === 'streaming' && message === messages.value.at(-1)
}
</script>

<template>
  <div class="flex min-h-72 w-full max-w-sm flex-col gap-4 py-6 text-sm">
    <template v-for="message in messages" :key="message.id">
      <Message v-if="message.role === 'user'" align="end">
        <MessageContent>
          <Bubble align="end" variant="muted">
            <BubbleContent>{{ getMessageText(message) }}</BubbleContent>
          </Bubble>
        </MessageContent>
      </Message>
      <Message v-else>
        <MessageContent class="gap-4">
          <template v-for="(run, runIndex) in groupParts(message.parts)" :key="runIndex">
            <Trace v-if="run.trace">
              <template v-for="(part, index) in run.parts" :key="part.type === 'dynamic-tool' ? part.toolCallId : index">
                <template v-if="part.type === 'dynamic-tool'">
                  <TraceCollapsible
                    v-if="part.state === 'output-available' || part.state === 'output-error'"
                  >
                    <TraceCollapsibleTrigger>
                      <TraceLine :status="part.state === 'output-error' ? 'error' : undefined">
                        <TraceIcon>
                          <TerminalIcon />
                        </TraceIcon>
                        <TraceTitle>
                          {{ part.state === 'output-error' ? 'Failed to run' : 'Ran' }} {{ part.input.command }}
                        </TraceTitle>
                      </TraceLine>
                    </TraceCollapsibleTrigger>
                    <TraceCollapsibleContent>
                      <CodeBlock size="sm">
                        <CodeBlockContent wrap class="max-h-64">
                          {{ part.state === 'output-error' ? part.errorText : part.output?.stdout }}
                        </CodeBlockContent>
                      </CodeBlock>
                    </TraceCollapsibleContent>
                  </TraceCollapsible>
                  <TraceLine v-else-if="part.state === 'output-denied'">
                    <TraceIcon>
                      <TerminalIcon />
                    </TraceIcon>
                    <TraceTitle>Denied {{ part.input.command }}</TraceTitle>
                  </TraceLine>
                  <TraceLine v-else :status="isLive(message) ? 'running' : 'error'">
                    <TraceIcon>
                      <TerminalIcon />
                    </TraceIcon>
                    <TraceTitle>
                      {{ isLive(message) ? 'Running' : 'Failed to run' }} {{ part.input.command }}
                    </TraceTitle>
                  </TraceLine>
                </template>
              </template>
            </Trace>
            <template v-else>
              <template v-for="(part, index) in run.parts" :key="`${runIndex}-${index}`">
                <p v-if="part.type === 'text'">
                  {{ part.text }}
                </p>
              </template>
            </template>
          </template>
        </MessageContent>
      </Message>
    </template>
    <Button
      v-if="!busy"
      variant="outline"
      size="sm"
      class="mt-auto self-start"
      @click="nextMessage ? sendMessage(nextMessage) : reset()"
    >
      {{ nextMessage ? 'Send message' : 'Reset' }}
    </Button>
  </div>
</template>

See Tool for more examples.

Accessibility

Trace adds no roles of its own. Each line gets its semantics from the element that wraps it.

  • A TraceLine on its own is a span. It is read as text and is not a tab stop.
  • Wrap it in a TraceCollapsibleTrigger and it becomes a button with aria-expanded. Wrap it in a link, or in the trigger for a dialog or sheet, 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.vue", not just "Trace.vue".
  • When a link opens a new tab, add "(opens in a new tab)" to the title with sr-only.
  • Status is shown with color and motion, so put it in the title too. Write "Running pnpm build" while it runs and "Failed to run pnpm build" when it errors.
  • The shimmer on a running title stops when the reader prefers reduced motion. The title still says what is happening.
  • Inside a Message Scroller, each line is announced once when it is added.
  • TraceCollapsibleContent mutes its text. If you put a block with a muted background inside it, give the block text-foreground so it keeps its contrast. CodeBlock sets its own text color.

API Reference

Trace

A list of rows. It also styles any trigger or link that wraps a TraceLine.

PropTypeDefaultDescription
classstring-Additional classes to apply to the list.

TraceLine

One line: an icon and a title. It is a span, so it is not a button or a tab stop. Wrap it in a trigger or a link to make it do something.

PropTypeDefaultDescription
status"running" | "error" | "pending"-The state of the work the line reports.
classstring-Additional classes to apply to the line.

TraceIcon

A decorative icon slot, hidden from assistive tech. Before the title it names the action; after it, it is the indicator.

PropTypeDefaultDescription
classstring-Additional classes to apply to the icon slot.

TraceTitle

One sentence on one line. It truncates and shimmers while the line is running.

PropTypeDefaultDescription
classstring-Additional classes to apply to the title.

TraceDescription

Secondary text after the title, such as a count or a duration. It does not truncate.

PropTypeDefaultDescription
classstring-Additional classes to apply to the description.

TraceCollapsible

A row that expands. It accepts the props of Collapsible.

Prop / EventTypeDefaultDescription
openboolean-The controlled open state. Use with v-model:open.
default-openbooleanfalseWhether the row starts open.
unmount-on-hidebooleantrueSet to false to keep the detail mounted while closed, so nested rows keep their open state.
update:open(open: boolean) => void-Emitted when the open state changes.
classstring-Additional classes to apply to the row.

TraceCollapsibleTrigger

The button that opens and closes the row. Put a TraceLine inside it. It adds a chevron after the line.

PropTypeDefaultDescription
classstring-Additional classes to apply to the trigger.

TraceCollapsibleContent

The detail of a row that expands.

PropTypeDefaultDescription
force-mountbooleanfalseAlways render the content, for use with your own animation library.
classstring-Additional classes to apply to the content.