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

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.
@@ -12,6 +12,7 @@ import {
12
12
  ErrorStackBlock,
13
13
  isStructuredErrorWithStack,
14
14
  } from './ui/error-stack-block';
15
+ import { MenuDropdown } from './ui/menu-dropdown';
15
16
  import { Skeleton } from './ui/skeleton';
16
17
 
17
18
  /**
@@ -378,9 +379,11 @@ function TreeGutter({
378
379
  function CopyableCell({
379
380
  value,
380
381
  className,
382
+ style: styleProp,
381
383
  }: {
382
384
  value: string;
383
385
  className?: string;
386
+ style?: React.CSSProperties;
384
387
  }): ReactNode {
385
388
  const [copied, setCopied] = useState(false);
386
389
  const resetCopiedTimeoutRef = useRef<number | null>(null);
@@ -412,7 +415,8 @@ function CopyableCell({
412
415
 
413
416
  return (
414
417
  <div
415
- className={`group/copy flex items-center gap-1 flex-1 min-w-0 px-4 ${className ?? ''}`}
418
+ className={`group/copy flex items-center gap-1 min-w-0 px-4 ${className ?? ''}`}
419
+ style={styleProp}
416
420
  >
417
421
  <span className="overflow-hidden text-ellipsis whitespace-nowrap">
418
422
  {value || '-'}
@@ -583,6 +587,15 @@ function PayloadBlock({
583
587
  );
584
588
  }
585
589
 
590
+ // ──────────────────────────────────────────────────────────────────────────
591
+ // Sort options for the events list
592
+ // ──────────────────────────────────────────────────────────────────────────
593
+
594
+ const SORT_OPTIONS = [
595
+ { value: 'desc' as const, label: 'Newest' },
596
+ { value: 'asc' as const, label: 'Oldest' },
597
+ ];
598
+
586
599
  // ──────────────────────────────────────────────────────────────────────────
587
600
  // Event row
588
601
  // ──────────────────────────────────────────────────────────────────────────
@@ -596,6 +609,13 @@ interface EventsListProps {
596
609
  onLoadMoreEvents?: () => Promise<void> | void;
597
610
  /** When provided, signals that decryption is active (triggers re-load of expanded events) */
598
611
  encryptionKey?: Uint8Array;
612
+ /** When true, shows a loading state instead of "No events found" for empty lists */
613
+ isLoading?: boolean;
614
+ /** Sort order for events. Defaults to 'asc'. */
615
+ sortOrder?: 'asc' | 'desc';
616
+ /** Called when the user changes sort order. When provided, the sort dropdown is shown
617
+ * and the parent is expected to refetch from the API with the new order. */
618
+ onSortOrderChange?: (order: 'asc' | 'desc') => void;
599
619
  }
600
620
 
