Meet Frontfriend.Your brand. Your rules. Your agents, connected.Explore what’s new

Stack Trace

A failed tool call with application stack frames and a separate disclosure for framework frames.

Interactive preview

The shared preview uses React Base. The source below uses Reka UI. Each example runs with local data.

Installation

Complete project setup with vue/reka, then install these components:

pnpm exec frontfriend download trace code-block icon button message bubble -f vue --primitive reka

Show application frames first. Keep the original stack text when a frame cannot be parsed. Each application frame in this example opens its source context; replace that disclosure with an editor link when your application can open files.

Usage

Complete example

Save as StackTraceExample.vue.

<script setup lang="ts">
import { ref, watch, onBeforeUnmount } from "vue";
import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleContent,
  TraceCollapsibleTrigger,
  TraceIcon,
  TraceLine,
  TraceTitle,
} from "@/components/ui/trace";
import { CodeBlock, CodeBlockContent } from "@/components/ui/code-block";
import { Icon } from "@/components/ui/icon";
import { Button } from "@/components/ui/button";
import { Message, MessageContent } from "@/components/ui/message";
import { Bubble, BubbleContent } from "@/components/ui/bubble";
type Phase = "complete" | "running" | "error" | "pending";
const props = withDefaults(defineProps<{ state?: Phase }>(), {
  state: "complete",
});
const phase = ref<Phase>(props.state);
let timer: ReturnType<typeof setTimeout> | undefined;
watch(
  () => props.state,
  (value) => {
    clearTimeout(timer);
    phase.value = value;
  },
);
onBeforeUnmount(() => clearTimeout(timer));
function replay() {
  clearTimeout(timer);
  if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
    phase.value = "complete";
    return;
  }
  phase.value = "running";
  timer = setTimeout(() => {
    phase.value = "complete";
  }, 3600);
}
const sourcePreviews: Record<string, string> = {
  "prisma/seed.ts:27":
    "25  for (const user of fixtures) {\n26    const profile = user.profile\n27    await createUser(profile.email)\n28  }",
  "prisma/seed.ts:51":
    "49  async function main() {\n50    await connect()\n51    await seedUsers()\n52  }",
};
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 [error, ...lines] = stack.split("\n").map((line) => line.trim());
const frames = lines.map((line) => {
  const match = line.match(/^at (.+) \((.+):(\d+):(\d+)\)$/);
  return {
    line,
    name: match?.[1],
    file: match?.[2],
    lineNumber: match?.[3],
    source: sourcePreviews[`${match?.[2]}:${match?.[3]}`] ?? line,
    internal: line.includes("node_modules") || line.includes("node:"),
  };
});
const appFrames = frames.filter((entry) => !entry.internal);
const internalFrames = frames.filter((entry) => entry.internal);
const resetOutput =
  "Database reset successful\n\nmigrations/\n  └─ 20260912_init/\n  └─ 20260920_add_profiles/";
const frameworkStack = internalFrames.map((entry) => entry.line).join("\n");
</script>

