10.7k

Stack Trace

PreviousNext

Displays a failed tool call and its stack frames.

Reset the database and seed it.

TypeError: Cannot read properties of undefined (reading 'email')

The migrations ran, but the seed failed in seedUsers. One fixture has no profile, so profile.email is undefined.

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

type ToolPart
  = | { toolCallId: string, state: 'input-available', input: { command: string } }
    | {
      toolCallId: string
      state: 'output-available'
      input: { command: string }
      output: { stdout: string }
    }
    | {
      toolCallId: string
      state: 'output-error'
      input: { command: string }
      errorText: string
      error: ReturnType<typeof parseStack>
    }

const QUESTION = 'Reset the database and seed it.'
const ANSWER
  = 'The migrations ran, but the seed failed in seedUsers. One fixture has no profile, so profile.email is undefined.'

const STACK = `TypeError: Cannot read properties of undefined (reading 'email')
    at seedUsers (prisma/seed.ts:27:34)
    at main (prisma/seed.ts:51:9)
    at Module._compile (node:internal/modules/cjs/loader:1554:14)
    at Object.transformer (node_modules/tsx/dist/register.cjs:3:1104)
    at Module.load (node:internal/modules/cjs/loader:1289:32)
    at process.processTicksAndRejections (node:internal/process/task_queues:105:5)`

const RESET_OUTPUT = `Database reset successful

The following migrations have been applied:

migrations/
  └─ 20260912_init/
  └─ 20260920_add_profiles/`

function parseFrame(line: string) {
  const match = line.match(/^at (.+) \((.+):(\d+):(\d+)\)$/)

  return {
    line,
    name: match?.[1],
    file: match?.[2],
    lineNumber: match?.[3],
    internal: line.includes('node_modules') || line.includes('node:'),
  }
}

function parseStack(stack: string) {
  const [error, ...lines] = stack.split('\n').map(line => line.trim())
  const frames = lines.map(parseFrame)

  return {
    error,
    appFrames: frames.filter(frame => !frame.internal),
    internalFrames: frames.filter(frame => frame.internal),
  }
}

function finalTools(): ToolPart[] {
  return [
    {
      toolCallId: 'reset',
      state: 'output-available',
      input: { command: 'pnpm prisma migrate reset --skip-seed' },
      output: { stdout: RESET_OUTPUT },
    },
    {
      toolCallId: 'seed',
      state: 'output-error',
      input: { command: 'pnpm prisma db seed' },
      errorText: STACK,
      error: parseStack(STACK),
    },
  ]
}

const tools = ref<ToolPart[]>(finalTools())
const text = ref(ANSWER)
const busy = ref(false)

// Unmounting bumps `run`, so a running script stops at its next wait.
let run = 0
tryOnScopeDispose(() => run++)

async function wait(ms: number, id: number) {
  await promiseTimeout(ms)
  return id === run
}

async function replay() {
  const id = ++run
  busy.value = true
  text.value = ''

  const [reset, seed] = finalTools()

  tools.value = [{ toolCallId: reset!.toolCallId, state: 'input-available', input: reset!.input }]
  if (!(await wait(900, id)))
    return
  tools.value = [reset!]

  tools.value = [reset!, { toolCallId: seed!.toolCallId, state: 'input-available', input: seed!.input }]
  if (!(await wait(1200, id)))
    return
  tools.value = [reset!, seed!]

  for (const word of ANSWER.split(/(?<= )/)) {
    if (!(await wait(20, id)))
      return
    text.value += word
  }

  busy.value = false
}
</script>

