'use client'; import { parseStepName, parseWorkflowName } from '@workflow/utils/parse-name'; import type { Event, Hook, Step, WorkflowRun } from '@workflow/world'; import { ChevronDown, ChevronUp, Clock, Copy, Info, Send, Type, X, XCircle, } from 'lucide-react'; import type { MouseEvent as ReactMouseEvent, ReactNode } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { useToast } from '../lib/toast'; import { ErrorBoundary } from './error-boundary'; import { EntityDetailPanel, type SelectedSpanInfo, type SpanSelectionInfo, } from './sidebar/entity-detail-panel'; import { ResolveHookModal } from './sidebar/resolve-hook-modal'; import { TraceViewerContextProvider, TraceViewerTimeline, useTraceViewer, } from './trace-viewer'; import type { Span } from './trace-viewer/types'; import { Skeleton } from './ui/skeleton'; import { Spinner } from './ui/spinner'; import { getCustomSpanClassName, getCustomSpanEventClassName, } from './workflow-traces/trace-colors'; import { buildTrace, type TraceWithMeta } from '../lib/trace-builder'; /** * While a run is live, continuously grow root.duration and rescale so the * trace always fits within the viewport. Individual span widths are grown * by each SpanComponent's own useEffect (see node.tsx). */ function useLiveTick(isLive: boolean): void { const { state, dispatch } = useTraceViewer(); const stateRef = useRef(state); stateRef.current = state; useEffect(() => { if (!isLive) return; // Grow root.duration on every frame so span rAFs see the latest time let rafId = 0; const tick = (): void => { const { root } = stateRef.current; if (root.startTime) { const nowMs = Date.now(); if (nowMs > root.endTime) { root.endTime = nowMs; root.duration = root.endTime - root.startTime; } } rafId = requestAnimationFrame(tick); }; rafId = requestAnimationFrame(tick); // Re-scale smoothly so the trace fits the viewport as it grows. // We dispatch detectBaseScale only when the computed baseScale has // changed enough to matter visually (>0.1% shift), avoiding // unnecessary React re-renders while keeping things smooth. let scaleRafId = 0; let lastBaseScale = 0; const scaleTick = (): void => { const s = stateRef.current; if (s.root.duration > 0) { const newBaseScale = (s.width - s.scrollbarWidth) / s.root.duration; const delta = Math.abs(newBaseScale - lastBaseScale); if (delta > lastBaseScale * 0.001 || lastBaseScale === 0) { lastBaseScale = newBaseScale; dispatch({ type: 'detectBaseScale' }); } } scaleRafId = requestAnimationFrame(scaleTick); }; scaleRafId = requestAnimationFrame(scaleTick); return () => { cancelAnimationFrame(rafId); cancelAnimationFrame(scaleRafId); }; }, [isLive, dispatch]); } const DEFAULT_PANEL_WIDTH = 450; const MIN_PANEL_WIDTH = 240; // ────────────────────────────────────────────────────────────────────────── // Right-click context menu for spans // ────────────────────────────────────────────────────────────────────────── type ResourceType = 'sleep' | 'step' | 'hook' | 'run' | 'unknown'; interface ContextMenuState { x: number; y: number; spanId: string; spanName: string; resourceType: ResourceType; /** Whether the span represents an active/pending resource (not yet completed) */ isActive: boolean; } interface ContextMenuItem { label: string; icon?: ReactNode; action: () => void; destructive?: boolean; disabled?: boolean; } function SpanContextMenu({ menu, items, onClose, }: { menu: ContextMenuState; items: ContextMenuItem[]; onClose: () => void; }): ReactNode { const menuRef = useRef(null); // Close on outside click useEffect(() => { const handleClick = (e: MouseEvent): void => { if (menuRef.current && !menuRef.current.contains(e.target as Node)) { onClose(); } }; const handleKeyDown = (e: KeyboardEvent): void => { if (e.key === 'Escape') { onClose(); } }; const handleScroll = (): void => { onClose(); }; // Use a timeout so we don't close immediately from the same event const timeout = setTimeout(() => { window.addEventListener('mousedown', handleClick); window.addEventListener('keydown', handleKeyDown); window.addEventListener('scroll', handleScroll, true); }, 0); return () => { clearTimeout(timeout); window.removeEventListener('mousedown', handleClick); window.removeEventListener('keydown', handleKeyDown); window.removeEventListener('scroll', handleScroll, true); }; }, [onClose]); // Adjust position if menu would overflow viewport const [adjustedPos, setAdjustedPos] = useState({ x: menu.x, y: menu.y }); useEffect(() => { const el = menuRef.current; if (!el) return; const rect = el.getBoundingClientRect(); let { x, y } = menu; if (rect.right > window.innerWidth) { x = window.innerWidth - rect.width - 8; } if (rect.bottom > window.innerHeight) { y = window.innerHeight - rect.height - 8; } if (x !== menu.x || y !== menu.y) { setAdjustedPos({ x, y }); } }, [menu]); if (items.length === 0) return null; return createPortal(
{menu.spanName}
{items.map((item) => ( ))}
, document.body ); } /** Inner wrapper that has access to the TraceViewer context */ function TraceViewerWithContextMenu({ trace, run, isLive, onWakeUpSleep, onCancelRun, onResolveHook, onLoadMoreSpans, hasMoreSpans = false, isLoadingMoreSpans = false, children, }: { trace: { spans: Span[] }; run: WorkflowRun; isLive: boolean; onWakeUpSleep?: ( runId: string, correlationId: string ) => Promise<{ stoppedCount: number }>; onCancelRun?: (runId: string) => Promise; onResolveHook?: ( hookToken: string, payload: unknown, hook?: Hook ) => Promise; onLoadMoreSpans?: () => void | Promise; hasMoreSpans?: boolean; isLoadingMoreSpans?: boolean; children: ReactNode; }): ReactNode { const toast = useToast(); const { state, dispatch } = useTraceViewer(); // Drive active span widths at 60fps without React re-renders useLiveTick(isLive); const [contextMenu, setContextMenu] = useState(null); const [resolveHookTarget, setResolveHookTarget] = useState<{ hookId: string; token: string; } | null>(null); const [resolvingHook, setResolvingHook] = useState(false); // Track hooks resolved in this session so the context menu item hides immediately const [resolvedHookIds, setResolvedHookIds] = useState>( new Set() ); // Build a lookup map: spanId -> span const spanLookup = useMemo(() => { const map = new Map(); for (const span of trace.spans) { map.set(span.spanId, span); } return map; }, [trace.spans]); const handleResolveHook = useCallback( async (payload: unknown) => { if (resolvingHook || !resolveHookTarget || !onResolveHook) return; if (!resolveHookTarget.token) { toast.error('Unable to resolve hook', { description: 'Missing hook token. Try refreshing the run data and retry.', }); return; } try { setResolvingHook(true); await onResolveHook(resolveHookTarget.token, payload); toast.success('Hook resolved', { description: 'The payload has been sent and the hook resolved.', }); // Mark this hook as resolved locally so the menu item hides immediately setResolvedHookIds((prev) => new Set(prev).add(resolveHookTarget.hookId) ); setResolveHookTarget(null); } catch (err) { console.error('Failed to resolve hook:', err); toast.error('Failed to resolve hook', { description: err instanceof Error ? err.message : 'An unknown error occurred', }); } finally { setResolvingHook(false); } }, [onResolveHook, resolveHookTarget, resolvingHook] ); const handleContextMenu = useCallback( (e: ReactMouseEvent | MouseEvent): void => { const target = e.target as HTMLElement; const $button = target.closest('[data-span-id]'); if (!$button) return; const spanId = $button.dataset.spanId; if (!spanId) return; e.preventDefault(); e.stopPropagation(); const span = spanLookup.get(spanId); if (!span) return; const resourceType = (span.attributes.resource as ResourceType) ?? 'unknown'; const spanData = span.attributes.data as | Record | undefined; const isActive = !spanData?.completedAt && !spanData?.disposedAt; setContextMenu({ x: e.clientX, y: e.clientY, spanId, spanName: span.name, resourceType, isActive, }); }, [spanLookup] ); const containerRef = useRef(null); useEffect(() => { const $container = containerRef.current; if (!$container) return; const onContextMenu = (event: MouseEvent): void => { handleContextMenu(event); }; $container.addEventListener('contextmenu', onContextMenu); return () => { $container.removeEventListener('contextmenu', onContextMenu); }; }, [handleContextMenu]); const loadingMoreRef = useRef(false); useEffect(() => { const timeline = state.timelineRef.current; if (!timeline || !onLoadMoreSpans || !hasMoreSpans) { return; } const thresholdPx = 200; const maybeLoadMore = () => { if (loadingMoreRef.current || isLoadingMoreSpans || !hasMoreSpans) { return; } const remaining = timeline.scrollHeight - timeline.scrollTop - timeline.clientHeight; if (remaining > thresholdPx) { return; } loadingMoreRef.current = true; Promise.resolve(onLoadMoreSpans()).finally(() => { loadingMoreRef.current = false; }); }; timeline.addEventListener('scroll', maybeLoadMore); maybeLoadMore(); return () => { timeline.removeEventListener('scroll', maybeLoadMore); }; }, [state.timelineRef, onLoadMoreSpans, hasMoreSpans, isLoadingMoreSpans]); const closeMenu = useCallback(() => { setContextMenu(null); }, []); const getMenuItems = useCallback( (menu: ContextMenuState): ContextMenuItem[] => { const items: ContextMenuItem[] = []; // Sleep-specific: Wake Up (only on active runs) const isRunActive = !run.completedAt; if ( menu.resourceType === 'sleep' && menu.isActive && isRunActive && onWakeUpSleep ) { items.push({ label: 'Wake Up Sleep', icon: , action: () => { onWakeUpSleep(run.runId, menu.spanId) .then((result) => { if (result.stoppedCount > 0) { toast.success('Sleep woken up', { description: `Woke up ${String(result.stoppedCount)} sleep${result.stoppedCount > 1 ? 's' : ''}`, }); } else { toast.info('No active sleeps found', { description: 'The sleep may have already completed.', }); } }) .catch((err: unknown) => { toast.error('Failed to wake up sleep', { description: err instanceof Error ? err.message : 'An unknown error occurred', }); }); }, }); } // Hook-specific: Resolve Hook (only on active, unresolved hooks) if (menu.resourceType === 'hook' && isRunActive && onResolveHook) { const span = spanLookup.get(menu.spanId); const hookData = span?.attributes?.data as | { token?: string; disposedAt?: unknown } | undefined; // Check data-level disposedAt, span events, AND local resolved state const isDisposed = Boolean(hookData?.disposedAt) || Boolean(span?.events?.some((e) => e.name === 'hook_disposed')) || resolvedHookIds.has(menu.spanId); if (hookData?.token && !isDisposed) { items.push({ label: 'Resolve Hook', icon: , action: () => { setResolveHookTarget({ hookId: menu.spanId, token: hookData.token!, }); }, }); } } // Run-specific: Cancel (only on active runs) if (menu.resourceType === 'run' && isRunActive && onCancelRun) { items.push({ label: 'Cancel Run', icon: , destructive: true, action: () => { onCancelRun(run.runId).catch((err: unknown) => { toast.error('Failed to cancel run', { description: err instanceof Error ? err.message : 'An unknown error occurred', }); }); }, }); } // Common actions items.push({ label: 'View Details', icon: , action: () => { dispatch({ type: 'select', id: menu.spanId }); }, }); items.push({ label: 'Copy Name', icon: , action: () => { navigator.clipboard .writeText(menu.spanName) .then(() => { toast.success('Name copied to clipboard'); }) .catch(() => { toast.error('Failed to copy name'); }); }, }); items.push({ label: 'Copy ID', icon: , action: () => { navigator.clipboard .writeText(menu.spanId) .then(() => { toast.success('ID copied to clipboard'); }) .catch(() => { toast.error('Failed to copy ID'); }); }, }); return items; }, [ dispatch, onWakeUpSleep, onCancelRun, onResolveHook, spanLookup, resolvedHookIds, run.runId, run.completedAt, ] ); return (
{children} {contextMenu ? ( ) : null} setResolveHookTarget(null)} onSubmit={handleResolveHook} isSubmitting={resolvingHook} />
); } /** Re-export SpanSelectionInfo for consumers */ export type { SpanSelectionInfo }; // --------------------------------------------------------------------------- // Bridge: reads selected span from trace viewer context and notifies parent // --------------------------------------------------------------------------- /** * Bridge component that lives INSIDE the TraceViewerContextProvider and * reads the selected span info, passing it up to the parent via callback. * This allows the parent to render the detail panel OUTSIDE the context. */ function SelectionBridge({ onSelectionChange, }: { onSelectionChange: (info: SelectedSpanInfo | null) => void; }) { const { state } = useTraceViewer(); const { selected } = state; const onSelectionChangeRef = useRef(onSelectionChange); onSelectionChangeRef.current = onSelectionChange; const prevSpanIdRef = useRef(undefined); useEffect(() => { const currentSpanId = selected?.span.spanId; if (currentSpanId === prevSpanIdRef.current) return; prevSpanIdRef.current = currentSpanId; if (selected) { onSelectionChangeRef.current({ data: selected.span.attributes?.data, resource: selected.span.attributes?.resource as string | undefined, spanId: selected.span.spanId, }); } else { onSelectionChangeRef.current(null); } }, [selected]); return null; } /** * Bridge component to deselect from outside the context. */ function DeselectBridge({ triggerDeselect }: { triggerDeselect: number }) { const { dispatch } = useTraceViewer(); useEffect(() => { if (triggerDeselect > 0) { dispatch({ type: 'deselect' }); } }, [triggerDeselect, dispatch]); return null; } /** * Bridge component to select a span from outside the context. */ function SelectBridge({ selectRequest, }: { selectRequest: { id: string; token: number } | null; }) { const { dispatch } = useTraceViewer(); useEffect(() => { if (selectRequest?.id) { dispatch({ type: 'select', id: selectRequest.id }); } }, [dispatch, selectRequest]); return null; } // --------------------------------------------------------------------------- // Panel chrome (header with name/duration, close button) // --------------------------------------------------------------------------- function PanelResizeHandle({ onResize, }: { onResize: (deltaX: number) => void; }) { const handlePointerDown = useCallback( (e: React.PointerEvent) => { e.preventDefault(); let lastX = e.clientX; const onPointerMove = (moveEvent: PointerEvent) => { const deltaX = lastX - moveEvent.clientX; lastX = moveEvent.clientX; onResize(deltaX); }; const onPointerUp = () => { document.removeEventListener('pointermove', onPointerMove); document.removeEventListener('pointerup', onPointerUp); }; document.addEventListener('pointermove', onPointerMove); document.addEventListener('pointerup', onPointerUp); }, [onResize] ); return (
); } // --------------------------------------------------------------------------- // Trace viewer footer — loading / waiting indicator // --------------------------------------------------------------------------- function TraceViewerFooter({ hasMore, isLive, isInitialLoading, }: { hasMore: boolean; isLive: boolean; isInitialLoading: boolean; }): ReactNode { const style = { color: 'var(--ds-gray-900)' }; if (hasMore || isInitialLoading) { return (
Loading more events…
); } if (isLive) { return (
Waiting for more events…
); } return (
End of run
); } // --------------------------------------------------------------------------- // Main component // --------------------------------------------------------------------------- export const WorkflowTraceViewer = ({ run, events, isLoading, error, spanDetailData, spanDetailLoading, spanDetailError, onWakeUpSleep, onResolveHook, onCancelRun, onStreamClick, onSpanSelect, onLoadEventData, onLoadMoreSpans, hasMoreSpans = false, isLoadingMoreSpans = false, encryptionKey, onDecrypt, isDecrypting = false, hasEncryptedData = false, }: { run: WorkflowRun; events: Event[]; isLoading?: boolean; error?: Error | null; spanDetailData?: WorkflowRun | Step | Hook | Event | null; spanDetailLoading?: boolean; spanDetailError?: Error | null; onWakeUpSleep?: ( runId: string, correlationId: string ) => Promise<{ stoppedCount: number }>; onResolveHook?: ( hookToken: string, payload: unknown, hook?: Hook ) => Promise; /** Callback to cancel the current run */ onCancelRun?: (runId: string) => Promise; /** Callback when a stream reference is clicked in the detail panel */ onStreamClick?: (streamId: string) => void; /** Callback when a span is selected. */ onSpanSelect?: (info: SpanSelectionInfo) => void; /** Callback to load event data for a specific event (lazy loading in sidebar) */ onLoadEventData?: ( correlationId: string, eventId: string ) => Promise; /** Load next trace page when vertical scroll reaches bottom. */ onLoadMoreSpans?: () => void | Promise; /** Whether trace pagination has more data to load. */ hasMoreSpans?: boolean; /** Whether trace pagination is currently fetching another page. */ isLoadingMoreSpans?: boolean; /** Encryption key (available after Decrypt), threaded to event list for re-loading */ encryptionKey?: Uint8Array; /** Callback to initiate decryption of encrypted run data */ onDecrypt?: () => void; /** Whether the encryption key is currently being fetched */ isDecrypting?: boolean; /** Run-level hint: the run contains encrypted data (from probe). */ hasEncryptedData?: boolean; }) => { const toast = useToast(); const [selectedSpan, setSelectedSpan] = useState( null ); const [panelWidth, setPanelWidth] = useState(DEFAULT_PANEL_WIDTH); const [deselectTrigger, setDeselectTrigger] = useState(0); const [selectRequest, setSelectRequest] = useState<{ id: string; token: number; } | null>(null); const isLive = Boolean(run && !run.completedAt); // Build trace only when actual data changes — no timer-driven rebuilds. // Active span widths are animated imperatively by useLiveTick at 60fps. const traceWithMeta: TraceWithMeta | undefined = useMemo(() => { if (!run?.runId) { return undefined; } return buildTrace(run, events, new Date()); // eslint-disable-next-line react-hooks/exhaustive-deps -- `new Date()` is intentionally not a dep; useLiveTick handles live growth }, [run, events]); const trace = traceWithMeta; useEffect(() => { if (error && !isLoading) { console.error(error); toast.error('Error loading workflow trace data', { description: error.message, }); } }, [error, isLoading]); const handleSpanSelect = useCallback( (info: SpanSelectionInfo) => { onSpanSelect?.(info); }, [onSpanSelect] ); const handleSelectionChange = useCallback( (info: SelectedSpanInfo | null) => { if (info) { // Filter raw events by the selected span's correlationId (stepId/hookId) // This bypasses the trace worker pipeline entirely. const correlationId = info.spanId; const rawEvents = correlationId ? events.filter((e) => e.correlationId === correlationId) : []; setSelectedSpan({ ...info, rawEvents }); } else { setSelectedSpan(null); } }, [events] ); // Keep selected span raw events live as polling updates arrive. useEffect(() => { const correlationId = selectedSpan?.spanId; if (!correlationId) return; const nextRawEvents = events.filter( (e) => e.correlationId === correlationId ); setSelectedSpan((prev) => { if (!prev || prev.spanId !== correlationId) { return prev; } const prevRawEvents = prev.rawEvents ?? []; const isSame = prevRawEvents.length === nextRawEvents.length && prevRawEvents.every( (event, index) => event.eventId === nextRawEvents[index]?.eventId ); if (isSame) { return prev; } return { ...prev, rawEvents: nextRawEvents, }; }); }, [events, selectedSpan?.spanId]); const handleClose = useCallback(() => { setSelectedSpan(null); setDeselectTrigger((n) => n + 1); }, []); const handleResize = useCallback((deltaX: number) => { setPanelWidth((w) => Math.max(MIN_PANEL_WIDTH, w + deltaX)); }, []); const selectedSpanIndex = useMemo(() => { if (!selectedSpan?.spanId || !trace) return -1; return trace.spans.findIndex((span) => span.spanId === selectedSpan.spanId); }, [selectedSpan?.spanId, trace]); const canSelectPrevSpan = selectedSpanIndex > 0; const canSelectNextSpan = selectedSpanIndex >= 0 && trace != null && selectedSpanIndex < trace.spans.length - 1; const handleSelectPrevSpan = useCallback(() => { if (!trace || selectedSpanIndex <= 0) return; const targetId = trace.spans[selectedSpanIndex - 1]?.spanId; if (!targetId) return; setSelectRequest({ id: targetId, token: Date.now() }); }, [selectedSpanIndex, trace]); const handleSelectNextSpan = useCallback(() => { if (!trace || selectedSpanIndex < 0) return; const targetId = trace.spans[selectedSpanIndex + 1]?.spanId; if (!targetId) return; setSelectRequest({ id: targetId, token: Date.now() }); }, [selectedSpanIndex, trace]); // Get the selected span name for the panel header const selectedSpanName = useMemo(() => { if (!selectedSpan?.data) return undefined; const data = selectedSpan.data as Record; const stepName = data.stepName as string | undefined; const workflowName = data.workflowName as string | undefined; return ( (stepName ? parseStepName(stepName)?.shortName : undefined) ?? (workflowName ? parseWorkflowName(workflowName)?.shortName : undefined) ?? stepName ?? workflowName ?? (data.hookId as string) ?? 'Details' ); }, [selectedSpan?.data]); if (!trace) { return (
); } return (
{/* Timeline (takes remaining space) */}
} />
{/* Detail panel (rendered outside the context, as a sibling) */} {selectedSpan && (
{/* Panel header */}
{selectedSpanName}
{/* Panel body */}
)}
); };