Skip to content
edmi-ui

Stack Trace

Collapsible error stack with file links and internal frame filtering.

StackBlitz ↗StackBlitz ↗StackBlitz ↗
TypeErrorCannot read properties of undefined (reading 'price')
atgetDrift
atrun
atprocessTicksAndRejections
TypeErrorCannot read properties of undefined (reading 'price')
atgetDrift
atrun
atprocessTicksAndRejections
TypeError Cannot read properties of undefined (reading 'price')
at getDrift
at run
at processTicksAndRejections

Built on collapsible and button; open state uses the ai-use-controllable-state hook.

Terminal window
npx shadcn@latest add @edmi-ui/ai-stack-trace
Terminal window
pnpm dlx shadcn@latest add @edmi-ui/ai-stack-trace
Terminal window
yarn dlx shadcn@latest add @edmi-ui/ai-stack-trace
Terminal window
bunx shadcn@latest add @edmi-ui/ai-stack-trace

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-vue@latest add @edmi-ui/ai-stack-trace
Terminal window
pnpm dlx shadcn-vue@latest add @edmi-ui/ai-stack-trace
Terminal window
yarn dlx shadcn-vue@latest add @edmi-ui/ai-stack-trace
Terminal window
bunx shadcn-vue@latest add @edmi-ui/ai-stack-trace

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-stack-trace.json
Terminal window
pnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-stack-trace.json
Terminal window
yarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-stack-trace.json
Terminal window
bunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-stack-trace.json

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