<template>
  <div class="flex min-h-72 w-full max-w-sm flex-col gap-4 py-6 text-sm">
    <Message align="end">
      <MessageContent>
        <Bubble align="end" variant="muted">
          <BubbleContent>{{ QUESTION }}</BubbleContent>
        </Bubble>
      </MessageContent>
    </Message>
    <Message v-if="tools.length > 0 || text">
      <MessageContent class="gap-4">
        <Trace v-if="tools.length > 0">
          <template v-for="part in tools" :key="part.toolCallId">
            <TraceCollapsible v-if="part.state === 'output-error'" default-open>
              <TraceCollapsibleTrigger>
                <TraceLine status="error">
                  <TraceIcon>
                    <TerminalIcon />
                  </TraceIcon>
                  <TraceTitle>Failed: {{ part.error.error }}</TraceTitle>
                </TraceLine>
              </TraceCollapsibleTrigger>
              <TraceCollapsibleContent class="flex flex-col gap-2">
                <p class="font-mono text-xs text-destructive">
                  {{ part.error.error }}
                </p>
                <Trace>
                  <a
                    v-for="frame in part.error.appFrames"
                    :key="frame.line"
                    :href="`#${frame.file}:${frame.lineNumber}`"
                  >
                    <TraceLine>
                      <TraceIcon>
                        <FileCodeIcon />
                      </TraceIcon>
                      <TraceTitle>
                        {{ frame.name }} in {{ frame.file }}:{{ frame.lineNumber }}
                      </TraceTitle>
                    </TraceLine>
                  </a>
                  <TraceCollapsible>
                    <TraceCollapsibleTrigger>
                      <TraceLine>
                        <TraceIcon>
                          <LayersIcon />
                        </TraceIcon>
                        <TraceTitle>
                          {{ part.error.internalFrames.length }} framework frames
                        </TraceTitle>
                      </TraceLine>
                    </TraceCollapsibleTrigger>
                    <TraceCollapsibleContent>
                      <ol class="flex flex-col gap-1 overflow-x-auto rounded-md bg-muted p-3 font-mono text-xs">
                        <li
                          v-for="frame in part.error.internalFrames"
                          :key="frame.line"
                          class="whitespace-nowrap"
                        >
                          {{ frame.line }}
                        </li>
                      </ol>
                    </TraceCollapsibleContent>
                  </TraceCollapsible>
                </Trace>
              </TraceCollapsibleContent>
            </TraceCollapsible>
            <TraceCollapsible v-else-if="part.state === 'output-available'">
              <TraceCollapsibleTrigger>
                <TraceLine>
                  <TraceIcon>
                    <TerminalIcon />
                  </TraceIcon>
                  <TraceTitle>Ran {{ part.input.command }}</TraceTitle>
                </TraceLine>
              </TraceCollapsibleTrigger>
              <TraceCollapsibleContent>
                <CodeBlock size="sm">
                  <CodeBlockContent wrap class="max-h-64">
                    {{ part.output.stdout }}
                  </CodeBlockContent>
                </CodeBlock>
              </TraceCollapsibleContent>
            </TraceCollapsible>
            <TraceLine v-else :status="busy ? 'running' : 'error'">
              <TraceIcon>
                <TerminalIcon />
              </TraceIcon>
              <TraceTitle>
                {{ busy ? 'Running' : 'Failed to run' }} {{ part.input.command }}
              </TraceTitle>
            </TraceLine>
          </template>
        </Trace>
        <p v-if="text">
          {{ text }}
        </p>
      </MessageContent>
    </Message>
    <Button
      v-if="!busy"
      variant="outline"
      size="sm"
      class="mt-auto self-start"
      @click="replay"
    >
      Replay
    </Button>
  </div>
</template>

Stack Trace is a line for a tool call that failed. The error is the title, like "Failed: TypeError". Open it for the stack frames, which can link to the file and line.

Stack Trace is built with Trace with a TraceCollapsible for the frames.

Installation

Stack Trace is built with the Trace and Code Block components.

pnpm dlx shadcn-vue@latest add trace code-block

Usage

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

const props = defineProps<{ stack: string }>()

const lines = computed(() => props.stack.split('\n').map(line => line.trim()))
const error = computed(() => lines.value[0])
const frames = computed(() => lines.value.slice(1))
</script>

<template>
  <Trace>
    <TraceCollapsible>
      <TraceCollapsibleTrigger>
        <TraceLine status="error">
          <TraceIcon>
            <CircleXIcon />
          </TraceIcon>
          <TraceTitle>Failed: {{ error }}</TraceTitle>
        </TraceLine>
      </TraceCollapsibleTrigger>
      <TraceCollapsibleContent>
        <div class="flex flex-col gap-2 rounded-md bg-muted p-3 font-mono text-xs">
          <p class="text-destructive">
            {{ error }}
          </p>
          <ol>
            <li
              v-for="(frame, index) in frames"
              :key="index"
              :class="frame.includes('node_modules') ? undefined : 'text-foreground'"
            >
              {{ frame }}
            </li>
          </ol>
        </div>
      </TraceCollapsibleContent>
    </TraceCollapsible>
  </Trace>
</template>

The title truncates, so repeat the full error at the top of the content. Frames from node_modules stay muted, so your own frames stand out.

Turn your own frames into links that open the file at the line. Fold the framework frames into a collapsible line of their own, so the frames you can fix come first.

TypeError: Cannot read properties of undefined (reading 'total')

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

const STACK = `TypeError: Cannot read properties of undefined (reading 'total')
    at formatInvoice (app/utils/billing.ts:42:31)
    at setup (app/components/InvoiceRow.vue:18:12)
    at callWithErrorHandling (node_modules/@vue/runtime-core/dist/runtime-core.cjs.js:200:19)
    at setupStatefulComponent (node_modules/@vue/runtime-core/dist/runtime-core.cjs.js:7848:25)
    at setupComponent (node_modules/@vue/runtime-core/dist/runtime-core.cjs.js:7809:36)
    at mountComponent (node_modules/@vue/runtime-core/dist/runtime-core.cjs.js:5159:7)`

