@workflow/web-shared 5.0.0-beta.42 → 5.0.0-beta.44

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 (116) 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/middle-truncate/measurement-batch.d.ts +9 -0
  29. package/dist/components/trace-viewer/components/middle-truncate/measurement-batch.d.ts.map +1 -0
  30. package/dist/components/trace-viewer/components/middle-truncate/measurement-batch.js +29 -0
  31. package/dist/components/trace-viewer/components/middle-truncate/middle-truncate.d.ts.map +1 -1
  32. package/dist/components/trace-viewer/components/middle-truncate/middle-truncate.js +16 -8
  33. package/dist/components/trace-viewer/components/middle-truncate/use-middle-truncate.d.ts.map +1 -1
  34. package/dist/components/trace-viewer/components/middle-truncate/use-middle-truncate.js +83 -19
  35. package/dist/components/trace-viewer/components/minimap.js +4 -4
  36. package/dist/components/trace-viewer/components/span-markers.d.ts +8 -9
  37. package/dist/components/trace-viewer/components/span-markers.d.ts.map +1 -1
  38. package/dist/components/trace-viewer/components/span-markers.js +13 -10
  39. package/dist/components/trace-viewer/components/timeline.d.ts +1 -1
  40. package/dist/components/trace-viewer/components/timeline.js +4 -4
  41. package/dist/components/trace-viewer/components/use-row-window.d.ts +1 -1
  42. package/dist/components/trace-viewer/components/use-row-window.js +2 -2
  43. package/dist/components/trace-viewer/trace-viewer.js +3 -3
  44. package/dist/components/trace-viewer/utils.d.ts +3 -1
  45. package/dist/components/trace-viewer/utils.d.ts.map +1 -1
  46. package/dist/components/trace-viewer/utils.js +6 -5
  47. package/dist/components/ui/data-inspector.d.ts +7 -1
  48. package/dist/components/ui/data-inspector.d.ts.map +1 -1
  49. package/dist/components/ui/data-inspector.js +10 -5
  50. package/dist/components/ui/decrypt-button.d.ts +5 -1
  51. package/dist/components/ui/decrypt-button.d.ts.map +1 -1
  52. package/dist/components/ui/decrypt-button.js +4 -4
  53. package/dist/components/ui/duplicate-event-tooltip.d.ts +15 -5
  54. package/dist/components/ui/duplicate-event-tooltip.d.ts.map +1 -1
  55. package/dist/components/ui/duplicate-event-tooltip.js +18 -9
  56. package/dist/components/ui/error-stack-block.js +4 -4
  57. package/dist/components/ui/timestamp-tooltip.d.ts +5 -3
  58. package/dist/components/ui/timestamp-tooltip.d.ts.map +1 -1
  59. package/dist/components/ui/timestamp-tooltip.js +9 -9
  60. package/dist/components/workflow-traces/event-colors.d.ts.map +1 -1
  61. package/dist/components/workflow-traces/event-colors.js +11 -1
  62. package/dist/components/workflow-traces/trace-span-construction.d.ts.map +1 -1
  63. package/dist/components/workflow-traces/trace-span-construction.js +5 -4
  64. package/dist/index.d.ts +1 -0
  65. package/dist/index.d.ts.map +1 -1
  66. package/dist/index.js +2 -1
  67. package/dist/lib/duplicate-events.d.ts +1 -1
  68. package/dist/lib/duplicate-events.js +2 -2
  69. package/dist/lib/hydration.d.ts.map +1 -1
  70. package/dist/lib/hydration.js +8 -13
  71. package/dist/lib/sealed-events.d.ts +28 -0
  72. package/dist/lib/sealed-events.d.ts.map +1 -0
  73. package/dist/lib/sealed-events.js +30 -0
  74. package/dist/lib/trace-builder.d.ts.map +1 -1
  75. package/dist/lib/trace-builder.js +8 -5
  76. package/dist/lib/utils.d.ts +1 -1
  77. package/dist/lib/utils.js +2 -2
  78. package/dist/lib/zstd-browser-decoder.js +2 -2
  79. package/package.json +5 -5
  80. package/src/components/event-list-view.tsx +57 -22
  81. package/src/components/sidebar/attribute-panel.tsx +15 -7
  82. package/src/components/sidebar/copyable-data-block.tsx +8 -1
  83. package/src/components/sidebar/entity-detail-panel.tsx +18 -2
  84. package/src/components/sidebar/events-list.tsx +16 -6
  85. package/src/components/sidebar/resolve-hook-modal.tsx +2 -2
  86. package/src/components/sidebar/sidebar-data-context.tsx +2 -0
  87. package/src/components/stream-viewer.tsx +1 -1
  88. package/src/components/trace-viewer/components/detail-panel-width.ts +4 -4
  89. package/src/components/trace-viewer/components/detail-panel.tsx +3 -1
  90. package/src/components/trace-viewer/components/draggable-border.tsx +1 -1
  91. package/src/components/trace-viewer/components/middle-truncate/measurement-batch.ts +47 -0
  92. package/src/components/trace-viewer/components/middle-truncate/middle-truncate.tsx +15 -7
  93. package/src/components/trace-viewer/components/middle-truncate/use-middle-truncate.ts +132 -38
  94. package/src/components/trace-viewer/components/minimap.tsx +3 -3
  95. package/src/components/trace-viewer/components/span-markers.tsx +18 -13
  96. package/src/components/trace-viewer/components/timeline.tsx +4 -4
  97. package/src/components/trace-viewer/components/use-row-window.ts +1 -1
  98. package/src/components/trace-viewer/trace-viewer.tsx +2 -2
  99. package/src/components/trace-viewer/utils.test.ts +5 -3
  100. package/src/components/trace-viewer/utils.ts +12 -8
  101. package/src/components/ui/data-inspector.tsx +19 -3
  102. package/src/components/ui/decrypt-button.tsx +9 -2
  103. package/src/components/ui/duplicate-event-tooltip.tsx +32 -10
  104. package/src/components/ui/error-stack-block.tsx +3 -3
  105. package/src/components/ui/timestamp-tooltip.tsx +16 -6
  106. package/src/components/workflow-traces/event-colors.ts +11 -0
  107. package/src/components/workflow-traces/trace-span-construction.test.ts +27 -1
  108. package/src/components/workflow-traces/trace-span-construction.ts +5 -3
  109. package/src/index.ts +4 -0
  110. package/src/lib/duplicate-events.ts +1 -1
  111. package/src/lib/hydration.ts +7 -12
  112. package/src/lib/sealed-events.ts +32 -0
  113. package/src/lib/trace-builder.test.ts +29 -1
  114. package/src/lib/trace-builder.ts +10 -5
  115. package/src/lib/utils.ts +1 -1
  116. package/src/lib/zstd-browser-decoder.ts +1 -1
