@workflow/web-shared 4.1.0-beta.63 → 4.1.0-beta.65

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +4 -0
  2. package/dist/components/event-list-view.d.ts +12 -1
  3. package/dist/components/event-list-view.d.ts.map +1 -1
  4. package/dist/components/event-list-view.js +233 -91
  5. package/dist/components/event-list-view.js.map +1 -1
  6. package/dist/components/index.d.ts +4 -0
  7. package/dist/components/index.d.ts.map +1 -1
  8. package/dist/components/index.js +4 -0
  9. package/dist/components/index.js.map +1 -1
  10. package/dist/components/sidebar/entity-detail-panel.d.ts +3 -1
  11. package/dist/components/sidebar/entity-detail-panel.d.ts.map +1 -1
  12. package/dist/components/sidebar/entity-detail-panel.js +4 -14
  13. package/dist/components/sidebar/entity-detail-panel.js.map +1 -1
  14. package/dist/components/stream-viewer.d.ts +3 -1
  15. package/dist/components/stream-viewer.d.ts.map +1 -1
  16. package/dist/components/stream-viewer.js +23 -28
  17. package/dist/components/stream-viewer.js.map +1 -1
  18. package/dist/components/ui/decrypt-button.d.ts +15 -0
  19. package/dist/components/ui/decrypt-button.d.ts.map +1 -0
  20. package/dist/components/ui/decrypt-button.js +12 -0
  21. package/dist/components/ui/decrypt-button.js.map +1 -0
  22. package/dist/components/ui/error-stack-block.d.ts +3 -4
  23. package/dist/components/ui/error-stack-block.d.ts.map +1 -1
  24. package/dist/components/ui/error-stack-block.js +18 -9
  25. package/dist/components/ui/error-stack-block.js.map +1 -1
  26. package/dist/components/ui/load-more-button.d.ts +13 -0
  27. package/dist/components/ui/load-more-button.d.ts.map +1 -0
  28. package/dist/components/ui/load-more-button.js +12 -0
  29. package/dist/components/ui/load-more-button.js.map +1 -0
  30. package/dist/components/ui/menu-dropdown.d.ts +16 -0
  31. package/dist/components/ui/menu-dropdown.d.ts.map +1 -0
  32. package/dist/components/ui/menu-dropdown.js +46 -0
  33. package/dist/components/ui/menu-dropdown.js.map +1 -0
  34. package/dist/components/ui/spinner.d.ts +9 -0
  35. package/dist/components/ui/spinner.d.ts.map +1 -0
  36. package/dist/components/ui/spinner.js +57 -0
  37. package/dist/components/ui/spinner.js.map +1 -0
  38. package/dist/components/workflow-trace-view.d.ts +3 -1
  39. package/dist/components/workflow-trace-view.d.ts.map +1 -1
  40. package/dist/components/workflow-trace-view.js +7 -6
  41. package/dist/components/workflow-trace-view.js.map +1 -1
  42. package/package.json +3 -3
  43. package/src/components/event-list-view.tsx +398 -141
  44. package/src/components/index.ts +4 -0
  45. package/src/components/sidebar/entity-detail-panel.tsx +9 -25
  46. package/src/components/stream-viewer.tsx +52 -63
  47. package/src/components/ui/decrypt-button.tsx +69 -0
  48. package/src/components/ui/error-stack-block.tsx +26 -16
  49. package/src/components/ui/load-more-button.tsx +38 -0
  50. package/src/components/ui/menu-dropdown.tsx +111 -0
  51. package/src/components/ui/spinner.tsx +76 -0
  52. package/src/components/workflow-trace-view.tsx +15 -22
@@ -6,12 +6,16 @@ import { Check, ChevronRight, Copy } from 'lucide-react';
6
6
  import type { MouseEvent as ReactMouseEvent, ReactNode } from 'react';
7
7
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
8
8
  import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso';
9
+ import { isEncryptedMarker } from '../lib/hydration';
10
+ import { DecryptButton } from './ui/decrypt-button';
9
11
  import { formatDuration } from '../lib/utils';
10
12
  import { DataInspector } from './ui/data-inspector';
11
13
  import {
12
14
  ErrorStackBlock,
13
15
  isStructuredErrorWithStack,
14
16
  } from './ui/error-stack-block';
17
+ import { LoadMoreButton } from './ui/load-more-button';
18
+ import { MenuDropdown } from './ui/menu-dropdown';
15
19
  import { Skeleton } from './ui/skeleton';
16
20
 