<template>
  <div
    style="
      width: 100%;
      max-width: 384px;
      min-width: 0;
      min-height: 288px;
      margin-inline: auto;
      display: flex;
      flex-direction: column;
      gap: 16px;
      padding-block: 24px;
      font-size: var(--text-sm);
      line-height: 1.5;
    "
  >
    <Message align="end">
      <MessageContent>
        <Bubble align="end" variant="muted">
          <BubbleContent>Reset the database and seed it.</BubbleContent>
        </Bubble>
      </MessageContent>
    </Message>
    <Message>
      <MessageContent style="gap: 16px; min-width: 0">
        <Trace>
          <TraceCollapsible :unmount-on-hide="false">
            <TraceCollapsibleTrigger>
              <TraceLine>
                <TraceIcon>
                  <Icon name="Terminal" />
                </TraceIcon>
                <TraceTitle>
                  Ran pnpm prisma migrate reset --skip-seed
                </TraceTitle>
              </TraceLine>
            </TraceCollapsibleTrigger>
            <TraceCollapsibleContent>
              <CodeBlock size="sm">
                <CodeBlockContent wrap>{{ resetOutput }}</CodeBlockContent>
              </CodeBlock>
            </TraceCollapsibleContent>
          </TraceCollapsible>
          <TraceLine
            v-if="phase === 'running' || phase === 'pending'"
            :status="phase"
          >
            <TraceIcon>
              <Icon name="Terminal" />
            </TraceIcon>
            <TraceTitle>
              {{ phase === "running" ? "Running" : "Waiting to run" }} pnpm
              prisma db seed
            </TraceTitle>
          </TraceLine>
          <TraceCollapsible :unmount-on-hide="false" v-else default-open>
            <TraceCollapsibleTrigger>
              <TraceLine status="error">
                <TraceIcon>
                  <Icon name="CircleAlert" />
                </TraceIcon>
                <TraceTitle>Failed: {{ error }}</TraceTitle>
              </TraceLine>
            </TraceCollapsibleTrigger>
            <TraceCollapsibleContent>
              <div style="display: grid; gap: 8px">
                <CodeBlock size="sm" variant="destructive">
                  <CodeBlockContent wrap>{{ error }}</CodeBlockContent>
                </CodeBlock>
                <Trace>
                  <TraceCollapsible
                    :unmount-on-hide="false"
                    v-for="entry in appFrames"
                    :key="entry.line"
                  >
                    <TraceCollapsibleTrigger>
                      <TraceLine>
                        <TraceIcon>
                          <Icon name="FileCode" />
                        </TraceIcon>
                        <TraceTitle>
                          {{
                            entry.name
                              ? `${entry.name} in ${entry.file}:${entry.lineNumber}`
                              : entry.line
                          }}
                        </TraceTitle>
                      </TraceLine>
                    </TraceCollapsibleTrigger>
                    <TraceCollapsibleContent>
                      <CodeBlock size="sm">
                        <CodeBlockContent wrap>
                          {{ entry.source }}
                        </CodeBlockContent>
                      </CodeBlock>
                    </TraceCollapsibleContent>
                  </TraceCollapsible>
                  <TraceCollapsible :unmount-on-hide="false">
                    <TraceCollapsibleTrigger>
                      <TraceLine>
                        <TraceIcon>
                          <Icon name="Layers" />
                        </TraceIcon>
                        <TraceTitle>
                          {{ internalFrames.length }} framework frames
                        </TraceTitle>
                      </TraceLine>
                    </TraceCollapsibleTrigger>
                    <TraceCollapsibleContent>
                      <CodeBlock size="sm">
                        <CodeBlockContent wrap>
                          {{ frameworkStack }}
                        </CodeBlockContent>
                      </CodeBlock>
                    </TraceCollapsibleContent>
                  </TraceCollapsible>
                </Trace>
              </div>
            </TraceCollapsibleContent>
          </TraceCollapsible>
        </Trace>
        <p v-if="phase === 'complete'">
          The migrations ran, but the seed failed in seedUsers. One fixture has
          no profile, so profile.email is undefined.
        </p>
      </MessageContent>
    </Message>
    <div>
      <Button
        size="sm"
        variant="outline"
        :disabled="phase === 'running'"
        @click="replay"
      >
        Replay
      </Button>
    </div>
  </div>
</template>

AI SDK

The adapter splits output-error errorText into the error message, application frames, and framework frames. Other states use the tool state mapping. Send stack details only in development: an onError callback in toUIMessageStreamResponse can return error.stack for an Error. Production errors should omit server paths.

The basic example runs without a provider. For the AI SDK adapter example only, install the SDK types:

pnpm add ai

Pass a message from your chat client to the adapter. The supplied message does not call a provider.

Server fragment for server/api/chat.post.ts.

import type { streamText } from "ai";

