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

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 (89) hide show
  1. package/dist/components/event-list-view.d.ts +5 -1
  2. package/dist/components/event-list-view.d.ts.map +1 -1
  3. package/dist/components/event-list-view.js +79 -22
  4. package/dist/components/event-list-view.js.map +1 -1
  5. package/dist/components/hook-actions.d.ts.map +1 -1
  6. package/dist/components/hook-actions.js +2 -1
  7. package/dist/components/hook-actions.js.map +1 -1
  8. package/dist/components/index.d.ts +3 -0
  9. package/dist/components/index.d.ts.map +1 -1
  10. package/dist/components/index.js +3 -0
  11. package/dist/components/index.js.map +1 -1
  12. package/dist/components/sidebar/attribute-panel.d.ts +3 -1
  13. package/dist/components/sidebar/attribute-panel.d.ts.map +1 -1
  14. package/dist/components/sidebar/attribute-panel.js +55 -38
  15. package/dist/components/sidebar/attribute-panel.js.map +1 -1
  16. package/dist/components/sidebar/copyable-data-block.d.ts.map +1 -1
  17. package/dist/components/sidebar/copyable-data-block.js +2 -1
  18. package/dist/components/sidebar/copyable-data-block.js.map +1 -1
  19. package/dist/components/sidebar/entity-detail-panel.d.ts +3 -1
  20. package/dist/components/sidebar/entity-detail-panel.d.ts.map +1 -1
  21. package/dist/components/sidebar/entity-detail-panel.js +16 -20
  22. package/dist/components/sidebar/entity-detail-panel.js.map +1 -1
  23. package/dist/components/sidebar/events-list.d.ts.map +1 -1
  24. package/dist/components/sidebar/events-list.js +7 -5
  25. package/dist/components/sidebar/events-list.js.map +1 -1
  26. package/dist/components/stream-viewer.d.ts +3 -1
  27. package/dist/components/stream-viewer.d.ts.map +1 -1
  28. package/dist/components/stream-viewer.js +23 -28
  29. package/dist/components/stream-viewer.js.map +1 -1
  30. package/dist/components/trace-viewer/components/span-segments.d.ts.map +1 -1
  31. package/dist/components/trace-viewer/components/span-segments.js +54 -1
  32. package/dist/components/trace-viewer/components/span-segments.js.map +1 -1
  33. package/dist/components/ui/decrypt-button.d.ts +15 -0
  34. package/dist/components/ui/decrypt-button.d.ts.map +1 -0
  35. package/dist/components/ui/decrypt-button.js +12 -0
  36. package/dist/components/ui/decrypt-button.js.map +1 -0
  37. package/dist/components/ui/error-stack-block.d.ts.map +1 -1
  38. package/dist/components/ui/error-stack-block.js +2 -1
  39. package/dist/components/ui/error-stack-block.js.map +1 -1
  40. package/dist/components/ui/load-more-button.d.ts +13 -0
  41. package/dist/components/ui/load-more-button.d.ts.map +1 -0
  42. package/dist/components/ui/load-more-button.js +12 -0
  43. package/dist/components/ui/load-more-button.js.map +1 -0
  44. package/dist/components/ui/menu-dropdown.d.ts.map +1 -1
  45. package/dist/components/ui/menu-dropdown.js +2 -6
  46. package/dist/components/ui/menu-dropdown.js.map +1 -1
  47. package/dist/components/ui/spinner.d.ts +9 -0
  48. package/dist/components/ui/spinner.d.ts.map +1 -0
  49. package/dist/components/ui/spinner.js +57 -0
  50. package/dist/components/ui/spinner.js.map +1 -0
  51. package/dist/components/ui/timestamp-tooltip.d.ts +6 -0
  52. package/dist/components/ui/timestamp-tooltip.d.ts.map +1 -0
  53. package/dist/components/ui/timestamp-tooltip.js +200 -0
  54. package/dist/components/ui/timestamp-tooltip.js.map +1 -0
  55. package/dist/components/workflow-trace-view.d.ts +3 -1
  56. package/dist/components/workflow-trace-view.d.ts.map +1 -1
  57. package/dist/components/workflow-trace-view.js +12 -4
  58. package/dist/components/workflow-trace-view.js.map +1 -1
  59. package/dist/components/workflow-traces/trace-span-construction.d.ts.map +1 -1
  60. package/dist/components/workflow-traces/trace-span-construction.js +10 -7
  61. package/dist/components/workflow-traces/trace-span-construction.js.map +1 -1
  62. package/dist/index.d.ts +2 -0
  63. package/dist/index.d.ts.map +1 -1
  64. package/dist/index.js +1 -0
  65. package/dist/index.js.map +1 -1
  66. package/dist/lib/toast.d.ts +25 -0
  67. package/dist/lib/toast.d.ts.map +1 -0
  68. package/dist/lib/toast.js +24 -0
  69. package/dist/lib/toast.js.map +1 -0
  70. package/package.json +6 -4
  71. package/src/components/event-list-view.tsx +241 -111
  72. package/src/components/hook-actions.tsx +2 -1
  73. package/src/components/index.ts +3 -0
  74. package/src/components/sidebar/attribute-panel.tsx +60 -33
  75. package/src/components/sidebar/copyable-data-block.tsx +2 -1
  76. package/src/components/sidebar/entity-detail-panel.tsx +22 -30
  77. package/src/components/sidebar/events-list.tsx +14 -13
  78. package/src/components/stream-viewer.tsx +52 -63
  79. package/src/components/trace-viewer/components/span-segments.ts +70 -1
  80. package/src/components/ui/decrypt-button.tsx +69 -0
  81. package/src/components/ui/error-stack-block.tsx +2 -1
  82. package/src/components/ui/load-more-button.tsx +38 -0
  83. package/src/components/ui/menu-dropdown.tsx +3 -6
  84. package/src/components/ui/spinner.tsx +76 -0
  85. package/src/components/ui/timestamp-tooltip.tsx +326 -0
  86. package/src/components/workflow-trace-view.tsx +14 -20
  87. package/src/components/workflow-traces/trace-span-construction.ts +12 -7
  88. package/src/index.ts +2 -0
  89. package/src/lib/toast.tsx +42 -0
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@workflow/web-shared",
3
3
  "description": "Shared components for Workflow Observability UI",
