diff --git a/.changeset/render-error-stack-text.md b/.changeset/render-error-stack-text.md new file mode 100644 index 0000000000..3e23db6e89 --- /dev/null +++ b/.changeset/render-error-stack-text.md @@ -0,0 +1,5 @@ +--- +"@workflow/web-shared": patch +--- + +Render structured error stack traces as readable pre-formatted text everywhere errors are displayed (attribute panel, sidebar events, and events tab) diff --git a/packages/web-shared/src/components/event-list-view.tsx b/packages/web-shared/src/components/event-list-view.tsx index 958f2d478c..13755f15f1 100644 --- a/packages/web-shared/src/components/event-list-view.tsx +++ b/packages/web-shared/src/components/event-list-view.tsx @@ -8,8 +8,22 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'; import { formatDuration } from '../lib/utils'; import { DataInspector } from './ui/data-inspector'; +import { + ErrorStackBlock, + isStructuredErrorWithStack, +} from './ui/error-stack-block'; import { Skeleton } from './ui/skeleton'; +/** + * Event types whose eventData contains an error field with a StructuredError. + */ +const ERROR_EVENT_TYPES = new Set([ + 'step_failed', + 'step_retrying', + 'run_failed', + 'workflow_failed', +]); + const BUTTON_RESET_STYLE: React.CSSProperties = { appearance: 'none', WebkitAppearance: 'none', @@ -449,7 +463,36 @@ function deepParseJson(value: unknown): unknown { return value; } -function PayloadBlock({ data }: { data: unknown }): ReactNode { +/** + * Extracts a structured error with a stack trace from event data, if present. + * Returns the error object to render with ErrorStackBlock, or null if not applicable. + */ +function extractStructuredError( + data: unknown, + eventType?: string +): (Record & { stack: string }) | null { + if (!eventType || !ERROR_EVENT_TYPES.has(eventType)) return null; + if (data == null || typeof data !== 'object') return null; + const record = data as Record; + // Check the nested `error` field first (the StructuredError) + if (isStructuredErrorWithStack(record.error)) return record.error; + // Some error formats put the stack at the top level of eventData + if (isStructuredErrorWithStack(record)) return record; + return null; +} + +function PayloadBlock({ + data, + eventType, +}: { + data: unknown; + eventType?: string; +}): ReactNode { + const structuredError = useMemo( + () => extractStructuredError(data, eventType), + [data, eventType] + ); + const [copied, setCopied] = useState(false); const resetCopiedTimeoutRef = useRef(null); const cleaned = useMemo(() => deepParseJson(data), [data]); @@ -487,6 +530,14 @@ function PayloadBlock({ data }: { data: unknown }): ReactNode { [formatted] ); + if (structuredError) { + return ( +
+ +
+ ); + } + return (
+ ) : loadError ? (
({ + tabs, + activeTab, + onTabChange, + ariaLabel, + children, }: { - conversation: ModelMessage[]; - args: unknown[]; + tabs: { id: T; label: string }[]; + activeTab: T; + onTabChange: (tab: T) => void; + ariaLabel: string; + children: ReactNode; }) { - const [activeTab, setActiveTab] = useState<'conversation' | 'json'>( - 'conversation' + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.key !== 'ArrowRight' && event.key !== 'ArrowLeft') return; + event.preventDefault(); + const currentIndex = tabs.findIndex((t) => t.id === activeTab); + const nextIndex = + event.key === 'ArrowRight' + ? (currentIndex + 1) % tabs.length + : (currentIndex - 1 + tabs.length) % tabs.length; + onTabChange(tabs[nextIndex].id); + }, + [tabs, activeTab, onTabChange] ); return ( - +
-
- setActiveTab('conversation')} - > - Conversation - + {tabs.map((tab) => ( setActiveTab('json')} + key={tab.id} + active={activeTab === tab.id} + onClick={() => onTabChange(tab.id)} > - Raw JSON + {tab.label} -
+ ))} +
+ +
{children}
+
+ ); +} +const conversationTabs = [ + { id: 'conversation' as const, label: 'Conversation' }, + { id: 'json' as const, label: 'Raw JSON' }, +]; + +/** + * Tabbed view for conversation and raw JSON + */ +function ConversationWithTabs({ + conversation, + args, +}: { + conversation: ModelMessage[]; + args: unknown[]; +}) { + const [activeTab, setActiveTab] = useState<'conversation' | 'json'>( + 'conversation' + ); + + return ( + + {activeTab === 'conversation' ? ( ) : ( @@ -108,7 +163,7 @@ function ConversationWithTabs({ : JsonBlock(args)}
)} -
+ ); } @@ -396,6 +451,21 @@ const attributeToDisplayFn: Record< }, error: (value: unknown) => { if (!hasDisplayContent(value)) return null; + + // If the error object has a `stack` field, render it as readable + // pre-formatted text. Otherwise fall back to the raw JSON viewer. + if (isStructuredErrorWithStack(value)) { + return ( + + + + ); + } + return ( - +
)} ); } +/** + * Renders event data, using ErrorStackBlock for error events that contain + * a structured error with a stack trace, and CopyableDataBlock otherwise. + */ +function EventDataBlock({ + eventType, + data, +}: { + eventType: string; + data: unknown; +}) { + // For error events (step_failed, step_retrying), the eventData has the shape + // { error: StructuredError, stack?: string, ... }. Check both the top-level + // value and the nested `error` field for a stack trace. + if ( + ERROR_EVENT_TYPES.has(eventType) && + data != null && + typeof data === 'object' + ) { + const record = data as Record; + + // Check the nested `error` field first (the StructuredError) + if (isStructuredErrorWithStack(record.error)) { + return ; + } + + // Some error formats put the stack at the top level of eventData + if (isStructuredErrorWithStack(record)) { + return ; + } + } + + // For non-error events or errors without a stack, fall back to the + // generic JSON viewer. + return ; +} + export function EventsList({ events, isLoading = false, diff --git a/packages/web-shared/src/components/ui/error-stack-block.tsx b/packages/web-shared/src/components/ui/error-stack-block.tsx new file mode 100644 index 0000000000..c9e1066580 --- /dev/null +++ b/packages/web-shared/src/components/ui/error-stack-block.tsx @@ -0,0 +1,80 @@ +'use client'; + +import { Copy } from 'lucide-react'; +import { toast } from 'sonner'; + +/** + * Check whether `value` looks like a structured error object with a `stack` + * field that we can render as pre-formatted text. + */ +export function isStructuredErrorWithStack( + value: unknown +): value is Record & { stack: string } { + return ( + value != null && + typeof value === 'object' && + 'stack' in value && + typeof (value as Record).stack === 'string' + ); +} + +/** + * Renders an error with a `stack` field as readable pre-formatted text, + * styled to match the CopyableDataBlock component. The error message is + * displayed at the top with a visual separator from the stack trace. + * The entire block is copyable via a copy button. + */ +export function ErrorStackBlock({ + value, +}: { + value: Record & { stack: string }; +}) { + const stack = value.stack; + const message = typeof value.message === 'string' ? value.message : undefined; + const copyText = message ? `${message}\n\n${stack}` : stack; + + return ( +
+ + + {message && ( +

+ {message} +

+ )} +
+        {stack}
+      
+
+ ); +}