17
21
  /**
@@ -378,9 +382,11 @@ function TreeGutter({
378
382
  function CopyableCell({
379
383
  value,
380
384
  className,
385
+ style: styleProp,
381
386
  }: {
382
387
  value: string;
383
388
  className?: string;
389
+ style?: React.CSSProperties;
384
390
  }): ReactNode {
385
391
  const [copied, setCopied] = useState(false);
386
392
  const resetCopiedTimeoutRef = useRef<number | null>(null);
@@ -412,7 +418,8 @@ function CopyableCell({
412
418
 
413
419
  return (
414
420
  <div
415
- className={`group/copy flex items-center gap-1 flex-1 min-w-0 px-4 ${className ?? ''}`}
421
+ className={`group/copy flex items-center gap-1 min-w-0 px-4 ${className ?? ''}`}
422
+ style={styleProp}
416
423
  >
417
424
  <span className="overflow-hidden text-ellipsis whitespace-nowrap">
418
425
  {value || '-'}
@@ -583,6 +590,39 @@ function PayloadBlock({
583
590
  );
584
591
  }
585
592
 
593
+ // ──────────────────────────────────────────────────────────────────────────
594
+ // Sort options for the events list
595
+ // ──────────────────────────────────────────────────────────────────────────
596
+
597
+ const SORT_OPTIONS = [
598
+ { value: 'desc' as const, label: 'Newest' },
599
+ { value: 'asc' as const, label: 'Oldest' },
600
+ ];
601
+
602
+ function RowsSkeleton() {
603
+ return (
604
+ <div className="flex-1 overflow-hidden">
605
+ {Array.from({ length: 8 }, (_, i) => (
606
+ <div
607
+ key={i}
608
+ className="flex items-center gap-3 px-4"
609
+ style={{ height: 40 }}
610
+ >
611
+ <Skeleton
612
+ className="h-2 w-2 flex-shrink-0"
613
+ style={{ borderRadius: '50%' }}
614
+ />
615
+ <Skeleton className="h-3" style={{ width: 90 }} />
616
+ <Skeleton className="h-3" style={{ width: 100 }} />
617
+ <Skeleton className="h-3" style={{ width: 80 }} />
618
+ <Skeleton className="h-3 flex-1" />
619
+ <Skeleton className="h-3 flex-1" />
620
+ </div>
621
+ ))}
622
+ </div>
623
+ );
624
+ }
625
+
586
626
  // ──────────────────────────────────────────────────────────────────────────
587
627
  // Event row
588
628
  // ──────────────────────────────────────────────────────────────────────────
@@ -596,6 +636,17 @@ interface EventsListProps {
596
636
  onLoadMoreEvents?: () => Promise<void> | void;
597
637
  /** When provided, signals that decryption is active (triggers re-load of expanded events) */
598
638
  encryptionKey?: Uint8Array;
639
+ /** When true, shows a loading state instead of "No events found" for empty lists */
640
+ isLoading?: boolean;
641
+ /** Sort order for events. Defaults to 'asc'. */
642
+ sortOrder?: 'asc' | 'desc';
643
+ /** Called when the user changes sort order. When provided, the sort dropdown is shown
644
+ * and the parent is expected to refetch from the API with the new order. */
645
+ onSortOrderChange?: (order: 'asc' | 'desc') => void;
646
+ /** Called when the user clicks the Decrypt button. */
647
+ onDecrypt?: () => void;
648
+ /** Whether the encryption key is currently being fetched. */
649
+ isDecrypting?: boolean;
599
650
  }
600
651
 