601
621
  function EventRow({
@@ -603,6 +623,8 @@ function EventRow({
603
623
  index,
604
624
  isFirst,
605
625
  isLast,
626
+ isExpanded,
627
+ onToggleExpand,
606
628
  activeGroupKey,
607
629
  selectedGroupKey,
608
630
  selectedGroupRange,
@@ -612,12 +634,16 @@ function EventRow({
612
634
  onSelectGroup,
613
635
  onHoverGroup,
614
636
  onLoadEventData,
637
+ cachedEventData,
638
+ onCacheEventData,
615
639
  encryptionKey,
616
640
  }: {
617
641
  event: Event;
618
642
  index: number;
619
643
  isFirst: boolean;
620
644
  isLast: boolean;
645
+ isExpanded: boolean;
646
+ onToggleExpand: (eventId: string) => void;
621
647
  activeGroupKey?: string;
622
648
  selectedGroupKey?: string;
623
649
  selectedGroupRange: { first: number; last: number } | null;
@@ -627,24 +653,22 @@ function EventRow({
627
653
  onSelectGroup: (groupKey: string | undefined) => void;
628
654
  onHoverGroup: (groupKey: string | undefined) => void;
629
655
  onLoadEventData?: (event: Event) => Promise<unknown | null>;
656
+ cachedEventData: unknown | null;
657
+ onCacheEventData: (eventId: string, data: unknown) => void;
630
658
  encryptionKey?: Uint8Array;
631
659
  }) {
632
- const [isExpanded, setIsExpanded] = useState(false);
633
660
  const [isLoading, setIsLoading] = useState(false);
634
- const [loadedEventData, setLoadedEventData] = useState<unknown | null>(null);
661
+ const [loadedEventData, setLoadedEventData] = useState<unknown | null>(
662
+ cachedEventData
663
+ );
635
664
  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);
665
+ const [hasAttemptedLoad, setHasAttemptedLoad] = useState(
666
+ cachedEventData !== null
667
+ );
641
668
 
642
- // Collapse when a different group gets selected
643
- useEffect(() => {
644
- if (selectedGroupKey !== undefined && selectedGroupKey !== rowGroupKey) {
645
- setIsExpanded(false);
646
- }
647
- }, [selectedGroupKey, rowGroupKey]);
669
+ const rowGroupKey = isRunLevel(event.eventType)
670
+ ? '__run__'
671
+ : (event.correlationId ?? undefined);
648
672
 
649
673
  const statusDotColor = getStatusDotColor(event.eventType);
650
674
  const createdAt = new Date(event.createdAt);
@@ -686,9 +710,10 @@ function EventRow({
686
710
  setLoadError('Event details unavailable');
687
711
  return;
688
712
  }
689
- const eventData = await onLoadEventData(event);
690
- if (eventData !== null && eventData !== undefined) {
691
- setLoadedEventData(eventData);
713
+ const data = await onLoadEventData(event);
714
+ if (data !== null && data !== undefined) {
715
+ setLoadedEventData(data);
716
+ onCacheEventData(event.eventId, data);
692
717
  }
693
718
  } catch (err) {
694
719
  setLoadError(
@@ -698,7 +723,27 @@ function EventRow({
698
723
  setIsLoading(false);
699
724
  setHasAttemptedLoad(true);
700
725
  }
701
- }, [event, loadedEventData, hasExistingEventData, onLoadEventData]);
726
+ }, [
727
+ event,
728
+ loadedEventData,
729
+ hasExistingEventData,
730
+ onLoadEventData,
731
+ onCacheEventData,
732
+ ]);
733
+
734
+ // Auto-load event data when remounting in expanded state without cached data
735
+ useEffect(() => {
736
+ if (
737
+ isExpanded &&
738
+ loadedEventData === null &&
739
+ !hasExistingEventData &&
740
+ !isLoading &&
741
+ !hasAttemptedLoad
742
+ ) {
743
+ loadEventDetails();
744
+ }
745
+ // eslint-disable-next-line react-hooks/exhaustive-deps
746
+ }, []);
702
747
 
703
748
  // When encryption key changes and this event was previously loaded,
704
749
  // re-load to get decrypted data
@@ -710,6 +755,7 @@ function EventRow({
710
755
  .then((data) => {
711
756
  if (data !== null && data !== undefined) {
712
757
  setLoadedEventData(data);
758
+ onCacheEventData(event.eventId, data);
713
759
  }
714
760
  setHasAttemptedLoad(true);
715
761
  })
@@ -720,25 +766,23 @@ function EventRow({
720
766
  // eslint-disable-next-line react-hooks/exhaustive-deps
721
767
  }, [encryptionKey]);
722
768
 
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
769
  const handleRowClick = useCallback(() => {
736
- if (selectedGroupKey === rowGroupKey) {
737
- onSelectGroup(undefined);
738
- } else {
739
- onSelectGroup(rowGroupKey);
770
+ onSelectGroup(rowGroupKey === selectedGroupKey ? undefined : rowGroupKey);
771
+ onToggleExpand(event.eventId);
772
+ if (!isExpanded && loadedEventData === null && !hasExistingEventData) {
773
+ loadEventDetails();
740
774
  }
741
- }, [selectedGroupKey, rowGroupKey, onSelectGroup]);
775
+ }, [
776
+ selectedGroupKey,
777
+ rowGroupKey,
778
+ onSelectGroup,
779
+ onToggleExpand,
780
+ event.eventId,
781
+ isExpanded,
782
+ loadedEventData,
783
+ hasExistingEventData,
784
+ loadEventDetails,
785
+ ]);
742
786
 
743
787
  const eventData = hasExistingEventData
744
788
  ? (event as Event & { eventData: unknown }).eventData
@@ -760,7 +804,7 @@ function EventRow({
760
804
  onKeyDown={(e) => {
761
805
  if (e.key === 'Enter' || e.key === ' ') handleRowClick();
762
806
  }}
763
- className="w-full text-left flex items-center gap-0 text-sm hover:bg-[var(--ds-gray-alpha-100)] transition-colors cursor-pointer"
807
+ className="w-full text-left flex items-center gap-0 text-[13px] hover:bg-[var(--ds-gray-alpha-100)] transition-colors cursor-pointer"
764
808
  style={{ minHeight: 40 }}
765
809
  >
766
810
  <TreeGutter
@@ -781,36 +825,32 @@ function EventRow({
781
825
  className="flex items-center flex-1 min-w-0"
782
826
  style={{ opacity: contentOpacity, transition: 'opacity 150ms' }}
783
827
  >
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"
828
+ {/* Expand chevron indicator */}
829
+ <div
830
+ className="flex items-center justify-center w-5 h-5 flex-shrink-0 rounded"
789
831
  style={{
790
- ...BUTTON_RESET_STYLE,
791
- border: '1px solid var(--ds-gray-alpha-400)',
832
+ border: '1px solid var(--ds-gray-400)',
792
833
  }}
793
- aria-label={isExpanded ? 'Collapse details' : 'Expand details'}
794
834
  >
795
835
  <ChevronRight
796
836
  className="h-3 w-3 transition-transform"
797
837
  style={{
798
- color: 'var(--ds-gray-700)',
838
+ color: 'var(--ds-gray-900)',
799
839
  transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)',
800
840
  }}
801
841
  />
802
- </button>
842
+ </div>
803
843
 
804
844
  {/* Time */}
805
845
  <div
806
- className="text-xs tabular-nums flex-1 min-w-0 px-4"
807
- style={{ color: 'var(--ds-gray-900)' }}
846
+ className="tabular-nums min-w-0 px-4"
847
+ style={{ color: 'var(--ds-gray-900)', flex: '2 1 0%' }}
808
848
  >
809
849
  {formatEventTime(createdAt)}
810
850
  </div>
811
851
 
812
852
  {/* Event Type */}
813
- <div className="text-xs font-medium flex-1 min-w-0 px-4">
853
+ <div className="font-medium min-w-0 px-4" style={{ flex: '2 1 0%' }}>
814
854
  <span
815
855
  className="inline-flex items-center gap-1.5"
816
856
  style={{ color: 'var(--ds-gray-900)' }}
@@ -852,7 +892,8 @@ function EventRow({
852
892
 
853
893
  {/* Name */}
854
894
  <div
855
- className="text-xs flex-1 min-w-0 px-4 overflow-hidden text-ellipsis whitespace-nowrap"
895
+ className="min-w-0 px-4 overflow-hidden text-ellipsis whitespace-nowrap"
896
+ style={{ flex: '2 1 0%' }}
856
897
  title={eventName !== '-' ? eventName : undefined}
857
898
  >
858
899
  {eventName}
@@ -861,11 +902,16 @@ function EventRow({
861
902
  {/* Correlation ID */}
862
903
  <CopyableCell
863
904
  value={event.correlationId || ''}
864
- className="font-mono text-xs"
905
+ className="font-mono"
906
+ style={{ flex: '3 1 0%' }}
865
907
  />
866
908
 
867
909
  {/* Event ID */}
868
- <CopyableCell value={event.eventId} className="font-mono text-xs" />
910
+ <CopyableCell
911
+ value={event.eventId}
912
+ className="font-mono"
913
+ style={{ flex: '3 1 0%' }}
914
+ />
869
915
  </div>
870
916
  </div>
871
917
 
@@ -971,14 +1017,34 @@ export function EventListView({
971
1017
  isLoadingMoreEvents = false,
972
1018
  onLoadMoreEvents,
973
1019
  encryptionKey,
1020
+ isLoading = false,
1021
+ sortOrder: sortOrderProp,
1022
+ onSortOrderChange,
974
1023
  }: EventsListProps) {
1024
+ const [internalSortOrder, setInternalSortOrder] = useState<'asc' | 'desc'>(
1025
+ 'asc'
1026
+ );
1027
+ const effectiveSortOrder = sortOrderProp ?? internalSortOrder;
1028
+ const handleSortOrderChange = useCallback(
1029
+ (order: 'asc' | 'desc') => {
1030
+ if (onSortOrderChange) {
1031
+ onSortOrderChange(order);
1032
+ } else {
1033
+ setInternalSortOrder(order);
1034
+ }
1035
+ },
1036
+ [onSortOrderChange]
1037
+ );
1038
+
975
1039
  const sortedEvents = useMemo(() => {
976
1040
  if (!events || events.length === 0) return [];
1041
+ const dir = effectiveSortOrder === 'desc' ? -1 : 1;
977
1042
  return [...events].sort(
978
1043
  (a, b) =>
979
- new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
1044
+ dir *
1045
+ (new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
980
1046
  );
981
- }, [events]);
1047
+ }, [events, effectiveSortOrder]);
982
1048
 
983
1049
  const { correlationNameMap, workflowName } = useMemo(
984
1050
  () => buildNameMaps(events ?? null, run ?? null),
@@ -1005,6 +1071,58 @@ export function EventListView({
1005
1071
 
1006
1072
  const activeGroupKey = selectedGroupKey ?? hoveredGroupKey;
1007
1073
 
1074
+ // Expanded state lifted out of EventRow so it survives virtualization
1075
+ const [expandedEventIds, setExpandedEventIds] = useState<Set<string>>(
1076
+ () => new Set()
1077
+ );
1078
+ const toggleEventExpanded = useCallback((eventId: string) => {
1079
+ setExpandedEventIds((prev) => {
1080
+ const next = new Set(prev);
1081
+ if (next.has(eventId)) {
1082
+ next.delete(eventId);
1083
+ } else {
1084
+ next.add(eventId);
1085
+ }
1086
+ return next;
1087
+ });
1088
+ }, []);
1089
+
1090
+ // Event data cache — ref avoids re-renders when cache updates
1091
+ const eventDataCacheRef = useRef<Map<string, unknown>>(new Map());
1092
+ const cacheEventData = useCallback((eventId: string, data: unknown) => {
1093
+ eventDataCacheRef.current.set(eventId, data);
1094
+ }, []);
1095
+
1096
+ // Lookup from eventId → groupKey for efficient collapse filtering
1097
+ const eventGroupKeyMap = useMemo(() => {
1098
+ const map = new Map<string, string>();
1099
+ for (const ev of sortedEvents) {
1100
+ const gk = isRunLevel(ev.eventType)
1101
+ ? '__run__'
1102
+ : (ev.correlationId ?? '');
1103
+ if (gk) map.set(ev.eventId, gk);
1104
+ }
1105
+ return map;
1106
+ }, [sortedEvents]);
1107
+
1108
+ // Collapse expanded events that don't belong to the newly selected group
1109
+ useEffect(() => {
1110
+ if (selectedGroupKey === undefined) return;
1111
+ setExpandedEventIds((prev) => {
1112
+ if (prev.size === 0) return prev;
1113
+ let changed = false;
1114
+ const next = new Set<string>();
1115
+ for (const eventId of prev) {
1116
+ if (eventGroupKeyMap.get(eventId) === selectedGroupKey) {
1117
+ next.add(eventId);
1118
+ } else {
1119
+ changed = true;
1120
+ }
1121
+ }
1122
+ return changed ? next : prev;
1123
+ });
1124
+ }, [selectedGroupKey, eventGroupKeyMap]);
1125
+
1008
1126
  // Compute the row-index range for the active group's connecting lane line.
1009
1127
  // Only applies to non-run groups (step/hook/wait correlations).
1010
1128
  const selectedGroupRange = useMemo(() => {
@@ -1025,24 +1143,34 @@ export function EventListView({
1025
1143
 
1026
1144
  const searchIndex = useMemo(() => {
1027
1145
  const entries: {
1028
- text: string;
1146
+ fields: string[];
1029
1147
  groupKey?: string;
1030
1148
  eventId: string;
1031
1149
  index: number;
1032
1150
  }[] = [];
1033
1151
  for (let i = 0; i < sortedEvents.length; i++) {
1034
1152
  const ev = sortedEvents[i];
1153
+ const isRun = isRunLevel(ev.eventType);
1154
+ const name = isRun
1155
+ ? (workflowName ?? '')
1156
+ : ev.correlationId
1157
+ ? (correlationNameMap.get(ev.correlationId) ?? '')
1158
+ : '';
1035
1159
  entries.push({
1036
- text: [ev.eventId, ev.correlationId ?? ''].join(' ').toLowerCase(),
1037
- groupKey:
1038
- ev.correlationId ??
1039
- (isRunLevel(ev.eventType) ? '__run__' : undefined),
1160
+ fields: [
1161
+ ev.eventId,
1162
+ ev.correlationId ?? '',
1163
+ ev.eventType,
1164
+ formatEventType(ev.eventType),
1165
+ name,
1166
+ ].map((f) => f.toLowerCase()),
1167
+ groupKey: ev.correlationId ?? (isRun ? '__run__' : undefined),
1040
1168
  eventId: ev.eventId,
1041
1169
  index: i,
1042
1170
  });
1043
1171
  }
1044
1172
  return entries;
1045
- }, [sortedEvents]);
1173
+ }, [sortedEvents, correlationNameMap, workflowName]);
1046
1174
 
1047
1175
  useEffect(() => {
1048
1176
  const q = searchQuery.trim().toLowerCase();
@@ -1050,11 +1178,23 @@ export function EventListView({
1050
1178
  setSelectedGroupKey(undefined);
1051
1179
  return;
1052
1180
  }
1053
- const match = searchIndex.find((entry) => entry.text.includes(q));
1054
- if (match) {
1055
- setSelectedGroupKey(match.groupKey);
1181
+ let bestMatch: (typeof searchIndex)[number] | null = null;
1182
+ let bestScore = 0;
1183
+ for (const entry of searchIndex) {
1184
+ for (const field of entry.fields) {
1185
+ if (field && field.includes(q)) {
1186
+ const score = q.length / field.length;
1187
+ if (score > bestScore) {
1188
+ bestScore = score;
1189
+ bestMatch = entry;
1190
+ }
1191
+ }
1192
+ }
1193
+ }
1194
+ if (bestMatch) {
1195
+ setSelectedGroupKey(bestMatch.groupKey);
1056
1196
  virtuosoRef.current?.scrollToIndex({
1057
- index: match.index,
1197
+ index: bestMatch.index,
1058
1198
  align: 'center',
1059
1199
  behavior: 'smooth',
1060
1200
  });
@@ -1062,6 +1202,51 @@ export function EventListView({
1062
1202
  }, [searchQuery, searchIndex]);
1063
1203
 
1064
1204
  if (!events || events.length === 0) {
1205
+ if (isLoading) {
1206
+ return (
1207
+ <div className="h-full flex flex-col overflow-hidden">
1208
+ {/* Skeleton search bar */}
1209
+ <div style={{ padding: 6 }}>
1210
+ <Skeleton style={{ height: 40, borderRadius: 6 }} />
1211
+ </div>
1212
+ {/* Skeleton header */}
1213
+ <div
1214
+ className="flex items-center gap-0 h-10 border-b flex-shrink-0 px-4"
1215
+ style={{ borderColor: 'var(--ds-gray-alpha-200)' }}
1216
+ >
1217
+ <Skeleton className="h-3" style={{ width: 60 }} />
1218
+ <div style={{ flex: 1 }} />
1219
+ <Skeleton className="h-3" style={{ width: 80 }} />
1220
+ <div style={{ flex: 1 }} />
1221
+ <Skeleton className="h-3" style={{ width: 50 }} />
1222
+ <div style={{ flex: 1 }} />
1223
+ <Skeleton className="h-3" style={{ width: 90 }} />
1224
+ <div style={{ flex: 1 }} />
1225
+ <Skeleton className="h-3" style={{ width: 70 }} />
1226
+ </div>
1227
+ {/* Skeleton rows */}
1228
+ <div className="flex-1 overflow-hidden">
1229
+ {Array.from({ length: 8 }, (_, i) => (
1230
+ <div
1231
+ key={i}
1232
+ className="flex items-center gap-3 px-4"
1233
+ style={{ height: 40 }}
1234
+ >
1235
+ <Skeleton
1236
+ className="h-2 w-2 flex-shrink-0"
1237
+ style={{ borderRadius: '50%' }}
1238
+ />
1239
+ <Skeleton className="h-3" style={{ width: 90 }} />
1240
+ <Skeleton className="h-3" style={{ width: 100 }} />
1241
+ <Skeleton className="h-3" style={{ width: 80 }} />
1242
+ <Skeleton className="h-3 flex-1" />
1243
+ <Skeleton className="h-3 flex-1" />
1244
+ </div>
1245
+ ))}
1246
+ </div>
1247
+ </div>
1248
+ );
1249
+ }
1065
1250
  return (
1066
1251
  <div
1067
1252
  className="flex items-center justify-center h-full text-sm"
@@ -1075,8 +1260,15 @@ export function EventListView({
1075
1260
  return (
1076
1261
  <div className="h-full flex flex-col overflow-hidden">
1077
1262
  <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)' }}>
1263
+ {/* Search bar + sort */}
1264
+ <div
1265
+ style={{
1266
+ padding: 6,
1267
+ backgroundColor: 'var(--ds-background-100)',
1268
+ display: 'flex',
1269
+ gap: 6,
1270
+ }}
1271
+ >
1080
1272
  <label
1081
1273
  style={{
1082
1274
  display: 'flex',
@@ -1086,6 +1278,8 @@ export function EventListView({
1086
1278
  boxShadow: '0 0 0 1px var(--ds-gray-alpha-400)',
1087
1279
  background: 'var(--ds-background-100)',
1088
1280
  height: 40,
1281
+ flex: 1,
1282
+ minWidth: 0,
1089
1283
  }}
1090
1284
  >
1091
1285
  <div
@@ -1124,7 +1318,7 @@ export function EventListView({
1124
1318
  </div>
1125
1319
  <input
1126
1320
  type="search"
1127
- placeholder="Search by event ID or correlation ID…"
1321
+ placeholder="Search by name, event type, or ID…"
1128
1322
  value={searchQuery}
1129
1323
  onChange={(e) => setSearchQuery(e.target.value)}
1130
1324
  style={{
@@ -1140,11 +1334,16 @@ export function EventListView({
1140
1334
  }}
1141
1335
  />
1142
1336
  </label>
1337
+ <MenuDropdown
1338
+ options={SORT_OPTIONS}
1339
+ value={effectiveSortOrder}
1340
+ onChange={handleSortOrderChange}
1341
+ />
1143
1342
  </div>
1144
1343
 
1145
1344
  {/* Header */}
1146
1345
  <div
1147
- className="flex items-center gap-0 text-sm font-medium h-10 border-b flex-shrink-0"
1346
+ className="flex items-center gap-0 text-[13px] font-medium h-10 border-b flex-shrink-0"
1148
1347
  style={{
1149
1348
  borderColor: 'var(--ds-gray-alpha-200)',
1150
1349
  color: 'var(--ds-gray-900)',
@@ -1153,11 +1352,21 @@ export function EventListView({
1153
1352
  >
1154
1353
  <div className="flex-shrink-0" style={{ width: GUTTER_WIDTH }} />
1155
1354
  <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>
1355
+ <div className="min-w-0 px-4" style={{ flex: '2 1 0%' }}>
1356
+ Time
1357
+ </div>
1358
+ <div className="min-w-0 px-4" style={{ flex: '2 1 0%' }}>
1359
+ Event Type
1360
+ </div>
1361
+ <div className="min-w-0 px-4" style={{ flex: '2 1 0%' }}>
1362
+ Name
1363
+ </div>
1364
+ <div className="min-w-0 px-4" style={{ flex: '3 1 0%' }}>
1365
+ Correlation ID
1366
+ </div>
1367
+ <div className="min-w-0 px-4" style={{ flex: '3 1 0%' }}>
1368
+ Event ID
1369
+ </div>
1161
1370
  </div>
1162
1371
 
1163
1372
  {/* Virtualized event rows */}
@@ -1173,12 +1382,15 @@ export function EventListView({
1173
1382
  void onLoadMoreEvents?.();
1174
1383
  }}
1175
1384
  itemContent={(index: number) => {
1385
+ const ev = sortedEvents[index];
1176
1386
  return (
1177
1387
  <EventRow
1178
- event={sortedEvents[index]}
1388
+ event={ev}
1179
1389
  index={index}
1180
1390
  isFirst={index === 0}
1181
1391
  isLast={index === sortedEvents.length - 1}
1392
+ isExpanded={expandedEventIds.has(ev.eventId)}
1393
+ onToggleExpand={toggleEventExpanded}
1182
1394
  activeGroupKey={activeGroupKey}
1183
1395
  selectedGroupKey={selectedGroupKey}
1184
1396
  selectedGroupRange={selectedGroupRange}
@@ -1188,14 +1400,17 @@ export function EventListView({
1188
1400
  onSelectGroup={onSelectGroup}
1189
1401
  onHoverGroup={onHoverGroup}
1190
1402
  onLoadEventData={onLoadEventData}
1403
+ cachedEventData={
1404
+ eventDataCacheRef.current.get(ev.eventId) ?? null
1405
+ }
1406
+ onCacheEventData={cacheEventData}
1191
1407
  encryptionKey={encryptionKey}
1192
1408
  />
1193
1409
  );
1194
1410
  }}
1195
1411
  components={{
1196
- Footer: () => (
1197
- <>
1198
- {hasMoreEvents && (
1412
+ Footer: hasMoreEvents
1413
+ ? () => (
1199
1414
  <div className="px-3 pt-3 flex justify-center">
1200
1415
  <button
1201
1416
  type="button"
@@ -1213,22 +1428,24 @@ export function EventListView({
1213
1428
  : 'Load more'}
1214
1429
  </button>
1215
1430
  </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
- ),
1431
+ )
1432
+ : undefined,
1229
1433
  }}
1230
1434
  style={{ flex: 1, minHeight: 0 }}
1231
1435
  />
1436
+
1437
+ {/* Fixed footer — always at the bottom of the visible area */}
1438
+ <div
1439
+ className="flex-shrink-0 border-t text-xs px-3 py-2"
1440
+ style={{
1441
+ borderColor: 'var(--ds-gray-alpha-200)',
1442
+ color: 'var(--ds-gray-900)',
1443
+ backgroundColor: 'var(--ds-background-100)',
1444
+ }}
1445
+ >
1446
+ {sortedEvents.length} event
1447
+ {sortedEvents.length !== 1 ? 's' : ''} total
1448
+ </div>
1232
1449
  </div>
1233
1450
  );
1234
1451
  }
@@ -22,4 +22,5 @@ export type {
22
22
  export { type StreamChunk, StreamViewer } from './stream-viewer';
23
23
  export type { Span, SpanEvent } from './trace-viewer/types';
24
24
  export { DataInspector, type DataInspectorProps } from './ui/data-inspector';
25
+ export { MenuDropdown, type MenuDropdownOption } from './ui/menu-dropdown';
25
26
  export { WorkflowTraceViewer } from './workflow-trace-view';