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

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 (54) hide show
  1. package/dist/components/event-list-view.d.ts +2 -3
  2. package/dist/components/event-list-view.d.ts.map +1 -1
  3. package/dist/components/event-list-view.js +13 -10
  4. package/dist/components/event-list-view.js.map +1 -1
  5. package/dist/components/run-trace-view.d.ts +1 -3
  6. package/dist/components/run-trace-view.d.ts.map +1 -1
  7. package/dist/components/run-trace-view.js +2 -2
  8. package/dist/components/run-trace-view.js.map +1 -1
  9. package/dist/components/sidebar/attribute-panel.d.ts.map +1 -1
  10. package/dist/components/sidebar/attribute-panel.js +11 -1
  11. package/dist/components/sidebar/attribute-panel.js.map +1 -1
  12. package/dist/components/sidebar/detail-card.d.ts.map +1 -1
  13. package/dist/components/sidebar/detail-card.js +4 -2
  14. package/dist/components/sidebar/detail-card.js.map +1 -1
  15. package/dist/components/sidebar/entity-detail-panel.d.ts +3 -3
  16. package/dist/components/sidebar/entity-detail-panel.d.ts.map +1 -1
  17. package/dist/components/sidebar/entity-detail-panel.js +43 -26
  18. package/dist/components/sidebar/entity-detail-panel.js.map +1 -1
  19. package/dist/components/trace-viewer/trace-viewer.d.ts +7 -1
  20. package/dist/components/trace-viewer/trace-viewer.d.ts.map +1 -1
  21. package/dist/components/trace-viewer/trace-viewer.js +36 -11
  22. package/dist/components/trace-viewer/trace-viewer.js.map +1 -1
  23. package/dist/components/workflow-trace-view.d.ts +3 -3
  24. package/dist/components/workflow-trace-view.d.ts.map +1 -1
  25. package/dist/components/workflow-trace-view.js +31 -129
  26. package/dist/components/workflow-trace-view.js.map +1 -1
  27. package/dist/components/workflow-traces/trace-span-construction.d.ts +18 -5
  28. package/dist/components/workflow-traces/trace-span-construction.d.ts.map +1 -1
  29. package/dist/components/workflow-traces/trace-span-construction.js +65 -18
  30. package/dist/components/workflow-traces/trace-span-construction.js.map +1 -1
  31. package/dist/index.d.ts +3 -1
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +2 -1
  34. package/dist/index.js.map +1 -1
  35. package/dist/lib/event-materialization.d.ts +72 -0
  36. package/dist/lib/event-materialization.d.ts.map +1 -0
  37. package/dist/lib/event-materialization.js +171 -0
  38. package/dist/lib/event-materialization.js.map +1 -0
  39. package/dist/lib/trace-builder.d.ts +32 -0
  40. package/dist/lib/trace-builder.d.ts.map +1 -0
  41. package/dist/lib/trace-builder.js +129 -0
  42. package/dist/lib/trace-builder.js.map +1 -0
  43. package/package.json +2 -2
  44. package/src/components/event-list-view.tsx +17 -13
  45. package/src/components/run-trace-view.tsx +0 -6
  46. package/src/components/sidebar/attribute-panel.tsx +17 -2
  47. package/src/components/sidebar/detail-card.tsx +10 -2
  48. package/src/components/sidebar/entity-detail-panel.tsx +59 -21
  49. package/src/components/trace-viewer/trace-viewer.tsx +47 -2
  50. package/src/components/workflow-trace-view.tsx +89 -195
  51. package/src/components/workflow-traces/trace-span-construction.ts +85 -32
  52. package/src/index.ts +13 -0
  53. package/src/lib/event-materialization.ts +243 -0
  54. package/src/lib/trace-builder.ts +201 -0
@@ -2,9 +2,10 @@
2
2
 
3
3
  import type { Event, Hook, Step, WorkflowRun } from '@workflow/world';
4
4
  import clsx from 'clsx';
