'use client'; import { EVENT_DATA_REF_FIELDS, type Event } from '@workflow/world'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { isExpiredMarker } from '../../lib/hydration'; import { ErrorCard } from '../ui/error-card'; import { ErrorStackBlock, isStructuredError } 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. */ const DATA_EVENT_TYPES = new Set([ 'step_created', 'step_completed', 'step_failed', 'step_retrying', 'hook_created', 'hook_received', 'run_created', 'run_completed', 'run_failed', 'wait_created', 'wait_completed', ]); /** * A single event row that can lazy-load its eventData when expanded. */ function EventItem({ event, onLoadEventData, encryptionKey, }: { event: Event; onLoadEventData?: ( correlationId: string, eventId: string ) => Promise; /** When this changes (e.g., Decrypt was clicked), invalidate cached data */ encryptionKey?: Uint8Array; }) { const [loadedData, setLoadedData] = useState(null); const [isLoading, setIsLoading] = useState(false); const [loadError, setLoadError] = useState(null); const wasExpandedRef = useRef(false); /** Mirrors whether we have a fetched payload; avoids stale `loadedData` in load closure. */ const loadedDataRef = useRef(null); // Check if the event already has eventData from the store const existingData = 'eventData' in event && event.eventData != null ? event.eventData : null; const mergedDisplay = loadedData ?? existingData; const canHaveData = DATA_EVENT_TYPES.has(event.eventType); const loadEventData = useCallback( async (options?: { force?: boolean }) => { if (!onLoadEventData || !event.correlationId || !event.eventId) return; if (!options?.force && loadedDataRef.current !== null) { return; } try { setIsLoading(true); setLoadError(null); const data = await onLoadEventData(event.correlationId, event.eventId); loadedDataRef.current = data; setLoadedData(data); } catch (err) { setLoadError(err instanceof Error ? err.message : String(err)); } finally { setIsLoading(false); } }, [onLoadEventData, event.correlationId, event.eventId] ); const handleExpand = useCallback(async () => { if (isLoading) return; wasExpandedRef.current = true; await loadEventData(); }, [isLoading, loadEventData]); // When the encryption key changes and this event was previously expanded, // re-load the data so it gets decrypted useEffect(() => { if (!encryptionKey || !wasExpandedRef.current) return; loadedDataRef.current = null; setLoadedData(null); void loadEventData({ force: true }); }, [encryptionKey, loadEventData]); const createdAt = new Date(event.createdAt); const displayPayload = isLoading ? loadedData : mergedDisplay; return ( {event.eventType} {' '} -{' '} {localMillisecondTime(createdAt.getTime())} } onToggle={ canHaveData ? (open) => { if (open) handleExpand(); } : undefined } > {/* Event attributes */}
eventId {event.eventId}
{event.correlationId && (
correlationId {event.correlationId}
)}
{/* Loading state */} {isLoading && (
)} {/* Error state */} {loadError && ( )} {/* Event data */} {displayPayload != null && (
)}
); } /** * Check if an eventData object has only expired marker values in ref/payload * fields for this event type (see {@link EVENT_DATA_REF_FIELDS}). Other keys * (e.g. `resumeAt`, `stepName`) are ignored. */ function hasOnlyExpiredFields(data: unknown, eventType: string): boolean { if (data === null || typeof data !== 'object' || Array.isArray(data)) { return false; } const record = data as Record; const refKeys = EVENT_DATA_REF_FIELDS[eventType] ?? []; const presentKeys = refKeys.filter((k) => k in record); return ( presentKeys.length > 0 && presentKeys.every((k) => isExpiredMarker(record[k])) ); } /** * Renders event data, using ErrorStackBlock for error events that contain a * structured error, and CopyableDataBlock otherwise. */ function EventDataBlock({ eventType, data, }: { eventType: string; data: unknown; }) { // Expired data — show a simple message instead of the raw stub. // Check both the top-level eventData and nested sub-fields (result, input, etc.) // since the server stubs each ref field independently. if (isExpiredMarker(data) || hasOnlyExpiredFields(data, eventType)) { return (
Data expired
); } // 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 (isStructuredError(record.error)) { return ; } // Some error formats put the message/stack at the top level of eventData. if (isStructuredError(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, error, onLoadEventData, encryptionKey, }: { events: Event[]; isLoading?: boolean; error?: Error | null; onLoadEventData?: ( correlationId: string, eventId: string ) => Promise; /** When provided, signals that decryption is active (triggers re-load of expanded events) */ encryptionKey?: Uint8Array; }) { // Sort by createdAt const sortedEvents = useMemo( () => [...events].sort( (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() ), [events] ); return (

Events {!isLoading && `(${sortedEvents.length})`}

{isLoading ? (
) : null} {!isLoading && !error && sortedEvents.length === 0 && (
No events found
)} {sortedEvents.length > 0 && !error ? (
{sortedEvents.map((event) => ( ))}
) : null}
); }