Stack Trace
Collapsible error stack with file links and internal frame filtering.
import { StackTrace, StackTraceActions, StackTraceContent, StackTraceCopyButton, StackTraceError, StackTraceErrorMessage, StackTraceErrorType, StackTraceExpandButton, StackTraceFrames, StackTraceHeader,} from "@/components/ai/stack-trace";
const trace = `TypeError: Cannot read properties of undefined (reading 'price') at getDrift (lib/drift.ts:14:22) at run (lib/keeper.ts:8:17) at processTicksAndRejections (node:internal/process/task_queues:95:5)`;
export default function Demo() { return ( <StackTrace className="max-w-xl" defaultOpen onFilePathClick={(file, line) => console.log(file, line)} trace={trace} > <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";
const trace = `TypeError: Cannot read properties of undefined (reading 'price') at getDrift (lib/drift.ts:14:22) at run (lib/keeper.ts:8:17) at processTicksAndRejections (node:internal/process/task_queues:95:5)`;</script>
<template> <StackTrace class="max-w-xl" default-open :trace="trace" @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";
const trace = `TypeError: Cannot read properties of undefined (reading 'price') at getDrift (lib/drift.ts:14:22) at run (lib/keeper.ts:8:17) at processTicksAndRejections (node:internal/process/task_queues:95:5)`;</script>
<StackTrace class="max-w-xl" open {trace} onFilePathClick={() => {}}> <StackTraceHeader> <StackTraceError> <StackTraceErrorType /> <StackTraceErrorMessage /> </StackTraceError> <StackTraceActions> <StackTraceCopyButton /> <StackTraceExpandButton /> </StackTraceActions> </StackTraceHeader> <StackTraceContent> <StackTraceFrames /> </StackTraceContent></StackTrace>Built on collapsible and button; open state uses the ai-use-controllable-state hook.
Installation
Section titled “Installation”npx shadcn@latest add @edmi-ui/ai-stack-tracepnpm dlx shadcn@latest add @edmi-ui/ai-stack-traceyarn dlx shadcn@latest add @edmi-ui/ai-stack-tracebunx shadcn@latest add @edmi-ui/ai-stack-tracePick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Add the Edmi components this one builds on:
button,collapsible.Copy the code from Component source into your project and update the import paths to match your aliases.
npx shadcn-vue@latest add @edmi-ui/ai-stack-tracepnpm dlx shadcn-vue@latest add @edmi-ui/ai-stack-traceyarn dlx shadcn-vue@latest add @edmi-ui/ai-stack-tracebunx shadcn-vue@latest add @edmi-ui/ai-stack-tracePick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Install the dependencies:
Terminal window npm install @lucide/vue @vueuse/coreTerminal window pnpm add @lucide/vue @vueuse/coreTerminal window yarn add @lucide/vue @vueuse/coreTerminal window bun add @lucide/vue @vueuse/coreAdd the Edmi components this one builds on:
button,collapsible.Copy the code from Component source into your project and update the import paths to match your aliases.
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-stack-trace.jsonpnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-stack-trace.jsonyarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-stack-trace.jsonbunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-stack-trace.jsonPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Add the Edmi components this one builds on:
button,collapsible.Copy the code from Component source into your project and update the import paths to match your aliases.
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>Component source
Section titled “Component source”"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 tracesconst 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";<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><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><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><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><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><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><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><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><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><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>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;}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, };}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";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,};<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><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><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><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><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><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><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><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><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><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>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 |
✦ Edmi additions
Section titled “✦ Edmi additions”- Frames outside your code (
node_modules,node:,internal/) are dimmed; your files are underlined links whenonFilePathClickis set. - The error type uses
--destructive-textwith a bug icon, as on the board.