'use client'; import type { Event, Hook, Step, WorkflowRun } from '@workflow/world'; import clsx from 'clsx'; import { Send, Zap } from 'lucide-react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useToast } from '../../lib/toast'; import { isEncryptedMarker } from '../../lib/hydration'; import { DecryptClickContext } from '../ui/data-inspector'; import { DecryptButton } from '../ui/decrypt-button'; import { AttributePanel } from './attribute-panel'; import { EventsList } from './events-list'; import { ResolveHookModal } from './resolve-hook-modal'; // Type guards for runtime validation of span attribute data function isStep(data: unknown): data is Step { return data !== null && typeof data === 'object' && 'stepId' in data; } function isWorkflowRun(data: unknown): data is WorkflowRun { return data !== null && typeof data === 'object' && 'runId' in data; } function isHook(data: unknown): data is Hook { return data !== null && typeof data === 'object' && 'hookId' in data; } /** * Info about the currently selected span */ export type SpanSelectionInfo = { resource: 'run' | 'step' | 'hook' | 'sleep'; resourceId: string; runId?: string; }; /** * Info about the selected span from the trace viewer. */ export interface SelectedSpanInfo { /** The raw data from the span attributes (step/run/hook object from the trace) */ data?: unknown; /** The span resource type (from span attributes) */ resource?: string; /** The span ID (correlationId for filtering events) */ spanId?: string; /** Raw correlated events from the store (NOT from the trace worker pipeline) */ rawEvents?: Event[]; } /** * Panel component for workflow traces that displays entity details. * * This component is rendered OUTSIDE the trace viewer context — it * receives all data via props rather than reading from context. */ export function EntityDetailPanel({ run, onStreamClick, spanDetailData, spanDetailError, spanDetailLoading, onSpanSelect, onWakeUpSleep, onLoadEventData, onResolveHook, encryptionKey, onDecrypt, isDecrypting = false, selectedSpan, hasEncryptedData = false, }: { run: WorkflowRun; /** Callback when a stream reference is clicked */ onStreamClick?: (streamId: string) => void; /** Pre-fetched span detail data for the selected span. */ spanDetailData: WorkflowRun | Step | Hook | Event | null; /** Error from external span detail fetch. */ spanDetailError?: Error | null; /** Loading state from external span detail fetch. */ spanDetailLoading?: boolean; /** Callback when a span is selected. Use this to fetch data externally and pass via spanDetailData. */ onSpanSelect: (info: SpanSelectionInfo) => void; /** Callback to wake up a pending sleep call. */ onWakeUpSleep?: ( runId: string, correlationId: string ) => Promise<{ stoppedCount: number }>; /** Callback to load event data for a specific event (lazy loading) */ onLoadEventData?: ( correlationId: string, eventId: string ) => Promise; /** Callback to resolve a hook with a payload. */ onResolveHook?: ( hookToken: string, payload: unknown, hook?: Hook ) => Promise; /** Encryption key (available after Decrypt is clicked), used to re-load event data */ encryptionKey?: Uint8Array; /** Callback to initiate decryption of encrypted run data */ onDecrypt?: () => void; /** Whether the encryption key is currently being fetched */ isDecrypting?: boolean; /** Info about the currently selected span from the trace viewer */ selectedSpan: SelectedSpanInfo | null; /** Run-level hint: the run contains encrypted data (from probe). */ hasEncryptedData?: boolean; }): React.JSX.Element | null { const toast = useToast(); const [stoppingSleep, setStoppingSleep] = useState(false); const [showResolveHookModal, setShowResolveHookModal] = useState(false); const [resolvingHook, setResolvingHook] = useState(false); // Track hooks that were resolved in this session so the button hides // immediately without waiting for the next event poll. const [resolvedHookIds, setResolvedHookIds] = useState>( new Set() ); const data = selectedSpan?.data; const rawEvents = selectedSpan?.rawEvents; const rawEventsLength = rawEvents?.length ?? 0; // Determine resource type, ID, and runId from the selected span const { resource, resourceId, runId } = useMemo(() => { if (!selectedSpan) { return { resource: undefined, resourceId: undefined, runId: undefined }; } const res = selectedSpan.resource; if (res === 'step' && isStep(data)) { return { resource: 'step', resourceId: data.stepId, runId: data.runId }; } if (res === 'run' && isWorkflowRun(data)) { return { resource: 'run', resourceId: data.runId, runId: undefined }; } if (res === 'hook' && isHook(data)) { return { resource: 'hook', resourceId: data.hookId, runId: undefined }; } if (res === 'sleep') { const waitData = data as { runId?: string } | undefined; return { resource: 'sleep', resourceId: selectedSpan.spanId, runId: waitData?.runId, }; } return { resource: undefined, resourceId: undefined, runId: undefined }; }, [selectedSpan, data]); // Notify parent when span selection changes. // Use a ref for the callback so the effect only fires when the actual // selection values change, not when the callback identity changes due to // parent re-renders from polling. const onSpanSelectRef = useRef(onSpanSelect); onSpanSelectRef.current = onSpanSelect; useEffect(() => { if ( resource && resourceId && ['run', 'step', 'hook', 'sleep'].includes(resource) ) { onSpanSelectRef.current({ resource: resource as 'run' | 'step' | 'hook' | 'sleep', resourceId, runId, }); } }, [resource, resourceId, runId]); // Check if this sleep is still pending and can be woken up const canWakeUp = useMemo(() => { void rawEventsLength; if (resource !== 'sleep' || !rawEvents) return false; const terminalStates = ['completed', 'failed', 'cancelled']; if (terminalStates.includes(run.status)) return false; const hasWaitCreated = rawEvents.some( (e) => e.eventType === 'wait_created' ); if (!hasWaitCreated) return false; const hasWaitCompleted = rawEvents.some( (e) => e.eventType === 'wait_completed' ); return !hasWaitCompleted; }, [resource, rawEvents, rawEventsLength, run.status]); // Check if this hook can be resolved const canResolveHook = useMemo(() => { void rawEventsLength; if (resource !== 'hook' || !rawEvents || !resourceId) return false; // Check if we already resolved this hook in this session if (resolvedHookIds.has(resourceId)) return false; const terminalStates = ['completed', 'failed', 'cancelled']; if (terminalStates.includes(run.status)) return false; const hasHookDisposed = rawEvents.some( (e) => e.eventType === 'hook_disposed' ); if (hasHookDisposed) return false; return true; }, [ resource, resourceId, rawEvents, rawEventsLength, run.status, resolvedHookIds, ]); const error = spanDetailError ?? undefined; const loading = spanDetailLoading ?? false; const hasEncryptedFields = useMemo(() => { if (!spanDetailData) return false; const d = spanDetailData as Record; return ( isEncryptedMarker(d.input) || isEncryptedMarker(d.output) || isEncryptedMarker(d.error) || isEncryptedMarker(d.metadata) ); }, [spanDetailData]); // Get the hook token for resolving (prefer fetched data, then hooks array fallback) const hookToken = useMemo(() => { if (resource !== 'hook' || !resourceId) return undefined; // 1. Try the externally-fetched detail data first if (isHook(spanDetailData) && spanDetailData.token) { return spanDetailData.token; } // 2. Try the span's inline data (reconstructed from hook_created event) if (isHook(data) && (data as Hook).token) { return (data as Hook).token; } return undefined; }, [resource, resourceId, spanDetailData, data]); useEffect(() => { if (error && selectedSpan && resource) { toast.error(`Failed to load ${resource} details`, { description: error.message, }); } }, [error, resource, selectedSpan]); const handleWakeUp = async () => { if (stoppingSleep || !resourceId) return; if (!onWakeUpSleep) { toast.error('Unable to wake up sleep', { description: 'No wake-up handler provided.', }); return; } try { setStoppingSleep(true); const result = await onWakeUpSleep(run.runId, resourceId); if (result.stoppedCount > 0) { toast.success('Run woken up', { description: 'The sleep call has been interrupted and the run woken up.', }); } else { toast.info('Sleep already completed', { description: 'This sleep call has already finished.', }); } } catch (err) { console.error('Failed to wake up run:', err); toast.error('Failed to wake up run', { description: err instanceof Error ? err.message : 'An unknown error occurred', }); } finally { setStoppingSleep(false); } }; const handleResolveHook = useCallback( async (payload: unknown) => { if (resolvingHook) return; if (!onResolveHook) { toast.error('Unable to resolve hook', { description: 'No resolve handler provided.', }); return; } if (!hookToken) { toast.error('Unable to resolve hook', { description: 'Missing hook token. Try refreshing the run data and retry.', }); return; } try { setResolvingHook(true); const candidate = spanDetailData ?? data; const hook = isHook(candidate) ? candidate : undefined; await onResolveHook(hookToken, payload, hook); toast.success('Hook resolved', { description: 'The payload has been sent and the hook resolved.', }); setShowResolveHookModal(false); // Mark this hook as resolved locally so the button hides immediately if (resourceId) { setResolvedHookIds((prev) => new Set(prev).add(resourceId)); } } 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, hookToken, resolvingHook, spanDetailData, data] ); // Prefer externally-fetched details when available. For sleep spans, the // host fetches full correlated events (withData=true) and materializes a wait // entity, so this includes resumeAt/completedAt without bloating trace payloads. const displayData = (spanDetailData ?? data) as | WorkflowRun | Step | Hook | Event; const moduleSpecifier = useMemo(() => { const displayRecord = displayData as Record; const displayStepName = displayRecord.stepName; const displayWorkflowName = displayRecord.workflowName; if (typeof displayStepName === 'string') { return displayStepName; } if (typeof displayWorkflowName === 'string') { return displayWorkflowName; } if (typeof run.workflowName === 'string') { return run.workflowName; } return undefined; }, [displayData, run.workflowName]); if (!selectedSpan || !resource || !resourceId) { return null; } const resourceLabel = resource.charAt(0).toUpperCase() + resource.slice(1); const hasPendingActions = (resource === 'sleep' && canWakeUp) || (resource === 'hook' && canResolveHook); const runStateLabel = run.completedAt ? 'Completed' : 'Live'; return (
{resourceLabel} {runStateLabel}

{resourceId}

{(hasEncryptedFields || hasEncryptedData || encryptionKey) && onDecrypt && ( )}
{hasPendingActions && (

Actions

{/* Wake up button for pending sleep calls */} {resource === 'sleep' && canWakeUp && ( )} {/* Resolve hook button for pending hooks */} {resource === 'hook' && canResolveHook && ( )}
)}

Details

{resource !== 'run' && rawEvents && (
)}
{/* Resolve Hook Modal */} setShowResolveHookModal(false)} onSubmit={handleResolveHook} isSubmitting={resolvingHook} />
); }