function parseFrame(line: string) {
  const match = line.match(/^at (.+) \((.+):(\d+):(\d+)\)$/)

  return {
    line,
    name: match?.[1],
    file: match?.[2],
    lineNumber: match?.[3],
    internal: line.includes('node_modules'),
  }
}

const [error, ...lines] = STACK.split('\n').map(line => line.trim())
const frames = lines.map(parseFrame)
const appFrames = frames.filter(frame => !frame.internal)
const internalFrames = frames.filter(frame => frame.internal)
</script>

<template>
  <Trace class="max-w-sm">
    <TraceCollapsible default-open :unmount-on-hide="false">
      <TraceCollapsibleTrigger>
        <TraceLine status="error">
          <TraceIcon>
            <CircleXIcon />
          </TraceIcon>
          <TraceTitle>Failed: {{ error }}</TraceTitle>
        </TraceLine>
      </TraceCollapsibleTrigger>
      <TraceCollapsibleContent class="flex flex-col gap-2">
        <p class="font-mono text-xs text-destructive">
          {{ error }}
        </p>
        <Trace>
          <a
            v-for="frame in appFrames"
            :key="frame.line"
            :href="`#${frame.file}:${frame.lineNumber}`"
          >
            <TraceLine>
              <TraceIcon>
                <FileCodeIcon />
              </TraceIcon>
              <TraceTitle>
                {{ frame.name }} in {{ frame.file }}:{{ frame.lineNumber }}
              </TraceTitle>
            </TraceLine>
          </a>
          <TraceCollapsible>
            <TraceCollapsibleTrigger>
              <TraceLine>
                <TraceIcon>
                  <LayersIcon />
                </TraceIcon>
                <TraceTitle>
                  {{ internalFrames.length }} framework frames
                </TraceTitle>
              </TraceLine>
            </TraceCollapsibleTrigger>
            <TraceCollapsibleContent>
              <ol class="flex flex-col gap-1 overflow-x-auto rounded-md bg-muted p-3 font-mono text-xs">
                <li
                  v-for="frame in internalFrames"
                  :key="frame.line"
                  class="whitespace-nowrap"
                >
                  {{ frame.line }}
                </li>
              </ol>
            </TraceCollapsibleContent>
          </TraceCollapsible>
        </Trace>
      </TraceCollapsibleContent>
    </TraceCollapsible>
  </Trace>
</template>

AI SDK

When a tool throws, its part is output-error. By default errorText is only the error message, with no frames. Return error.stack from onError in toUIMessageStreamResponse to send them.

server/api/chat.post.ts
return result.toUIMessageStreamResponse({
  onError: error =>
    error instanceof Error ? (error.stack ?? error.message) : String(error),
})

A stack shows file paths from your server, so only send it in development.

<script setup lang="ts">
const [error, ...frames] = part.errorText
  .split('\n')
  .map(line => line.trim())
</script>

<template>
  <TraceCollapsible v-if="part.state === 'output-error'">
    …
  </TraceCollapsible>
</template>

Map the other tool states as in Tool.

Migrating from AI Elements Vue

If you added Stack Trace from AI Elements Vue, your project has a components/ai-elements/stack-trace/ folder. Paste this prompt into your coding agent to rebuild it on Trace. Every export keeps its name and props, so the code that uses it keeps working.

Migrate components/ai-elements/stack-trace/ from AI Elements Vue to the shadcn-vue Trace component.

Keep every export, with the same name and props, so the code that imports it keeps working. Only change how each part renders.

1. Install Trace: npx shadcn-vue@latest add trace
2. Read https://www.shadcn-vue.com/docs/ai-elements/stack-trace and build Stack Trace the same way.
3. Rebuild each part:
   - StackTrace renders a Trace with a TraceCollapsible. Keep trace, open, defaultOpen, the update:open and filePathClick events, and the parsing.
   - StackTraceHeader renders a TraceCollapsibleTrigger with a TraceLine with status="error" and a TraceIcon.
   - StackTraceError renders a TraceTitle, like "Failed: TypeError: …". Keep StackTraceErrorType and StackTraceErrorMessage as spans inside it.
   - StackTraceContent renders a TraceCollapsibleContent.
   - StackTraceFrames renders the frames as a list in a muted, monospace block. Dim the frames from node_modules, and keep the file path buttons.
   - StackTraceExpandButton renders nothing, since the whole line opens the frames.
   - Buttons cannot sit inside a trigger. Render StackTraceActions and StackTraceCopyButton next to the TraceCollapsibleTrigger, not inside it.
4. Remove the imports the files no longer use.
5. Run vue-tsc and fix any errors.

API Reference

See Trace.