import {
StackTrace,
StackTraceActions,
StackTraceContent,
StackTraceCopyButton,
StackTraceError,
StackTraceErrorMessage,
StackTraceErrorType,
StackTraceExpandButton,
StackTraceFrames,
StackTraceHeader,
} from "@/components/ai/stack-trace";
<StackTrace
className="max-w-xl"
defaultOpen
onFilePathClick={(file, line) => console.log(file, line)}
>
<StackTraceHeader>
<StackTraceError>
<StackTraceErrorType />
<StackTraceErrorMessage />
</StackTraceError>
<StackTraceActions>
<StackTraceCopyButton />
<StackTraceExpandButton />
</StackTraceActions>
</StackTraceHeader>
<StackTraceContent>
<StackTraceFrames />
</StackTraceContent>
</StackTrace>
<script setup lang="ts">
import {
StackTrace,
StackTraceActions,
StackTraceContent,
StackTraceCopyButton,
StackTraceError,
StackTraceErrorMessage,
StackTraceErrorType,
StackTraceExpandButton,
StackTraceFrames,
StackTraceHeader,
} from "@/components/ai/stack-trace";
</script>
<template>
<StackTrace class="max-w-xl" default-open @file-path-click="() => {}">
<StackTraceHeader>
<StackTraceError>
<StackTraceErrorType />
<StackTraceErrorMessage />
</StackTraceError>
<StackTraceActions>
<StackTraceCopyButton />
<StackTraceExpandButton />
</StackTraceActions>
</StackTraceHeader>
<StackTraceContent>
<StackTraceFrames />
</StackTraceContent>
</StackTrace>
</template>
<script lang="ts">
import {
StackTrace,
StackTraceActions,
StackTraceContent,
StackTraceCopyButton,
StackTraceError,
StackTraceErrorMessage,
StackTraceErrorType,
StackTraceExpandButton,
StackTraceFrames,
StackTraceHeader,
} from "$lib/components/ai/stack-trace";
</script>
<StackTrace class="max-w-xl" open onFilePathClick={() => {}}>
<StackTraceHeader>
<StackTraceError>
<StackTraceErrorType />
<StackTraceErrorMessage />
</StackTraceError>
<StackTraceActions>
<StackTraceCopyButton />
<StackTraceExpandButton />
</StackTraceActions>
</StackTraceHeader>
<StackTraceContent>
<StackTraceFrames />
</StackTraceContent>
</StackTrace>
registry/components/ai/stack-trace.tsx
"use client";
import { cn } from "cn";
import type { ComponentProps } from "react";
import {
createContext,
memo,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { CheckIcon, CopyIcon, CaretDownIcon } from "@phosphor-icons/react";
import { useControllableState } from "@/registry/edmi/hooks/ai/use-controllable-state";
import { Button } from "@/registry/edmi/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/registry/edmi/ui/collapsible";
// Regex patterns for parsing stack traces
const STACK_FRAME_WITH_PARENS_REGEX = /^at\s+(.+?)\s+\((.+):(\d+):(\d+)\)$/;
const STACK_FRAME_WITHOUT_FN_REGEX = /^at\s+(.+):(\d+):(\d+)$/;
const ERROR_TYPE_REGEX = /^(\w+Error|Error):\s*(.*)$/;
const AT_PREFIX_REGEX = /^at\s+/;
interface StackFrame {
raw: string;
functionName: string | null;
filePath: string | null;
lineNumber: number | null;
columnNumber: number | null;
isInternal: boolean;
}
interface ParsedStackTrace {
errorType: string | null;
errorMessage: string;
frames: StackFrame[];
raw: string;
}
interface StackTraceContextValue {
trace: ParsedStackTrace;
raw: string;
isOpen: boolean;
setIsOpen: (open: boolean) => void;
onFilePathClick?: (filePath: string, line?: number, column?: number) => void;
}
const StackTraceContext = createContext<StackTraceContextValue | null>(null);
const useStackTrace = () => {
const context = useContext(StackTraceContext);
if (!context) {
throw new Error("StackTrace components must be used within StackTrace");
}
return context;
};
const isInternalPath = (filePath: string) =>
filePath.includes("node_modules") ||
filePath.startsWith("node:") ||
filePath.includes("internal/");
const parseStackFrame = (line: string): StackFrame => {
const trimmed = line.trim();
// Pattern: at functionName (filePath:line:column)
const withParensMatch = trimmed.match(STACK_FRAME_WITH_PARENS_REGEX);
if (withParensMatch) {
const [, functionName, filePath, lineNum, colNum] = withParensMatch;
return {
columnNumber: colNum ? Number.parseInt(colNum, 10) : null,
filePath: filePath ?? null,
functionName: functionName ?? null,
isInternal: isInternalPath(filePath),
lineNumber: lineNum ? Number.parseInt(lineNum, 10) : null,
raw: trimmed,
};
}
// Pattern: at filePath:line:column (no function name)
const withoutFnMatch = trimmed.match(STACK_FRAME_WITHOUT_FN_REGEX);
if (withoutFnMatch) {
const [, filePath, lineNum, colNum] = withoutFnMatch;
return {
columnNumber: colNum ? Number.parseInt(colNum, 10) : null,
filePath: filePath ?? null,
functionName: null,
isInternal: isInternalPath(filePath ?? ""),
lineNumber: lineNum ? Number.parseInt(lineNum, 10) : null,
raw: trimmed,
};
}
// Fallback: unparseable line
return {
columnNumber: null,
filePath: null,
functionName: null,
isInternal: trimmed.includes("node_modules") || trimmed.includes("node:"),
lineNumber: null,
raw: trimmed,
};
};
const parseStackTrace = (trace: string): ParsedStackTrace => {
const lines = trace.split("\n").filter((line) => line.trim());
if (lines.length === 0) {
return {
errorMessage: trace,
errorType: null,
frames: [],
raw: trace,
};
}
const firstLine = lines[0].trim();
let errorType: string | null = null;
let errorMessage = firstLine;
// Try to extract error type from "ErrorType: message" format
const errorMatch = firstLine.match(ERROR_TYPE_REGEX);
if (errorMatch) {
const [, type, msg] = errorMatch;
errorType = type;
errorMessage = msg || "";
}
// Parse stack frames (lines starting with "at")
const frames = lines
.slice(1)
.filter((line) => line.trim().startsWith("at "))
.map(parseStackFrame);
return {
errorMessage,
errorType,
frames,
raw: trace,
};
};
export type StackTraceProps = Omit<
ComponentProps<typeof Collapsible>,
"open" | "defaultOpen" | "onOpenChange"
> & {
trace: string;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
onFilePathClick?: (filePath: string, line?: number, column?: number) => void;
};
export const StackTrace = memo(
({
trace,
className,
open,
defaultOpen = false,
onOpenChange,
onFilePathClick,
children,
...props
}: StackTraceProps) => {
const [isOpen, setIsOpen] = useControllableState({
defaultProp: defaultOpen,
onChange: onOpenChange,
prop: open,
});
const parsedTrace = useMemo(() => parseStackTrace(trace), [trace]);
const contextValue = useMemo(
() => ({
isOpen,
onFilePathClick,
raw: trace,
setIsOpen,
trace: parsedTrace,
}),
[parsedTrace, trace, isOpen, setIsOpen, onFilePathClick],
);
return (
<StackTraceContext.Provider value={contextValue}>
<Collapsible
data-slot="ai-stack-trace"
className={cn(
"not-prose w-full overflow-hidden rounded-[calc(var(--radius)*1.2)] border border-border bg-card font-mono text-sm",
className,
)}
onOpenChange={setIsOpen}
open={isOpen}
{...props}
>
{children}
</Collapsible>
</StackTraceContext.Provider>
);
},
);
export type StackTraceHeaderProps = ComponentProps<typeof CollapsibleTrigger>;
export const StackTraceHeader = memo(
({ className, children, ...props }: StackTraceHeaderProps) => (
<CollapsibleTrigger
data-slot="ai-stack-trace-header"
className={cn(
"flex w-full cursor-pointer items-start gap-2.5 px-3.5 py-2.5 text-left outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring",
className,
)}
nativeButton={false}
render={<div />}
{...props}
>
{children}
</CollapsibleTrigger>
),
);
export type StackTraceErrorProps = ComponentProps<"div">;
export const StackTraceError = memo(
({ className, children, ...props }: StackTraceErrorProps) => (
<div
data-slot="ai-stack-trace-error"
className={cn(
"flex flex-1 items-start gap-2.5 overflow-hidden",
className,
)}
{...props}
>
<svg
aria-hidden="true"
className="mt-px size-[15px] shrink-0 text-destructive-text"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.7"
viewBox="0 0 24 24"
>
<rect height="13" rx="5" width="10" x="7" y="7" />
<path d="M12 7V4M7 12H3M21 12h-4M7 17l-3 2M17 17l3 2M7 9 4 6M17 9l3-3" />
</svg>
<div className="min-w-0 flex-1">{children}</div>
</div>
),
);
export type StackTraceErrorTypeProps = ComponentProps<"span">;
export const StackTraceErrorType = memo(
({ className, children, ...props }: StackTraceErrorTypeProps) => {
const { trace } = useStackTrace();
return (
<span
className={cn(
"block font-mono text-[12.5px] font-semibold text-destructive-text",
className,
)}
{...props}
>
{children ?? trace.errorType}
</span>
);
},
);
export type StackTraceErrorMessageProps = ComponentProps<"span">;
export const StackTraceErrorMessage = memo(
({ className, children, ...props }: StackTraceErrorMessageProps) => {
const { trace } = useStackTrace();
return (
<span
className={cn(
"mt-0.5 block font-sans text-[13px] break-words text-foreground",
className,
)}
{...props}
>
{children ?? trace.errorMessage}
</span>
);
},
);
export type StackTraceActionsProps = ComponentProps<"div">;
const handleActionsClick = (e: React.MouseEvent) => e.stopPropagation();
const handleActionsKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.stopPropagation();
}
};
export const StackTraceActions = memo(
({ className, children, ...props }: StackTraceActionsProps) => (
// biome-ignore lint/a11y/useSemanticElements: stops the header toggle; a fieldset would change layout
<div
data-slot="ai-stack-trace-actions"
className={cn("flex shrink-0 items-center gap-0.5", className)}
onClick={handleActionsClick}
onKeyDown={handleActionsKeyDown}
role="group"
{...props}
>
{children}
</div>
),
);
export type StackTraceCopyButtonProps = ComponentProps<typeof Button> & {
onCopy?: () => void;
onError?: (error: Error) => void;
timeout?: number;
};
export const StackTraceCopyButton = memo(
({
onCopy,
onError,
timeout = 2000,
className,
children,
...props
}: StackTraceCopyButtonProps) => {
const [isCopied, setIsCopied] = useState(false);
const timeoutRef = useRef<number>(0);
const { raw } = useStackTrace();
const copyToClipboard = useCallback(async () => {
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
onError?.(new Error("Clipboard API not available"));
return;
}
try {
await navigator.clipboard.writeText(raw);
setIsCopied(true);
onCopy?.();
timeoutRef.current = window.setTimeout(
() => setIsCopied(false),
timeout,
);
} catch (error) {
onError?.(error as Error);
}
}, [raw, onCopy, onError, timeout]);
useEffect(
() => () => {
window.clearTimeout(timeoutRef.current);
},
[],
);
return (
<Button
aria-label="Copy stack trace"
className={className}
onClick={copyToClipboard}
size="icon-xs"
variant="ghost"
{...props}
>
{children ??
(isCopied ? (
<CheckIcon
className="size-3.5"
/>
) : (
<CopyIcon
className="size-3.5"
/>
))}
</Button>
);
},
);
export type StackTraceExpandButtonProps = ComponentProps<"div">;
export const StackTraceExpandButton = memo(
({ className, ...props }: StackTraceExpandButtonProps) => {
const { isOpen } = useStackTrace();
return (
<div
data-slot="ai-stack-trace-expand"
className={cn("flex size-6 items-center justify-center", className)}
{...props}
>
<CaretDownIcon
className={cn(
"size-3.5 text-muted-foreground transition-transform",
isOpen ? "rotate-180" : "rotate-0",
)}
/>
</div>
);
},
);
export type StackTraceContentProps = ComponentProps<
typeof CollapsibleContent
> & {
maxHeight?: number;
};
export const StackTraceContent = memo(
({
className,
maxHeight = 400,
children,
style,
...props
}: StackTraceContentProps) => (
<CollapsibleContent
data-slot="ai-stack-trace-content"
className={cn("overflow-auto border-t border-border", className)}
style={{ maxHeight, ...style }}
{...props}
>
{children}
</CollapsibleContent>
),
);
export type StackTraceFramesProps = ComponentProps<"div"> & {
showInternalFrames?: boolean;
};
interface FilePathButtonProps {
frame: StackFrame;
onFilePathClick?: (
filePath: string,
lineNumber?: number,
columnNumber?: number,
) => void;
}
const FilePathButton = memo(
({ frame, onFilePathClick }: FilePathButtonProps) => {
const handleClick = useCallback(() => {
if (frame.filePath) {
onFilePathClick?.(
frame.filePath,
frame.lineNumber ?? undefined,
frame.columnNumber ?? undefined,
);
}
}, [frame, onFilePathClick]);
return (
<button
className={cn(
"text-muted-foreground",
!frame.isInternal && "underline underline-offset-[3px]",
onFilePathClick && "cursor-pointer hover:text-foreground",
)}
disabled={!onFilePathClick}
onClick={handleClick}
type="button"
>
{frame.filePath}
{frame.lineNumber !== null && `:${frame.lineNumber}`}
{frame.columnNumber !== null && `:${frame.columnNumber}`}
</button>
);
},
);
FilePathButton.displayName = "FilePathButton";
export const StackTraceFrames = memo(
({
className,
showInternalFrames = true,
...props
}: StackTraceFramesProps) => {
const { trace, onFilePathClick } = useStackTrace();
const framesToShow = showInternalFrames
? trace.frames
: trace.frames.filter((f) => !f.isInternal);
return (
<div
data-slot="ai-stack-trace-frames"
className={cn("px-3.5 py-3", className)}
{...props}
>
{framesToShow.map((frame) => (
<div
className={cn(
"flex items-center gap-2 text-xs leading-[1.9]",
frame.isInternal && "opacity-50",
)}
key={frame.raw}
>
<span className="text-muted-foreground">at</span>
{frame.functionName && (
<span className="text-foreground">{frame.functionName}</span>
)}
{frame.filePath && (
<FilePathButton frame={frame} onFilePathClick={onFilePathClick} />
)}
{!(frame.filePath || frame.functionName) && (
<span>{frame.raw.replace(AT_PREFIX_REGEX, "")}</span>
)}
</div>
))}
{framesToShow.length === 0 && (
<div className="text-xs text-muted-foreground">No stack frames</div>
)}
</div>
);
},
);
StackTrace.displayName = "StackTrace";
StackTraceHeader.displayName = "StackTraceHeader";
StackTraceError.displayName = "StackTraceError";
StackTraceErrorType.displayName = "StackTraceErrorType";
StackTraceErrorMessage.displayName = "StackTraceErrorMessage";
StackTraceActions.displayName = "StackTraceActions";
StackTraceCopyButton.displayName = "StackTraceCopyButton";
StackTraceExpandButton.displayName = "StackTraceExpandButton";
StackTraceContent.displayName = "StackTraceContent";
StackTraceFrames.displayName = "StackTraceFrames";
registry/components/ai/stack-trace/StackTrace.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import type { ParsedStackTrace } from "./context"
import { useVModel } from "@vueuse/core"
import { computed, provide } from "vue"
import { cn } from "@/registry/edmi/lib/utils"
import { Collapsible } from "@/registry/edmi/ui/collapsible"
import { StackTraceKey } from "./context"
import { parseStackTrace } from "./utils"
const props = withDefaults(defineProps<{
trace: string
modelValue?: boolean
defaultOpen?: boolean
class?: HTMLAttributes["class"]
}>(), {
defaultOpen: false,
modelValue: undefined,
})
const emit = defineEmits<{
(e: "update:modelValue", value: boolean): void
(e: "openChange", value: boolean): void
(e: "filePathClick", filePath: string, line?: number, column?: number): void
}>()
const isOpen = useVModel(props, "modelValue", emit, {
defaultValue: props.defaultOpen,
passive: true,
})
const parsedTrace = computed<ParsedStackTrace>(() => parseStackTrace(props.trace))
function onFilePathClick(filePath: string, line?: number, column?: number) {
emit("filePathClick", filePath, line, column)
}
function setIsOpen(value: boolean) {
isOpen.value = value
emit("openChange", value)
}
provide(StackTraceKey, {
trace: parsedTrace,
raw: computed(() => props.trace),
isOpen,
setIsOpen,
onFilePathClick,
})
</script>
<template>
<Collapsible
data-slot="ai-stack-trace"
:open="isOpen"
:class="cn(
'not-prose w-full overflow-hidden rounded-[calc(var(--radius)*1.2)] border border-border bg-card font-mono text-sm',
props.class,
)"
@update:open="setIsOpen"
>
<slot />
</Collapsible>
</template>
registry/components/ai/stack-trace/StackTraceActions.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/registry/edmi/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<!-- Stops clicks and Enter/Space here so the actions do not toggle the header. -->
<div
data-slot="ai-stack-trace-actions"
role="group"
:class="cn('flex shrink-0 items-center gap-0.5', props.class)"
@click.stop
@keydown.enter.stop
@keydown.space.stop
>
<slot />
</div>
</template>
registry/components/ai/stack-trace/StackTraceContent.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/registry/edmi/lib/utils"
import { CollapsibleContent } from "@/registry/edmi/ui/collapsible"
const props = withDefaults(defineProps<{
maxHeight?: number
class?: HTMLAttributes["class"]
}>(), {
maxHeight: 400,
})
</script>
<template>
<CollapsibleContent
data-slot="ai-stack-trace-content"
:class="cn('overflow-auto border-t border-border', props.class)"
:style="{ maxHeight: `${props.maxHeight}px` }"
>
<slot />
</CollapsibleContent>
</template>
registry/components/ai/stack-trace/StackTraceCopyButton.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { CheckIcon, CopyIcon } from "@lucide/vue"
import { onBeforeUnmount, ref } from "vue"
import { Button } from "@/registry/edmi/ui/button"
import { useStackTraceContext } from "./context"
const props = withDefaults(defineProps<{
timeout?: number
class?: HTMLAttributes["class"]
}>(), {
timeout: 2000,
})
const emit = defineEmits<{
(e: "copy"): void
(e: "error", error: Error): void
}>()
const { raw } = useStackTraceContext("StackTraceCopyButton")
const isCopied = ref(false)
let timer: ReturnType<typeof setTimeout> | undefined
async function copyToClipboard() {
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
emit("error", new Error("Clipboard API not available"))
return
}
try {
await navigator.clipboard.writeText(raw.value)
isCopied.value = true
emit("copy")
timer = setTimeout(() => {
isCopied.value = false
}, props.timeout)
}
catch (error) {
emit("error", error as Error)
}
}
onBeforeUnmount(() => clearTimeout(timer))
</script>
<template>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label="Copy stack trace"
:class="props.class"
@click="copyToClipboard"
>
<slot>
<CheckIcon v-if="isCopied" class="size-3.5" />
<CopyIcon v-else class="size-3.5" />
</slot>
</Button>
</template>
registry/components/ai/stack-trace/StackTraceError.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/registry/edmi/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="ai-stack-trace-error"
:class="cn('flex flex-1 items-start gap-2.5 overflow-hidden', props.class)"
>
<svg
aria-hidden="true"
class="mt-px size-[15px] shrink-0 text-destructive-text"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.7"
viewBox="0 0 24 24"
>
<rect height="13" rx="5" width="10" x="7" y="7" />
<path d="M12 7V4M7 12H3M21 12h-4M7 17l-3 2M17 17l3 2M7 9 4 6M17 9l3-3" />
</svg>
<div class="min-w-0 flex-1">
<slot />
</div>
</div>
</template>
registry/components/ai/stack-trace/StackTraceErrorMessage.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/registry/edmi/lib/utils"
import { useStackTraceContext } from "./context"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
const { trace } = useStackTraceContext("StackTraceErrorMessage")
</script>
<template>
<span :class="cn('mt-0.5 block font-sans text-[13px] break-words text-foreground', props.class)">
<slot>{{ trace.errorMessage }}</slot>
</span>
</template>
registry/components/ai/stack-trace/StackTraceErrorType.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/registry/edmi/lib/utils"
import { useStackTraceContext } from "./context"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
const { trace } = useStackTraceContext("StackTraceErrorType")
</script>
<template>
<span :class="cn('block font-mono text-[12.5px] font-semibold text-destructive-text', props.class)">
<slot>{{ trace.errorType }}</slot>
</span>
</template>
registry/components/ai/stack-trace/StackTraceExpandButton.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { ChevronDownIcon } from "@lucide/vue"
import { cn } from "@/registry/edmi/lib/utils"
import { useStackTraceContext } from "./context"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
const { isOpen } = useStackTraceContext("StackTraceExpandButton")
</script>
<template>
<div
data-slot="ai-stack-trace-expand"
:class="cn('flex size-6 items-center justify-center', props.class)"
>
<ChevronDownIcon
:class="cn('size-3.5 text-muted-foreground transition-transform', isOpen ? 'rotate-180' : 'rotate-0')"
/>
</div>
</template>
registry/components/ai/stack-trace/StackTraceFrames.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { computed } from "vue"
import { cn } from "@/registry/edmi/lib/utils"
import { useStackTraceContext } from "./context"
import { AT_PREFIX_REGEX } from "./utils"
const props = withDefaults(defineProps<{
showInternalFrames?: boolean
class?: HTMLAttributes["class"]
}>(), {
showInternalFrames: true,
})
const { trace, onFilePathClick } = useStackTraceContext("StackTraceFrames")
const framesToShow = computed(() =>
props.showInternalFrames
? trace.value.frames
: trace.value.frames.filter(f => !f.isInternal))
</script>
<template>
<div data-slot="ai-stack-trace-frames" :class="cn('px-3.5 py-3', props.class)">
<div
v-for="(frame, index) in framesToShow"
:key="`${frame.raw}-${index}`"
:class="cn('flex items-center gap-2 text-xs leading-[1.9]', frame.isInternal && 'opacity-50')"
>
<span class="text-muted-foreground">at</span>
<span v-if="frame.functionName" class="text-foreground">{{ frame.functionName }}</span>
<button
v-if="frame.filePath"
type="button"
:disabled="!onFilePathClick"
:class="cn(
'text-muted-foreground',
!frame.isInternal && 'underline underline-offset-[3px]',
onFilePathClick && 'cursor-pointer hover:text-foreground',
)"
@click="onFilePathClick?.(frame.filePath, frame.lineNumber ?? undefined, frame.columnNumber ?? undefined)"
>
{{ frame.filePath }}<template v-if="frame.lineNumber !== null">:{{ frame.lineNumber }}</template><template v-if="frame.columnNumber !== null">:{{ frame.columnNumber }}</template>
</button>
<span v-else-if="!frame.functionName">{{ frame.raw.replace(AT_PREFIX_REGEX, "") }}</span>
</div>
<div v-if="framesToShow.length === 0" class="text-xs text-muted-foreground">
No stack frames
</div>
</div>
</template>
registry/components/ai/stack-trace/StackTraceHeader.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/registry/edmi/lib/utils"
import { CollapsibleTrigger } from "@/registry/edmi/ui/collapsible"
import { useStackTraceContext } from "./context"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
const { isOpen, setIsOpen } = useStackTraceContext("StackTraceHeader")
</script>
<template>
<CollapsibleTrigger as-child>
<div
data-slot="ai-stack-trace-header"
role="button"
tabindex="0"
:aria-expanded="!!isOpen"
:class="cn(
'flex w-full cursor-pointer items-start gap-2.5 px-3.5 py-2.5 text-left outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring',
props.class,
)"
@keydown.enter.self.prevent="setIsOpen(!isOpen)"
@keydown.space.self.prevent="setIsOpen(!isOpen)"
>
<slot />
</div>
</CollapsibleTrigger>
</template>
registry/components/ai/stack-trace/context.ts
import type { InjectionKey, Ref } from "vue";
import { inject } from "vue";
export interface StackFrame {
raw: string;
functionName: string | null;
filePath: string | null;
lineNumber: number | null;
columnNumber: number | null;
isInternal: boolean;
}
export interface ParsedStackTrace {
errorType: string | null;
errorMessage: string;
frames: StackFrame[];
raw: string;
}
export interface StackTraceContextValue {
trace: Ref<ParsedStackTrace>;
raw: Ref<string>;
isOpen: Ref<boolean | undefined>;
setIsOpen: (open: boolean) => void;
onFilePathClick?: (filePath: string, line?: number, column?: number) => void;
}
export const StackTraceKey: InjectionKey<StackTraceContextValue> =
Symbol("StackTrace");
export function useStackTraceContext(
componentName: string,
): StackTraceContextValue {
const context = inject(StackTraceKey);
if (!context) {
throw new Error(`${componentName} must be used within StackTrace`);
}
return context;
}
registry/components/ai/stack-trace/utils.ts
import type { ParsedStackTrace, StackFrame } from "./context";
export const STACK_FRAME_WITH_PARENS_REGEX =
/^at\s+(.+?)\s+\((.+):(\d+):(\d+)\)$/;
export const STACK_FRAME_WITHOUT_FN_REGEX = /^at\s+(.+):(\d+):(\d+)$/;
export const ERROR_TYPE_REGEX = /^(\w+Error|Error):\s*(.*)$/;
export const AT_PREFIX_REGEX = /^at\s+/;
function parseStackFrame(line: string): StackFrame {
const trimmed = line.trim();
// Pattern: at functionName (filePath:line:column)
const withParensMatch = trimmed.match(STACK_FRAME_WITH_PARENS_REGEX);
if (withParensMatch) {
const [, functionName, filePath, lineNum, colNum] = withParensMatch;
const isInternal =
filePath.includes("node_modules") ||
filePath.startsWith("node:") ||
filePath.includes("internal/");
return {
raw: trimmed,
functionName: functionName ?? null,
filePath: filePath ?? null,
lineNumber: lineNum ? Number.parseInt(lineNum, 10) : null,
columnNumber: colNum ? Number.parseInt(colNum, 10) : null,
isInternal,
};
}
// Pattern: at filePath:line:column (no function name)
const withoutFnMatch = trimmed.match(STACK_FRAME_WITHOUT_FN_REGEX);
if (withoutFnMatch) {
const [, filePath, lineNum, colNum] = withoutFnMatch;
const isInternal =
(filePath?.includes("node_modules") ?? false) ||
(filePath?.startsWith("node:") ?? false) ||
(filePath?.includes("internal/") ?? false);
return {
raw: trimmed,
functionName: null,
filePath: filePath ?? null,
lineNumber: lineNum ? Number.parseInt(lineNum, 10) : null,
columnNumber: colNum ? Number.parseInt(colNum, 10) : null,
isInternal,
};
}
// Fallback: unparseable line
return {
raw: trimmed,
functionName: null,
filePath: null,
lineNumber: null,
columnNumber: null,
isInternal: trimmed.includes("node_modules") || trimmed.includes("node:"),
};
}
export function parseStackTrace(trace: string): ParsedStackTrace {
const lines = trace.split("\n").filter((line) => line.trim());
if (lines.length === 0) {
return {
errorType: null,
errorMessage: trace,
frames: [],
raw: trace,
};
}
const firstLine = lines[0].trim();
let errorType: string | null = null;
let errorMessage = firstLine;
// Try to extract error type from "ErrorType: message" format
const errorMatch = firstLine.match(ERROR_TYPE_REGEX);
if (errorMatch) {
errorType = errorMatch[1];
errorMessage = errorMatch[2] || "";
}
// Parse stack frames (lines starting with "at")
const frames = lines
.slice(1)
.filter((line) => line.trim().startsWith("at "))
.map(parseStackFrame);
return {
errorType,
errorMessage,
frames,
raw: trace,
};
}
registry/components/ai/stack-trace/index.ts
export type { ParsedStackTrace, StackFrame } from "./context";
export { default as StackTrace } from "./StackTrace.vue";
export { default as StackTraceActions } from "./StackTraceActions.vue";
export { default as StackTraceContent } from "./StackTraceContent.vue";
export { default as StackTraceCopyButton } from "./StackTraceCopyButton.vue";
export { default as StackTraceError } from "./StackTraceError.vue";
export { default as StackTraceErrorMessage } from "./StackTraceErrorMessage.vue";
export { default as StackTraceErrorType } from "./StackTraceErrorType.vue";
export { default as StackTraceExpandButton } from "./StackTraceExpandButton.vue";
export { default as StackTraceFrames } from "./StackTraceFrames.vue";
export { default as StackTraceHeader } from "./StackTraceHeader.vue";
src/lib/registry/ai/stack-trace/index.ts
import StackTrace from "./stack-trace.svelte";
import StackTraceActions from "./stack-trace-actions.svelte";
import StackTraceContent from "./stack-trace-content.svelte";
import StackTraceCopyButton from "./stack-trace-copy-button.svelte";
import StackTraceError from "./stack-trace-error.svelte";
import StackTraceErrorMessage from "./stack-trace-error-message.svelte";
import StackTraceErrorType from "./stack-trace-error-type.svelte";
import StackTraceExpandButton from "./stack-trace-expand-button.svelte";
import StackTraceFrames from "./stack-trace-frames.svelte";
import StackTraceHeader from "./stack-trace-header.svelte";
export type { ParsedStackTrace, StackFrame } from "./use-stack-trace.svelte.js";
export {
StackTrace,
StackTraceActions,
StackTraceContent,
StackTraceCopyButton,
StackTraceError,
StackTraceErrorMessage,
StackTraceErrorType,
StackTraceExpandButton,
StackTraceFrames,
StackTraceHeader,
};
src/lib/registry/ai/stack-trace/stack-trace-actions.svelte
<script lang="ts">
import { cn } from "$lib/utils.js";
import type { Snippet } from "svelte";
let { class: className, children }: { class?: string; children?: Snippet } = $props();
function stop(event: Event) {
event.stopPropagation();
}
function stopKey(event: KeyboardEvent) {
if (event.key === "Enter" || event.key === " ") event.stopPropagation();
}
</script>
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<!-- Stops clicks and Enter/Space here so the actions do not toggle the header. -->
<div
data-slot="ai-stack-trace-actions"
role="group"
class={cn("flex shrink-0 items-center gap-0.5", className)}
onclick={stop}
onkeydown={stopKey}
>
{@render children?.()}
</div>
src/lib/registry/ai/stack-trace/stack-trace-content.svelte
<script lang="ts">
import * as Collapsible from "$lib/registry/ui/collapsible/index.js";
import { cn } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
maxHeight = 400,
class: className,
style,
children,
...restProps
}: ComponentProps<typeof Collapsible.Content> & { maxHeight?: number } = $props();
</script>
<Collapsible.Content
bind:ref
data-slot="ai-stack-trace-content"
class={cn("overflow-auto border-t border-border", className)}
style={`max-height: ${maxHeight}px;${style ? ` ${style}` : ""}`}
{...restProps}
>
{@render children?.()}
</Collapsible.Content>
src/lib/registry/ai/stack-trace/stack-trace-copy-button.svelte
<script lang="ts">
import CheckIcon from "phosphor-svelte/lib/Check";
import CopyIcon from "phosphor-svelte/lib/Copy";
import { Button, type ButtonProps } from "$lib/registry/ui/button/index.js";
import { onDestroy } from "svelte";
import { useStackTraceContext } from "./use-stack-trace.svelte.js";
let {
onCopy,
onError,
timeout = 2000,
class: className,
children,
...restProps
}: Omit<ButtonProps, "onclick" | "href"> & {
onCopy?: () => void;
onError?: (error: Error) => void;
timeout?: number;
} = $props();
const stack = useStackTraceContext();
let isCopied = $state(false);
let timer: ReturnType<typeof setTimeout> | undefined;
async function copy() {
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
onError?.(new Error("Clipboard API not available"));
return;
}
try {
await navigator.clipboard.writeText(stack.raw);
isCopied = true;
onCopy?.();
clearTimeout(timer);
timer = setTimeout(() => (isCopied = false), timeout);
} catch (error) {
onError?.(error as Error);
}
}
onDestroy(() => clearTimeout(timer));
</script>
<Button
aria-label="Copy stack trace"
class={className}
onclick={copy}
size="icon-xs"
type="button"
variant="ghost"
{...restProps}
>
{#if children}
{@render children()}
{:else if isCopied}
<CheckIcon
class="size-3.5"
/>
{:else}
<CopyIcon
class="size-3.5"
/>
{/if}
</Button>
src/lib/registry/ai/stack-trace/stack-trace-error-message.svelte
<script lang="ts">
import { cn } from "$lib/utils.js";
import type { Snippet } from "svelte";
import { useStackTraceContext } from "./use-stack-trace.svelte.js";
let { class: className, children }: { class?: string; children?: Snippet } = $props();
const stack = useStackTraceContext();
</script>
<span class={cn("mt-0.5 block font-sans text-[13px] break-words text-foreground", className)}>
{#if children}{@render children()}{:else}{stack.trace.errorMessage}{/if}
</span>
src/lib/registry/ai/stack-trace/stack-trace-error-type.svelte
<script lang="ts">
import { cn } from "$lib/utils.js";
import type { Snippet } from "svelte";
import { useStackTraceContext } from "./use-stack-trace.svelte.js";
let { class: className, children }: { class?: string; children?: Snippet } = $props();
const stack = useStackTraceContext();
</script>
<span class={cn("block font-mono text-[12.5px] font-semibold text-destructive-text", className)}>
{#if children}{@render children()}{:else}{stack.trace.errorType}{/if}
</span>
src/lib/registry/ai/stack-trace/stack-trace-error.svelte
<script lang="ts">
import { cn } from "$lib/utils.js";
import type { Snippet } from "svelte";
let { class: className, children }: { class?: string; children?: Snippet } = $props();
</script>
<div
data-slot="ai-stack-trace-error"
class={cn("flex flex-1 items-start gap-2.5 overflow-hidden", className)}
>
<svg
aria-hidden="true"
class="mt-px size-[15px] shrink-0 text-destructive-text"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.7"
viewBox="0 0 24 24"
>
<rect height="13" rx="5" width="10" x="7" y="7" />
<path d="M12 7V4M7 12H3M21 12h-4M7 17l-3 2M17 17l3 2M7 9 4 6M17 9l3-3" />
</svg>
<div class="min-w-0 flex-1">
{@render children?.()}
</div>
</div>
src/lib/registry/ai/stack-trace/stack-trace-expand-button.svelte
<script lang="ts">
import CaretDownIcon from "phosphor-svelte/lib/CaretDown";
import { cn } from "$lib/utils.js";
import { useStackTraceContext } from "./use-stack-trace.svelte.js";
let { class: className }: { class?: string } = $props();
const stack = useStackTraceContext();
</script>
<div data-slot="ai-stack-trace-expand" class={cn("flex size-6 items-center justify-center", className)}>
<CaretDownIcon
class={cn("size-3.5 text-muted-foreground transition-transform", stack.open ? "rotate-180" : "rotate-0")}
/>
</div>
src/lib/registry/ai/stack-trace/stack-trace-frames.svelte
<script lang="ts">
import { cn } from "$lib/utils.js";
import { AT_PREFIX_REGEX, useStackTraceContext } from "./use-stack-trace.svelte.js";
let {
showInternalFrames = true,
class: className,
}: { showInternalFrames?: boolean; class?: string } = $props();
const stack = useStackTraceContext();
const framesToShow = $derived(
showInternalFrames ? stack.trace.frames : stack.trace.frames.filter((f) => !f.isInternal)
);
</script>
<div data-slot="ai-stack-trace-frames" class={cn("px-3.5 py-3", className)}>
{#each framesToShow as frame, index (`${frame.raw}-${index}`)}
<div class={cn("flex items-center gap-2 text-xs leading-[1.9]", frame.isInternal && "opacity-50")}>
<span class="text-muted-foreground">at</span>
{#if frame.functionName}
<span class="text-foreground">{frame.functionName}</span>
{/if}
{#if frame.filePath}
<button
type="button"
disabled={!stack.onFilePathClick}
class={cn(
"text-muted-foreground",
!frame.isInternal && "underline underline-offset-[3px]",
stack.onFilePathClick && "cursor-pointer hover:text-foreground"
)}
onclick={() =>
frame.filePath &&
stack.onFilePathClick?.(
frame.filePath,
frame.lineNumber ?? undefined,
frame.columnNumber ?? undefined
)}
>
{frame.filePath}{frame.lineNumber !== null ? `:${frame.lineNumber}` : ""}{frame.columnNumber !==
null
? `:${frame.columnNumber}`
: ""}
</button>
{:else if !frame.functionName}
<span>{frame.raw.replace(AT_PREFIX_REGEX, "")}</span>
{/if}
</div>
{/each}
{#if framesToShow.length === 0}
<div class="text-xs text-muted-foreground">No stack frames</div>
{/if}
</div>
src/lib/registry/ai/stack-trace/stack-trace-header.svelte
<script lang="ts">
import * as Collapsible from "$lib/registry/ui/collapsible/index.js";
import { cn } from "$lib/utils.js";
import type { Snippet } from "svelte";
import { useStackTraceContext } from "./use-stack-trace.svelte.js";
let { class: className, children }: { class?: string; children?: Snippet } = $props();
const stack = useStackTraceContext();
function onkeydown(event: KeyboardEvent) {
if (event.target !== event.currentTarget) return;
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
stack.open = !stack.open;
}
}
</script>
<!-- A div, not a button: the actions inside the header are buttons themselves. -->
<Collapsible.Trigger>
{#snippet child({ props })}
<div
{...props}
data-slot="ai-stack-trace-header"
role="button"
tabindex="0"
{onkeydown}
class={cn(
"flex w-full cursor-pointer items-start gap-2.5 px-3.5 py-2.5 text-left outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring",
className
)}
>
{@render children?.()}
</div>
{/snippet}
</Collapsible.Trigger>
src/lib/registry/ai/stack-trace/stack-trace.svelte
<script lang="ts">
import * as Collapsible from "$lib/registry/ui/collapsible/index.js";
import { cn } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
import { parseStackTrace, setStackTraceContext } from "./use-stack-trace.svelte.js";
let {
ref = $bindable(null),
trace,
open = $bindable(false),
onFilePathClick,
class: className,
children,
...restProps
}: ComponentProps<typeof Collapsible.Root> & {
trace: string;
onFilePathClick?: (filePath: string, line?: number, column?: number) => void;
} = $props();
const parsed = $derived(parseStackTrace(trace));
setStackTraceContext({
get trace() {
return parsed;
},
get raw() {
return trace;
},
get open() {
return open;
},
set open(value: boolean) {
open = value;
},
get onFilePathClick() {
return onFilePathClick;
},
});
</script>
<Collapsible.Root
bind:ref
bind:open
data-slot="ai-stack-trace"
class={cn(
"not-prose w-full overflow-hidden rounded-[calc(var(--radius)*1.2)] border border-border bg-card font-mono text-sm",
className
)}
{...restProps}
>
{@render children?.()}
</Collapsible.Root>
src/lib/registry/ai/stack-trace/use-stack-trace.svelte.ts
import { getContext, setContext } from "svelte";
export interface StackFrame {
raw: string;
functionName: string | null;
filePath: string | null;
lineNumber: number | null;
columnNumber: number | null;
isInternal: boolean;
}
export interface ParsedStackTrace {
errorType: string | null;
errorMessage: string;
frames: StackFrame[];
raw: string;
}
export interface StackTraceContext {
readonly trace: ParsedStackTrace;
readonly raw: string;
open: boolean;
onFilePathClick?: (filePath: string, line?: number, column?: number) => void;
}
const KEY = Symbol("ai-stack-trace");
export function setStackTraceContext(ctx: StackTraceContext) {
setContext(KEY, ctx);
}
export function useStackTraceContext(): StackTraceContext {
const ctx = getContext<StackTraceContext | undefined>(KEY);
if (!ctx)
throw new Error("StackTrace components must be used within StackTrace");
return ctx;
}
const STACK_FRAME_WITH_PARENS_REGEX = /^at\s+(.+?)\s+\((.+):(\d+):(\d+)\)$/;
const STACK_FRAME_WITHOUT_FN_REGEX = /^at\s+(.+):(\d+):(\d+)$/;
const ERROR_TYPE_REGEX = /^(\w+Error|Error):\s*(.*)$/;
export const AT_PREFIX_REGEX = /^at\s+/;
function parseStackFrame(line: string): StackFrame {
const trimmed = line.trim();
// Pattern: at functionName (filePath:line:column)
const withParensMatch = trimmed.match(STACK_FRAME_WITH_PARENS_REGEX);
if (withParensMatch) {
const [, functionName, filePath, lineNum, colNum] = withParensMatch;
const isInternal =
filePath.includes("node_modules") ||
filePath.startsWith("node:") ||
filePath.includes("internal/");
return {
raw: trimmed,
functionName: functionName ?? null,
filePath: filePath ?? null,
lineNumber: lineNum ? Number.parseInt(lineNum, 10) : null,
columnNumber: colNum ? Number.parseInt(colNum, 10) : null,
isInternal,
};
}
// Pattern: at filePath:line:column (no function name)
const withoutFnMatch = trimmed.match(STACK_FRAME_WITHOUT_FN_REGEX);
if (withoutFnMatch) {
const [, filePath, lineNum, colNum] = withoutFnMatch;
const isInternal =
(filePath?.includes("node_modules") ?? false) ||
(filePath?.startsWith("node:") ?? false) ||
(filePath?.includes("internal/") ?? false);
return {
raw: trimmed,
functionName: null,
filePath: filePath ?? null,
lineNumber: lineNum ? Number.parseInt(lineNum, 10) : null,
columnNumber: colNum ? Number.parseInt(colNum, 10) : null,
isInternal,
};
}
// Fallback: unparseable line
return {
raw: trimmed,
functionName: null,
filePath: null,
lineNumber: null,
columnNumber: null,
isInternal: trimmed.includes("node_modules") || trimmed.includes("node:"),
};
}
export function parseStackTrace(trace: string): ParsedStackTrace {
const lines = trace.split("\n").filter((line) => line.trim());
if (lines.length === 0) {
return {
errorType: null,
errorMessage: trace,
frames: [],
raw: trace,
};
}
const firstLine = lines[0].trim();
let errorType: string | null = null;
let errorMessage = firstLine;
// Try to extract error type from "ErrorType: message" format
const errorMatch = firstLine.match(ERROR_TYPE_REGEX);
if (errorMatch) {
errorType = errorMatch[1];
errorMessage = errorMatch[2] || "";
}
// Parse stack frames (lines starting with "at")
const frames = lines
.slice(1)
.filter((line) => line.trim().startsWith("at "))
.map(parseStackFrame);
return {
errorType,
errorMessage,
frames,
raw: trace,
};
}
Part Prop Type Default
StackTrace trace, open, defaultOpen, onOpenChange string, boolean, boolean, (open) => void false
StackTrace onFilePathClick (file, line?, column?) => void
StackTraceContent maxHeight number 400
StackTraceFrames showInternalFrames boolean true
StackTraceHeader, Error, ErrorType, ErrorMessage, Actions, CopyButton, ExpandButton
  • Frames outside your code (node_modules, node:, internal/) are dimmed; your files are underlined links when onFilePathClick is set.
  • The error type uses --destructive-text with a bug icon, as on the board.