@@ -0,0 +1,47 @@
1
+ interface MeasurementTask<T> {
2
+ measure: (measurement: T) => void;
3
+ read: () => T | null;
4
+ }
5
+
6
+ type PendingMeasurement = () => (() => void) | null;
7
+
8
+ const pendingMeasurements = new Map<object, PendingMeasurement>();
9
+ let measurementFrame = 0;
10
+
11
+ function flushMeasurements(): void {
12
+ measurementFrame = 0;
13
+
14
+ const reads = [...pendingMeasurements.values()];
15
+ pendingMeasurements.clear();
16
+ const measures = reads.map((read) => read());
17
+
18
+ for (const measure of measures) {
19
+ measure?.();
20
+ }
21
+ }
22
+
23
+ function scheduleMeasurement<T>(
24
+ key: object,
25
+ { read, measure }: MeasurementTask<T>
26
+ ): void {
27
+ pendingMeasurements.set(key, () => {
28
+ const measurement = read();
29
+ return measurement === null ? null : () => measure(measurement);
30
+ });
31
+
32
+ if (measurementFrame === 0) {
33
+ measurementFrame = requestAnimationFrame(flushMeasurements);
34
+ }
35
+ }
36
+
37
+ function cancelMeasurement(key: object): void {
38
+ pendingMeasurements.delete(key);
39
+
40
+ if (pendingMeasurements.size === 0 && measurementFrame !== 0) {
41
+ cancelAnimationFrame(measurementFrame);
42
+ measurementFrame = 0;
43
+ }
44
+ }
45
+
46
+ export { cancelMeasurement, scheduleMeasurement };
47
+ export type { MeasurementTask };
@@ -28,13 +28,20 @@ function getRangeOffsets(
28
28
  return null;
29
29
  }
