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 Radix UI. Each example runs with local data.

Installation

Complete project setup with react/radix, then install these components:

pnpm exec frontfriend download trace code-block icon button message bubble -f react --primitive radix

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

import { Message, MessageContent } from "@/components/ui/message";
import { Bubble, BubbleContent } from "@/components/ui/bubble";
import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleTrigger,
  TraceCollapsibleContent,
  TraceLine,
  TraceIcon,
  TraceTitle,
} from "@/components/ui/trace";
import { Icon } from "@/components/ui/icon";
import { CodeBlock, CodeBlockContent } from "@/components/ui/code-block";
import { Button } from "@/components/ui/button";
import * as React from "react";
type Phase = "complete" | "running" | "error" | "pending";
function useRun(initial: Phase) {
  const [phase, setPhase] = React.useState(initial);
  const timer = React.useRef<ReturnType<typeof setTimeout> | undefined>(
    undefined,
  );
  React.useEffect(() => {
    clearTimeout(timer.current);
    setPhase(initial);
  }, [initial]);
  React.useEffect(() => () => clearTimeout(timer.current), []);
  function replay() {
    clearTimeout(timer.current);
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
      setPhase("complete");
      return;
    }
    setPhase("running");
    timer.current = setTimeout(() => setPhase("complete"), 3600);
  }
  return { phase, replay };
}
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 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 frame: React.CSSProperties = {
  width: "100%",
  maxWidth: 384,
  minWidth: 0,
  minHeight: 288,
  marginInline: "auto",
  display: "flex",
  flexDirection: "column",
  gap: 16,
  paddingBlock: 24,
  fontSize: "var(--text-sm)",
  lineHeight: 1.5,
};
function TracePanel({
  trigger,
  children,
  open: controlledOpen,
  defaultOpen = false,
  onOpenChange,
  ...props
}: React.ComponentProps<typeof TraceCollapsible> & {
  trigger: React.ReactNode;
}) {
  const contentRef = React.useRef<HTMLDivElement>(null);
  const [height, setHeight] = React.useState<number>();
  const [localOpen, setLocalOpen] = React.useState(defaultOpen);
  const expanded = controlledOpen ?? localOpen;
  const [hasOpened, setHasOpened] = React.useState(expanded);
  React.useEffect(() => {
    if (expanded) setHasOpened(true);
  }, [expanded]);
  React.useLayoutEffect(() => {
    const content = contentRef.current;
    const inner = content?.firstElementChild;
    if (!expanded || !content || !inner) return;
    const measure = () => setHeight(content.scrollHeight);
    measure();
    const observer = new ResizeObserver(measure);
    observer.observe(inner);
    return () => observer.disconnect();
  }, [expanded]);
  return (
    <TraceCollapsible
      {...props}
      open={expanded}
      onOpenChange={(value) => {
        if (!value && contentRef.current) {
          setHeight(contentRef.current.getBoundingClientRect().height);
        }
        setLocalOpen(value);
        onOpenChange?.(value);
      }}
    >
      <TraceCollapsibleTrigger>{trigger}</TraceCollapsibleTrigger>
      <TraceCollapsibleContent
        ref={contentRef}
        style={
          height === undefined
            ? undefined
            : ({
                "--radix-collapsible-content-height": `${height}px`,
              } as React.CSSProperties)
        }
        forceMount={hasOpened ? true : undefined}
        inert={!expanded}
      >
        {children}
      </TraceCollapsibleContent>
    </TraceCollapsible>
  );
}
export default function AiStackTraceExample() {
  const { phase, replay } = useRun("complete");
  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);
  return (
    <div style={frame}>
      <Message align="end">
        <MessageContent>
          <Bubble align="end" variant="muted">
            <BubbleContent>Reset the database and seed it.</BubbleContent>
          </Bubble>
        </MessageContent>
      </Message>
      <Message>
        <MessageContent style={{ gap: 16, minWidth: 0 }}>
          <Trace>
            <TracePanel
              trigger={
                <TraceLine>
                  <TraceIcon>
                    <Icon name="Terminal" />
                  </TraceIcon>
                  <TraceTitle>
                    Ran pnpm prisma migrate reset --skip-seed
                  </TraceTitle>
                </TraceLine>
              }
            >
              <CodeBlock size="sm">
                <CodeBlockContent wrap>
                  {
                    "Database reset successful\n\nmigrations/\n  └─ 20260912_init/\n  └─ 20260920_add_profiles/"
                  }
                </CodeBlockContent>
              </CodeBlock>
            </TracePanel>
            {phase === "running" || phase === "pending" ? (
              <TraceLine status={phase}>
                <TraceIcon>
                  <Icon name="Terminal" />
                </TraceIcon>
                <TraceTitle>
                  {phase === "running" ? "Running" : "Waiting to run"} pnpm
                  prisma db seed
                </TraceTitle>
              </TraceLine>
            ) : (
              <TracePanel
                defaultOpen
                trigger={
                  <TraceLine status="error">
                    <TraceIcon>
                      <Icon name="CircleAlert" />
                    </TraceIcon>
                    <TraceTitle>Failed: {error}</TraceTitle>
                  </TraceLine>
                }
              >
                <div style={{ display: "grid", gap: 8 }}>
                  <CodeBlock size="sm" variant="destructive">
                    <CodeBlockContent wrap>{error}</CodeBlockContent>
                  </CodeBlock>
                  <Trace>
                    {appFrames.map((entry) => (
                      <TracePanel
                        key={entry.line}
                        trigger={
                          <TraceLine>
                            <TraceIcon>
                              <Icon name="FileCode" />
                            </TraceIcon>
                            <TraceTitle>
                              {entry.name
                                ? `${entry.name} in ${entry.file}:${entry.lineNumber}`
                                : entry.line}
                            </TraceTitle>
                          </TraceLine>
                        }
                      >
                        <CodeBlock size="sm">
                          <CodeBlockContent wrap>
                            {entry.source}
                          </CodeBlockContent>
                        </CodeBlock>
                      </TracePanel>
                    ))}
                    <TracePanel
                      trigger={
                        <TraceLine>
                          <TraceIcon>
                            <Icon name="Layers" />
                          </TraceIcon>
                          <TraceTitle>
                            {internalFrames.length} framework frames
                          </TraceTitle>
                        </TraceLine>
                      }
                    >
                      <CodeBlock size="sm">
                        <CodeBlockContent wrap>
                          {internalFrames.map((entry) => entry.line).join("\n")}
                        </CodeBlockContent>
                      </CodeBlock>
                    </TracePanel>
                  </Trace>
                </div>
              </TracePanel>
            )}
          </Trace>
          {phase === "complete" && (
            <p>
              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"
          onClick={replay}
          disabled={phase === "running"}
        >
          Replay
        </Button>
      </div>
    </div>
  );
}

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 app/api/chat/route.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),
  });
}
import type { UIMessage } from "ai";
import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleTrigger,
  TraceCollapsibleContent,
  TraceLine,
  TraceIcon,
  TraceTitle,
} from "@/components/ui/trace";
import { Icon } from "@/components/ui/icon";
import { CodeBlock, CodeBlockContent } from "@/components/ui/code-block";
import * as React from "react";
const frame: React.CSSProperties = {
  width: "100%",
  maxWidth: 384,
  minWidth: 0,
  minHeight: 288,
  marginInline: "auto",
  display: "flex",
  flexDirection: "column",
  gap: 16,
  paddingBlock: 24,
  fontSize: "var(--text-sm)",
  lineHeight: 1.5,
};
type SdkCommandMessage = UIMessage<
  unknown,
  {},
  {
    runCommand: {
      input: {
        command: string;
      };
      output: {
        stdout: string;
        exitCode: number;
      };
    };
  }
