Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/render-error-stack-text.md
Original file line number Diff line number Diff line change
@@ -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)
55 changes: 53 additions & 2 deletions packages/web-shared/src/components/event-list-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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<string, unknown> & { 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<string, unknown>;
// 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<number | null>(null);
const cleaned = useMemo(() => deepParseJson(data), [data]);
Expand Down Expand Up @@ -487,6 +530,14 @@ function PayloadBlock({ data }: { data: unknown }): ReactNode {
[formatted]
);

if (structuredError) {
return (
<div className="p-2">
<ErrorStackBlock value={structuredError} />
</div>
);
}

return (
<div className="relative group/payload">
<div
Expand Down Expand Up @@ -838,7 +889,7 @@ function EventRow({

{/* Payload */}
{eventData != null ? (
<PayloadBlock data={eventData} />
<PayloadBlock data={eventData} eventType={event.eventType} />
) : loadError ? (
<div
className="rounded-md border p-3 text-xs"
Expand Down
130 changes: 100 additions & 30 deletions packages/web-shared/src/components/sidebar/attribute-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,19 @@
import { parseStepName, parseWorkflowName } from '@workflow/utils/parse-name';
import type { Event, Hook, Step, WorkflowRun } from '@workflow/world';
import type { ModelMessage } from 'ai';
import type { ReactNode } from 'react';
import type { KeyboardEvent, ReactNode } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { toast } from 'sonner';
import { extractConversation, isDoStreamStep } from '../../lib/utils';
import { StreamClickContext } from '../ui/data-inspector';
import { ErrorCard } from '../ui/error-card';
import {
ErrorStackBlock,
isStructuredErrorWithStack,
} from '../ui/error-stack-block';
import { Skeleton } from '../ui/skeleton';
import { CopyableDataBlock } from './copyable-data-block';
import { ConversationView } from './conversation-view';
import { CopyableDataBlock } from './copyable-data-block';
import { DetailCard } from './detail-card';

/**
Expand All @@ -29,6 +33,9 @@ function TabButton({
return (
<button
type="button"
role="tab"
aria-selected={active}
tabIndex={active ? 0 : -1}
onClick={onClick}
className="px-3 py-1.5 text-[11px] font-medium transition-colors -mb-px"
style={{
Expand All @@ -52,49 +59,97 @@ function TabButton({
}

/**
* Tabbed view for conversation and raw JSON
* Shared tabbed container with accessible ARIA roles and keyboard navigation.
* Used by ConversationWithTabs for the conversation/JSON toggle.
*/
function ConversationWithTabs({
conversation,
args,
function TabbedContainer<T extends string>({
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<HTMLDivElement>) => {
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 (
<DetailCard summary={`Input (${conversation.length} messages)`}>
<div
className="rounded-md border"
style={{
borderColor: 'var(--ds-gray-300)',
backgroundColor: 'transparent',
}}
>
<div
className="rounded-md border"
className="flex gap-1 border-b"
role="tablist"
aria-label={ariaLabel}
onKeyDown={handleKeyDown}
style={{
borderColor: 'var(--ds-gray-300)',
backgroundColor: 'transparent',
}}
>
<div
className="flex gap-1 border-b"
style={{
borderColor: 'var(--ds-gray-300)',
backgroundColor: 'transparent',
}}
>
<TabButton
active={activeTab === 'conversation'}
onClick={() => setActiveTab('conversation')}
>
Conversation
</TabButton>
{tabs.map((tab) => (
<TabButton
active={activeTab === 'json'}
onClick={() => setActiveTab('json')}
key={tab.id}
active={activeTab === tab.id}
onClick={() => onTabChange(tab.id)}
>
Raw JSON
{tab.label}
</TabButton>
</div>
))}
</div>

<div role="tabpanel">{children}</div>
</div>
);
}

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 (
<DetailCard summary={`Input (${conversation.length} messages)`}>
<TabbedContainer
tabs={conversationTabs}
activeTab={activeTab}
onTabChange={setActiveTab}
ariaLabel="Conversation view"
>
{activeTab === 'conversation' ? (
<ConversationView messages={conversation} />
) : (
Expand All @@ -108,7 +163,7 @@ function ConversationWithTabs({
: JsonBlock(args)}
</div>
)}
</div>
</TabbedContainer>
</DetailCard>
);
}
Expand Down Expand Up @@ -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 (
<DetailCard
summary="Error"
summaryClassName="text-base py-2"
contentClassName="mt-0"
>
<ErrorStackBlock value={value} />
</DetailCard>
);
}

return (
<DetailCard
summary="Error"
Expand Down
48 changes: 47 additions & 1 deletion packages/web-shared/src/components/sidebar/events-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,20 @@

import type { Event } from '@workflow/world';
import { useCallback, useMemo, useState } from 'react';
import {
ErrorStackBlock,
isStructuredErrorWithStack,
} from '../ui/error-stack-block';
import { Skeleton } from '../ui/skeleton';
import { localMillisecondTime } from './attribute-panel';
import { CopyableDataBlock } from './copyable-data-block';
import { DetailCard } from './detail-card';

/**
* Event types whose eventData contains an error field with a StructuredError.
*/
const ERROR_EVENT_TYPES = new Set(['step_failed', 'step_retrying']);

/**
* Event types that carry user-serialized data in their eventData field.
*/
Expand Down Expand Up @@ -168,13 +177,50 @@ function EventItem({
{/* Event data */}
{displayData != null && (
<div className="mt-2">
<CopyableDataBlock data={displayData} />
<EventDataBlock eventType={event.eventType} data={displayData} />
</div>
)}
</DetailCard>
);
}

/**
* 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<string, unknown>;

// Check the nested `error` field first (the StructuredError)
if (isStructuredErrorWithStack(record.error)) {
return <ErrorStackBlock value={record.error} />;
}

// Some error formats put the stack at the top level of eventData
if (isStructuredErrorWithStack(record)) {
return <ErrorStackBlock value={record} />;
}
}

// For non-error events or errors without a stack, fall back to the
// generic JSON viewer.
return <CopyableDataBlock data={data} />;
}

export function EventsList({
events,
isLoading = false,
Expand Down
Loading
Loading