30
30
 
31
- const startRange = document.createRange();
32
- startRange.selectNodeContents(container);
33
- startRange.setEnd(range.startContainer, range.startOffset);
34
-
35
- const endRange = document.createRange();
36
- endRange.selectNodeContents(container);
37
- endRange.setEnd(range.endContainer, range.endOffset);
31
+ let startRange: Range;
32
+ let endRange: Range;
33
+
34
+ try {
35
+ startRange = document.createRange();
36
+ startRange.selectNodeContents(container);
37
+ startRange.setEnd(range.startContainer, range.startOffset);
38
+
39
+ endRange = document.createRange();
40
+ endRange.selectNodeContents(container);
41
+ endRange.setEnd(range.endContainer, range.endOffset);
42
+ } catch {
43
+ return null;
44
+ }
38
45
 
39
46
  return {
40
47
  end: endRange.toString().length,
@@ -80,6 +87,7 @@ function MiddleTruncate({
80
87
  let copyText: string | null = null;
81
88
 
82
89
  if (
90
+ selectionText.includes(ELLIPSIS) &&
83
91
  e.currentTarget.contains(range.startContainer) &&
84
92
  e.currentTarget.contains(range.endContainer) &&
85
93
  visibleEl
@@ -8,6 +8,7 @@ import {
8
8
  useRef,
9
9
  useState,
10
10
  } from 'react';
11
+ import { cancelMeasurement, scheduleMeasurement } from './measurement-batch';
11
12
  import { middleTruncate, toGraphemes } from './truncate';
12
13
 
13
14
  const useIsomorphicLayoutEffect =
@@ -22,6 +23,17 @@ interface MiddleTruncateState {
22
23
  suffixText: string;
23
24
  }
24
25
 
26
+ interface MiddleTruncateMeasurement {
27
+ availableWidth: number;
28
+ typography: string;
29
+ }
30
+
31
+ interface TextMeasurementCache {
32
+ typography: string;
33
+ value: string;
34
+ widths: Map<string, number>;
35
+ }
36
+
25
37
  function createFullState(
26
38
  value: string,
27
39
  graphemes: string[]
@@ -62,7 +74,15 @@ function useMiddleTruncate(value: string): {
62
74
  const ref = useRef<HTMLSpanElement | null>(null);
63
75
  const measureRef = useRef<HTMLSpanElement | null>(null);
64
76
  const [state, setState] = useState<MiddleTruncateState>(() => fullState);
65
- const rafRef = useRef<number>(0);
77
+ const measurementKeyRef = useRef<object>({});
78
+ const textMeasurementCacheRef = useRef<TextMeasurementCache>({
79
+ typography: '',
80
+ value,
81
+ widths: new Map(),
82
+ });
83
+ const lastMeasurementRef = useRef<
84
+ (MiddleTruncateMeasurement & { value: string }) | null
85
+ >(null);
66
86
 
67
87
  const updateState = useCallback((nextState: MiddleTruncateState) => {
68
88
  setState((currentState) => {
@@ -81,76 +101,150 @@ function useMiddleTruncate(value: string): {
81
101
  });
82
102
  }, []);
83
103
 
84
- const recalculate = useCallback(() => {
104
+ const readMeasurement = useCallback((): MiddleTruncateMeasurement | null => {
85
105
  const el = ref.current;
86
106
  const measureEl = measureRef.current;
87
- if (!el || !measureEl) return;
107
+ if (!el || !measureEl) return null;
88
108
 
89
- const available = el.clientWidth;
109
+ const availableWidth = el.clientWidth;
110
+ const style = getComputedStyle(measureEl);
90
111
 
91
- if (available <= 0) {
92
- updateState(fullState);
93
- return;
94
- }
95
-
96
- const measure = (text: string): number => {
97
- measureEl.textContent = text;
98
- return measureEl.scrollWidth;
112
+ return {
113
+ availableWidth,
114
+ typography: [
115
+ style.fontFamily,
116
+ style.fontFeatureSettings,
117
+ style.fontKerning,
118
+ style.fontSize,
119
+ style.fontStretch,
120
+ style.fontStyle,
121
+ style.fontVariationSettings,
122
+ style.fontWeight,
123
+ style.letterSpacing,
124
+ style.textTransform,
125
+ ].join('\0'),
99
126
  };
127
+ }, []);
100
128
 
101
- const fullWidth = measure(value);
129
+ const recalculate = useCallback(
130
+ ({ availableWidth, typography }: MiddleTruncateMeasurement) => {
131
+ const measureEl = measureRef.current;
132
+ if (!measureEl) return;
102
133
 
103
- if (fullWidth <= available) {
104
- updateState(fullState);
105
- return;
106
- }
134
+ const lastMeasurement = lastMeasurementRef.current;
135
+ if (
136
+ lastMeasurement?.availableWidth === availableWidth &&
137
+ lastMeasurement.typography === typography &&
138
+ lastMeasurement.value === value
139
+ ) {
140
+ return;
141
+ }
107
142
 
108
- const result = middleTruncate(graphemes, available, measure, fullWidth);
109
- updateState({
110
- displayText: result.text,
111
- isTruncated: result.truncated,
112
- prefixGraphemeCount: result.prefixGraphemeCount,
113
- prefixText: result.prefixText,
114
- suffixGraphemeCount: result.suffixGraphemeCount,
115
- suffixText: result.suffixText,
116
- });
117
- }, [fullState, graphemes, updateState, value]);
143
+ lastMeasurementRef.current = {
144
+ availableWidth,
145
+ typography,
146
+ value,
147
+ };
148
+
149
+ if (availableWidth <= 0) {
150
+ updateState(fullState);
151
+ return;
152
+ }
153
+
154
+ const textMeasurementCache = textMeasurementCacheRef.current;
155
+ if (
156
+ textMeasurementCache.typography !== typography ||
157
+ textMeasurementCache.value !== value
158
+ ) {
159
+ textMeasurementCache.typography = typography;
160
+ textMeasurementCache.value = value;
161
+ textMeasurementCache.widths.clear();
162
+ }
163
+
164
+ const measure = (text: string): number => {
165
+ const cachedWidth = textMeasurementCache.widths.get(text);
166
+ if (cachedWidth !== undefined) {
167
+ return cachedWidth;
168
+ }
169
+
170
+ measureEl.textContent = text;
171
+ const width = measureEl.scrollWidth;
172
+ textMeasurementCache.widths.set(text, width);
173
+ return width;
174
+ };
175
+
176
+ const fullWidth = measure(value);
177
+
178
+ if (fullWidth <= availableWidth) {
179
+ updateState(fullState);
180
+ return;
181
+ }
182
+
183
+ const result = middleTruncate(
184
+ graphemes,
185
+ availableWidth,
186
+ measure,
187
+ fullWidth
188
+ );
189
+ updateState({
190
+ displayText: result.text,
191
+ isTruncated: result.truncated,
192
+ prefixGraphemeCount: result.prefixGraphemeCount,
193
+ prefixText: result.prefixText,
194
+ suffixGraphemeCount: result.suffixGraphemeCount,
195
+ suffixText: result.suffixText,
196
+ });
197
+ },
198
+ [fullState, graphemes, updateState, value]
199
+ );
118
200
 
119
201
  // Measure on mount and when value changes - before paint
120
202
  useIsomorphicLayoutEffect(() => {
121
- recalculate();
122
- }, [recalculate]);
203
+ const measurement = readMeasurement();
204
+ if (measurement) {
205
+ recalculate(measurement);
206
+ }
207
+ }, [readMeasurement, recalculate]);
123
208
 
124
209
  // ResizeObserver + font loading for ongoing responsiveness
125
210
  useEffect(() => {
126
211
  const el = ref.current;
127
212
  if (!el) return;
128
213
 
129
- const debouncedRecalc = (): void => {
130
- cancelAnimationFrame(rafRef.current);
131
- rafRef.current = requestAnimationFrame(recalculate);
214
+ const measurementKey = measurementKeyRef.current;
215
+ const scheduleRecalculation = (): void => {
216
+ scheduleMeasurement(measurementKey, {
217
+ read: readMeasurement,
218
+ measure: recalculate,
219
+ });
132
220
  };
133
221
 
134
222
  const ro =
135
223
  typeof ResizeObserver !== 'undefined'
136
- ? new ResizeObserver(debouncedRecalc)
224
+ ? new ResizeObserver(scheduleRecalculation)
137
225
  : null;
138
226
  ro?.observe(el);
139
- window.addEventListener('resize', debouncedRecalc);
227
+ if (!ro) {
228
+ window.addEventListener('resize', scheduleRecalculation);
229
+ }
140
230
 
141
231
  const onFontsLoaded = (): void => {
142
- debouncedRecalc();
232
+ textMeasurementCacheRef.current.widths.clear();
233
+ lastMeasurementRef.current = null;
234
+ scheduleRecalculation();
143
235
  };
144
236
  const fontSet = 'fonts' in document ? document.fonts : null;
145
237
  fontSet?.addEventListener?.('loadingdone', onFontsLoaded);
146
238
 
147
239
  return () => {
148
240
  ro?.disconnect();
149
- window.removeEventListener('resize', debouncedRecalc);
150
- cancelAnimationFrame(rafRef.current);
241
+ if (!ro) {
242
+ window.removeEventListener('resize', scheduleRecalculation);
243
+ }
244
+ cancelMeasurement(measurementKey);
151
245
  fontSet?.removeEventListener?.('loadingdone', onFontsLoaded);
152
246
  };
153
- }, [recalculate]);
247
+ }, [readMeasurement, recalculate]);
154
248
 
155
249
  return {
156
250
  ref,
@@ -200,9 +200,9 @@ export const Minimap = memo(function Minimap({
200
200
 
201
201
  // Density canvas: one thin line per span across the full run. Sizing is
202
202
  // read live and redraws run straight from the ResizeObserver (which fires
203
- // before paint), so layout changes e.g. the detail panel opening never
203
+ // before paint), so layout changes (e.g. the detail panel opening) never
204
204
  // paint a frame with a stale, stretched bitmap.
205
- // biome-ignore lint/correctness/useExhaustiveDependencies: themeVersion is a redraw trigger the canvas re-resolves its token colors when the theme flips
205
+ // biome-ignore lint/correctness/useExhaustiveDependencies: themeVersion is a redraw trigger; the canvas re-resolves its token colors when the theme flips
206
206
  useLayoutEffect(() => {
207
207
  const canvas = canvasRef.current;
208
208
  const container = containerRef.current;
@@ -276,7 +276,7 @@ export const Minimap = memo(function Minimap({
276
276
  return { mode: 'resize-right' };
277
277
  }
278
278
  // Panning a full-extent window is a no-op, so let a drag on the brush
279
- // start a selection instead the natural first gesture on the map.
279
+ // start a selection instead, the natural first gesture on the map.
280
280
  const { start, end } = viewportRef.current;
281
281
  const isFullExtent =
282
282
  start - rootStartMs < rootDurationMs * 0.001 &&
@@ -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
  );