5
- import { Send, Zap } from 'lucide-react';
5
+ import { Lock, Send, Unlock, Zap } from 'lucide-react';
6
6
  import { useCallback, useEffect, useMemo, useState } from 'react';
7
7
  import { toast } from 'sonner';
8
+ import { isEncryptedMarker } from '../../lib/hydration';
8
9
  import { AttributePanel } from './attribute-panel';
9
10
  import { EventsList } from './events-list';
10
11
  import { ResolveHookModal } from './resolve-hook-modal';
@@ -53,7 +54,6 @@ export interface SelectedSpanInfo {
53
54
  */
54
55
  export function EntityDetailPanel({
55
56
  run,
56
- hooks,
57
57
  onStreamClick,
58
58
  spanDetailData,
59
59
  spanDetailError,
@@ -63,11 +63,10 @@ export function EntityDetailPanel({
63
63
  onLoadEventData,
64
64
  onResolveHook,
65
65
  encryptionKey,
66
+ onDecrypt,
66
67
  selectedSpan,
67
68
  }: {
68
69
  run: WorkflowRun;
69
- /** All hooks for the current run (used as fallback for token lookup). */
70
- hooks?: Hook[];
71
70
  /** Callback when a stream reference is clicked */
72
71
  onStreamClick?: (streamId: string) => void;
73
72
  /** Pre-fetched span detail data for the selected span. */
@@ -96,6 +95,8 @@ export function EntityDetailPanel({
96
95
  ) => Promise<void>;
97
96
  /** Encryption key (available after Decrypt is clicked), used to re-load event data */
98
97
  encryptionKey?: Uint8Array;
98
+ /** Callback to initiate decryption of encrypted run data */
99
+ onDecrypt?: () => void;
99
100
  /** Info about the currently selected span from the trace viewer */
100
101
  selectedSpan: SelectedSpanInfo | null;
101
102
  }): React.JSX.Element | null {
@@ -129,10 +130,11 @@ export function EntityDetailPanel({
129
130
  return { resource: 'hook', resourceId: data.hookId, runId: undefined };
130
131
  }
131
132
  if (res === 'sleep') {
133
+ const waitData = data as { runId?: string } | undefined;
132
134
  return {
133
135
  resource: 'sleep',
134
136
  resourceId: selectedSpan.spanId,
135
- runId: undefined,
137
+ runId: waitData?.runId,
136
138
  };
137
139
  }
138
140
  return { resource: undefined, resourceId: undefined, runId: undefined };
@@ -196,6 +198,17 @@ export function EntityDetailPanel({
196
198
  const error = spanDetailError ?? undefined;
197
199
  const loading = spanDetailLoading ?? false;
198
200
 
201
+ const hasEncryptedFields = useMemo(() => {
202
+ if (!spanDetailData) return false;
203
+ const d = spanDetailData as Record<string, unknown>;
204
+ return (
205
+ isEncryptedMarker(d.input) ||
206
+ isEncryptedMarker(d.output) ||
207
+ isEncryptedMarker(d.error) ||
208
+ isEncryptedMarker(d.metadata)
209
+ );
210
+ }, [spanDetailData]);
211
+
199
212
  // Get the hook token for resolving (prefer fetched data, then hooks array fallback)
200
213
  const hookToken = useMemo(() => {
201
214
  if (resource !== 'hook' || !resourceId) return undefined;
@@ -203,17 +216,12 @@ export function EntityDetailPanel({
203
216
  if (isHook(spanDetailData) && spanDetailData.token) {
204
217
  return spanDetailData.token;
205
218
  }
206
- // 2. Try the hooks array (always has tokens)
207
- const hookFromArray = hooks?.find((h) => h.hookId === resourceId);
208
- if (hookFromArray?.token) {
209
- return hookFromArray.token;
210
- }
211
- // 3. Try the span's inline data (partial hook from events - may lack token)
219
+ // 2. Try the span's inline data (reconstructed from hook_created event)
212
220
  if (isHook(data) && (data as Hook).token) {
213
221
  return (data as Hook).token;
214
222
  }
215
223
  return undefined;
216
- }, [resource, resourceId, spanDetailData, data, hooks]);
224
+ }, [resource, resourceId, spanDetailData, data]);
217
225
 
218
226
  useEffect(() => {
219
227
  if (error && selectedSpan && resource) {
@@ -299,16 +307,15 @@ export function EntityDetailPanel({
299
307
  [onResolveHook, hookToken, resolvingHook, spanDetailData, data]
300
308
  );
301
309
 
302
- if (!selectedSpan || !resource || !resourceId) {
303
- return null;
304
- }
310
+ // Prefer externally-fetched details when available. For sleep spans, the
311
+ // host fetches full correlated events (withData=true) and materializes a wait
312
+ // entity, so this includes resumeAt/completedAt without bloating trace payloads.
313
+ const displayData = (spanDetailData ?? data) as
314
+ | WorkflowRun
315
+ | Step
316
+ | Hook
317
+ | Event;
305
318
 
306
- // For sleep spans, spanDetailData from the host is typically an events array
307
- // (not a single entity), so always prefer the inline wait entity from span
308
- // attributes which contains waitId, runId, createdAt, resumeAt, completedAt.
309
- const displayData = (
310
- resource === 'sleep' ? data : (spanDetailData ?? data)
311
- ) as WorkflowRun | Step | Hook | Event;
312
319
  const moduleSpecifier = useMemo(() => {
313
320
  const displayRecord = displayData as Record<string, unknown>;
314
321
  const displayStepName = displayRecord.stepName;
@@ -325,6 +332,10 @@ export function EntityDetailPanel({
325
332
  return undefined;
326
333
  }, [displayData, run.workflowName]);
327
334
 
335
+ if (!selectedSpan || !resource || !resourceId) {
336
+ return null;
337
+ }
338
+
328
339
  const resourceLabel = resource.charAt(0).toUpperCase() + resource.slice(1);
329
340
  const hasPendingActions =
330
341
  (resource === 'sleep' && canWakeUp) ||
@@ -369,6 +380,33 @@ export function EntityDetailPanel({
369
380
  {resourceId}
370
381
  </p>
371
382
  </div>
383
+ {(hasEncryptedFields || encryptionKey) && onDecrypt && (
384
+ <button
385
+ type="button"
386
+ onClick={onDecrypt}
387
+ disabled={!!encryptionKey}
388
+ className="inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs font-medium transition-colors flex-shrink-0"
389
+ style={{
390
+ borderColor: encryptionKey
391
+ ? 'var(--ds-green-400)'
392
+ : 'var(--ds-gray-300)',
393
+ color: encryptionKey
394
+ ? 'var(--ds-green-900)'
395
+ : 'var(--ds-gray-900)',
396
+ backgroundColor: encryptionKey
397
+ ? 'var(--ds-green-100)'
398
+ : 'var(--ds-background-100)',
399
+ cursor: encryptionKey ? 'default' : 'pointer',
400
+ }}
401
+ >
402
+ {encryptionKey ? (
403
+ <Unlock className="h-3 w-3" />
404
+ ) : (
405
+ <Lock className="h-3 w-3" />
406
+ )}
407
+ {encryptionKey ? 'Decrypted' : 'Decrypt'}
408
+ </button>
409
+ )}
372
410
  </div>
373
411
  </div>
374
412
 
@@ -103,7 +103,16 @@ export function TraceViewerTimeline({
103
103
  highlightedSpans,
104
104
  eagerRender = false,
105
105
  isLive = false,
106
- }: Omit<TraceViewerProps, 'getQuickLinks'>): ReactNode {
106
+ footer,
107
+ knownDurationMs,
108
+ hasMoreData = false,
109
+ }: Omit<TraceViewerProps, 'getQuickLinks'> & {
110
+ footer?: ReactNode;
111
+ /** Duration in ms from trace start to the latest known event. Used to render the unknown-time overlay. */
112
+ knownDurationMs?: number;
113
+ /** Whether more data pages are expected. Controls the unknown-data overlay visibility. */
114
+ hasMoreData?: boolean;
115
+ }): ReactNode {
107
116
  const isSkeleton = trace === skeletonTrace;
108
117
  const { state, dispatch } = useTraceViewer();
109
118
  const { timelineRef, scrollSnapshotRef } = state;
@@ -450,7 +459,7 @@ export function TraceViewerTimeline({
450
459
  style={{
451
460
  position: 'relative',
452
461
  width: state.timelineWidth,
453
- height: state.timelineHeight - TIMELINE_PADDING * 2,
462
+ minHeight: state.timelineHeight - TIMELINE_PADDING * 2,
454
463
  padding: TIMELINE_PADDING,
455
464
  paddingBottom: 0,
456
465
  }}
@@ -487,8 +496,44 @@ export function TraceViewerTimeline({
487
496
  scrollSnapshotRef={scrollSnapshotRef}
488
497
  spans={spans}
489
498
  />
499
+ {/* Horizontal "unknown time" overlay — covers the region to the
500
+ right of the latest known event, indicating data beyond this
501
+ point hasn't been loaded yet. */}
502
+ {knownDurationMs != null &&
503
+ knownDurationMs > 0 &&
504
+ (hasMoreData || isLive) &&
505
+ state.root.duration > 0 &&
506
+ (() => {
507
+ const knownPx = knownDurationMs * scale;
508
+ const totalPx = state.root.duration * scale;
509
+ const unknownWidth = totalPx - knownPx;
510
+ // Only show if the unknown region is meaningfully wide
511
+ if (unknownWidth < 4) return null;
512
+ // Offset ~5% into the unknown region so it doesn't touch spans
513
+ const insetPx = Math.min(unknownWidth * 0.05, 20);
514
+ return (
515
+ <div
516
+ style={{
517
+ position: 'absolute',
518
+ top: 0,
519
+ left: knownPx + insetPx,
520
+ width: unknownWidth - insetPx,
521
+ height: '100%',
522
+ pointerEvents: 'none',
523
+ zIndex: 1,
524
+ maskImage:
525
+ 'linear-gradient(to right, transparent 1%, black 3%)',
526
+ WebkitMaskImage:
527
+ 'linear-gradient(to right, transparent 1%, black 3%)',
528
+ background:
529
+ 'repeating-linear-gradient(-45deg, var(--ds-background-200) 0, var(--ds-background-200) 11px, var(--ds-gray-200) 11px, var(--ds-gray-200) 12px)',
530
+ }}
531
+ />
532
+ );
533
+ })()}
490
534
  </div>
491
535
  </div>
536
+ {footer}
492
537
  </div>
493
538
  <div className={styles.zoomButtonTraceViewer}>
494
539
  <ZoomButton />
@@ -35,14 +35,7 @@ import {
35
35
  getCustomSpanClassName,
36
36
  getCustomSpanEventClassName,
37
37
  } from './workflow-traces/trace-colors';
38
- import {
39
- hookToSpan,
40
- runToSpan,
41
- stepToSpan,
42
- WORKFLOW_LIBRARY,
43
- waitToSpan,
44
- } from './workflow-traces/trace-span-construction';
45
- import { otelTimeToMs } from './workflow-traces/trace-time-utils';
38
+ import { buildTrace, type TraceWithMeta } from '../lib/trace-builder';
46
39
 
47
40
  /**
48
41
  * While a run is live, continuously grow root.duration and rescale so the
@@ -273,7 +266,6 @@ function SpanContextMenu({
273
266
  function TraceViewerWithContextMenu({
274
267
  trace,
275
268
  run,
276
- hooks,
277
269
  isLive,
278
270
  onWakeUpSleep,
279
271
  onCancelRun,
@@ -285,7 +277,6 @@ function TraceViewerWithContextMenu({
285
277
  }: {
286
278
  trace: { spans: Span[] };
287
279
  run: WorkflowRun;
288
- hooks: Hook[];
289
280
  isLive: boolean;
290
281
  onWakeUpSleep?: (
291
282
  runId: string,
@@ -307,7 +298,10 @@ function TraceViewerWithContextMenu({
307
298
  // Drive active span widths at 60fps without React re-renders
308
299
  useLiveTick(isLive);
309
300
  const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
310
- const [resolveHookTarget, setResolveHookTarget] = useState<Hook | null>(null);
301
+ const [resolveHookTarget, setResolveHookTarget] = useState<{
302
+ hookId: string;
303
+ token: string;
304
+ } | null>(null);
311
305
  const [resolvingHook, setResolvingHook] = useState(false);
312
306
  // Track hooks resolved in this session so the context menu item hides immediately
313
307
  const [resolvedHookIds, setResolvedHookIds] = useState<Set<string>>(
@@ -323,15 +317,6 @@ function TraceViewerWithContextMenu({
323
317
  return map;
324
318
  }, [trace.spans]);
325
319
 
326
- // Build a lookup map: hookId -> Hook
327
- const hookLookup = useMemo(() => {
328
- const map = new Map<string, Hook>();
329
- for (const hook of hooks) {
330
- map.set(hook.hookId, hook);
331
- }
332
- return map;
333
- }, [hooks]);
334
-
335
320
  const handleResolveHook = useCallback(
336
321
  async (payload: unknown) => {
337
322
  if (resolvingHook || !resolveHookTarget || !onResolveHook) return;
@@ -344,11 +329,7 @@ function TraceViewerWithContextMenu({
344
329
  }
345
330
  try {
346
331
  setResolvingHook(true);
347
- await onResolveHook(
348
- resolveHookTarget.token,
349
- payload,
350
- resolveHookTarget
351
- );
332
+ await onResolveHook(resolveHookTarget.token, payload);
352
333
  toast.success('Hook resolved', {
353
334
  description: 'The payload has been sent and the hook resolved.',
354
335
  });
@@ -496,22 +477,24 @@ function TraceViewerWithContextMenu({
496
477
 
497
478
  // Hook-specific: Resolve Hook (only on active, unresolved hooks)
498
479
  if (menu.resourceType === 'hook' && isRunActive && onResolveHook) {
499
- const hook = hookLookup.get(menu.spanId);
500
480
  const span = spanLookup.get(menu.spanId);
501
- // Check data-level disposedAt, span events, AND local resolved state
502
481
  const hookData = span?.attributes?.data as
503
- | { disposedAt?: unknown }
482
+ | { token?: string; disposedAt?: unknown }
504
483
  | undefined;
484
+ // Check data-level disposedAt, span events, AND local resolved state
505
485
  const isDisposed =
506
486
  Boolean(hookData?.disposedAt) ||
507
487
  Boolean(span?.events?.some((e) => e.name === 'hook_disposed')) ||
508
488
  resolvedHookIds.has(menu.spanId);
509
- if (hook?.token && !isDisposed) {
489
+ if (hookData?.token && !isDisposed) {
510
490
  items.push({
511
491
  label: 'Resolve Hook',
512
492
  icon: <Send className="h-3.5 w-3.5" />,
513
493
  action: () => {
514
- setResolveHookTarget(hook);
494
+ setResolveHookTarget({
495
+ hookId: menu.spanId,
496
+ token: hookData.token!,
497
+ });
515
498
  },
516
499
  });
517
500
  }
@@ -582,7 +565,6 @@ function TraceViewerWithContextMenu({
582
565
  onWakeUpSleep,
583
566
  onCancelRun,
584
567
  onResolveHook,
585
- hookLookup,
586
568
  spanLookup,
587
569
  resolvedHookIds,
588
570
  run.runId,
@@ -610,159 +592,6 @@ function TraceViewerWithContextMenu({
610
592
  );
611
593
  }
612
594
 
613
- type GroupedEvents = {
614
- eventsByStepId: Map<string, Event[]>;
615
- eventsByHookId: Map<string, Event[]>;
616
- runLevelEvents: Event[];
617
- timerEvents: Map<string, Event[]>;
618
- hookEvents: Map<string, Event[]>;
619
- };
620
-
621
- const isTimerEvent = (eventType: string) =>
622
- eventType === 'wait_created' || eventType === 'wait_completed';
623
-
624
- const isHookLifecycleEvent = (eventType: string) =>
625
- eventType === 'hook_received' ||
626
- eventType === 'hook_created' ||
627
- eventType === 'hook_disposed';
628
-
629
- const pushEvent = (
630
- map: Map<string, Event[]>,
631
- correlationId: string,
632
- event: Event
633
- ) => {
634
- const existing = map.get(correlationId);
635
- if (existing) {
636
- existing.push(event);
637
- return;
638
- }
639
- map.set(correlationId, [event]);
640
- };
641
-
642
- const groupEventsByCorrelation = (
643
- events: Event[],
644
- steps: Step[],
645
- hooks: Hook[]
646
- ): GroupedEvents => {
647
- const eventsByStepId = new Map<string, Event[]>();
648
- const eventsByHookId = new Map<string, Event[]>();
649
- const runLevelEvents: Event[] = [];
650
- const timerEvents = new Map<string, Event[]>();
651
- const hookEvents = new Map<string, Event[]>();
652
- const stepIds = new Set(steps.map((step) => step.stepId));
653
- const hookIds = new Set(hooks.map((hook) => hook.hookId));
654
-
655
- for (const event of events) {
656
- const correlationId = event.correlationId;
657
- if (!correlationId) {
658
- runLevelEvents.push(event);
659
- continue;
660
- }
661
-
662
- if (isTimerEvent(event.eventType)) {
663
- pushEvent(timerEvents, correlationId, event);
664
- continue;
665
- }
666
-
667
- if (isHookLifecycleEvent(event.eventType)) {
668
- pushEvent(hookEvents, correlationId, event);
669
- continue;
670
- }
671
-
672
- if (stepIds.has(correlationId)) {
673
- pushEvent(eventsByStepId, correlationId, event);
674
- continue;
675
- }
676
-
677
- if (hookIds.has(correlationId)) {
678
- pushEvent(eventsByHookId, correlationId, event);
679
- continue;
680
- }
681
-
682
- runLevelEvents.push(event);
683
- }
684
-
685
- return {
686
- eventsByStepId,
687
- eventsByHookId,
688
- runLevelEvents,
689
- timerEvents,
690
- hookEvents,
691
- };
692
- };
693
-
694
- const buildSpans = (
695
- run: WorkflowRun,
696
- steps: Step[],
697
- groupedEvents: GroupedEvents,
698
- now: Date
699
- ) => {
700
- const viewerEndTime = new Date(run.completedAt || now);
701
- const stepSpans = steps.map((step) => {
702
- const stepEvents = groupedEvents.eventsByStepId.get(step.stepId) || [];
703
- return stepToSpan(step, stepEvents, now, viewerEndTime);
704
- });
705
-
706
- const hookSpans = Array.from(groupedEvents.hookEvents.values())
707
- .map((events) => hookToSpan(events, run, now))
708
- .filter((span): span is Span => span !== null);
709
-
710
- const waitSpans = Array.from(groupedEvents.timerEvents.values())
711
- .map((events) => waitToSpan(events, run, now))
712
- .filter((span): span is Span => span !== null);
713
-
714
- return {
715
- runSpan: runToSpan(run, groupedEvents.runLevelEvents, now),
716
- spans: [...stepSpans, ...hookSpans, ...waitSpans],
717
- };
718
- };
719
-
720
- const cascadeSpans = (runSpan: Span, spans: Span[]) => {
721
- const sortedSpans = [
722
- runSpan,
723
- ...spans.slice().sort((a, b) => {
724
- const aStart = otelTimeToMs(a.startTime);
725
- const bStart = otelTimeToMs(b.startTime);
726
- return aStart - bStart;
727
- }),
728
- ];
729
-
730
- return sortedSpans.map((span, index) => {
731
- const parentSpanId =
732
- index === 0 ? undefined : String(sortedSpans[index - 1].spanId);
733
- return {
734
- ...span,
735
- parentSpanId,
736
- };
737
- });
738
- };
739
-
740
- const buildTrace = (
741
- run: WorkflowRun,
742
- steps: Step[],
743
- hooks: Hook[],
744
- events: Event[],
745
- now: Date
746
- ) => {
747
- const groupedEvents = groupEventsByCorrelation(events, steps, hooks);
748
- const { runSpan, spans } = buildSpans(run, steps, groupedEvents, now);
749
- const sortedCascadingSpans = cascadeSpans(runSpan, spans);
750
-
751
- return {
752
- traceId: run.runId,
753
- rootSpanId: run.runId,
754
- spans: sortedCascadingSpans,
755
- resources: [
756
- {
757
- name: 'workflow',
758
- attributes: {
759
- 'service.name': WORKFLOW_LIBRARY.name,
760
- },
761
- },
762
- ],
763
- };
764
- };
765
-
766
595
  /** Re-export SpanSelectionInfo for consumers */
767
596
  export type { SpanSelectionInfo };
768
597
 
@@ -870,14 +699,73 @@ function PanelResizeHandle({
870
699
  );
871
700
  }
872
701
 
702
+ // ---------------------------------------------------------------------------
703
+ // Trace viewer footer — loading / waiting indicator
704
+ // ---------------------------------------------------------------------------
705
+
706
+ function TraceViewerFooter({
707
+ hasMore,
708
+ isLive,
709
+ }: {
710
+ hasMore: boolean;
711
+ isLive: boolean;
712
+ }): ReactNode {
713
+ const style = { color: 'var(--ds-gray-900)' };
714
+ if (hasMore) {
715
+ return (
716
+ <div
717
+ className="flex items-center justify-center gap-2 py-3 text-xs"
718
+ style={style}
719
+ >
720
+ <svg
721
+ className="h-3.5 w-3.5 animate-spin"
722
+ viewBox="0 0 24 24"
723
+ fill="none"
724
+ >
725
+ <circle
726
+ className="opacity-25"
727
+ cx="12"
728
+ cy="12"
729
+ r="10"
730
+ stroke="currentColor"
731
+ strokeWidth="4"
732
+ />
733
+ <path
734
+ className="opacity-75"
735
+ fill="currentColor"
736
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
737
+ />
738
+ </svg>
739
+ Loading more events…
740
+ </div>
741
+ );
742
+ }
743
+ if (isLive) {
744
+ return (
745
+ <div
746
+ className="flex items-center justify-center py-3 text-xs"
747
+ style={style}
748
+ >
749
+ Waiting for more events…
750
+ </div>
751
+ );
752
+ }
753
+ return (
754
+ <div
755
+ className="flex items-center justify-center py-3 text-xs"
756
+ style={style}
757
+ >
758
+ End of run
759
+ </div>
760
+ );
761
+ }
762
+
873
763
  // ---------------------------------------------------------------------------
874
764
  // Main component
875
765
  // ---------------------------------------------------------------------------
876
766
 
877
767
  export const WorkflowTraceViewer = ({
878
768
  run,
879
- steps,
880
- hooks,
881
769
  events,
882
770
  isLoading,
883
771
  error,
@@ -894,10 +782,9 @@ export const WorkflowTraceViewer = ({
894
782
  hasMoreSpans = false,
895
783
  isLoadingMoreSpans = false,
896
784
  encryptionKey,
785
+ onDecrypt,
897
786
  }: {
898
787
  run: WorkflowRun;
899
- steps: Step[];
900
- hooks: Hook[];
901
788
  events: Event[];
902
789
  isLoading?: boolean;
903
790
  error?: Error | null;
@@ -932,6 +819,8 @@ export const WorkflowTraceViewer = ({
932
819
  isLoadingMoreSpans?: boolean;
933
820
  /** Encryption key (available after Decrypt), threaded to event list for re-loading */
934
821
  encryptionKey?: Uint8Array;
822
+ /** Callback to initiate decryption of encrypted run data */
823
+ onDecrypt?: () => void;
935
824
  }) => {
936
825
  const [selectedSpan, setSelectedSpan] = useState<SelectedSpanInfo | null>(
937
826
  null
@@ -947,13 +836,14 @@ export const WorkflowTraceViewer = ({
947
836
 
948
837
  // Build trace only when actual data changes — no timer-driven rebuilds.
949
838
  // Active span widths are animated imperatively by useLiveTick at 60fps.
950
- const trace = useMemo(() => {
951
- if (!run) {
839
+ const traceWithMeta: TraceWithMeta | undefined = useMemo(() => {
840
+ if (!run?.runId) {
952
841
  return undefined;
953
842
  }
954
- return buildTrace(run, steps, hooks, events, new Date());
843
+ return buildTrace(run, events, new Date());
955
844
  // eslint-disable-next-line react-hooks/exhaustive-deps -- `new Date()` is intentionally not a dep; useLiveTick handles live growth
956
- }, [run, steps, hooks, events]);
845
+ }, [run, events]);
846
+ const trace = traceWithMeta;
957
847
 
958
848
  useEffect(() => {
959
849
  if (error && !isLoading) {
@@ -1064,7 +954,7 @@ export const WorkflowTraceViewer = ({
1064
954
  );
1065
955
  }, [selectedSpan?.data]);
1066
956
 
1067
- if (isLoading || !trace) {
957
+ if (!trace) {
1068
958
  return (
1069
959
  <div className="relative w-full h-full">
1070
960
  <div className="border-b border-gray-alpha-400 w-full" />
@@ -1093,7 +983,6 @@ export const WorkflowTraceViewer = ({
1093
983
  <TraceViewerWithContextMenu
1094
984
  trace={trace}
1095
985
  run={run}
1096
- hooks={hooks}
1097
986
  isLive={isLive}
1098
987
  onWakeUpSleep={onWakeUpSleep}
1099
988
  onCancelRun={onCancelRun}
@@ -1107,6 +996,11 @@ export const WorkflowTraceViewer = ({
1107
996
  height="100%"
1108
997
  isLive={isLive}
1109
998
  trace={trace}
999
+ knownDurationMs={traceWithMeta?.knownDurationMs}
1000
+ hasMoreData={hasMoreSpans}
1001
+ footer={
1002
+ <TraceViewerFooter hasMore={hasMoreSpans} isLive={isLive} />
1003
+ }
1110
1004
  />
1111
1005
  </TraceViewerWithContextMenu>
1112
1006
  </TraceViewerContextProvider>
@@ -1246,7 +1140,6 @@ export const WorkflowTraceViewer = ({
1246
1140
  <ErrorBoundary title="Failed to load entity details">
1247
1141
  <EntityDetailPanel
1248
1142
  run={run}
1249
- hooks={hooks}
1250
1143
  onStreamClick={onStreamClick}
1251
1144
  spanDetailData={spanDetailData ?? null}
1252
1145
  spanDetailError={spanDetailError}
@@ -1256,6 +1149,7 @@ export const WorkflowTraceViewer = ({
1256
1149
  onLoadEventData={onLoadEventData}
1257
1150
  onResolveHook={onResolveHook}
1258
1151
  encryptionKey={encryptionKey}
1152
+ onDecrypt={onDecrypt}
1259
1153
  selectedSpan={selectedSpan}
1260
1154
  />
1261
1155
  </ErrorBoundary>