4
- "version": "4.1.0-beta.64",
4
+ "version": "4.1.0-beta.66",
5
5
  "private": false,
6
6
  "files": [
7
7
  "dist",
@@ -52,23 +52,25 @@
52
52
  "streamdown": "2.3.0",
53
53
  "tailwind-merge": "3.5.0",
54
54
  "tailwindcss": "4",
55
- "@workflow/core": "4.2.0-beta.69",
55
+ "@workflow/core": "4.2.0-beta.71",
56
56
  "@workflow/utils": "4.1.0-beta.13",
57
- "@workflow/world": "4.1.0-beta.12"
57
+ "@workflow/world": "4.1.0-beta.13"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@biomejs/biome": "^2.4.4",
61
61
  "@types/node": "22.19.0",
62
62
  "@types/react": "19",
63
63
  "@types/react-dom": "19",
64
- "ai": "5.0.104",
64
+ "ai": "6.0.116",
65
65
  "typescript": "^5.9.3",
66
+ "vitest": "^4.0.18",
66
67
  "@workflow/tsconfig": "4.0.1-beta.0"
67
68
  },
68
69
  "scripts": {
69
70
  "build": "tsc && cp -r src/components/trace-viewer/*.css dist/components/trace-viewer/ && cp src/styles.css dist/styles.css",
70
71
  "dev": "tsc --watch",
71
72
  "clean": "tsc --build --clean && rm -r dist ||:",
73
+ "test": "vitest run",
72
74
  "typecheck": "tsc --noEmit",
73
75
  "lint": "biome check",
74
76
  "format": "biome format --write"
@@ -6,14 +6,18 @@ 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';
15
18
  import { MenuDropdown } from './ui/menu-dropdown';
16
19
  import { Skeleton } from './ui/skeleton';
20
+ import { TimestampTooltip } from './ui/timestamp-tooltip';
17
21
 
18
22
  /**
19
23
  * Event types whose eventData contains an error field with a StructuredError.
@@ -208,6 +212,15 @@ function buildDurationMap(events: Event[]): Map<string, DurationInfo> {
208
212
  return durations;
209
213
  }
210
214
 
215
+ /** Check if a loaded eventData object contains any encrypted marker values. */
216
+ function hasEncryptedValues(data: unknown): boolean {
217
+ if (!data || typeof data !== 'object') return false;
218
+ for (const val of Object.values(data as Record<string, unknown>)) {
219
+ if (isEncryptedMarker(val)) return true;
220
+ }
221
+ return false;
222
+ }
223
+
211
224
  function isRunLevel(eventType: string): boolean {
212
225
  return (
213
226
  eventType === 'run_created' ||
@@ -596,6 +609,76 @@ const SORT_OPTIONS = [
596
609
  { value: 'asc' as const, label: 'Oldest' },
597
610
  ];
598
611
 
612
+ function RowsSkeleton() {
613
+ return (
614
+ <div className="flex-1 overflow-hidden">
615
+ {Array.from({ length: 16 }, (_, i) => (
616
+ <div key={i} className="flex items-center gap-0" style={{ height: 40 }}>
617
+ {/* Gutter area */}
618
+ <div
619
+ className="relative flex-shrink-0 self-stretch flex items-center"
620
+ style={{ width: GUTTER_WIDTH }}
621
+ >
622
+ {/* Vertical line skeleton */}
623
+ <div
624
+ style={{
625
+ position: 'absolute',
626
+ left: 8,
627
+ top: i === 0 ? '50%' : 0,
628
+ bottom: 0,
629
+ width: 2,
630
+ }}
631
+ >
632
+ <Skeleton className="w-full h-full" style={{ borderRadius: 1 }} />
633
+ </div>
634
+ {/* Dot skeleton */}
635
+ <Skeleton
636
+ className="flex-shrink-0"
637
+ style={{
638
+ width: i % 4 === 0 ? 8 : 6,
639
+ height: i % 4 === 0 ? 8 : 6,
640
+ borderRadius: '50%',
641
+ marginLeft: i % 4 === 0 ? 5 : 6,
642
+ }}
643
+ />
644
+ </div>
645
+ {/* Chevron placeholder */}
646
+ <div className="w-5 flex-shrink-0 flex items-center justify-center">
647
+ <Skeleton className="w-5 h-5" style={{ borderRadius: 4 }} />
648
+ </div>
649
+ {/* Time */}
650
+ <div className="min-w-0 px-4" style={{ flex: '2 1 0%' }}>
651
+ <Skeleton className="h-3" style={{ width: '70%' }} />
652
+ </div>
653
+ {/* Event Type */}
654
+ <div
655
+ className="min-w-0 px-4 flex items-center gap-1.5"
656
+ style={{ flex: '2 1 0%' }}
657
+ >
658
+ <Skeleton
659
+ className="flex-shrink-0"
660
+ style={{ width: 6, height: 6, borderRadius: '50%' }}
661
+ />
662
+ <Skeleton className="h-3" style={{ width: '60%' }} />
663
+ </div>
664
+ {/* Name */}
665
+ <div className="min-w-0 px-4" style={{ flex: '2 1 0%' }}>
666
+ <Skeleton className="h-3" style={{ width: '50%' }} />
667
+ </div>
668
+ {/* Correlation ID */}
669
+ <div className="min-w-0 px-4" style={{ flex: '3 1 0%' }}>
670
+ <Skeleton className="h-3" style={{ width: '75%' }} />
671
+ </div>
672
+ {/* Event ID */}
673
+ <div className="min-w-0 px-4" style={{ flex: '3 1 0%' }}>
674
+ <Skeleton className="h-3" style={{ width: '75%' }} />
675
+ </div>
676
+ </div>
677
+ ))}
678
+ </div>
679
+ );
680
+ }
681
+
599
682
  // ──────────────────────────────────────────────────────────────────────────
600
683
  // Event row
601
684
  // ──────────────────────────────────────────────────────────────────────────
@@ -616,6 +699,10 @@ interface EventsListProps {
616
699
  /** Called when the user changes sort order. When provided, the sort dropdown is shown
617
700
  * and the parent is expected to refetch from the API with the new order. */
618
701
  onSortOrderChange?: (order: 'asc' | 'desc') => void;
702
+ /** Called when the user clicks the Decrypt button. */
703
+ onDecrypt?: () => void;
704
+ /** Whether the encryption key is currently being fetched. */
705
+ isDecrypting?: boolean;
619
706
  }
620
707
 
621
708
  function EventRow({
@@ -637,6 +724,7 @@ function EventRow({
637
724
  cachedEventData,
638
725
  onCacheEventData,
639
726
  encryptionKey,
727
+ onEncryptedDataDetected,
640
728
  }: {
641
729
  event: Event;
642
730
  index: number;
@@ -656,6 +744,7 @@ function EventRow({
656
744
  cachedEventData: unknown | null;
657
745
  onCacheEventData: (eventId: string, data: unknown) => void;
658
746
  encryptionKey?: Uint8Array;
747
+ onEncryptedDataDetected?: () => void;
659
748
  }) {
660
749
  const [isLoading, setIsLoading] = useState(false);
661
750
  const [loadedEventData, setLoadedEventData] = useState<unknown | null>(
@@ -666,6 +755,18 @@ function EventRow({
666
755
  cachedEventData !== null
667
756
  );
668
757
 
758
+ // Notify parent if cached data has encrypted markers on mount
759
+ useEffect(() => {
760
+ if (
761
+ cachedEventData !== null &&
762
+ !encryptionKey &&
763
+ hasEncryptedValues(cachedEventData)
764
+ ) {
765
+ onEncryptedDataDetected?.();
766
+ }
767
+ // eslint-disable-next-line react-hooks/exhaustive-deps
768
+ }, []);
769
+
669
770
  const rowGroupKey = isRunLevel(event.eventType)
670
771
  ? '__run__'
671
772
  : (event.correlationId ?? undefined);
@@ -714,6 +815,9 @@ function EventRow({
714
815
  if (data !== null && data !== undefined) {
715
816
  setLoadedEventData(data);
716
817
  onCacheEventData(event.eventId, data);
818
+ if (!encryptionKey && hasEncryptedValues(data)) {
819
+ onEncryptedDataDetected?.();
820
+ }
717
821
  }
718
822
  } catch (err) {
719
823
  setLoadError(
@@ -729,6 +833,8 @@ function EventRow({
729
833
  hasExistingEventData,
730
834
  onLoadEventData,
731
835
  onCacheEventData,
836
+ encryptionKey,
837
+ onEncryptedDataDetected,
732
838
  ]);
733
839
 
734
840
  // Auto-load event data when remounting in expanded state without cached data
@@ -846,7 +952,9 @@ function EventRow({
846
952
  className="tabular-nums min-w-0 px-4"
847
953
  style={{ color: 'var(--ds-gray-900)', flex: '2 1 0%' }}
848
954
  >
849
- {formatEventTime(createdAt)}
955
+ <TimestampTooltip date={createdAt}>
956
+ <span>{formatEventTime(createdAt)}</span>
957
+ </TimestampTooltip>
850
958
  </div>
851
959
 
852
960
  {/* Event Type */}
@@ -1020,6 +1128,8 @@ export function EventListView({
1020
1128
  isLoading = false,
1021
1129
  sortOrder: sortOrderProp,
1022
1130
  onSortOrderChange,
1131
+ onDecrypt,
1132
+ isDecrypting = false,
1023
1133
  }: EventsListProps) {
1024
1134
  const [internalSortOrder, setInternalSortOrder] = useState<'asc' | 'desc'>(
1025
1135
  'asc'
@@ -1046,6 +1156,26 @@ export function EventListView({
1046
1156
  );
1047
1157
  }, [events, effectiveSortOrder]);
1048
1158
 
1159
+ // Detect encrypted fields across all loaded events (inline eventData).
1160
+ const hasEncryptedInlineData = useMemo(() => {
1161
+ if (!events) return false;
1162
+ for (const event of events) {
1163
+ const ed = (event as Record<string, unknown>).eventData;
1164
+ if (hasEncryptedValues(ed)) return true;
1165
+ }
1166
+ return false;
1167
+ }, [events]);
1168
+
1169
+ // Tracks whether any expanded row's lazy-loaded data contained encrypted markers.
1170
+ // Set to true by EventRow via onEncryptedDataDetected; never reset (sticky).
1171
+ const [foundEncryptedInLazyData, setFoundEncryptedInLazyData] =
1172
+ useState(false);
1173
+ const handleEncryptedDataDetected = useCallback(() => {
1174
+ setFoundEncryptedInLazyData(true);
1175
+ }, []);
1176
+
1177
+ const hasEncryptedData = hasEncryptedInlineData || foundEncryptedInLazyData;
1178
+
1049
1179
  const { correlationNameMap, workflowName } = useMemo(
1050
1180
  () => buildNameMaps(events ?? null, run ?? null),
1051
1181
  [events, run]
@@ -1201,52 +1331,51 @@ export function EventListView({
1201
1331
  }
1202
1332
  }, [searchQuery, searchIndex]);
1203
1333
 
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 }} />
1334
+ // Track whether we've ever had events to distinguish initial load from refetch
1335
+ const hasHadEventsRef = useRef(false);
1336
+ if (sortedEvents.length > 0) {
1337
+ hasHadEventsRef.current = true;
1338
+ }
1339
+ const isInitialLoad = isLoading && !hasHadEventsRef.current;
1340
+ const isRefetching =
1341
+ isLoading && hasHadEventsRef.current && sortedEvents.length === 0;
1342
+
1343
+ if (isInitialLoad) {
1344
+ return (
1345
+ <div className="h-full flex flex-col overflow-hidden">
1346
+ {/* Skeleton search bar */}
1347
+ <div style={{ padding: 6 }}>
1348
+ <Skeleton style={{ height: 40, borderRadius: 6 }} />
1349
+ </div>
1350
+ {/* Skeleton header */}
1351
+ <div
1352
+ className="flex items-center gap-0 h-10 border-b flex-shrink-0"
1353
+ style={{ borderColor: 'var(--ds-gray-alpha-200)' }}
1354
+ >
1355
+ <div className="flex-shrink-0" style={{ width: GUTTER_WIDTH }} />
1356
+ <div className="w-5 flex-shrink-0" />
1357
+ <div className="min-w-0 px-4" style={{ flex: '2 1 0%' }}>
1358
+ <Skeleton className="h-3" style={{ width: 40 }} />
1211
1359
  </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 }} />
1360
+ <div className="min-w-0 px-4" style={{ flex: '2 1 0%' }}>
1361
+ <Skeleton className="h-3" style={{ width: 72 }} />
1226
1362
  </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
- ))}
1363
+ <div className="min-w-0 px-4" style={{ flex: '2 1 0%' }}>
1364
+ <Skeleton className="h-3" style={{ width: 44 }} />
1365
+ </div>
1366
+ <div className="min-w-0 px-4" style={{ flex: '3 1 0%' }}>
1367
+ <Skeleton className="h-3" style={{ width: 92 }} />
1368
+ </div>
1369
+ <div className="min-w-0 px-4" style={{ flex: '3 1 0%' }}>
1370
+ <Skeleton className="h-3" style={{ width: 60 }} />
1246
1371
  </div>
1247
1372
  </div>
1248
- );
1249
- }
1373
+ <RowsSkeleton />
1374
+ </div>
1375
+ );
1376
+ }
1377
+
1378
+ if (!isLoading && (!events || events.length === 0)) {
1250
1379
  return (
1251
1380
  <div
1252
1381
  className="flex items-center justify-center h-full text-sm"
@@ -1339,6 +1468,13 @@ export function EventListView({
1339
1468
  value={effectiveSortOrder}
1340
1469
  onChange={handleSortOrderChange}
1341
1470
  />
1471
+ {(hasEncryptedData || encryptionKey) && onDecrypt && (
1472
+ <DecryptButton
1473
+ decrypted={!!encryptionKey}
1474
+ loading={isDecrypting}
1475
+ onClick={onDecrypt}
1476
+ />
1477
+ )}
1342
1478
  </div>
1343
1479
 
1344
1480
  {/* Header */}
@@ -1369,82 +1505,76 @@ export function EventListView({
1369
1505
  </div>
1370
1506
  </div>
1371
1507
 
1372
- {/* Virtualized event rows */}
1373
- <Virtuoso
1374
- ref={virtuosoRef}
1375
- totalCount={sortedEvents.length}
1376
- overscan={20}
1377
- defaultItemHeight={40}
1378
- endReached={() => {
1379
- if (!hasMoreEvents || isLoadingMoreEvents) {
1380
- return;
1381
- }
1382
- void onLoadMoreEvents?.();
1383
- }}
1384
- itemContent={(index: number) => {
1385
- const ev = sortedEvents[index];
1386
- return (
1387
- <EventRow
1388
- event={ev}
1389
- index={index}
1390
- isFirst={index === 0}
1391
- isLast={index === sortedEvents.length - 1}
1392
- isExpanded={expandedEventIds.has(ev.eventId)}
1393
- onToggleExpand={toggleEventExpanded}
1394
- activeGroupKey={activeGroupKey}
1395
- selectedGroupKey={selectedGroupKey}
1396
- selectedGroupRange={selectedGroupRange}
1397
- correlationNameMap={correlationNameMap}
1398
- workflowName={workflowName}
1399
- durationMap={durationMap}
1400
- onSelectGroup={onSelectGroup}
1401
- onHoverGroup={onHoverGroup}
1402
- onLoadEventData={onLoadEventData}
1403
- cachedEventData={
1404
- eventDataCacheRef.current.get(ev.eventId) ?? null
1405
- }
1406
- onCacheEventData={cacheEventData}
1407
- encryptionKey={encryptionKey}
1408
- />
1409
- );
1410
- }}
1411
- components={{
1412
- Footer: hasMoreEvents
1413
- ? () => (
1414
- <div className="px-3 pt-3 flex justify-center">
1415
- <button
1416
- type="button"
1417
- onClick={() => void onLoadMoreEvents?.()}
1418
- disabled={isLoadingMoreEvents}
1419
- className="h-8 px-3 text-xs rounded-md border transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
1420
- style={{
1421
- borderColor: 'var(--ds-gray-alpha-400)',
1422
- color: 'var(--ds-gray-900)',
1423
- backgroundColor: 'var(--ds-background-100)',
1424
- }}
1425
- >
1426
- {isLoadingMoreEvents
1427
- ? 'Loading more events...'
1428
- : 'Load more'}
1429
- </button>
1430
- </div>
1431
- )
1432
- : undefined,
1433
- }}
1434
- style={{ flex: 1, minHeight: 0 }}
1435
- />
1508
+ {/* Virtualized event rows or refetching skeleton */}
1509
+ {isRefetching ? (
1510
+ <RowsSkeleton />
1511
+ ) : (
1512
+ <Virtuoso
1513
+ ref={virtuosoRef}
1514
+ totalCount={sortedEvents.length}
1515
+ overscan={20}
1516
+ defaultItemHeight={40}
1517
+ endReached={() => {
1518
+ if (!hasMoreEvents || isLoadingMoreEvents) {
1519
+ return;
1520
+ }
1521
+ void onLoadMoreEvents?.();
1522
+ }}
1523
+ itemContent={(index: number) => {
1524
+ const ev = sortedEvents[index];
1525
+ return (
1526
+ <EventRow
1527
+ event={ev}
1528
+ index={index}
1529
+ isFirst={index === 0}
1530
+ isLast={index === sortedEvents.length - 1}
1531
+ isExpanded={expandedEventIds.has(ev.eventId)}
1532
+ onToggleExpand={toggleEventExpanded}
1533
+ activeGroupKey={activeGroupKey}
1534
+ selectedGroupKey={selectedGroupKey}
1535
+ selectedGroupRange={selectedGroupRange}
1536
+ correlationNameMap={correlationNameMap}
1537
+ workflowName={workflowName}
1538
+ durationMap={durationMap}
1539
+ onSelectGroup={onSelectGroup}
1540
+ onHoverGroup={onHoverGroup}
1541
+ onLoadEventData={onLoadEventData}
1542
+ cachedEventData={
1543
+ eventDataCacheRef.current.get(ev.eventId) ?? null
1544
+ }
1545
+ onCacheEventData={cacheEventData}
1546
+ encryptionKey={encryptionKey}
1547
+ onEncryptedDataDetected={handleEncryptedDataDetected}
1548
+ />
1549
+ );
1550
+ }}
1551
+ style={{ flex: 1, minHeight: 0 }}
1552
+ />
1553
+ )}
1436
1554
 
1437
- {/* Fixed footer — always at the bottom of the visible area */}
1555
+ {/* Fixed footer — count + load more */}
1438
1556
  <div
1439
- className="flex-shrink-0 border-t text-xs px-3 py-2"
1557
+ className="relative flex-shrink-0 flex items-center h-10 border-t px-4 text-xs"
1440
1558
  style={{
1441
1559
  borderColor: 'var(--ds-gray-alpha-200)',
1442
1560
  color: 'var(--ds-gray-900)',
1443
1561
  backgroundColor: 'var(--ds-background-100)',
1444
1562
  }}
1445
1563
  >
1446
- {sortedEvents.length} event
1447
- {sortedEvents.length !== 1 ? 's' : ''} total
1564
+ <span>
1565
+ {sortedEvents.length} event
1566
+ {sortedEvents.length !== 1 ? 's' : ''} loaded
1567
+ </span>
1568
+ {hasMoreEvents && (
1569
+ <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
1570
+ <div className="pointer-events-auto">
1571
+ <LoadMoreButton
1572
+ loading={isLoadingMoreEvents}
1573
+ onClick={() => void onLoadMoreEvents?.()}
1574
+ />
1575
+ </div>
1576
+ </div>
1577
+ )}
1448
1578
  </div>
1449
1579
  </div>
1450
1580
  );
@@ -3,7 +3,7 @@
3
3
  import type { Hook, WorkflowRunStatus } from '@workflow/world';
4
4
  import { Send } from 'lucide-react';
5
5
  import { useCallback, useState } from 'react';
6
- import { toast } from 'sonner';
6
+ import { useToast } from '../lib/toast';
7
7
  import { ResolveHookModal } from './sidebar/resolve-hook-modal';
8
8
 
9
9
  // ============================================================================
@@ -45,6 +45,7 @@ export function useHookActions({
45
45
  onResolve,
46
46
  callbacks,
47
47
  }: UseHookActionsOptions): UseHookActionsReturn {
48
+ const toast = useToast();
48
49
  const [isResolving, setIsResolving] = useState(false);
49
50
  const [selectedHook, setSelectedHook] = useState<Hook | null>(null);
50
51
 
@@ -22,5 +22,8 @@ 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 { DecryptButton } from './ui/decrypt-button';
26
+ export { LoadMoreButton } from './ui/load-more-button';
25
27
  export { MenuDropdown, type MenuDropdownOption } from './ui/menu-dropdown';
28
+ export { Spinner } from './ui/spinner';
26
29
  export { WorkflowTraceViewer } from './workflow-trace-view';