>;
type SdkCommandPart = Extract<
  SdkCommandMessage["parts"][number],
  {
    type: "tool-runCommand";
  }
>;
function sdkCommandState(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 waiting = 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 && !waiting && !approved && !streaming);
  const status = denied
    ? undefined
    : waiting
      ? "pending"
      : failed
        ? "error"
        : finished
          ? undefined
          : "running";
  const title = `${denied ? "Denied" : waiting ? "Waiting to run" : failed ? "Failed to run" : finished ? "Ran" : "Running"} ${command}`;
  const output =
    part.state === "output-error"
      ? part.errorText
      : part.state === "output-available"
        ? part.output.stdout
        : undefined;
  return { status, title, output } as const;
}
function TracePanel({
  trigger,
  children,
  open: controlledOpen,
  defaultOpen = false,
  onOpenChange,
  ...props
}: React.ComponentProps<typeof TraceCollapsible> & {
  trigger: React.ReactNode;
}) {
  const contentRef = React.useRef<HTMLDivElement>(null);
  const [height, setHeight] = React.useState<number>();
  const [localOpen, setLocalOpen] = React.useState(defaultOpen);
  const expanded = controlledOpen ?? localOpen;
  const [hasOpened, setHasOpened] = React.useState(expanded);
  React.useEffect(() => {
    if (expanded) setHasOpened(true);
  }, [expanded]);
  React.useLayoutEffect(() => {
    const content = contentRef.current;
    const inner = content?.firstElementChild;
    if (!expanded || !content || !inner) return;
    const measure = () => setHeight(content.scrollHeight);
    measure();
    const observer = new ResizeObserver(measure);
    observer.observe(inner);
    return () => observer.disconnect();
  }, [expanded]);
  return (
    <TraceCollapsible
      {...props}
      open={expanded}
      onOpenChange={(value) => {
        if (!value && contentRef.current) {
          setHeight(contentRef.current.getBoundingClientRect().height);
        }
        setLocalOpen(value);
        onOpenChange?.(value);
      }}
    >
      <TraceCollapsibleTrigger>{trigger}</TraceCollapsibleTrigger>
      <TraceCollapsibleContent
        ref={contentRef}
        style={
          height === undefined
            ? undefined
            : ({
                "--radix-collapsible-content-height": `${height}px`,
              } as React.CSSProperties)
        }
        forceMount={hasOpened ? true : undefined}
        inert={!expanded}
      >
        {children}
      </TraceCollapsibleContent>
    </TraceCollapsible>
  );
}
function SdkPanel({
  title,
  status,
  children,
  initialOpen = false,
}: {
  title: string;
  status?: "running" | "pending" | "error";
  children: React.ReactNode;
  initialOpen?: boolean;
}) {
  const [open, setOpen] = React.useState<boolean>();
  const expanded = open ?? initialOpen;
  return (
    <TracePanel
      open={expanded}
      onOpenChange={setOpen}
      trigger={
        <TraceLine status={status}>
          <TraceIcon>
            <Icon name={status === "error" ? "CircleAlert" : "Terminal"} />
          </TraceIcon>
          <TraceTitle>{title}</TraceTitle>
        </TraceLine>
      }
    >
      {children}
    </TracePanel>
  );
}
function SdkCommandRow({
  part,
  streaming,
}: {
  part: SdkCommandPart;
  streaming: boolean;
}) {
  const view = sdkCommandState(part, streaming);
  const output = view.output;
  if (output !== undefined)
    return (
      <SdkPanel
        title={view.title}
        status={view.status}
        initialOpen={view.status === "error" || view.status === "running"}
      >
        <CodeBlock
          size="sm"
          variant={view.status === "error" ? "destructive" : "default"}
        >
          <CodeBlockContent wrap style={{ maxHeight: 256 }}>
            {output}
          </CodeBlockContent>
        </CodeBlock>
      </SdkPanel>
    );
  return (
    <TraceLine status={view.status}>
      <TraceIcon>
        <Icon name="Terminal" />
      </TraceIcon>
      <TraceTitle>{view.title}</TraceTitle>
    </TraceLine>
  );
}
function StackTraceSdkAdapter({
  message,
  streaming,
}: {
  message: SdkCommandMessage;
  streaming: boolean;
}) {
  return (
    <Trace>
      {message.parts.map((part) => {
        if (part.type !== "tool-runCommand") return null;
        if (part.state !== "output-error")
          return (
            <SdkCommandRow
              key={part.toolCallId}
              part={part}
              streaming={streaming}
            />
          );
        const [error, ...frames] = part.errorText
          .split("\n")
          .map((line) => line.trim())
          .filter(Boolean);
        const appFrames = frames.filter(
          (line) => !/node_modules|node:internal/.test(line),
        );
        const frameworkFrames = frames.filter((line) =>
          /node_modules|node:internal/.test(line),
        );
        return (
          <SdkPanel
            key={part.toolCallId}
            title={error || part.errorText}
            status="error"
            initialOpen
          >
            <CodeBlock size="sm" variant="destructive">
              <CodeBlockContent wrap>{error}</CodeBlockContent>
            </CodeBlock>
            <Trace>
              {appFrames.map((line, index) => (
                <TraceLine key={index}>
                  <TraceIcon>
                    <Icon name="FileCode" />
                  </TraceIcon>
                  <TraceTitle>{line}</TraceTitle>
                </TraceLine>
              ))}
              {frameworkFrames.length > 0 && (
                <SdkPanel title={`${frameworkFrames.length} framework frames`}>
                  <CodeBlock size="sm">
                    <CodeBlockContent wrap>
                      {frameworkFrames.join("\n")}
                    </CodeBlockContent>
                  </CodeBlock>
                </SdkPanel>
              )}
            </Trace>
          </SdkPanel>
        );
      })}
    </Trace>
  );
}
const sdkMessage: SdkCommandMessage = {
  id: "seed",
  role: "assistant",
  parts: [
    {
      type: "tool-runCommand",
      toolCallId: "seed-command",
      state: "output-error",
      input: { command: "pnpm prisma db seed" },
      errorText:
        "TypeError: Cannot read properties of undefined (reading 'email')\n    at seedUsers (prisma/seed.ts:27:18)\n    at main (prisma/seed.ts:51:3)\n    at processTicksAndRejections (node:internal/process/task_queues:95:5)",
    },
  ],
};
export default function AiStackTraceExample() {
  return (
    <div style={frame}>
      <StackTraceSdkAdapter message={sdkMessage} streaming={false} />
    </div>
  );
}