export function stackResponse(result: ReturnType<typeof streamText>, development: boolean) {
  return result.toUIMessageStreamResponse({
    onError: (error) => error instanceof Error
      ? development ? error.stack ?? error.message : error.message
      : String(error),
  });
}

Save as AiStackTraceAdapter.vue.

<script setup lang="ts">
import type { UIMessage } from "ai";
import { computed, ref } from "vue";
import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleTrigger,
  TraceCollapsibleContent,
  TraceLine,
  TraceTitle,
  TraceIcon,
} from "@/components/ui/trace";
import { CodeBlock, CodeBlockContent } from "@/components/ui/code-block";
import { Icon } from "@/components/ui/icon";
type SdkCommandMessage = UIMessage<
  unknown,
  {},
  {
    runCommand: {
      input: { command: string };
      output: { stdout: string; exitCode: number };
    };
  }
>;
type SdkCommandPart = Extract<
  SdkCommandMessage["parts"][number],
  { type: "tool-runCommand" }
>;
function commandState(part: SdkCommandPart, streaming: boolean) {
  const command = part.input?.command ?? "command";
  const denied =
    part.state === "output-denied" ||
    (part.state === "approval-responded" && !part.approval.approved);
  const finished = part.state === "output-available" && !part.preliminary;
  const pending = part.state === "approval-requested";
  const approved =
    part.state === "approval-responded" && part.approval.approved;
  const failed =
    part.state === "output-error" ||
    (finished && part.output.exitCode !== 0) ||
    (!denied && !finished && !pending && !approved && !streaming);
  const status = denied
    ? undefined
    : pending
      ? "pending"
      : failed
        ? "error"
        : finished
          ? undefined
          : "running";
  return {
    status,
    title: `${denied ? "Denied" : pending ? "Waiting to run" : failed ? "Failed to run" : finished ? "Ran" : "Running"} ${command}`,
    output:
      part.state === "output-error"
        ? part.errorText
        : part.state === "output-available"
          ? part.output.stdout
          : undefined,
  } as const;
}

const props = defineProps<{ message: SdkCommandMessage; streaming: boolean }>();
const rows = computed(() =>
  props.message.parts
    .filter((part): part is SdkCommandPart => part.type === "tool-runCommand")
    .map((part) => {
      const lines =
        part.state === "output-error"
          ? part.errorText
              .split("\n")
              .map((line) => line.trim())
              .filter(Boolean)
          : [];
      return {
        id: part.toolCallId,
        ...commandState(part, props.streaming),
        error: lines[0],
        frames: lines
          .slice(1)
          .filter((line) => !/node_modules|node:internal/.test(line)),
        framework: lines
          .slice(1)
          .filter((line) => /node_modules|node:internal/.test(line)),
      };
    }),
);
const open = ref<Record<string, boolean>>({});
function setOpen(id: string, value: boolean) {
  open.value = { ...open.value, [id]: value };
}
</script>
<template>
  <div
    style="
      width: 100%;
      max-width: 384px;
      min-width: 0;
      margin-inline: auto;
      display: grid;
      gap: 16px;
      padding-block: 24px;
      font-size: var(--text-sm);
      line-height: 1.5;
    "
  >
    <Trace>
      <template v-for="row in rows" :key="row.id">
        <TraceCollapsible
          :unmount-on-hide="false"
          v-if="row.error"
          :open="open[row.id] ?? true"
          @update:open="setOpen(row.id, $event)"
        >
          <TraceCollapsibleTrigger>
            <TraceLine status="error">
              <TraceIcon>
                <Icon name="CircleAlert" />
              </TraceIcon>
              <TraceTitle>{{ row.error }}</TraceTitle>
            </TraceLine>
          </TraceCollapsibleTrigger>
          <TraceCollapsibleContent>
            <CodeBlock size="sm" variant="destructive">
              <CodeBlockContent wrap>{{ row.error }}</CodeBlockContent>
            </CodeBlock>
            <Trace>
              <TraceLine v-for="(entry, index) in row.frames" :key="index">
                <TraceIcon>
                  <Icon name="FileCode" />
                </TraceIcon>
                <TraceTitle>{{ entry }}</TraceTitle>
              </TraceLine>
              <TraceCollapsible
                :unmount-on-hide="false"
                v-if="row.framework.length"
                :open="open[row.id + '-framework'] ?? false"
                @update:open="setOpen(row.id + '-framework', $event)"
              >
                <TraceCollapsibleTrigger>
                  <TraceLine>
                    <TraceTitle>
                      {{ row.framework.length }} framework frames
                    </TraceTitle>
                  </TraceLine>
                </TraceCollapsibleTrigger>
                <TraceCollapsibleContent>
                  <CodeBlock size="sm">
                    <CodeBlockContent wrap>
                      {{ row.framework.join("\n") }}
                    </CodeBlockContent>
                  </CodeBlock>
                </TraceCollapsibleContent>
              </TraceCollapsible>
            </Trace>
          </TraceCollapsibleContent>
        </TraceCollapsible>
        <template v-else>
          <TraceLine :status="row.status">
            <TraceIcon>
              <Icon
                :name="row.status === 'error' ? 'CircleAlert' : 'Terminal'"
              />
            </TraceIcon>
            <TraceTitle>{{ row.title }}</TraceTitle>
          </TraceLine>
          <CodeBlock v-if="row.output !== undefined" size="sm">
            <CodeBlockContent wrap>{{ row.output }}</CodeBlockContent>
          </CodeBlock>
        </template>
      </template>
    </Trace>
  </div>
