'use client'; import { parseStepName, parseWorkflowName } from '@workflow/utils/parse-name'; import type { Event, WorkflowRun } from '@workflow/world'; import { Check, ChevronRight, Copy } from 'lucide-react'; import type { KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent, ReactNode, } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'; import { type ExactIdSearchResult, type ExactWorkflowSearchIdKind, looksLikeWorkflowIdSearchInput, parseExactWorkflowSearchId, } from '../lib/exact-event-search-id'; import { isEncryptedMarker } from '../lib/hydration'; import { useToast } from '../lib/toast'; import { formatDuration } from '../lib/utils'; import { ContextCardProvider } from './ui/context-card'; import { DataInspector, DecryptClickContext } from './ui/data-inspector'; import { DecryptButton } from './ui/decrypt-button'; import { ErrorStackBlock, isStructuredError, type StructuredErrorRecord, } from './ui/error-stack-block'; import { LoadMoreButton } from './ui/load-more-button'; import { MenuDropdown } from './ui/menu-dropdown'; import { Skeleton } from './ui/skeleton'; import { TimestampTooltip } from './ui/timestamp-tooltip'; /** * 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', border: 'none', background: 'transparent', }; const DOT_PULSE_ANIMATION = 'workflow-dot-pulse 1.25s cubic-bezier(0, 0, 0.2, 1) infinite'; // ────────────────────────────────────────────────────────────────────────── // Helpers // ────────────────────────────────────────────────────────────────────────── function formatEventTime(date: Date): string { return ( date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, }) + '.' + date.getMilliseconds().toString().padStart(3, '0') ); } function formatEventType(eventType: Event['eventType']): string { return eventType .split('_') .map((word: string) => word.charAt(0).toUpperCase() + word.slice(1)) .join(' '); } // ────────────────────────────────────────────────────────────────────────── // Event type → status color (small dot only) // ────────────────────────────────────────────────────────────────────────── /** Returns a CSS color using Geist design tokens for the status dot. */ function getStatusDotColor(eventType: string): string { // Failed → red if ( eventType === 'step_failed' || eventType === 'run_failed' || eventType === 'workflow_failed' ) { return 'var(--ds-red-700)'; } // Cancelled → amber if (eventType === 'run_cancelled') { return 'var(--ds-amber-700)'; } // Retrying → amber if (eventType === 'step_retrying') { return 'var(--ds-amber-700)'; } // Completed/succeeded → green if ( eventType === 'step_completed' || eventType === 'run_completed' || eventType === 'workflow_completed' || eventType === 'hook_disposed' || eventType === 'wait_completed' ) { return 'var(--ds-green-700)'; } // Started/running → blue if ( eventType === 'step_started' || eventType === 'run_started' || eventType === 'workflow_started' || eventType === 'hook_received' ) { return 'var(--ds-blue-700)'; } // Created/pending → gray return 'var(--ds-gray-600)'; } /** * Build a map from correlationId (stepId) → display name using step_created * events, and parse the workflow name from the run. */ function buildNameMaps( events: Event[] | null, run: WorkflowRun | null ): { correlationNameMap: Map; workflowName: string | null; } { const correlationNameMap = new Map(); // Map step correlationId (= stepId) → parsed step name from step_created events if (events) { for (const event of events) { if (event.eventType === 'step_created' && event.correlationId) { const stepName = event.eventData?.stepName ?? ''; const parsed = parseStepName(String(stepName)); correlationNameMap.set( event.correlationId, parsed?.shortName ?? stepName ); } } } // Parse workflow name from run const workflowName = run?.workflowName ? (parseWorkflowName(run.workflowName)?.shortName ?? run.workflowName) : null; return { correlationNameMap, workflowName }; } export interface DurationInfo { /** Time from created → started (ms) */ queued?: number; /** Time from started → completed/failed/cancelled (ms) */ ran?: number; } /** * Build a map from correlationId → duration info by diffing * created ↔ started (queued) and started ↔ completed/failed/cancelled (ran). * Also computes run-level durations under the key '__run__'. */ export function buildDurationMap(events: Event[]): Map { // Process events in chronological order so the result doesn't depend on // the caller's sort direction. Retried steps emit multiple `step_started` // events for the same correlationId; the queued duration must be measured // against the first one, not the last. const chronological = [...events].sort( (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() ); const createdTimes = new Map(); const firstStartedTimes = new Map(); const startedTimes = new Map(); const durations = new Map(); for (const event of chronological) { const ts = new Date(event.createdAt).getTime(); const key = event.correlationId ?? '__run__'; const type: string = event.eventType; // Track created times (first event for each correlation) if (type === 'step_created' || type === 'run_created') { if (!createdTimes.has(key)) { createdTimes.set(key, ts); } } // Track started times & compute queued duration if ( type === 'step_started' || type === 'run_started' || type === 'workflow_started' ) { startedTimes.set(key, ts); // The queued duration is anchored on the first start event only — // subsequent step_started events come from retries. if (!firstStartedTimes.has(key)) { firstStartedTimes.set(key, ts); // If no explicit created event was seen, use the started time as created if (!createdTimes.has(key)) { createdTimes.set(key, ts); } const createdAt = createdTimes.get(key); const info = durations.get(key) ?? {}; if (createdAt !== undefined) { info.queued = ts - createdAt; } durations.set(key, info); } } // Compute ran duration on terminal events if ( type === 'step_completed' || type === 'step_failed' || type === 'run_completed' || type === 'run_failed' || type === 'run_cancelled' || type === 'workflow_completed' || type === 'workflow_failed' || type === 'wait_completed' || type === 'hook_disposed' ) { const startedAt = startedTimes.get(key); const info = durations.get(key) ?? {}; if (startedAt !== undefined) { info.ran = ts - startedAt; } durations.set(key, info); } } return durations; } /** Check if a loaded eventData object contains any encrypted marker values. */ function hasEncryptedValues(data: unknown): boolean { if (!data || typeof data !== 'object') return false; for (const val of Object.values(data as Record)) { if (isEncryptedMarker(val)) return true; } return false; } function isRunLevel(eventType: string): boolean { return ( eventType === 'run_created' || eventType === 'run_started' || eventType === 'run_completed' || eventType === 'run_failed' || eventType === 'run_cancelled' || eventType === 'workflow_started' || eventType === 'workflow_completed' || eventType === 'workflow_failed' ); } // ────────────────────────────────────────────────────────────────────────── // Tree gutter — fixed-width, shows branch lines only for the selected group // ────────────────────────────────────────────────────────────────────────── /** Fixed gutter width: 20px root area + 16px for one branch lane */ const GUTTER_WIDTH = 36; /** X position of the single branch lane line */ const LANE_X = 20; const ROOT_LINE_COLOR = 'var(--ds-gray-500)'; function TreeGutter({ isFirst, isLast, isRunLevel: isRun, statusDotColor, pulse = false, hasSelection, showBranch, showLaneLine, isLaneStart, isLaneEnd, continuationOnly = false, }: { isFirst: boolean; isLast: boolean; isRunLevel: boolean; statusDotColor?: string; pulse?: boolean; /** Whether any group is currently active (selected or hovered) */ hasSelection: boolean; /** Whether to show a horizontal branch line for this row (event belongs to active group) */ showBranch: boolean; /** Whether the vertical lane line passes through this row */ showLaneLine: boolean; /** Whether the vertical lane line starts at this row (top clipped to 50%) */ isLaneStart: boolean; /** Whether the vertical lane line ends at this row (bottom clipped to 50%) */ isLaneEnd: boolean; continuationOnly?: boolean; }) { const dotSize = isRun ? 8 : 6; const dotLeft = isRun ? 5 : 6; const dotOpacity = hasSelection && !showBranch && !isRun ? 0.3 : 1; return (
{/* Root vertical line (leftmost, always visible) */}
{!continuationOnly && ( <> {/* Status dot on the root line for every event */}
{/* Opaque backdrop ensures gutter lines never visually cut through dots */}
{pulse && (
)}
{/* Horizontal branch from root to gutter edge (selected group events only) */} {showBranch && (
)} )} {/* Vertical lane line connecting the selected group's events */} {showLaneLine && (
)}
); } // ────────────────────────────────────────────────────────────────────────── // Copyable cell — shows a copy button on hover // ────────────────────────────────────────────────────────────────────────── function CopyableCell({ value, className, style: styleProp, }: { value: string; className?: string; style?: React.CSSProperties; }): ReactNode { const [copied, setCopied] = useState(false); const resetCopiedTimeoutRef = useRef(null); useEffect(() => { return () => { if (resetCopiedTimeoutRef.current !== null) { window.clearTimeout(resetCopiedTimeoutRef.current); } }; }, []); const handleCopy = useCallback( (e: ReactMouseEvent) => { e.stopPropagation(); navigator.clipboard.writeText(value).then(() => { setCopied(true); if (resetCopiedTimeoutRef.current !== null) { window.clearTimeout(resetCopiedTimeoutRef.current); } resetCopiedTimeoutRef.current = window.setTimeout(() => { setCopied(false); resetCopiedTimeoutRef.current = null; }, 1500); }); }, [value] ); return (
{value || '-'} {value ? ( ) : null}
); } /** Recursively parse stringified JSON values so escaped slashes / quotes are cleaned up */ function deepParseJson(value: unknown): unknown { if (typeof value === 'string') { const trimmed = value.trim(); if ( (trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']')) || (trimmed.startsWith('"') && trimmed.endsWith('"')) ) { try { return deepParseJson(JSON.parse(trimmed)); } catch { return value; } } return value; } if (Array.isArray(value)) { return value.map(deepParseJson); } if (value !== null && typeof value === 'object') { // Preserve objects with custom constructors (e.g., encrypted markers, // class instance refs) — don't destructure them into plain objects if (value.constructor !== Object) { return value; } const result: Record = {}; for (const [k, v] of Object.entries(value)) { result[k] = deepParseJson(v); } return result; } return value; } /** * Extracts a structured error from event data, if present. * Returns the error object to render with ErrorStackBlock, or null if not applicable. */ function extractStructuredError( data: unknown, eventType?: string ): StructuredErrorRecord | 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 (isStructuredError(record.error)) return record.error; // Some error formats put the message/stack at the top level of eventData. if (isStructuredError(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]); useEffect(() => { return () => { if (resetCopiedTimeoutRef.current !== null) { window.clearTimeout(resetCopiedTimeoutRef.current); } }; }, []); const formatted = useMemo(() => { try { return JSON.stringify(cleaned, null, 2); } catch { return String(cleaned); } }, [cleaned]); const handleCopy = useCallback( (e: ReactMouseEvent) => { e.stopPropagation(); navigator.clipboard.writeText(formatted).then(() => { setCopied(true); if (resetCopiedTimeoutRef.current !== null) { window.clearTimeout(resetCopiedTimeoutRef.current); } resetCopiedTimeoutRef.current = window.setTimeout(() => { setCopied(false); resetCopiedTimeoutRef.current = null; }, 1500); }); }, [formatted] ); if (structuredError) { return (
); } return (
); } // ────────────────────────────────────────────────────────────────────────── // Sort options for the events list // ────────────────────────────────────────────────────────────────────────── const SORT_OPTIONS = [ { value: 'desc' as const, label: 'Newest' }, { value: 'asc' as const, label: 'Oldest' }, ]; function RowsSkeleton() { return (
{Array.from({ length: 16 }, (_, i) => (
{/* Gutter area */}
{/* Vertical line skeleton */}
{/* Dot skeleton */}
{/* Chevron placeholder */}
{/* Time */}
{/* Event Type */}
{/* Name */}
{/* Correlation ID */}
{/* Event ID */}
))}
); } // ────────────────────────────────────────────────────────────────────────── // Event row // ────────────────────────────────────────────────────────────────────────── interface EventsListProps { events: Event[] | null; run?: WorkflowRun | null; onLoadEventData?: (event: Event) => Promise; hasMoreEvents?: boolean; isLoadingMoreEvents?: boolean; onLoadMoreEvents?: () => Promise | void; /** When provided, signals that decryption is active (triggers re-load of expanded events) */ encryptionKey?: Uint8Array; /** When true, shows a loading state instead of "No events found" for empty lists */ isLoading?: boolean; /** Sort order for events. Defaults to 'asc'. */ sortOrder?: 'asc' | 'desc'; /** Called when the user changes sort order. When provided, the sort dropdown is shown * and the parent is expected to refetch from the API with the new order. */ onSortOrderChange?: (order: 'asc' | 'desc') => void; /** Called when the user clicks the Decrypt button. */ onDecrypt?: () => void; /** Whether the encryption key is currently being fetched. */ isDecrypting?: boolean; /** Run-level hint: the run contains encrypted data (from probe). */ hasEncryptedData?: boolean; /** Fetch events for an exact correlation or event ID. */ onExactIdSearch?: ( id: string, kind: ExactWorkflowSearchIdKind, signal?: AbortSignal ) => Promise; } function EventRow({ event, index, isFirst, isLast, isExpanded, onToggleExpand, activeGroupKey, selectedGroupKey, selectedGroupRange, correlationNameMap, workflowName, durationMap, onSelectGroup, onHoverGroup, onLoadEventData, cachedEventData, onCacheEventData, encryptionKey, onEncryptedDataDetected, suppressGroupDimming = false, }: { event: Event; index: number; isFirst: boolean; isLast: boolean; isExpanded: boolean; onToggleExpand: (eventId: string) => void; activeGroupKey?: string; selectedGroupKey?: string; selectedGroupRange: { first: number; last: number } | null; correlationNameMap: Map; workflowName: string | null; durationMap: Map; onSelectGroup: (groupKey: string | undefined) => void; onHoverGroup: (groupKey: string | undefined) => void; onLoadEventData?: (event: Event) => Promise; cachedEventData: unknown | null; onCacheEventData: (eventId: string, data: unknown) => void; encryptionKey?: Uint8Array; onEncryptedDataDetected?: () => void; /** Exact-ID search results should not dim unrelated rows. */ suppressGroupDimming?: boolean; }) { const [isLoading, setIsLoading] = useState(false); const [loadedEventData, setLoadedEventData] = useState( cachedEventData ); const [loadError, setLoadError] = useState(null); const [hasAttemptedLoad, setHasAttemptedLoad] = useState( cachedEventData !== null ); // Notify parent if cached data has encrypted markers on mount useEffect(() => { if ( cachedEventData !== null && !encryptionKey && hasEncryptedValues(cachedEventData) ) { onEncryptedDataDetected?.(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const rowGroupKey = isRunLevel(event.eventType) ? '__run__' : (event.correlationId ?? undefined); const statusDotColor = getStatusDotColor(event.eventType); const createdAt = new Date(event.createdAt); const hasExistingEventData = 'eventData' in event && event.eventData != null; const isRun = isRunLevel(event.eventType); const eventName = isRun ? (workflowName ?? '-') : event.correlationId ? (correlationNameMap.get(event.correlationId) ?? '-') : '-'; const durationKey = event.correlationId ?? (isRun ? '__run__' : ''); const durationInfo = durationKey ? durationMap.get(durationKey) : undefined; const hasActive = activeGroupKey !== undefined; const isRelated = rowGroupKey !== undefined && rowGroupKey === activeGroupKey; const isDimmed = hasActive && !isRelated && !suppressGroupDimming; const isPulsing = hasActive && isRelated; // Gutter state derived from selectedGroupRange const showBranch = hasActive && isRelated && !isRun; const showLaneLine = selectedGroupRange !== null && index >= selectedGroupRange.first && index <= selectedGroupRange.last; const isLaneStart = selectedGroupRange !== null && index === selectedGroupRange.first; const isLaneEnd = selectedGroupRange !== null && index === selectedGroupRange.last; const loadEventDetails = useCallback(async () => { if (loadedEventData !== null) { return; } if (cachedEventData !== null) { setLoadedEventData(cachedEventData); setHasAttemptedLoad(true); return; } if (isLoading) { return; } setIsLoading(true); setLoadError(null); try { if (!onLoadEventData) { setLoadError('Event details unavailable'); return; } const data = await onLoadEventData(event); if (data !== null && data !== undefined) { setLoadedEventData(data); onCacheEventData(event.eventId, data); if (!encryptionKey && hasEncryptedValues(data)) { onEncryptedDataDetected?.(); } } } catch (err) { setLoadError( err instanceof Error ? err.message : 'Failed to load event details' ); } finally { setIsLoading(false); setHasAttemptedLoad(true); } }, [ event, loadedEventData, isLoading, onLoadEventData, onCacheEventData, encryptionKey, onEncryptedDataDetected, cachedEventData, ]); // Auto-load event data when remounting in expanded state without cached data useEffect(() => { if (!isExpanded || isLoading) { return; } void loadEventDetails(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // When encryption key changes and this event was previously loaded, // re-load to get decrypted data useEffect(() => { if (encryptionKey && hasAttemptedLoad && onLoadEventData) { setLoadedEventData(null); setHasAttemptedLoad(false); onLoadEventData(event) .then((data) => { if (data !== null && data !== undefined) { setLoadedEventData(data); onCacheEventData(event.eventId, data); } setHasAttemptedLoad(true); }) .catch(() => { setHasAttemptedLoad(true); }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [encryptionKey]); const handleRowClick = useCallback(() => { onSelectGroup(rowGroupKey === selectedGroupKey ? undefined : rowGroupKey); onToggleExpand(event.eventId); if (!isExpanded) { void loadEventDetails(); } }, [ selectedGroupKey, rowGroupKey, onSelectGroup, onToggleExpand, event.eventId, isExpanded, loadEventDetails, ]); const mergedEventData = loadedEventData ?? (hasExistingEventData ? (event as Event & { eventData: unknown }).eventData : null); const displayPayload = isLoading ? loadedEventData : mergedEventData; const contentOpacity = isDimmed ? 0.3 : 1; return (
onHoverGroup(rowGroupKey)} onMouseLeave={() => onHoverGroup(undefined)} > {/* Row */}
{ if (e.key === 'Enter' || e.key === ' ') handleRowClick(); }} className="w-full text-left flex items-center gap-0 text-[13px] hover:bg-[var(--ds-gray-alpha-100)] transition-colors cursor-pointer" style={{ minHeight: 40 }} > {/* Content area — dims when unrelated */}
{/* Expand chevron indicator */}
{/* Time */}
{formatEventTime(createdAt)}
{/* Event Type */}
{isPulsing && ( )} {formatEventType(event.eventType)}
{/* Name */}
{eventName}
{/* Correlation ID */} {/* Event ID */}
{/* Expanded details — tree lines continue through this area */} {isExpanded && (
{/* Continuation gutter — lane line continues if not at lane end */} {/* Spacer for chevron column */}
{/* Duration info */} {(durationInfo?.queued !== undefined || durationInfo?.ran !== undefined) && (
{durationInfo.queued !== undefined && durationInfo.queued > 0 && ( Queued for{' '} {formatDuration(durationInfo.queued)} )} {durationInfo.ran !== undefined && ( Ran for{' '} {formatDuration(durationInfo.ran)} )}
)} {/* Payload */} {displayPayload != null ? ( ) : loadError ? (
{loadError}
) : isLoading || (loadedEventData === null && !hasAttemptedLoad && event.correlationId) ? (
) : (
No data
)}
)}
); } // ────────────────────────────────────────────────────────────────────────── // Main component // ────────────────────────────────────────────────────────────────────────── function EventListViewInner({ events, run, onLoadEventData, hasMoreEvents = false, isLoadingMoreEvents = false, onLoadMoreEvents, encryptionKey, isLoading = false, sortOrder: sortOrderProp, onSortOrderChange, onDecrypt, isDecrypting = false, hasEncryptedData: hasEncryptedDataProp = false, onExactIdSearch, }: EventsListProps) { const toast = useToast(); const [internalSortOrder, setInternalSortOrder] = useState<'asc' | 'desc'>( 'asc' ); const effectiveSortOrder = sortOrderProp ?? internalSortOrder; const handleSortOrderChange = useCallback( (order: 'asc' | 'desc') => { if (onSortOrderChange) { onSortOrderChange(order); } else { setInternalSortOrder(order); } }, [onSortOrderChange] ); const [searchQuery, setSearchQuery] = useState(''); const [searchResults, setSearchResults] = useState(null); const [searchResultsTruncated, setSearchResultsTruncated] = useState(false); const [searchError, setSearchError] = useState(null); const [searchLoading, setSearchLoading] = useState(false); const [searchNotFound, setSearchNotFound] = useState(false); const searchRequestRef = useRef(0); const virtuosoRef = useRef(null); const parsedSearchId = useMemo( () => parseExactWorkflowSearchId(searchQuery), [searchQuery] ); const isExactSearchActive = searchResults !== null; const sortedEvents = useMemo(() => { const sourceEvents = isExactSearchActive ? searchResults : (events ?? []); if (sourceEvents.length === 0) return []; const dir = effectiveSortOrder === 'desc' ? -1 : 1; return [...sourceEvents].sort( (a, b) => dir * (new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) ); }, [events, effectiveSortOrder, isExactSearchActive, searchResults]); // Detect encrypted fields across all loaded events (inline eventData). const hasEncryptedInlineData = useMemo(() => { const sourceEvents = isExactSearchActive ? searchResults : events; if (!sourceEvents) return false; for (const event of sourceEvents) { const ed = (event as Record).eventData; if (hasEncryptedValues(ed)) return true; } return false; }, [events, isExactSearchActive, searchResults]); // Tracks whether any expanded row's lazy-loaded data contained encrypted markers. // Set to true by EventRow via onEncryptedDataDetected; never reset (sticky). const [foundEncryptedInLazyData, setFoundEncryptedInLazyData] = useState(false); const handleEncryptedDataDetected = useCallback(() => { setFoundEncryptedInLazyData(true); }, []); const hasEncryptedData = hasEncryptedDataProp || hasEncryptedInlineData || foundEncryptedInLazyData; const { correlationNameMap, workflowName } = useMemo( () => buildNameMaps( isExactSearchActive ? searchResults : (events ?? null), run ?? null ), [events, isExactSearchActive, run, searchResults] ); const durationMap = useMemo( () => buildDurationMap(sortedEvents), [sortedEvents] ); const [selectedGroupKey, setSelectedGroupKey] = useState( undefined ); const [hoveredGroupKey, setHoveredGroupKey] = useState( undefined ); const onSelectGroup = useCallback((groupKey: string | undefined) => { setSelectedGroupKey(groupKey); }, []); const onHoverGroup = useCallback((groupKey: string | undefined) => { setHoveredGroupKey(groupKey); }, []); const activeGroupKey = selectedGroupKey ?? hoveredGroupKey; // Expanded state lifted out of EventRow so it survives virtualization const [expandedEventIds, setExpandedEventIds] = useState>( () => new Set() ); const toggleEventExpanded = useCallback((eventId: string) => { setExpandedEventIds((prev) => { const next = new Set(prev); if (next.has(eventId)) { next.delete(eventId); } else { next.add(eventId); } return next; }); }, []); // Event data cache — ref avoids re-renders when cache updates const eventDataCacheRef = useRef>(new Map()); const cacheEventData = useCallback((eventId: string, data: unknown) => { eventDataCacheRef.current.set(eventId, data); }, []); // Lookup from eventId → groupKey for efficient collapse filtering const eventGroupKeyMap = useMemo(() => { const map = new Map(); for (const ev of sortedEvents) { const gk = isRunLevel(ev.eventType) ? '__run__' : (ev.correlationId ?? ''); if (gk) map.set(ev.eventId, gk); } return map; }, [sortedEvents]); // Collapse expanded events that don't belong to the newly selected group useEffect(() => { if (selectedGroupKey === undefined) return; setExpandedEventIds((prev) => { if (prev.size === 0) return prev; let changed = false; const next = new Set(); for (const eventId of prev) { if (eventGroupKeyMap.get(eventId) === selectedGroupKey) { next.add(eventId); } else { changed = true; } } return changed ? next : prev; }); }, [selectedGroupKey, eventGroupKeyMap]); // Compute the row-index range for the active group's connecting lane line. // Only applies to non-run groups (step/hook/wait correlations). const selectedGroupRange = useMemo(() => { if (!activeGroupKey || activeGroupKey === '__run__') return null; let first = -1; let last = -1; for (let i = 0; i < sortedEvents.length; i++) { if (sortedEvents[i].correlationId === activeGroupKey) { if (first === -1) first = i; last = i; } } return first >= 0 ? { first, last } : null; }, [activeGroupKey, sortedEvents]); useEffect(() => { const trimmed = searchQuery.trim(); if (!trimmed) { searchRequestRef.current += 1; setSearchResults(null); setSearchResultsTruncated(false); setSearchError(null); setSearchLoading(false); setSearchNotFound(false); setSelectedGroupKey(undefined); return; } const parsed = parseExactWorkflowSearchId(trimmed); if (!parsed || !onExactIdSearch) { setSearchResults(null); setSearchLoading(false); setSearchNotFound(false); return; } const requestId = ++searchRequestRef.current; setSearchLoading(true); setSearchNotFound(false); setSearchError(null); const abortController = new AbortController(); const timer = setTimeout(() => { void (async () => { try { const results = await onExactIdSearch( parsed.id, parsed.kind, abortController.signal ); if ( abortController.signal.aborted || searchRequestRef.current !== requestId ) { return; } if (results.status === 'error') { setSearchResults([]); setSearchResultsTruncated(false); setSearchNotFound(false); setSearchError(results.message); setSelectedGroupKey(undefined); return; } if ( results.status === 'not_found' || (results.status === 'ok' && results.events.length === 0) ) { setSearchResults([]); setSearchResultsTruncated(false); setSearchNotFound(true); setSearchError(null); setSelectedGroupKey(undefined); return; } setSearchResults(results.events); setSearchResultsTruncated(Boolean(results.truncated)); setSearchNotFound(false); setSearchError(null); setSelectedGroupKey( parsed.kind === 'event' ? (() => { const first = results.events[0]; if (!first) return undefined; return isRunLevel(first.eventType) ? '__run__' : (first.correlationId ?? undefined); })() : parsed.id ); virtuosoRef.current?.scrollToIndex({ index: 0, align: 'start', behavior: 'smooth', }); } catch { if ( abortController.signal.aborted || searchRequestRef.current !== requestId ) { return; } setSearchResults([]); setSearchResultsTruncated(false); setSearchNotFound(false); setSearchError('Failed to search events. Try again.'); setSelectedGroupKey(undefined); } finally { if ( searchRequestRef.current === requestId && !abortController.signal.aborted ) { setSearchLoading(false); } } })(); }, 300); return () => { clearTimeout(timer); abortController.abort(); }; }, [searchQuery, onExactIdSearch]); const handleSearchKeyDown = useCallback( (event: ReactKeyboardEvent) => { if (event.key !== 'Enter') { return; } const trimmed = searchQuery.trim(); if ( !trimmed || parseExactWorkflowSearchId(trimmed) || !onExactIdSearch || !looksLikeWorkflowIdSearchInput(trimmed) ) { return; } toast.info('Enter a full step ID, wait ID, hook ID, or event ID'); }, [searchQuery, onExactIdSearch, toast] ); // Track whether we've ever had events to distinguish initial load from refetch const hasHadEventsRef = useRef(false); if (sortedEvents.length > 0) { hasHadEventsRef.current = true; } const isInitialLoad = isLoading && !hasHadEventsRef.current; const isRefetching = isLoading && hasHadEventsRef.current && sortedEvents.length === 0; if (isInitialLoad) { return (
{/* Skeleton search bar */}
{/* Skeleton header */}
); } return (
{/* Search bar + sort */}
{(hasEncryptedData || encryptionKey) && onDecrypt && ( )}
{/* Header */}
Time
Event Type
Name
Correlation ID
Event ID
{/* Virtualized event rows or refetching skeleton */} {isRefetching || searchLoading ? ( ) : sortedEvents.length === 0 ? (
{searchNotFound && searchQuery.trim() ? `No events found for ${searchQuery.trim()}` : searchError ? searchError : parsedSearchId && searchQuery.trim() && !onExactIdSearch ? 'Exact ID search is unavailable in this view.' : 'No events found'}
) : ( { if ( isExactSearchActive || !hasMoreEvents || isLoadingMoreEvents ) { return; } void onLoadMoreEvents?.(); }} itemContent={(index: number) => { const ev = sortedEvents[index]; return ( ); }} style={{ flex: 1, minHeight: 0 }} /> )} {/* Fixed footer — count + load more */}
{isExactSearchActive ? searchError ? searchError : searchNotFound ? `No events found for ${searchQuery.trim()}` : `${sortedEvents.length} event${sortedEvents.length !== 1 ? 's' : ''} for ${searchQuery.trim()}${searchResultsTruncated ? ' (results may be truncated)' : ''}` : `${sortedEvents.length} event${sortedEvents.length !== 1 ? 's' : ''} loaded`} {!isExactSearchActive && hasMoreEvents && (
void onLoadMoreEvents?.()} />
)}
); } export function EventListView(props: EventsListProps) { return ( ); }