Frames

import {
  Trace,
  TraceCollapsible,
  TraceCollapsibleTrigger,
  TraceCollapsibleContent,
  TraceLine,
  TraceIcon,
  TraceTitle,
} from "@/components/ui/trace";
import { Icon } from "@/components/ui/icon";
import * as React from "react";
const frame: React.CSSProperties = {
  width: "100%",
  maxWidth: 384,
  minWidth: 0,
  minHeight: 288,
  marginInline: "auto",
  display: "flex",
  flexDirection: "column",
  gap: 16,
  paddingBlock: 24,
  fontSize: "var(--text-sm)",
  lineHeight: 1.5,
};
function TracePanel({
  trigger,
  children,
  open: controlledOpen,
  defaultOpen = false,
  onOpenChange,
  ...props
}: React.ComponentProps<typeof TraceCollapsible> & {
  trigger: React.ReactNode;
}) {
  const contentRef = React.useRef<HTMLDivElement>(null);
  const [height, setHeight] = React.useState<number>();
  const [localOpen, setLocalOpen] = React.useState(defaultOpen);
  const expanded = controlledOpen ?? localOpen;
  const [hasOpened, setHasOpened] = React.useState(expanded);
  React.useEffect(() => {
    if (expanded) setHasOpened(true);
  }, [expanded]);
  React.useLayoutEffect(() => {
    const content = contentRef.current;
    const inner = content?.firstElementChild;
    if (!expanded || !content || !inner) return;
    const measure = () => setHeight(content.scrollHeight);
    measure();
    const observer = new ResizeObserver(measure);
    observer.observe(inner);
    return () => observer.disconnect();
  }, [expanded]);
  return (
    <TraceCollapsible
      {...props}
      open={expanded}
      onOpenChange={(value) => {
        if (!value && contentRef.current) {
          setHeight(contentRef.current.getBoundingClientRect().height);
        }
        setLocalOpen(value);
        onOpenChange?.(value);
      }}
    >
      <TraceCollapsibleTrigger>{trigger}</TraceCollapsibleTrigger>
      <TraceCollapsibleContent
        ref={contentRef}
        style={
          height === undefined
            ? undefined
            : ({
                "--radix-collapsible-content-height": `${height}px`,
              } as React.CSSProperties)
        }
        forceMount={hasOpened ? true : undefined}
        inert={!expanded}
      >
        {children}
      </TraceCollapsibleContent>
    </TraceCollapsible>
  );
}
const namedStackTraceFramesSTACK = `TypeError: Cannot read properties of undefined (reading 'total')
    at formatInvoice (app/lib/billing.ts:42:31)
    at InvoiceRow (app/components/invoice-row.tsx:18:12)
    at renderWithHooks (node_modules/react-dom/cjs/react-dom.development.js:14985:18)
    at beginWork (node_modules/react-dom/cjs/react-dom.development.js:19049:16)
    at performUnitOfWork (node_modules/react-dom/cjs/react-dom.development.js:22776:12)
    at workLoopSync (node_modules/react-dom/cjs/react-dom.development.js:22707:5)`;