</template>

Frames

Save as StackTraceFramesExample.vue.

<script setup lang="ts">
import { Icon } from "@/components/ui/icon";
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>
  <div
    style="
      width: 100%;
      max-width: 384px;
      min-width: 0;
      margin-inline: auto;
      padding-block: 24px;
      font-size: var(--text-sm);
      line-height: 1.5;
    "
  >
    <Trace>
      <TraceCollapsible default-open :unmount-on-hide="false">
        <TraceCollapsibleTrigger>
          <TraceLine status="error">
            <TraceIcon>
              <Icon name="CircleX" />
            </TraceIcon>
            <TraceTitle>Failed: {{ error }}</TraceTitle>
          </TraceLine>
        </TraceCollapsibleTrigger>
        <TraceCollapsibleContent>
          <p>
            {{ error }}
          </p>
          <Trace>
            <a
              v-for="frame in appFrames"
              :key="frame.line"
              :href="`#${frame.file}:${frame.lineNumber}`"
            >
              <TraceLine>
                <TraceIcon>
                  <Icon name="FileCode" />
                </TraceIcon>
                <TraceTitle>
                  {{ frame.name }} in {{ frame.file }}:{{ frame.lineNumber }}
                </TraceTitle>
              </TraceLine>
            </a>
            <TraceCollapsible :unmount-on-hide="false">
              <TraceCollapsibleTrigger>
                <TraceLine>
                  <TraceIcon>
                    <Icon name="Layers" />
                  </TraceIcon>
                  <TraceTitle>
                    {{ internalFrames.length }} framework frames
                  </TraceTitle>
                </TraceLine>
              </TraceCollapsibleTrigger>
              <TraceCollapsibleContent>
                <ol>
                  <li v-for="frame in internalFrames" :key="frame.line">
                    {{ frame.line }}
                  </li>
                </ol>
              </TraceCollapsibleContent>
            </TraceCollapsible>
          </Trace>
        </TraceCollapsibleContent>
      </TraceCollapsible>
    </Trace>
  </div>
</template>

Components and API

This composition has no separate component API. Use the APIs for its installed components.

ComponentTarget reference
trace Reka UI API
code-block Reka UI API
icon Reka UI API
button Reka UI API
message Reka UI API
bubble Reka UI API