@workflow/web-shared 5.0.0-beta.43 → 5.0.0-beta.46

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 (106) hide show
  1. package/README.md +4 -4
  2. package/dist/components/event-list-view.d.ts +12 -0
  3. package/dist/components/event-list-view.d.ts.map +1 -1
  4. package/dist/components/event-list-view.js +41 -20
  5. package/dist/components/sidebar/attribute-panel.d.ts +6 -2
  6. package/dist/components/sidebar/attribute-panel.d.ts.map +1 -1
  7. package/dist/components/sidebar/attribute-panel.js +10 -8
  8. package/dist/components/sidebar/copyable-data-block.d.ts.map +1 -1
  9. package/dist/components/sidebar/copyable-data-block.js +2 -2
  10. package/dist/components/sidebar/entity-detail-panel.d.ts +6 -2
  11. package/dist/components/sidebar/entity-detail-panel.d.ts.map +1 -1
  12. package/dist/components/sidebar/entity-detail-panel.js +13 -5
  13. package/dist/components/sidebar/events-list.d.ts.map +1 -1
  14. package/dist/components/sidebar/events-list.js +12 -5
  15. package/dist/components/sidebar/resolve-hook-modal.js +1 -1
  16. package/dist/components/sidebar/sidebar-data-context.d.ts +2 -0
  17. package/dist/components/sidebar/sidebar-data-context.d.ts.map +1 -1
  18. package/dist/components/sidebar/sidebar-data-context.js +1 -1
  19. package/dist/components/stream-viewer.js +2 -2
  20. package/dist/components/trace-viewer/components/detail-panel-width.d.ts +4 -4
  21. package/dist/components/trace-viewer/components/detail-panel-width.d.ts.map +1 -1
  22. package/dist/components/trace-viewer/components/detail-panel-width.js +5 -5
  23. package/dist/components/trace-viewer/components/detail-panel.d.ts +1 -1
  24. package/dist/components/trace-viewer/components/detail-panel.d.ts.map +1 -1
  25. package/dist/components/trace-viewer/components/detail-panel.js +3 -3
  26. package/dist/components/trace-viewer/components/draggable-border.d.ts +1 -1
  27. package/dist/components/trace-viewer/components/draggable-border.js +2 -2
  28. package/dist/components/trace-viewer/components/minimap.js +4 -4
  29. package/dist/components/trace-viewer/components/span-markers.d.ts +8 -9
  30. package/dist/components/trace-viewer/components/span-markers.d.ts.map +1 -1
  31. package/dist/components/trace-viewer/components/span-markers.js +13 -10
  32. package/dist/components/trace-viewer/components/timeline.d.ts +1 -1
  33. package/dist/components/trace-viewer/components/timeline.js +4 -4
  34. package/dist/components/trace-viewer/components/use-row-window.d.ts +1 -1
  35. package/dist/components/trace-viewer/components/use-row-window.js +2 -2
  36. package/dist/components/trace-viewer/trace-viewer.js +3 -3
  37. package/dist/components/trace-viewer/utils.d.ts +3 -1
  38. package/dist/components/trace-viewer/utils.d.ts.map +1 -1
  39. package/dist/components/trace-viewer/utils.js +6 -5
  40. package/dist/components/ui/data-inspector.d.ts +7 -1
  41. package/dist/components/ui/data-inspector.d.ts.map +1 -1
  42. package/dist/components/ui/data-inspector.js +10 -5
  43. package/dist/components/ui/decrypt-button.d.ts +5 -1
  44. package/dist/components/ui/decrypt-button.d.ts.map +1 -1
  45. package/dist/components/ui/decrypt-button.js +4 -4
  46. package/dist/components/ui/duplicate-event-tooltip.d.ts +15 -5
  47. package/dist/components/ui/duplicate-event-tooltip.d.ts.map +1 -1
  48. package/dist/components/ui/duplicate-event-tooltip.js +18 -9
  49. package/dist/components/ui/error-stack-block.js +4 -4
  50. package/dist/components/ui/timestamp-tooltip.d.ts +5 -3
  51. package/dist/components/ui/timestamp-tooltip.d.ts.map +1 -1
  52. package/dist/components/ui/timestamp-tooltip.js +9 -9
  53. package/dist/components/workflow-traces/event-colors.d.ts.map +1 -1
  54. package/dist/components/workflow-traces/event-colors.js +11 -1
  55. package/dist/components/workflow-traces/trace-span-construction.d.ts.map +1 -1
  56. package/dist/components/workflow-traces/trace-span-construction.js +5 -4
  57. package/dist/index.d.ts +1 -0
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js +2 -1
  60. package/dist/lib/duplicate-events.d.ts +1 -1
  61. package/dist/lib/duplicate-events.js +2 -2
  62. package/dist/lib/hydration.d.ts.map +1 -1
  63. package/dist/lib/hydration.js +8 -13
  64. package/dist/lib/sealed-events.d.ts +28 -0
  65. package/dist/lib/sealed-events.d.ts.map +1 -0
  66. package/dist/lib/sealed-events.js +30 -0
  67. package/dist/lib/trace-builder.d.ts.map +1 -1
  68. package/dist/lib/trace-builder.js +8 -5
  69. package/dist/lib/utils.d.ts +1 -1
  70. package/dist/lib/utils.js +2 -2
  71. package/dist/lib/zstd-browser-decoder.js +2 -2
  72. package/package.json +5 -5
  73. package/src/components/event-list-view.tsx +57 -22
  74. package/src/components/sidebar/attribute-panel.tsx +15 -7
  75. package/src/components/sidebar/copyable-data-block.tsx +8 -1
  76. package/src/components/sidebar/entity-detail-panel.tsx +18 -2
  77. package/src/components/sidebar/events-list.tsx +16 -6
  78. package/src/components/sidebar/resolve-hook-modal.tsx +2 -2
  79. package/src/components/sidebar/sidebar-data-context.tsx +2 -0
  80. package/src/components/stream-viewer.tsx +1 -1
  81. package/src/components/trace-viewer/components/detail-panel-width.ts +4 -4
  82. package/src/components/trace-viewer/components/detail-panel.tsx +3 -1
  83. package/src/components/trace-viewer/components/draggable-border.tsx +1 -1
  84. package/src/components/trace-viewer/components/minimap.tsx +3 -3
  85. package/src/components/trace-viewer/components/span-markers.tsx +18 -13
  86. package/src/components/trace-viewer/components/timeline.tsx +4 -4
  87. package/src/components/trace-viewer/components/use-row-window.ts +1 -1
  88. package/src/components/trace-viewer/trace-viewer.tsx +2 -2
  89. package/src/components/trace-viewer/utils.test.ts +5 -3
  90. package/src/components/trace-viewer/utils.ts +12 -8
  91. package/src/components/ui/data-inspector.tsx +19 -3
  92. package/src/components/ui/decrypt-button.tsx +9 -2
  93. package/src/components/ui/duplicate-event-tooltip.tsx +32 -10
  94. package/src/components/ui/error-stack-block.tsx +3 -3
  95. package/src/components/ui/timestamp-tooltip.tsx +16 -6
  96. package/src/components/workflow-traces/event-colors.ts +11 -0
  97. package/src/components/workflow-traces/trace-span-construction.test.ts +27 -1
  98. package/src/components/workflow-traces/trace-span-construction.ts +5 -3
  99. package/src/index.ts +4 -0
  100. package/src/lib/duplicate-events.ts +1 -1
  101. package/src/lib/hydration.ts +7 -12
  102. package/src/lib/sealed-events.ts +32 -0
  103. package/src/lib/trace-builder.test.ts +29 -1
  104. package/src/lib/trace-builder.ts +10 -5
  105. package/src/lib/utils.ts +1 -1
  106. package/src/lib/zstd-browser-decoder.ts +1 -1
