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.
| Component | Target 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 |