601
652
  function EventRow({
@@ -603,6 +654,8 @@ function EventRow({
603
654
  index,
604
655
  isFirst,
605
656
  isLast,
657
+ isExpanded,
658
+ onToggleExpand,
606
659
  activeGroupKey,
607
660
  selectedGroupKey,
608
661
  selectedGroupRange,
@@ -612,12 +665,16 @@ function EventRow({
612
665
  onSelectGroup,
613
666
  onHoverGroup,
614
667
  onLoadEventData,
668
+ cachedEventData,
669
+ onCacheEventData,
615
670
  encryptionKey,
616
671
  }: {
617
672
  event: Event;
618
673
  index: number;
619
674
  isFirst: boolean;
620
675
  isLast: boolean;
676
+ isExpanded: boolean;
677
+ onToggleExpand: (eventId: string) => void;
621
678
  activeGroupKey?: string;
622
679
  selectedGroupKey?: string;
623
680
  selectedGroupRange: { first: number; last: number } | null;
@@ -627,24 +684,22 @@ function EventRow({
627
684
  onSelectGroup: (groupKey: string | undefined) => void;
628
685
  onHoverGroup: (groupKey: string | undefined) => void;
629
686
  onLoadEventData?: (event: Event) => Promise<unknown | null>;
687
+ cachedEventData: unknown | null;
688
+ onCacheEventData: (eventId: string, data: unknown) => void;
630
689
  encryptionKey?: Uint8Array;
631
690
  }) {
632
- const [isExpanded, setIsExpanded] = useState(false);
633
691
  const [isLoading, setIsLoading] = useState(false);
634
- const [loadedEventData, setLoadedEventData] = useState<unknown | null>(null);
692
+ const [loadedEventData, setLoadedEventData] = useState<unknown | null>(
693
+ cachedEventData
694
+ );
635
695
  const [loadError, setLoadError] = useState<string | null>(null);
636
- const [hasAttemptedLoad, setHasAttemptedLoad] = useState(false);
637
-
638
- const rowGroupKey =
639
- event.correlationId ??
640
- (isRunLevel(event.eventType) ? '__run__' : undefined);
696
+ const [hasAttemptedLoad, setHasAttemptedLoad] = useState(
697
+ cachedEventData !== null
698
+ );
641
699
 
642
- // Collapse when a different group gets selected
643
- useEffect(() => {
644
- if (selectedGroupKey !== undefined && selectedGroupKey !== rowGroupKey) {
645
- setIsExpanded(false);
646
- }
647
- }, [selectedGroupKey, rowGroupKey]);
700
+ const rowGroupKey = isRunLevel(event.eventType)
701
+ ? '__run__'
702
+ : (event.correlationId ?? undefined);
648
703
 
649
704
  const statusDotColor = getStatusDotColor(event.eventType);
650
705
  const createdAt = new Date(event.createdAt);
@@ -686,9 +741,10 @@ function EventRow({
686
741
  setLoadError('Event details unavailable');
687
742
  return;
688
743
  }
689
- const eventData = await onLoadEventData(event);
690
- if (eventData !== null && eventData !== undefined) {
691
- setLoadedEventData(eventData);
744
+ const data = await onLoadEventData(event);
745
+ if (data !== null && data !== undefined) {
746
+ setLoadedEventData(data);
747
+ onCacheEventData(event.eventId, data);
692
748
  }
693
749
  } catch (err) {
694
750
  setLoadError(
@@ -698,7 +754,27 @@ function EventRow({
698
754
  setIsLoading(false);
699
755
  setHasAttemptedLoad(true);
700
756
  }
701
- }, [event, loadedEventData, hasExistingEventData, onLoadEventData]);
757
+ }, [
758
+ event,
759
+ loadedEventData,
760
+ hasExistingEventData,
761
+ onLoadEventData,
762
+ onCacheEventData,
763
+ ]);
764
+
765
+ // Auto-load event data when remounting in expanded state without cached data
766
+ useEffect(() => {
767
+ if (
768
+ isExpanded &&
769
+ loadedEventData === null &&
770
+ !hasExistingEventData &&
771
+ !isLoading &&
772
+ !hasAttemptedLoad
773
+ ) {
774
+ loadEventDetails();
775
+ }
776
+ // eslint-disable-next-line react-hooks/exhaustive-deps
777
+ }, []);
702
778
 
703
779
  // When encryption key changes and this event was previously loaded,
704
780
  // re-load to get decrypted data
@@ -710,6 +786,7 @@ function EventRow({
710
786
  .then((data) => {
711
787
  if (data !== null && data !== undefined) {
712
788
  setLoadedEventData(data);
789
+ onCacheEventData(event.eventId, data);
713
790
  }
714
791
  setHasAttemptedLoad(true);
715
792
  })
@@ -720,25 +797,23 @@ function EventRow({
720
797
  // eslint-disable-next-line react-hooks/exhaustive-deps
721
798
  }, [encryptionKey]);
722
799
 
723
- const handleExpandToggle = useCallback(
724
- (e: ReactMouseEvent) => {
725
- e.stopPropagation();
726
- const newExpanded = !isExpanded;
727
- setIsExpanded(newExpanded);
728
- if (newExpanded && loadedEventData === null && !hasExistingEventData) {
729
- loadEventDetails();
730
- }
731
- },
732
- [isExpanded, loadedEventData, hasExistingEventData, loadEventDetails]
733
- );
734
-
735
800
  const handleRowClick = useCallback(() => {
736
- if (selectedGroupKey === rowGroupKey) {
737
- onSelectGroup(undefined);
738
- } else {
739
- onSelectGroup(rowGroupKey);
801
+ onSelectGroup(rowGroupKey === selectedGroupKey ? undefined : rowGroupKey);
802
+ onToggleExpand(event.eventId);
803
+ if (!isExpanded && loadedEventData === null && !hasExistingEventData) {
804
+ loadEventDetails();
740
805
  }
741
- }, [selectedGroupKey, rowGroupKey, onSelectGroup]);
806
+ }, [
807
+ selectedGroupKey,
808
+ rowGroupKey,
809
+ onSelectGroup,
810
+ onToggleExpand,
811
+ event.eventId,
812
+ isExpanded,
813
+ loadedEventData,
814
+ hasExistingEventData,
815
+ loadEventDetails,
816
+ ]);
742
817
 
743
818
  const eventData = hasExistingEventData
744
819
  ? (event as Event & { eventData: unknown }).eventData
@@ -760,7 +835,7 @@ function EventRow({
760
835
  onKeyDown={(e) => {
761
836
  if (e.key === 'Enter' || e.key === ' ') handleRowClick();
762
837
  }}
763
- className="w-full text-left flex items-center gap-0 text-sm hover:bg-[var(--ds-gray-alpha-100)] transition-colors cursor-pointer"
838
+ className="w-full text-left flex items-center gap-0 text-[13px] hover:bg-[var(--ds-gray-alpha-100)] transition-colors cursor-pointer"
764
839
  style={{ minHeight: 40 }}
765
840
  >
766
841
  <TreeGutter
@@ -781,36 +856,32 @@ function EventRow({
781
856
  className="flex items-center flex-1 min-w-0"
782
857
  style={{ opacity: contentOpacity, transition: 'opacity 150ms' }}
783
858
  >
784
- {/* Expand chevron button */}
785
- <button
786
- type="button"
787
- onClick={handleExpandToggle}
788
- className="flex items-center justify-center w-5 h-5 flex-shrink-0 rounded hover:bg-[var(--ds-gray-alpha-200)] transition-colors"
859
+ {/* Expand chevron indicator */}
860
+ <div
861
+ className="flex items-center justify-center w-5 h-5 flex-shrink-0 rounded"
789
862
  style={{
790
- ...BUTTON_RESET_STYLE,
791
- border: '1px solid var(--ds-gray-alpha-400)',
863
+ border: '1px solid var(--ds-gray-400)',
792
864
  }}
793
- aria-label={isExpanded ? 'Collapse details' : 'Expand details'}
794
865
  >
795
866
  <ChevronRight
796
867
  className="h-3 w-3 transition-transform"
797
868
  style={{
798
- color: 'var(--ds-gray-700)',
869
+ color: 'var(--ds-gray-900)',
799
870
  transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)',
800
871
  }}
801
872
  />
802
- </button>
873
+ </div>
803
874
 
804
875
  {/* Time */}
805
876
  <div
806
- className="text-xs tabular-nums flex-1 min-w-0 px-4"
807
- style={{ color: 'var(--ds-gray-900)' }}
877
+ className="tabular-nums min-w-0 px-4"
878
+ style={{ color: 'var(--ds-gray-900)', flex: '2 1 0%' }}
808
879
  >
809
880
  {formatEventTime(createdAt)}
810
881
  </div>
811
882
 
812
883
  {/* Event Type */}
813
- <div className="text-xs font-medium flex-1 min-w-0 px-4">
884
+ <div className="font-medium min-w-0 px-4" style={{ flex: '2 1 0%' }}>
814
885
  <span
815
886
  className="inline-flex items-center gap-1.5"
816
887
  style={{ color: 'var(--ds-gray-900)' }}
@@ -852,7 +923,8 @@ function EventRow({
852
923
 
853
924
  {/* Name */}
854
925
  <div
855
- className="text-xs flex-1 min-w-0 px-4 overflow-hidden text-ellipsis whitespace-nowrap"
926
+ className="min-w-0 px-4 overflow-hidden text-ellipsis whitespace-nowrap"
927
+ style={{ flex: '2 1 0%' }}
856
928
  title={eventName !== '-' ? eventName : undefined}
857
929
  >
858
930
  {eventName}
@@ -861,11 +933,16 @@ function EventRow({
861
933
  {/* Correlation ID */}
862
934
  <CopyableCell
863
935
  value={event.correlationId || ''}
864
- className="font-mono text-xs"
936
+ className="font-mono"
937
+ style={{ flex: '3 1 0%' }}
865
938
  />
866
939
 
867
940
  {/* Event ID */}
868
- <CopyableCell value={event.eventId} className="font-mono text-xs" />
941
+ <CopyableCell
942
+ value={event.eventId}
943
+ className="font-mono"
944
+ style={{ flex: '3 1 0%' }}
945
+ />
869
946
  </div>
870
947
  </div>
871
948
 
@@ -971,13 +1048,51 @@ export function EventListView({
971
1048
  isLoadingMoreEvents = false,
972
1049
  onLoadMoreEvents,
973
1050
  encryptionKey,
1051
+ isLoading = false,
1052
+ sortOrder: sortOrderProp,
1053
+ onSortOrderChange,
1054
+ onDecrypt,
1055
+ isDecrypting = false,
974
1056
  }: EventsListProps) {
1057
+ const [internalSortOrder, setInternalSortOrder] = useState<'asc' | 'desc'>(
1058
+ 'asc'
1059
+ );
1060
+ const effectiveSortOrder = sortOrderProp ?? internalSortOrder;
1061
+ const handleSortOrderChange = useCallback(
1062
+ (order: 'asc' | 'desc') => {
1063
+ if (onSortOrderChange) {
1064
+ onSortOrderChange(order);
1065
+ } else {
1066
+ setInternalSortOrder(order);
1067
+ }
1068
+ },
1069
+ [onSortOrderChange]
1070
+ );
1071
+
975
1072
  const sortedEvents = useMemo(() => {
976
1073
  if (!events || events.length === 0) return [];
1074
+ const dir = effectiveSortOrder === 'desc' ? -1 : 1;
977
1075
  return [...events].sort(
978
1076
  (a, b) =>
979
- new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
1077
+ dir *
1078
+ (new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
980
1079
  );
1080
+ }, [events, effectiveSortOrder]);
1081
+
1082
+ // Detect encrypted fields across all loaded events.
1083
+ // Only checks top-level eventData values (input, output, result, etc.) —
1084
+ // the current data model guarantees encrypted markers appear at this level.
1085
+ const hasEncryptedData = useMemo(() => {
1086
+ if (!events) return false;
1087
+ for (const event of events) {
1088
+ const ed = (event as Record<string, unknown>).eventData;
1089
+ if (!ed || typeof ed !== 'object') continue;
1090
+ const data = ed as Record<string, unknown>;
1091
+ for (const val of Object.values(data)) {
1092
+ if (isEncryptedMarker(val)) return true;
1093
+ }
1094
+ }
1095
+ return false;
981
1096
  }, [events]);
982
1097
 
983
1098
  const { correlationNameMap, workflowName } = useMemo(
@@ -1005,6 +1120,58 @@ export function EventListView({
1005
1120
 
1006
1121
  const activeGroupKey = selectedGroupKey ?? hoveredGroupKey;
1007
1122
 
1123
+ // Expanded state lifted out of EventRow so it survives virtualization
1124
+ const [expandedEventIds, setExpandedEventIds] = useState<Set<string>>(
1125
+ () => new Set()
1126
+ );
1127
+ const toggleEventExpanded = useCallback((eventId: string) => {
1128
+ setExpandedEventIds((prev) => {
1129
+ const next = new Set(prev);
1130
+ if (next.has(eventId)) {
1131
+ next.delete(eventId);
1132
+ } else {
1133
+ next.add(eventId);
1134
+ }
1135
+ return next;
1136
+ });
1137
+ }, []);
1138
+
1139
+ // Event data cache — ref avoids re-renders when cache updates
1140
+ const eventDataCacheRef = useRef<Map<string, unknown>>(new Map());
1141
+ const cacheEventData = useCallback((eventId: string, data: unknown) => {
1142
+ eventDataCacheRef.current.set(eventId, data);
1143
+ }, []);
1144
+
1145
+ // Lookup from eventId → groupKey for efficient collapse filtering
1146
+ const eventGroupKeyMap = useMemo(() => {
1147
+ const map = new Map<string, string>();
1148
+ for (const ev of sortedEvents) {
1149
+ const gk = isRunLevel(ev.eventType)
1150
+ ? '__run__'
1151
+ : (ev.correlationId ?? '');
1152
+ if (gk) map.set(ev.eventId, gk);
1153
+ }
1154
+ return map;
1155
+ }, [sortedEvents]);
1156
+
1157
+ // Collapse expanded events that don't belong to the newly selected group
1158
+ useEffect(() => {
1159
+ if (selectedGroupKey === undefined) return;
1160
+ setExpandedEventIds((prev) => {
1161
+ if (prev.size === 0) return prev;
1162
+ let changed = false;
1163
+ const next = new Set<string>();
1164
+ for (const eventId of prev) {
1165
+ if (eventGroupKeyMap.get(eventId) === selectedGroupKey) {
1166
+ next.add(eventId);
1167
+ } else {
1168
+ changed = true;
1169
+ }
1170
+ }
1171
+ return changed ? next : prev;
1172
+ });
1173
+ }, [selectedGroupKey, eventGroupKeyMap]);
1174
+
1008
1175
  // Compute the row-index range for the active group's connecting lane line.
1009
1176
  // Only applies to non-run groups (step/hook/wait correlations).
1010
1177
  const selectedGroupRange = useMemo(() => {
@@ -1025,24 +1192,34 @@ export function EventListView({
1025
1192
 
1026
1193
  const searchIndex = useMemo(() => {
1027
1194
  const entries: {
1028
- text: string;
1195
+ fields: string[];
1029
1196
  groupKey?: string;
1030
1197
  eventId: string;
1031
1198
  index: number;
1032
1199
  }[] = [];
1033
1200
  for (let i = 0; i < sortedEvents.length; i++) {
1034
1201
  const ev = sortedEvents[i];
1202
+ const isRun = isRunLevel(ev.eventType);
1203
+ const name = isRun
1204
+ ? (workflowName ?? '')
1205
+ : ev.correlationId
1206
+ ? (correlationNameMap.get(ev.correlationId) ?? '')
1207
+ : '';
1035
1208
  entries.push({
1036
- text: [ev.eventId, ev.correlationId ?? ''].join(' ').toLowerCase(),
1037
- groupKey:
1038
- ev.correlationId ??
1039
- (isRunLevel(ev.eventType) ? '__run__' : undefined),
1209
+ fields: [
1210
+ ev.eventId,
1211
+ ev.correlationId ?? '',
1212
+ ev.eventType,
1213
+ formatEventType(ev.eventType),
1214
+ name,
1215
+ ].map((f) => f.toLowerCase()),
1216
+ groupKey: ev.correlationId ?? (isRun ? '__run__' : undefined),
1040
1217
  eventId: ev.eventId,
1041
1218
  index: i,
1042
1219
  });
1043
1220
  }
1044
1221
  return entries;
1045
- }, [sortedEvents]);
1222
+ }, [sortedEvents, correlationNameMap, workflowName]);
1046
1223
 
1047
1224
  useEffect(() => {
1048
1225
  const q = searchQuery.trim().toLowerCase();
@@ -1050,18 +1227,66 @@ export function EventListView({
1050
1227
  setSelectedGroupKey(undefined);
1051
1228
  return;
1052
1229
  }
1053
- const match = searchIndex.find((entry) => entry.text.includes(q));
1054
- if (match) {
1055
- setSelectedGroupKey(match.groupKey);
1230
+ let bestMatch: (typeof searchIndex)[number] | null = null;
1231
+ let bestScore = 0;
1232
+ for (const entry of searchIndex) {
1233
+ for (const field of entry.fields) {
1234
+ if (field && field.includes(q)) {
1235
+ const score = q.length / field.length;
1236
+ if (score > bestScore) {
1237
+ bestScore = score;
1238
+ bestMatch = entry;
1239
+ }
1240
+ }
1241
+ }
1242
+ }
1243
+ if (bestMatch) {
1244
+ setSelectedGroupKey(bestMatch.groupKey);
1056
1245
  virtuosoRef.current?.scrollToIndex({
1057
- index: match.index,
1246
+ index: bestMatch.index,
1058
1247
  align: 'center',
1059
1248
  behavior: 'smooth',
1060
1249
  });
1061
1250
  }
1062
1251
  }, [searchQuery, searchIndex]);
1063
1252
 
1064
- if (!events || events.length === 0) {
1253
+ // Track whether we've ever had events to distinguish initial load from refetch
1254
+ const hasHadEventsRef = useRef(false);
1255
+ if (sortedEvents.length > 0) {
1256
+ hasHadEventsRef.current = true;
1257
+ }
1258
+ const isInitialLoad = isLoading && !hasHadEventsRef.current;
1259
+ const isRefetching =
1260
+ isLoading && hasHadEventsRef.current && sortedEvents.length === 0;
1261
+
1262
+ if (isInitialLoad) {
1263
+ return (
1264
+ <div className="h-full flex flex-col overflow-hidden">
1265
+ {/* Skeleton search bar */}
1266
+ <div style={{ padding: 6 }}>
1267
+ <Skeleton style={{ height: 40, borderRadius: 6 }} />
1268
+ </div>
1269
+ {/* Skeleton header */}
1270
+ <div
1271
+ className="flex items-center gap-0 h-10 border-b flex-shrink-0 px-4"
1272
+ style={{ borderColor: 'var(--ds-gray-alpha-200)' }}
1273
+ >
1274
+ <Skeleton className="h-3" style={{ width: 60 }} />
1275
+ <div style={{ flex: 1 }} />
1276
+ <Skeleton className="h-3" style={{ width: 80 }} />
1277
+ <div style={{ flex: 1 }} />
1278
+ <Skeleton className="h-3" style={{ width: 50 }} />
1279
+ <div style={{ flex: 1 }} />
1280
+ <Skeleton className="h-3" style={{ width: 90 }} />
1281
+ <div style={{ flex: 1 }} />
1282
+ <Skeleton className="h-3" style={{ width: 70 }} />
1283
+ </div>
1284
+ <RowsSkeleton />
1285
+ </div>
1286
+ );
1287
+ }
1288
+
1289
+ if (!isLoading && (!events || events.length === 0)) {
1065
1290
  return (
1066
1291
  <div
1067
1292
  className="flex items-center justify-center h-full text-sm"
@@ -1075,8 +1300,15 @@ export function EventListView({
1075
1300
  return (
1076
1301
  <div className="h-full flex flex-col overflow-hidden">
1077
1302
  <style>{`@keyframes workflow-dot-pulse{0%{transform:scale(1);opacity:.7}70%,100%{transform:scale(2.2);opacity:0}}`}</style>
1078
- {/* Search bar */}
1079
- <div style={{ padding: 6, backgroundColor: 'var(--ds-background-100)' }}>
1303
+ {/* Search bar + sort */}
1304
+ <div
1305
+ style={{
1306
+ padding: 6,
1307
+ backgroundColor: 'var(--ds-background-100)',
1308
+ display: 'flex',
1309
+ gap: 6,
1310
+ }}
1311
+ >
1080
1312
  <label
1081
1313
  style={{
1082
1314
  display: 'flex',
@@ -1086,6 +1318,8 @@ export function EventListView({
1086
1318
  boxShadow: '0 0 0 1px var(--ds-gray-alpha-400)',
1087
1319
  background: 'var(--ds-background-100)',
1088
1320
  height: 40,
1321
+ flex: 1,
1322
+ minWidth: 0,
1089
1323
  }}
1090
1324
  >
1091
1325
  <div
@@ -1124,7 +1358,7 @@ export function EventListView({
1124
1358
  </div>
1125
1359
  <input
1126
1360
  type="search"
1127
- placeholder="Search by event ID or correlation ID…"
1361
+ placeholder="Search by name, event type, or ID…"
1128
1362
  value={searchQuery}
1129
1363
  onChange={(e) => setSearchQuery(e.target.value)}
1130
1364
  style={{
@@ -1140,11 +1374,23 @@ export function EventListView({
1140
1374
  }}
1141
1375
  />
1142
1376
  </label>
1377
+ <MenuDropdown
1378
+ options={SORT_OPTIONS}
1379
+ value={effectiveSortOrder}
1380
+ onChange={handleSortOrderChange}
1381
+ />
1382
+ {(hasEncryptedData || encryptionKey) && onDecrypt && (
1383
+ <DecryptButton
1384
+ decrypted={!!encryptionKey}
1385
+ loading={isDecrypting}
1386
+ onClick={onDecrypt}
1387
+ />
1388
+ )}
1143
1389
  </div>
1144
1390
 
1145
1391
  {/* Header */}
1146
1392
  <div
1147
- className="flex items-center gap-0 text-sm font-medium h-10 border-b flex-shrink-0"
1393
+ className="flex items-center gap-0 text-[13px] font-medium h-10 border-b flex-shrink-0"
1148
1394
  style={{
1149
1395
  borderColor: 'var(--ds-gray-alpha-200)',
1150
1396
  color: 'var(--ds-gray-900)',
@@ -1153,82 +1399,93 @@ export function EventListView({
1153
1399
  >
1154
1400
  <div className="flex-shrink-0" style={{ width: GUTTER_WIDTH }} />
1155
1401
  <div className="w-5 flex-shrink-0" />
1156
- <div className="flex-1 min-w-0 px-4">Time</div>
1157
- <div className="flex-1 min-w-0 px-4">Event Type</div>
1158
- <div className="flex-1 min-w-0 px-4">Name</div>
1159
- <div className="flex-1 min-w-0 px-4">Correlation ID</div>
1160
- <div className="flex-1 min-w-0 px-4">Event ID</div>
1402
+ <div className="min-w-0 px-4" style={{ flex: '2 1 0%' }}>
1403
+ Time
1404
+ </div>
1405
+ <div className="min-w-0 px-4" style={{ flex: '2 1 0%' }}>
1406
+ Event Type
1407
+ </div>
1408
+ <div className="min-w-0 px-4" style={{ flex: '2 1 0%' }}>
1409
+ Name
1410
+ </div>
1411
+ <div className="min-w-0 px-4" style={{ flex: '3 1 0%' }}>
1412
+ Correlation ID
1413
+ </div>
1414
+ <div className="min-w-0 px-4" style={{ flex: '3 1 0%' }}>
1415
+ Event ID
1416
+ </div>
1161
1417
  </div>
1162
1418
 
1163
- {/* Virtualized event rows */}
1164
- <Virtuoso
1165
- ref={virtuosoRef}
1166
- totalCount={sortedEvents.length}
1167
- overscan={20}
1168
- defaultItemHeight={40}
1169
- endReached={() => {
1170
- if (!hasMoreEvents || isLoadingMoreEvents) {
1171
- return;
1172
- }
1173
- void onLoadMoreEvents?.();
1174
- }}
1175
- itemContent={(index: number) => {
1176
- return (
1177
- <EventRow
1178
- event={sortedEvents[index]}
1179
- index={index}
1180
- isFirst={index === 0}
1181
- isLast={index === sortedEvents.length - 1}
1182
- activeGroupKey={activeGroupKey}
1183
- selectedGroupKey={selectedGroupKey}
1184
- selectedGroupRange={selectedGroupRange}
1185
- correlationNameMap={correlationNameMap}
1186
- workflowName={workflowName}
1187
- durationMap={durationMap}
1188
- onSelectGroup={onSelectGroup}
1189
- onHoverGroup={onHoverGroup}
1190
- onLoadEventData={onLoadEventData}
1191
- encryptionKey={encryptionKey}
1192
- />
1193
- );
1194
- }}
1195
- components={{
1196
- Footer: () => (
1197
- <>
1198
- {hasMoreEvents && (
1199
- <div className="px-3 pt-3 flex justify-center">
1200
- <button
1201
- type="button"
1202
- onClick={() => void onLoadMoreEvents?.()}
1203
- disabled={isLoadingMoreEvents}
1204
- className="h-8 px-3 text-xs rounded-md border transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
1205
- style={{
1206
- borderColor: 'var(--ds-gray-alpha-400)',
1207
- color: 'var(--ds-gray-900)',
1208
- backgroundColor: 'var(--ds-background-100)',
1209
- }}
1210
- >
1211
- {isLoadingMoreEvents
1212
- ? 'Loading more events...'
1213
- : 'Load more'}
1214
- </button>
1215
- </div>
1216
- )}
1217
- <div
1218
- className="mt-4 pt-3 border-t text-xs px-3"
1219
- style={{
1220
- borderColor: 'var(--ds-gray-alpha-200)',
1221
- color: 'var(--ds-gray-900)',
1222
- }}
1223
- >
1224
- {sortedEvents.length} event
1225
- {sortedEvents.length !== 1 ? 's' : ''} total
1226
- </div>
1227
- </>
1228
- ),
1419
+ {/* Virtualized event rows or refetching skeleton */}
1420
+ {isRefetching ? (
1421
+ <RowsSkeleton />
1422
+ ) : (
1423
+ <Virtuoso
1424
+ ref={virtuosoRef}
1425
+ totalCount={sortedEvents.length}
1426
+ overscan={20}
1427
+ defaultItemHeight={40}
1428
+ endReached={() => {
1429
+ if (!hasMoreEvents || isLoadingMoreEvents) {
1430
+ return;
1431
+ }
1432
+ void onLoadMoreEvents?.();
1433
+ }}
1434
+ itemContent={(index: number) => {
1435
+ const ev = sortedEvents[index];
1436
+ return (
1437
+ <EventRow
1438
+ event={ev}
1439
+ index={index}
1440
+ isFirst={index === 0}
1441
+ isLast={index === sortedEvents.length - 1}
1442
+ isExpanded={expandedEventIds.has(ev.eventId)}
1443
+ onToggleExpand={toggleEventExpanded}
1444
+ activeGroupKey={activeGroupKey}
1445
+ selectedGroupKey={selectedGroupKey}
1446
+ selectedGroupRange={selectedGroupRange}
1447
+ correlationNameMap={correlationNameMap}
1448
+ workflowName={workflowName}
1449
+ durationMap={durationMap}
1450
+ onSelectGroup={onSelectGroup}
1451
+ onHoverGroup={onHoverGroup}
1452
+ onLoadEventData={onLoadEventData}
1453
+ cachedEventData={
1454
+ eventDataCacheRef.current.get(ev.eventId) ?? null
1455
+ }
1456
+ onCacheEventData={cacheEventData}
1457
+ encryptionKey={encryptionKey}
1458
+ />
1459
+ );
1460
+ }}
1461
+ style={{ flex: 1, minHeight: 0 }}
1462
+ />
1463
+ )}
1464
+
1465
+ {/* Fixed footer — count + load more */}
1466
+ <div
1467
+ className="relative flex-shrink-0 flex items-center h-10 border-t px-4 text-xs"
1468
+ style={{
1469
+ borderColor: 'var(--ds-gray-alpha-200)',
1470
+ color: 'var(--ds-gray-900)',
1471
+ backgroundColor: 'var(--ds-background-100)',
1229
1472
  }}
1230
- style={{ flex: 1, minHeight: 0 }}
1231
- />
1473
+ >
1474
+ <span>
1475
+ {sortedEvents.length} event
1476
+ {sortedEvents.length !== 1 ? 's' : ''} loaded
1477
+ </span>
1478
+ {hasMoreEvents && (
1479
+ <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
1480
+ <div className="pointer-events-auto">
1481
+ <LoadMoreButton
1482
+ loading={isLoadingMoreEvents}
1483
+ onClick={() => void onLoadMoreEvents?.()}
1484
+ />
1485
+ </div>
1486
+ </div>
1487
+ )}
1488
+ </div>
1232
1489
  </div>
1233
1490
  );
1234
1491
  }