@@ -4,7 +4,12 @@ import { ArrowLeft, ArrowRight } from 'lucide-react';
4
4
  import type { ReactNode } from 'react';
5
5
  import { cn } from '../../../lib/cn';
6
6
  import { TimestampTooltip } from '../../ui/timestamp-tooltip';
7
- import type { SpanMarker } from '../utils';
7
+ import type { SpanMarker, SpanMarkerKind } from '../utils';
8
+
9
+ const MARKER_KIND_PREFIX: Record<SpanMarkerKind, string> = {
10
+ hook_received: 'Hook received',
11
+ attr_set: 'Attribute set',
12
+ };
8
13
 
9
14
  // ---------------------------------------------------------------------------
10
15
  // Marker projection
@@ -12,8 +17,9 @@ import type { SpanMarker } from '../utils';
12
17
 
13
18
  export interface VisibleMarker {
14
19
  leftPct: number;
15
- /** Absolute (epoch) timestamp in ms for the tooltip. */
20
+ /** Absolute (epoch) timestamp in ms, for the tooltip. */
16
21
  timeMs: number;
22
+ kind: SpanMarkerKind;
17
23
  }
18
24
 
19
25
  /**
@@ -37,6 +43,7 @@ export function projectMarkers(
37
43
  {
38
44
  leftPct: ((m.timeMs - visibleStartMs) / visibleDurationMs) * 100,
39
45
  timeMs: m.timeMs,
46
+ kind: m.kind,
40
47
  },
41
48
  ];
42
49
  });
@@ -48,7 +55,7 @@ const MARKER_MIN_GAP_PX = 16;
48
55
  /**
49
56
  * Thin out ticks that would visually collide: walk left-to-right and keep each
50
57
  * one unless it sits within MARKER_MIN_GAP_PX of the last kept tick. Well-spaced
51
- * markers survive even when a tight cluster elsewhere on the bar gets thinned
58
+ * markers survive even when a tight cluster elsewhere on the bar gets thinned;
52
59
  * zoom in to resolve a cluster.
53
60
  */