const namedStackTraceFramesLines = namedStackTraceFramesSTACK
  .split("\n")
  .map((line) => line.trim());
const namedStackTraceFramesError = namedStackTraceFramesLines[0];
function namedStackTraceFramesParseFrame(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 namedStackTraceFramesFrames = namedStackTraceFramesLines
  .slice(1)
  .map(namedStackTraceFramesParseFrame);
const namedStackTraceFramesAppFrames = namedStackTraceFramesFrames.filter(
  (frame) => !frame.internal,
);
const namedStackTraceFramesInternalFrames = namedStackTraceFramesFrames.filter(
  (frame) => frame.internal,
);
function StackTraceFramesExample() {
  return (
    <Trace>
      <TracePanel
        defaultOpen
        trigger={
          <TraceLine status="error">
            <TraceIcon>
              <Icon name="CircleX" />
            </TraceIcon>
            <TraceTitle>Failed: {namedStackTraceFramesError}</TraceTitle>
          </TraceLine>
        }
      >
        <p>{namedStackTraceFramesError}</p>
        <Trace>
          {namedStackTraceFramesAppFrames.map((frame) => (
            <a 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>
          ))}
          <TracePanel
            trigger={
              <TraceLine>
                <TraceIcon>
                  <Icon name="Layers" />
                </TraceIcon>
                <TraceTitle>
                  {namedStackTraceFramesInternalFrames.length} framework frames
                </TraceTitle>
              </TraceLine>
            }
          >
            <ol>
              {namedStackTraceFramesInternalFrames.map((frame) => (
                <li key={frame.line}>{frame.line}</li>
              ))}
            </ol>
          </TracePanel>
        </Trace>
      </TracePanel>
    </Trace>
  );
}
export default function AiStackTraceExample() {
  return (
    <div style={frame}>
      <StackTraceFramesExample />
    </div>
  );
}

Components and API

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

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