54
61
  export function cullCollidingMarkers(
@@ -80,13 +87,11 @@ function MarkerTick({ className }: { className?: string }): ReactNode {
80
87
  }
81
88
 
82
89
  /**
83
- * Point-in-time markers overlaid on a bar one vertical tick per event (hook
90
+ * Point-in-time markers overlaid on a bar: one vertical tick per event (hook
84
91
  * resumptions and attribute writes), centered on the bar. Each tick sits inside
85
- * a larger hit target and, on hover, shows the shared `TimestampTooltip` card
86
- * (the same light Geist card used for every other timestamp in the app, e.g. the
87
- * attribute panel and event list) so the time reads consistently with the rest
88
- * of the product. The position is clamped a hair inside the bar so an edge
89
- * marker never jams the rounded corner.
92
+ * a larger hit target and, on hover, shows the shared relative-time context
93
+ * card, prefixed with the event kind. The position is clamped a hair inside
94
+ * the bar so an edge marker never jams the rounded corner.
90
95
  */
91
96
  export function MarkerLayer({
92
97
  markers,
@@ -95,13 +100,13 @@ export function MarkerLayer({
95
100
  }): ReactNode {
96
101
  return (
97
102
  <>
98
- {markers.map((m) => (
103
+ {markers.map((m, index) => (
99
104
  <span
100
- key={m.timeMs}
105
+ key={`${m.timeMs}-${index}`}
101
106
  className="pointer-events-auto absolute top-0 bottom-0 z-10 flex w-8 -translate-x-1/2 items-center justify-center"
102
107
  style={{ left: `clamp(8px, ${m.leftPct}%, calc(100% - 8px))` }}
103
108
  >
104
- <TimestampTooltip date={m.timeMs}>
109
+ <TimestampTooltip date={m.timeMs} prefix={MARKER_KIND_PREFIX[m.kind]}>
105
110
  <span className="flex h-6 w-8 items-center justify-center">
106
111
  <MarkerTick className="h-3" />
107
112
  </span>
@@ -147,7 +152,7 @@ export function OffscreenMarkerIndicator({
147
152
  aria-label={label}
148
153
  title={label}
149
154
  onClick={(e) => {
150
- // Don't let the row's onClick fire revealing shouldn't also
155
+ // Don't let the row's onClick fire, since revealing shouldn't also
151
156
  // change the span selection.
152
157
  e.stopPropagation();
153
158
  onReveal?.(targetMs);
@@ -432,7 +432,7 @@ const TimelineBar = memo(function TimelineBar({
432
432
  );
433
433
 
434
434
  // Markers that fall outside the visible window (scrolled off while zoomed in)
435
- // surfaced as edge indicators so they aren't silently lost.
435
+ // are surfaced as edge indicators so they aren't silently lost.
436
436
  const offscreen = useMemo<OffscreenMarkers>(
437
437
  () =>
438
438
  geometry.mode.kind === 'full'
@@ -543,7 +543,7 @@ export { TimelineBar };
543
543
  // ---------------------------------------------------------------------------
544
544
 
545
545
  // Horizontal distance between the anchor bar's measured edge and the vertical
546
- // guide also the width of the connector stub bridging the two.
546
+ // guide; also the width of the connector stub bridging the two.
547
547
  const MEASURE_GUIDE_OUTSET_PX = 4;
548
548
 
549
549
  const DeltaMeasureLine = memo(function DeltaMeasureLine({
@@ -567,7 +567,7 @@ const DeltaMeasureLine = memo(function DeltaMeasureLine({
567
567
  // It sits just outside the anchor bar's measured edge (so it doesn't blend
568
568
  // into the bar's border), joined to the bar by a short horizontal stub. The
569
569
  // line runs from the elbow corner (the guide's x) to the hovered span's
570
- // measured edge pulled short of the edge arrow when the hovered span is
570
+ // measured edge, pulled short of the edge arrow when the hovered span is
571
571
  // fully off-screen.
572
572
  const guideTop = Math.min(anchorCenterY, lineY);
573
573
  const guideBottom = Math.max(anchorCenterY, lineY);
@@ -675,7 +675,7 @@ export function TimelineHeader({
675
675
  export interface TimelineHover {
676
676
  /** Pointer x as a fraction of the timeline's content width, in [0, 1]. */
677
677
  fraction: number;
678
- /** Row index under the pointer; may be past the last row not validated. */
678
+ /** Row index under the pointer; may be past the last row (not validated). */
679
679
  rowIndex: number;
680
680
  }
681
681
 
@@ -96,7 +96,7 @@ export const ROW_HEIGHT_PX = 40;
96
96
  * Scroll a windowed row into view by index.
97
97
  *
98
98
  * The list is virtualized (fixed `rowHeight` rows), so an off-screen row has no
99
- * DOM node to `scrollIntoView` its target offset is computed from its index
99
+ * DOM node to `scrollIntoView`; its target offset is computed from its index
100
100
  * instead. Walks up from `listEl` to the shared scrollable ancestor (the same
101
101
  * one `useRowWindow` measures against), only scrolls when the row sits outside
102
102
  * the visible area, leaves a one-row `margin` of breathing room past it, and
@@ -231,7 +231,7 @@ function TraceViewerContent({
231
231
  [root.startTime, root.endTime]
232
232
  );
233
233
 
234
- // Pan (keeping the current zoom) so `timeMs` is centered in view used by the
234
+ // Pan (keeping the current zoom) so `timeMs` is centered in view; used by the
235
235
  // off-screen marker indicators to scroll their marker into view.
236
236
  const handleRevealTime = useCallback(
237
237
  (timeMs: number) => {
@@ -307,7 +307,7 @@ function TraceViewerContent({
307
307
 
308
308
  // Bring a row into view when keyboard/button navigation lands on a span that
309
309
  // sits outside the shared scroll container's visible area. The list is
310
- // windowed, so an off-screen row has no DOM node to `scrollIntoView` —
310
+ // windowed, so an off-screen row has no DOM node to `scrollIntoView`;
311
311
  // `scrollRowIntoView` computes the target offset from the span's index.
312
312
  const scrollSpanIntoView = useCallback(
313
313
  (spanId: string) => {
@@ -153,8 +153,10 @@ describe('computeSpanMarkers', () => {
153
153
  attrSetMs: [10_000, 70_000],
154
154
  });
155
155
 
156
- expect(computeSpanMarkers(span).map((m) => m.timeMs)).toEqual([
157
- 10_000, 50_000, 70_000,
156
+ expect(computeSpanMarkers(span)).toEqual([
157
+ { timeMs: 10_000, kind: 'attr_set' },
158
+ { timeMs: 50_000, kind: 'hook_received' },
159
+ { timeMs: 70_000, kind: 'attr_set' },
158
160
  ]);
159
161
  });
160
162
 
@@ -165,7 +167,7 @@ describe('computeSpanMarkers', () => {
165
167
  });
166
168
 
167
169
  describe('computeOffscreenMarkers', () => {
168
- const mk = (timeMs: number) => ({ timeMs });
170
+ const mk = (timeMs: number) => ({ timeMs, kind: 'hook_received' as const });
169
171
 
170
172
  it('partitions markers by side with the nearest one per side', () => {
171
173
  const markers = [mk(5), mk(8), mk(50), mk(92), mk(99)];
@@ -78,7 +78,7 @@ export function clampViewportToRoot(
78
78
  }
79
79
 
80
80
  // ---------------------------------------------------------------------------
81
- // Wheel gestures shared between the timeline and the minimap
81
+ // Wheel gestures: shared between the timeline and the minimap
82
82
  // ---------------------------------------------------------------------------
83
83
 
84
84
  /** Convert a wheel delta to pixel units (line-mode deltas arrive in lines). */
@@ -164,7 +164,7 @@ export function computeTimeMarkers(
164
164
  }
165
165
 
166
166
  // ---------------------------------------------------------------------------
167
- // Span gaps time deltas between consecutive spans (Alt-key overlay)
167
+ // Span gaps: time deltas between consecutive spans (Alt-key overlay)
168
168
  // ---------------------------------------------------------------------------
169
169
 
170
170
  export interface SpanGap {
@@ -254,8 +254,8 @@ export function computeSpanDelta(
254
254
  const deltaMs = laterStart - originMs;
255
255
 
256
256
  // Entirely outside the viewport. Compared in time-space (not clamped
257
- // fractions) so a zero-delta point sitting exactly on a viewport edge
258
- // e.g. the root span selected at default zoom still renders.
257
+ // fractions) so a zero-delta point sitting exactly on a viewport edge
258
+ // (e.g. the root span selected at default zoom) still renders.
259
259
  if (laterStart < viewStart || originMs > viewEnd) {
260
260
  return null;
261
261
  }
@@ -294,7 +294,7 @@ export const RESOURCE_CLASS_NAMES: Record<
294
294
  className: 'border-green-500 bg-green-200',
295
295
  errorClassName: 'border-red-500 bg-red-200',
296
296
  },
297
- // Passive spans (hooks) stay gray matches event-list icons and the minimap.
297
+ // Passive spans (hooks) stay gray; matches event-list icons and the minimap.
298
298
  hook: {
299
299
  className: 'border-gray-500 bg-gray-200',
300
300
  errorClassName: 'border-red-500 bg-red-200',
@@ -317,7 +317,7 @@ export function getResourceClassNames(resource: string): {
317
317
  }
318
318
 
319
319
  // ---------------------------------------------------------------------------
320
- // Span segments split a timeline bar into colored sections by event state
320
+ // Span segments: split a timeline bar into colored sections by event state
321
321
  // ---------------------------------------------------------------------------
322
322
 
323
323
  export type SegmentStatus =
@@ -603,11 +603,14 @@ export function computeSpanSegments(span: Span): Segment[] {
603
603
  }
604
604
 
605
605
  // ---------------------------------------------------------------------------
606
- // Span markers point-in-time events rendered as ticks on top of a bar
606
+ // Span markers: point-in-time events rendered as ticks on top of a bar
607
607
  // ---------------------------------------------------------------------------
608
608
 
609
+ export type SpanMarkerKind = 'hook_received' | 'attr_set';
610
+
609
611
  export interface SpanMarker {
610
612
  timeMs: number;
613
+ kind: SpanMarkerKind;
611
614
  }
612
615
 
613
616
  // `hook_received` = a resumption; `attr_set` = attributes written mid-span.
@@ -616,12 +619,13 @@ const MARKER_EVENT_NAMES = ['hook_received', 'attr_set'];
616
619
  export function computeSpanMarkers(span: Span): SpanMarker[] {
617
620
  return sortedEventMarks(span.events, MARKER_EVENT_NAMES).map((mark) => ({
618
621
  timeMs: mark.time,
622
+ kind: mark.type as SpanMarkerKind,
619
623
  }));
620
624
  }
621
625
 
622
626
  export interface OffscreenSide {
623
627
  count: number;
624
- /** Nearest off-screen marker the one a reveal jumps to. */
628
+ /** Nearest off-screen marker: the one a reveal jumps to. */
625
629
  nearestMs: number;
626
630
  }
627
631
 
@@ -145,6 +145,8 @@ export const StreamClickContext = createContext<
145
145
  export type DecryptClickContextValue = {
146
146
  onDecrypt: () => void;
147
147
  isDecrypting: boolean;
148
+ isDecryptDisabled?: boolean;
149
+ decryptDisabledReason?: string;
148
150
  hasEncryptedData?: boolean;
149
151
  };
150
152
 
@@ -167,7 +169,8 @@ function EncryptedInlineLabel() {
167
169
  <Button
168
170
  size="xs"
169
171
  className="align-baseline gap-x-1"
170
- disabled={ctx.isDecrypting}
172
+ disabled={ctx.isDecrypting || ctx.isDecryptDisabled}
173
+ title={ctx.isDecryptDisabled ? ctx.decryptDisabledReason : undefined}
171
174
  onClick={(e) => {
172
175
  e.stopPropagation();
173
176
  ctx.onDecrypt();
@@ -842,7 +845,7 @@ export function collapseRefs(data: unknown): unknown {
842
845
  if (data instanceof Set) {
843
846
  return new Set(Array.from(data.values(), collapseRefs));
844
847
  }
845
- // Only recurse into plain objects leave class instances untouched
848
+ // Only recurse into plain objects; leave class instances untouched
846
849
  const proto = Object.getPrototypeOf(data);
847
850
  if (proto !== Object.prototype && proto !== null) return data;
848
851
  const result: Record<string, unknown> = {};
@@ -871,6 +874,10 @@ export interface DataInspectorProps {
871
874
  onDecrypt?: () => void;
872
875
  /** Whether decryption is currently in progress */
873
876
  isDecrypting?: boolean;
877
+ /** Whether decryption is unavailable */
878
+ isDecryptDisabled?: boolean;
879
+ /** Explains why decryption is unavailable */
880
+ decryptDisabledReason?: string;
874
881
  }
875
882
 
876
883
  export function DataInspector({
@@ -881,6 +888,8 @@ export function DataInspector({
881
888
  onRunClick,
882
889
  onDecrypt,
883
890
  isDecrypting = false,
891
+ isDecryptDisabled = false,
892
+ decryptDisabledReason,
884
893
  }: DataInspectorProps) {
885
894
  const collapsedData = useMemo(() => collapseRefs(data), [data]);
886
895
  const stableData = useStableInspectorData(collapsedData);
@@ -912,7 +921,14 @@ export function DataInspector({
912
921
  }
913
922
  if (onDecrypt) {
914
923
  content = (
915
- <DecryptClickContext.Provider value={{ onDecrypt, isDecrypting }}>
924
+ <DecryptClickContext.Provider
925
+ value={{
926
+ onDecrypt,
927
+ isDecrypting,
928
+ isDecryptDisabled,
929
+ decryptDisabledReason,
930
+ }}
931
+ >
916
932
  {content}
917
933
  </DecryptClickContext.Provider>
918
934
  );
@@ -2,13 +2,17 @@
2
2
 
3
3
  import { Spinner } from './spinner';
4
4
 
5
- const STYLES = `.wf-decrypt-btn{appearance:none;-webkit-appearance:none;border:none;display:inline-flex;align-items:center;justify-content:center;height:40px;padding:0 12px;border-radius:6px;font-size:14px;font-weight:500;line-height:20px;cursor:pointer;white-space:nowrap;gap:6px;transition:background 150ms}.wf-decrypt-idle{color:var(--ds-gray-1000);background:var(--ds-background-100);box-shadow:0 0 0 1px var(--ds-gray-400)}.wf-decrypt-idle:hover{background:var(--ds-gray-alpha-200)}.wf-decrypt-done{color:var(--ds-green-900);background:var(--ds-green-100);box-shadow:0 0 0 1px var(--ds-green-400);cursor:default}`;
5
+ const STYLES = `.wf-decrypt-btn{appearance:none;-webkit-appearance:none;border:none;display:inline-flex;align-items:center;justify-content:center;height:40px;padding:0 12px;border-radius:6px;font-size:14px;font-weight:500;line-height:20px;cursor:pointer;white-space:nowrap;gap:6px;transition:background 150ms}.wf-decrypt-idle{color:var(--ds-gray-1000);background:var(--ds-background-100);box-shadow:0 0 0 1px var(--ds-gray-400)}.wf-decrypt-idle:hover{background:var(--ds-gray-alpha-200)}.wf-decrypt-btn:disabled{cursor:not-allowed;opacity:.5}.wf-decrypt-done{color:var(--ds-green-900);background:var(--ds-green-100);box-shadow:0 0 0 1px var(--ds-green-400);cursor:default}`;
6
6
 
7
7
  interface DecryptButtonProps {
8
8
  /** Whether an encryption key has been obtained (decryption is active). */
9
9
  decrypted?: boolean;
10
10
  /** Whether the key is currently being fetched. */
11
11
  loading?: boolean;
12
+ /** Whether decryption is unavailable. */
13
+ disabled?: boolean;
14
+ /** Explains why decryption is unavailable. */
15
+ disabledReason?: string;
12
16
  /** Called when the user clicks to initiate decryption. */
13
17
  onClick?: () => void;
14
18
  }
@@ -20,6 +24,8 @@ interface DecryptButtonProps {
20
24
  export function DecryptButton({
21
25
  decrypted = false,
22
26
  loading = false,
27
+ disabled = false,
28
+ disabledReason,
23
29
  onClick,
24
30
  }: DecryptButtonProps) {
25
31
  return (
@@ -28,7 +34,8 @@ export function DecryptButton({
28
34
  <button
29
35
  type="button"
30
36
  onClick={decrypted ? undefined : onClick}
31
- disabled={decrypted || loading}
37
+ disabled={decrypted || loading || disabled}
38
+ title={disabled ? disabledReason : undefined}
32
39
  className={`wf-decrypt-btn ${decrypted ? 'wf-decrypt-done' : 'wf-decrypt-idle'}`}
33
40
  >
34
41
  {loading ? (
@@ -10,22 +10,24 @@ import {
10
10
  } from './tooltip';
11
11
 
12
12
  /**
13
- * Explains why an event is shown greyed out: it repeats a class the log
14
- * already records for the same entity, after that entity finished.
13
+ * Explains why an event row is shown greyed out, whether it is a repeat the
14
+ * runtime read past ({@link DUPLICATE_EVENT_MESSAGE}), a backend seal for an
15
+ * abandoned position (`SEALED_EVENT_MESSAGE`), or any other notice a list
16
+ * attaches.
15
17
  *
16
- * Renders `children` untouched when `isDuplicate` is false, so a call site can
18
+ * Renders `children` untouched when `notice` is absent, so a call site can
17
19
  * wrap an event label unconditionally. Mounts its own {@link TooltipProvider}
18
- * so it works in the sidebar and the events table alike; nesting one inside an
19
- * existing provider is harmless.
20
+ * so it works in the sidebar and the events table alike; nesting one inside
21
+ * an existing provider is harmless.
20
22
  */
21
- export function DuplicateEventTooltip({
22
- isDuplicate = false,
23
+ export function EventNoticeTooltip({
24
+ notice,
23
25
  children,
24
26
  }: {
25
- isDuplicate?: boolean;
27
+ notice?: string;
26
28
  children: ReactNode;
27
29
  }): ReactNode {
28
- if (!isDuplicate) return children;
30
+ if (!notice) return children;
29
31
 
30
32
  return (
31
33
  <TooltipProvider delayDuration={300}>
@@ -36,9 +38,29 @@ export function DuplicateEventTooltip({
36
38
  collisionPadding={8}
37
39
  side="top"
38
40
  >
39
- {DUPLICATE_EVENT_MESSAGE}
41
+ {notice}
40
42
  </TooltipContent>
41
43
  </Tooltip>
42
44
  </TooltipProvider>
43
45
  );
44
46
  }
47
+
48
+ /**
49
+ * The duplicate-specific wrapper kept for existing call sites: greys the
50
+ * event out as a repeat the runtime read past.
51
+ */
52
+ export function DuplicateEventTooltip({
53
+ isDuplicate = false,
54
+ children,
55
+ }: {
56
+ isDuplicate?: boolean;
57
+ children: ReactNode;
58
+ }): ReactNode {
59
+ return (
60
+ <EventNoticeTooltip
61
+ notice={isDuplicate ? DUPLICATE_EVENT_MESSAGE : undefined}
62
+ >
63
+ {children}
64
+ </EventNoticeTooltip>
65
+ );
66
+ }
@@ -39,10 +39,10 @@ export function isStructuredErrorWithStack(
39
39
  /**
40
40
  * Pull a short, single-line title out of an error message.
41
41
  *
42
- * Workflow's structured error messages are multi-line the first line is
42
+ * Workflow's structured error messages are multi-line: the first line is
43
43
  * the headline (`Failed to serialize step return value`) and the rest are
44
44
  * `╰▶ hint:` / `╰▶ docs:` framed details. The full message belongs in the
45
- * body of the error block; the title should just be the headline so the
45
+ * body of the error block; the title should be the headline so the
46
46
  * card stays scannable.
47
47
  */
48
48
  function deriveTitle(message: string): string {
@@ -94,7 +94,7 @@ export function ErrorStackBlock({ value }: { value: StructuredErrorRecord }) {
94
94
  <AlertCircle className="h-4 w-4 shrink-0" />
95
95
  <p
96
96
  className="text-label-12 font-semibold m-0 truncate"
97
- // The full message or stack is in the body below; the header just
97
+ // The full message or stack is in the body below; the header
98
98
  // shows the first line, single-line, with overflow
99
99
  // ellipsised so a long title doesn't push the copy button or
100
100
  // wrap into the framed hint/docs lines.
@@ -156,7 +156,13 @@ function ZoneDateTimeRow({
156
156
  );
157
157
  }
158
158
 
159
- function RelativeTimeContextCardContent({ date }: { date: number }): ReactNode {
159
+ function RelativeTimeContextCardContent({
160
+ date,
161
+ prefix,
162
+ }: {
163
+ date: number;
164
+ prefix?: string;
165
+ }): ReactNode {
160
166
  const localTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
161
167
  const timeAgo = useTimeAgo(date);
162
168
 
@@ -164,7 +170,7 @@ function RelativeTimeContextCardContent({ date }: { date: number }): ReactNode {
164
170
  <div className="flex flex-col gap-3 min-w-[300px]">
165
171
  <div className="flex flex-col gap-3">
166
172
  <span className="tabular-nums text-label-13 text-gray-900">
167
- {timeAgo}
173
+ {prefix ? `${prefix} ${timeAgo}` : timeAgo}
168
174
  </span>
169
175
  </div>
170
176
  <div className="flex flex-col gap-2">
@@ -196,17 +202,19 @@ type RelativeTimeCardProps = Omit<
196
202
  date?: number | null;
197
203
  /** Custom content to render instead of the default relative time text. */
198
204
  children?: ReactNode;
205
+ prefix?: string;
199
206
  };
200
207
 
201
208
  /**
202
209
  * Relative time label that reveals a context card with detailed UTC and local
203
210
  * timestamps on hover. Renders a default short relative time label (e.g.
204
- * "3 days ago") when `children` is omitted; renders just the children without
211
+ * "3 days ago") when `children` is omitted; renders only the children without
205
212
  * the hover card when `date` is nullish.
206
213
  */
207
214
  export function RelativeTimeCard({
208
215
  date,
209
216
  children: _children,
217
+ prefix,
210
218
  ...props
211
219
  }: RelativeTimeCardProps): ReactNode {
212
220
  const children =
@@ -216,7 +224,7 @@ export function RelativeTimeCard({
216
224
 
217
225
  return (
218
226
  <ContextCardTrigger
219
- content={<RelativeTimeContextCardContent date={date} />}
227
+ content={<RelativeTimeContextCardContent date={date} prefix={prefix} />}
220
228
  {...props}
221
229
  >
222
230
  {children}
@@ -225,7 +233,7 @@ export function RelativeTimeCard({
225
233
  }
226
234
 
227
235
  // ---------------------------------------------------------------------------
228
- // TimestampTooltip convenience wrapper used across the observability UI
236
+ // TimestampTooltip: convenience wrapper used across the observability UI
229
237
  // ---------------------------------------------------------------------------
230
238
 
231
239
  /**
@@ -238,10 +246,12 @@ export function TimestampTooltip({
238
246
  date,
239
247
  children,
240
248
  side = 'top',
249
+ prefix,
241
250
  }: {
242
251
  date: number | Date | string | null | undefined;
243
252
  children: ReactNode;
244
253
  side?: ContextCardTriggerProps['side'];
254
+ prefix?: string;
245
255
  }): ReactNode {
246
256
  const hasProvider = useHasContextCardProvider();
247
257
 
@@ -255,7 +265,7 @@ export function TimestampTooltip({
255
265
  if (ts == null || Number.isNaN(ts)) return <>{children}</>;
256
266
 
257
267
  const card = (
258
- <RelativeTimeCard date={ts} side={side} asChild>
268
+ <RelativeTimeCard date={ts} side={side} prefix={prefix} asChild>
259
269
  {children}
260
270
  </RelativeTimeCard>
261
271
  );
@@ -72,6 +72,17 @@ export function getEventColor(
72
72
  };
73
73
  }
74
74
 
75
+ // Sealed positions - neutral gray: backend log filler, not run activity
76
+ if (eventType === 'noop') {
77
+ return {
78
+ color: 'var(--ds-gray-500)',
79
+ background: 'var(--ds-gray-100)',
80
+ border: 'var(--ds-gray-400)',
81
+ text: 'var(--ds-gray-900)',
82
+ secondary: 'var(--ds-gray-700)',
83
+ };
84
+ }
85
+
75
86
  // Default - Blue
76
87
  return {
77
88
  color: 'var(--ds-blue-600)',
@@ -1,6 +1,6 @@
1
1
  import type { Event, WorkflowRun } from '@workflow/world';
2
2
  import { describe, expect, it } from 'vitest';
3
- import { runToSpan } from './trace-span-construction';
3
+ import { runToSpan, stepToSpan } from './trace-span-construction';
4
4
  import { otelTimeToMs } from './trace-time-utils';
5
5
 
6
6
  const date = (ms: number) => new Date(ms);
@@ -83,3 +83,29 @@ describe('runToSpan', () => {
83
83
  expect(data).not.toHaveProperty('occurredAt');
84
84
  });
85
85
  });
86
+
87
+ describe('stepToSpan', () => {
88
+ it('uses the short name for a workflow-prefixed step name', () => {
89
+ const span = stepToSpan(
90
+ [
91
+ event({
92
+ eventType: 'step_created',
93
+ correlationId: 'step_1',
94
+ eventData: {
95
+ stepName: 'workflow//src/billing.ts//fetchInvoices',
96
+ input: null,
97
+ },
98
+ }),
99
+ event({
100
+ eventType: 'step_completed',
101
+ correlationId: 'step_1',
102
+ createdAt: date(2_000),
103
+ eventData: { result: null },
104
+ }),
105
+ ],
106
+ date(2_000)
107
+ );
108
+
109
+ expect(span?.name).toBe('fetchInvoices');
110
+ });
111
+ });
@@ -175,7 +175,7 @@ export const stepEventsToStepEntity = (
175
175
  return null;
176
176
  }
177
177
 
178
- // Walk events in order to derive status, attempt count, and timestamps.
178
+ // Walk events to derive status, attempt count, and timestamps.
179
179
  // Handles both step_retrying and consecutive step_started as retry signals.
180
180
  let status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' =
181
181
  'pending';
@@ -236,7 +236,9 @@ export function stepToSpan(stepEvents: Event[], maxEndTime: Date): Span | null {
236
236
  if (!step) {
237
237
  return null;
238
238
  }
239
- const parsedName = parseStepName(String(step.stepName));
239
+ const parsedName =
240
+ parseStepName(String(step.stepName)) ??
241
+ parseWorkflowName(String(step.stepName));
240
242
 
241
243
  const attributes = {
242
244
  resource: 'step' as const,
@@ -404,7 +406,7 @@ export function runToSpan(
404
406
  getEventTimestamp(terminalEvent) ?? run.completedAt ?? undefined;
405
407
  const endTime = completedAt ?? now;
406
408
 
407
- // Only embed identification fields not the full object with
409
+ // Only embed identification fields, not the full object with
408
410
  // input/output/error which may contain non-cloneable types. Lifecycle
409
411
  // timestamps are event-derived so detail rows align with the span timeline.
410
412
  const { input: _i, output: _o, error: _e, ...runIdentity } = run;
package/src/index.ts CHANGED
@@ -59,6 +59,10 @@ export {
59
59
  STREAM_REF_TYPE,
60
60
  truncateId,
61
61
  } from './lib/hydration';
62
+ export {
63
+ isSealedNoopEvent,
64
+ SEALED_EVENT_MESSAGE,
65
+ } from './lib/sealed-events';
62
66
  export type { DecodedStreamChunkSource } from './lib/stream-display';
63
67
  export type { ToastAdapter } from './lib/toast';
64
68
  export { ToastProvider, useToast } from './lib/toast';
@@ -162,7 +162,7 @@ function foldDuplicates(ordered: readonly Event[]): Set<string> {
162
162
  * `isCompleteHistory` must be false whenever the caller holds a subset of the
163
163
  * run's log: one page of a paginated list, or the result of a search. Which
164
164
  * occurrence of a class came first is a property of the whole log, so on a
165
- * subset the earlier event may simply be missing, and the fold would report
165
+ * subset the earlier event may be missing, and the fold would report
166
166
  * the surviving one. Nothing is classified in that case.
167
167
  *
168
168
  * Two other things make the answer unknowable and yield the same empty result: