@workflow/web-shared 5.0.0-beta.26 → 5.0.0-beta.28

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 (94) hide show
  1. package/dist/components/new-trace-viewer/components/detail-panel-width.d.ts +34 -0
  2. package/dist/components/new-trace-viewer/components/detail-panel-width.d.ts.map +1 -0
  3. package/dist/components/new-trace-viewer/components/detail-panel-width.js +67 -0
  4. package/dist/components/new-trace-viewer/components/detail-panel.d.ts +14 -0
  5. package/dist/components/new-trace-viewer/components/detail-panel.d.ts.map +1 -0
  6. package/dist/components/new-trace-viewer/components/detail-panel.js +122 -0
  7. package/dist/components/new-trace-viewer/components/draggable-border.d.ts +31 -0
  8. package/dist/components/new-trace-viewer/components/draggable-border.d.ts.map +1 -0
  9. package/dist/components/new-trace-viewer/components/draggable-border.js +140 -0
  10. package/dist/components/new-trace-viewer/components/event-list.d.ts +1 -1
  11. package/dist/components/new-trace-viewer/components/event-list.d.ts.map +1 -1
  12. package/dist/components/new-trace-viewer/components/event-list.js +3 -5
  13. package/dist/components/new-trace-viewer/components/middle-truncate/truncate.d.ts +1 -1
  14. package/dist/components/new-trace-viewer/components/middle-truncate/truncate.d.ts.map +1 -1
  15. package/dist/components/new-trace-viewer/components/middle-truncate/truncate.js +2 -2
  16. package/dist/components/new-trace-viewer/components/minimap.d.ts +14 -0
  17. package/dist/components/new-trace-viewer/components/minimap.d.ts.map +1 -0
  18. package/dist/components/new-trace-viewer/components/minimap.js +337 -0
  19. package/dist/components/new-trace-viewer/components/pane-constants.d.ts +14 -0
  20. package/dist/components/new-trace-viewer/components/pane-constants.d.ts.map +1 -0
  21. package/dist/components/new-trace-viewer/components/pane-constants.js +16 -0
  22. package/dist/components/new-trace-viewer/components/split-pane.d.ts +1 -4
  23. package/dist/components/new-trace-viewer/components/split-pane.d.ts.map +1 -1
  24. package/dist/components/new-trace-viewer/components/split-pane.js +18 -16
  25. package/dist/components/new-trace-viewer/components/timeline.d.ts +2 -2
  26. package/dist/components/new-trace-viewer/components/timeline.d.ts.map +1 -1
  27. package/dist/components/new-trace-viewer/components/timeline.js +5 -7
  28. package/dist/components/new-trace-viewer/components/trace-viewer-skeleton.d.ts.map +1 -1
  29. package/dist/components/new-trace-viewer/components/trace-viewer-skeleton.js +10 -3
  30. package/dist/components/new-trace-viewer/components/use-element-width.d.ts +8 -0
  31. package/dist/components/new-trace-viewer/components/use-element-width.d.ts.map +1 -0
  32. package/dist/components/new-trace-viewer/components/use-element-width.js +25 -0
  33. package/dist/components/new-trace-viewer/context.d.ts +2 -0
  34. package/dist/components/new-trace-viewer/context.d.ts.map +1 -1
  35. package/dist/components/new-trace-viewer/context.js +3 -2
  36. package/dist/components/new-trace-viewer/trace-viewer.d.ts.map +1 -1
  37. package/dist/components/new-trace-viewer/trace-viewer.js +53 -181
  38. package/dist/components/new-trace-viewer/utils.d.ts +21 -0
  39. package/dist/components/new-trace-viewer/utils.d.ts.map +1 -1
  40. package/dist/components/new-trace-viewer/utils.js +44 -3
  41. package/dist/components/sidebar/attribute-panel.d.ts.map +1 -1
  42. package/dist/components/sidebar/attribute-panel.js +38 -35
  43. package/dist/components/sidebar/attributes-block.d.ts +18 -7
  44. package/dist/components/sidebar/attributes-block.d.ts.map +1 -1
  45. package/dist/components/sidebar/attributes-block.js +76 -18
  46. package/dist/components/sidebar/events-list.d.ts +1 -1
  47. package/dist/components/sidebar/events-list.d.ts.map +1 -1
  48. package/dist/components/sidebar/events-list.js +9 -10
  49. package/dist/components/trace-viewer/components/map.d.ts +2 -2
  50. package/dist/components/trace-viewer/components/map.d.ts.map +1 -1
  51. package/dist/components/trace-viewer/components/node.d.ts +7 -7
  52. package/dist/components/trace-viewer/components/node.d.ts.map +1 -1
  53. package/dist/components/ui/collapsible.d.ts +46 -0
  54. package/dist/components/ui/collapsible.d.ts.map +1 -0
  55. package/dist/components/ui/collapsible.js +66 -0
  56. package/dist/components/ui/data-inspector.js +2 -7
  57. package/dist/index.d.ts +2 -2
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js +3 -3
  60. package/dist/lib/hydration.d.ts +12 -2
  61. package/dist/lib/hydration.d.ts.map +1 -1
  62. package/dist/lib/hydration.js +41 -18
  63. package/dist/lib/utils.d.ts +8 -5
  64. package/dist/lib/utils.d.ts.map +1 -1
  65. package/dist/lib/utils.js +13 -16
  66. package/package.json +6 -6
  67. package/src/components/new-trace-viewer/components/detail-panel-width.test.ts +123 -0
  68. package/src/components/new-trace-viewer/components/detail-panel-width.ts +76 -0
  69. package/src/components/new-trace-viewer/components/detail-panel.tsx +272 -0
  70. package/src/components/new-trace-viewer/components/draggable-border.tsx +216 -0
  71. package/src/components/new-trace-viewer/components/event-list.tsx +3 -5
  72. package/src/components/new-trace-viewer/components/middle-truncate/truncate.ts +1 -1
  73. package/src/components/new-trace-viewer/components/minimap.tsx +559 -0
  74. package/src/components/new-trace-viewer/components/pane-constants.ts +19 -0
  75. package/src/components/new-trace-viewer/components/split-pane.tsx +44 -55
  76. package/src/components/new-trace-viewer/components/timeline.tsx +3 -5
  77. package/src/components/new-trace-viewer/components/trace-viewer-skeleton.tsx +21 -1
  78. package/src/components/new-trace-viewer/components/use-element-width.ts +29 -0
  79. package/src/components/new-trace-viewer/context.tsx +4 -1
  80. package/src/components/new-trace-viewer/trace-viewer.tsx +93 -312
  81. package/src/components/new-trace-viewer/utils.test.ts +65 -0
  82. package/src/components/new-trace-viewer/utils.ts +76 -2
  83. package/src/components/sidebar/attribute-panel.tsx +106 -105
  84. package/src/components/sidebar/attributes-block.tsx +170 -50
  85. package/src/components/sidebar/events-list.tsx +107 -92
  86. package/src/components/ui/collapsible.tsx +219 -0
  87. package/src/index.ts +8 -7
  88. package/src/lib/hydration.ts +48 -25
  89. package/src/lib/utils.test.ts +33 -0
  90. package/src/lib/utils.ts +12 -16
  91. package/dist/components/sidebar/detail-card.d.ts +0 -13
  92. package/dist/components/sidebar/detail-card.d.ts.map +0 -1
  93. package/dist/components/sidebar/detail-card.js +0 -36
  94. package/src/components/sidebar/detail-card.tsx +0 -143
@@ -1,7 +1,6 @@
1
1
  import { Circle } from 'lucide-react';
2
2
  import { useRef } from 'react';
3
3
  import { cn } from '../../../lib/cn';
4
- import type { Span } from '../types';
5
4
  import { formatDurationPrecise } from '../../trace-viewer/util/timing';
6
5
  import {
7
6
  SleepIcon,
@@ -10,7 +9,8 @@ import {
10
9
  WorkflowIcon,
11
10
  } from '../icons';
12
11
  import { isSpanDimmedBySearch, type SpanSearchResult } from '../search';
13
- import { getSpanDurationMs } from '../utils';
12
+ import type { Span } from '../types';
13
+ import { getSpanDurationMs, isSpanErrored } from '../utils';
14
14
  import { MiddleTruncate } from './middle-truncate/middle-truncate';
15
15
  import { ROW_HEIGHT_PX, useRowWindow } from './use-row-window';
16
16
 
@@ -53,9 +53,7 @@ const EventRow = ({
53
53
  onSelectSpan: (spanId: string) => void;
54
54
  }) => {
55
55
  const durationMs = getSpanDurationMs(span);
56
- const workflowStatus = (span.attributes.data as Record<string, unknown>)
57
- ?.status as string | undefined;
58
- const isErrored = span.status.code === 2 || workflowStatus === 'failed';
56
+ const isErrored = isSpanErrored(span);
59
57
  const { icon: Icon, className: tagClassName } = getEventStyle(
60
58
  span.resource,
61
59
  isErrored
@@ -1,4 +1,4 @@
1
- const ELLIPSIS = '...';
1
+ const ELLIPSIS = '';
2
2
  const MIN_START = 3;
3
3
  const MIN_END = 3;
4
4
  const MIN_KEPT = MIN_START + MIN_END;
@@ -0,0 +1,559 @@
1
+ 'use client';
2
+
3
+ import type { ReactNode } from 'react';
4
+ import {
5
+ memo,
6
+ useCallback,
7
+ useEffect,
8
+ useLayoutEffect,
9
+ useRef,
10
+ useState,
11
+ } from 'react';
12
+ import { cn } from '../../../lib/cn';
13
+ import {
14
+ formatDurationPrecise,
15
+ getHighResInMs,
16
+ } from '../../trace-viewer/util/timing';
17
+ import type { Span } from '../types';
18
+ import {
19
+ clampViewportToRoot,
20
+ isSpanErrored,
21
+ type RootBounds,
22
+ type ViewportRange,
23
+ wheelDeltaToPixels,
24
+ wheelZoomScaleFactor,
25
+ } from '../utils';
26
+ import { TIMELINE_PADDING_PX } from './timeline';
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Constants
30
+ // ---------------------------------------------------------------------------
31
+
32
+ const CANVAS_PAD_Y_PX = 6;
33
+ const MIN_LINE_HEIGHT_PX = 1.5;
34
+ const MAX_LINE_HEIGHT_PX = 3;
35
+ const MIN_LINE_WIDTH_PX = 2;
36
+ /** Pointer travel below this is treated as a click rather than a drag. */
37
+ const CLICK_DRAG_THRESHOLD_PX = 3;
38
+ const MIN_BRUSH_WIDTH_PX = 6;
39
+ /** Fraction of the current window that arrow keys pan by. */
40
+ const KEYBOARD_PAN_FRACTION = 0.1;
41
+
42
+ /**
43
+ * Line colors resolved from the design-system tokens on the container, so the
44
+ * canvas follows the active theme. Runs and steps carry the accent hues;
45
+ * passive spans (hooks, sleeps) stay gray like their event-list icons.
46
+ */
47
+ const RESOURCE_LINE_TOKENS: Record<string, string> = {
48
+ run: '--ds-blue-400',
49
+ step: '--ds-green-400',
50
+ hook: '--ds-gray-400',
51
+ sleep: '--ds-gray-400',
52
+ };
53
+ const DEFAULT_LINE_TOKEN = '--ds-gray-400';
54
+ const ERROR_LINE_TOKEN = '--ds-red-400';
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Density layer geometry
58
+ // ---------------------------------------------------------------------------
59
+
60
+ interface DensityLayout {
61
+ lineHeight: number;
62
+ /** [x, y, width] rects grouped by color token, one fill pass per color. */
63
+ rectsByToken: Map<string, [number, number, number][]>;
64
+ }
65
+
66
+ function computeDensityLayout(
67
+ spans: Span[],
68
+ rootStartMs: number,
69
+ rootDurationMs: number,
70
+ contentWidth: number,
71
+ contentHeight: number
72
+ ): DensityLayout {
73
+ const count = spans.length;
74
+ const lineHeight = Math.max(
75
+ MIN_LINE_HEIGHT_PX,
76
+ Math.min(MAX_LINE_HEIGHT_PX, contentHeight / count)
77
+ );
78
+ // Distribute rows across the strip; never spread a handful of spans further
79
+ // apart than one line + 1px gap.
80
+ const rowStep =
81
+ count > 1
82
+ ? Math.min((contentHeight - lineHeight) / (count - 1), lineHeight + 1)
83
+ : 0;
84
+
85
+ const rectsByToken = new Map<string, [number, number, number][]>();
86
+ spans.forEach((span, index) => {
87
+ const token = isSpanErrored(span)
88
+ ? ERROR_LINE_TOKEN
89
+ : (RESOURCE_LINE_TOKENS[span.resource] ?? DEFAULT_LINE_TOKEN);
90
+ const startMs = getHighResInMs(span.startTime);
91
+ const endMs = getHighResInMs(span.endTime);
92
+ const x =
93
+ TIMELINE_PADDING_PX +
94
+ ((startMs - rootStartMs) / rootDurationMs) * contentWidth;
95
+ const w = Math.max(
96
+ MIN_LINE_WIDTH_PX,
97
+ ((endMs - startMs) / rootDurationMs) * contentWidth
98
+ );
99
+ const y = CANVAS_PAD_Y_PX + index * rowStep;
100
+ const rects = rectsByToken.get(token) ?? [];
101
+ rects.push([x, y, w]);
102
+ rectsByToken.set(token, rects);
103
+ });
104
+
105
+ return { lineHeight, rectsByToken };
106
+ }
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // Hooks
110
+ // ---------------------------------------------------------------------------
111
+
112
+ /** Track the display's device pixel ratio (changes when moving monitors). */
113
+ function useDpr(): number {
114
+ const [dpr, setDpr] = useState(1);
115
+ useLayoutEffect(() => {
116
+ const onChange = (): void => setDpr(window.devicePixelRatio || 1);
117
+ const media = matchMedia(`(resolution: ${dpr}dppx)`);
118
+ media.addEventListener('change', onChange);
119
+ onChange();
120
+ return () => media.removeEventListener('change', onChange);
121
+ }, [dpr]);
122
+ return dpr;
123
+ }
124
+
125
+ /** Bump a counter when the theme (html class/data-theme) changes. */
126
+ function useThemeVersion(): number {
127
+ const [version, setVersion] = useState(0);
128
+ useEffect(() => {
129
+ const observer = new MutationObserver(() => setVersion((v) => v + 1));
130
+ observer.observe(document.documentElement, {
131
+ attributes: true,
132
+ attributeFilter: ['class', 'data-theme', 'style'],
133
+ });
134
+ return () => observer.disconnect();
135
+ }, []);
136
+ return version;
137
+ }
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // Minimap
141
+ // ---------------------------------------------------------------------------
142
+
143
+ type DragState =
144
+ | { mode: 'pan'; grabOffsetMs: number }
145
+ | { mode: 'resize-left' }
146
+ | { mode: 'resize-right' }
147
+ | { mode: 'select'; originX: number; originMs: number };
148
+
149
+ export const Minimap = memo(function Minimap({
150
+ spans,
151
+ root,
152
+ viewport,
153
+ minViewportMs,
154
+ onViewportChange,
155
+ onAnimateTo,
156
+ }: {
157
+ spans: Span[];
158
+ root: RootBounds;
159
+ viewport: ViewportRange;
160
+ minViewportMs: number;
161
+ /** Immediate viewport update (used while dragging). */
162
+ onViewportChange: (viewport: ViewportRange) => void;
163
+ /** Eased viewport transition (used for click-to-jump and selections). */
164
+ onAnimateTo: (viewport: ViewportRange) => void;
165
+ }): ReactNode {
166
+ const {
167
+ startTime: rootStartMs,
168
+ endTime: rootEndMs,
169
+ duration: rootDurationMs,
170
+ } = root;
171
+
172
+ const containerRef = useRef<HTMLDivElement>(null);
173
+ const canvasRef = useRef<HTMLCanvasElement>(null);
174
+ const dpr = useDpr();
175
+ const themeVersion = useThemeVersion();
176
+
177
+ // Latest viewport for pointer handlers, without re-binding them per frame.
178
+ const viewportRef = useRef<ViewportRange>(viewport);
179
+ viewportRef.current = viewport;
180
+
181
+ const [drag, setDrag] = useState<DragState | null>(null);
182
+ const [selection, setSelection] = useState<{ x0: number; x1: number } | null>(
183
+ null
184
+ );
185
+
186
+ const xToTime = useCallback(
187
+ (x: number, contentWidth: number): number => {
188
+ if (contentWidth <= 0) return rootStartMs;
189
+ const fraction = (x - TIMELINE_PADDING_PX) / contentWidth;
190
+ return Math.min(
191
+ Math.max(rootStartMs + fraction * rootDurationMs, rootStartMs),
192
+ rootEndMs
193
+ );
194
+ },
195
+ [rootStartMs, rootDurationMs, rootEndMs]
196
+ );
197
+
198
+ const clamp = useCallback(
199
+ (next: ViewportRange): ViewportRange =>
200
+ clampViewportToRoot(next, rootStartMs, rootEndMs, minViewportMs),
201
+ [rootStartMs, rootEndMs, minViewportMs]
202
+ );
203
+
204
+ // Density canvas: one thin line per span across the full run. Sizing is
205
+ // read live and redraws run straight from the ResizeObserver (which fires
206
+ // before paint), so layout changes — e.g. the detail panel opening — never
207
+ // paint a frame with a stale, stretched bitmap.
208
+ // biome-ignore lint/correctness/useExhaustiveDependencies: themeVersion is a redraw trigger — the canvas re-resolves its token colors when the theme flips
209
+ useLayoutEffect(() => {
210
+ const canvas = canvasRef.current;
211
+ const container = containerRef.current;
212
+ const ctx = canvas?.getContext('2d');
213
+ if (!canvas || !container || !ctx) return;
214
+
215
+ const draw = (): void => {
216
+ const width = canvas.clientWidth;
217
+ const height = canvas.clientHeight;
218
+ const contentWidth = width - TIMELINE_PADDING_PX * 2;
219
+ if (width <= 0 || height <= 0 || contentWidth <= 0) return;
220
+
221
+ canvas.width = Math.round(width * dpr);
222
+ canvas.height = Math.round(height * dpr);
223
+ ctx.scale(dpr, dpr);
224
+ ctx.clearRect(0, 0, width, height);
225
+ if (spans.length === 0 || rootDurationMs <= 0) return;
226
+
227
+ const { lineHeight, rectsByToken } = computeDensityLayout(
228
+ spans,
229
+ rootStartMs,
230
+ rootDurationMs,
231
+ contentWidth,
232
+ height - CANVAS_PAD_Y_PX * 2
233
+ );
234
+
235
+ const style = getComputedStyle(container);
236
+ const drawToken = (token: string): void => {
237
+ const rects = rectsByToken.get(token);
238
+ if (!rects) return;
239
+ ctx.fillStyle =
240
+ style.getPropertyValue(token).trim() || 'rgb(160,160,160)';
241
+ ctx.beginPath();
242
+ for (const [x, y, w] of rects) {
243
+ ctx.roundRect(x, y, w, lineHeight, lineHeight / 2);
244
+ }
245
+ ctx.fill();
246
+ };
247
+ // Errored spans are drawn last so red never disappears under neighbors.
248
+ for (const token of rectsByToken.keys()) {
249
+ if (token !== ERROR_LINE_TOKEN) drawToken(token);
250
+ }
251
+ drawToken(ERROR_LINE_TOKEN);
252
+ };
253
+
254
+ draw();
255
+ const observer = new ResizeObserver(draw);
256
+ observer.observe(container);
257
+ return () => observer.disconnect();
258
+ }, [spans, rootStartMs, rootDurationMs, dpr, themeVersion]);
259
+
260
+ // Content width is read from the live rect at event time, so pointer math
261
+ // stays exact while the strip is being resized mid-gesture.
262
+ const pointerFrame = useCallback(
263
+ (e: React.PointerEvent<HTMLDivElement>): { x: number; cw: number } => {
264
+ const rect = e.currentTarget.getBoundingClientRect();
265
+ return {
266
+ x: e.clientX - rect.left,
267
+ cw: rect.width - TIMELINE_PADDING_PX * 2,
268
+ };
269
+ },
270
+ []
271
+ );
272
+
273
+ const resolveDragState = useCallback(
274
+ (target: HTMLElement, x: number, cw: number): DragState => {
275
+ if (target.closest('[data-minimap-handle="left"]')) {
276
+ return { mode: 'resize-left' };
277
+ }
278
+ if (target.closest('[data-minimap-handle="right"]')) {
279
+ return { mode: 'resize-right' };
280
+ }
281
+ // Panning a full-extent window is a no-op, so let a drag on the brush
282
+ // start a selection instead — the natural first gesture on the map.
283
+ const { start, end } = viewportRef.current;
284
+ const isFullExtent =
285
+ start - rootStartMs < rootDurationMs * 0.001 &&
286
+ rootEndMs - end < rootDurationMs * 0.001;
287
+ if (target.closest('[data-minimap-brush]') && !isFullExtent) {
288
+ return { mode: 'pan', grabOffsetMs: xToTime(x, cw) - start };
289
+ }
290
+ return { mode: 'select', originX: x, originMs: xToTime(x, cw) };
291
+ },
292
+ [xToTime, rootStartMs, rootDurationMs, rootEndMs]
293
+ );
294
+
295
+ const handlePointerDown = useCallback(
296
+ (e: React.PointerEvent<HTMLDivElement>) => {
297
+ if (e.button !== 0) return;
298
+ const { x, cw } = pointerFrame(e);
299
+ if (cw <= 0) return;
300
+ e.currentTarget.setPointerCapture(e.pointerId);
301
+ setDrag(resolveDragState(e.target as HTMLElement, x, cw));
302
+ },
303
+ [pointerFrame, resolveDragState]
304
+ );
305
+
306
+ const handlePointerMove = useCallback(
307
+ (e: React.PointerEvent<HTMLDivElement>) => {
308
+ if (!drag) return;
309
+ const { x, cw } = pointerFrame(e);
310
+ const t = xToTime(x, cw);
311
+ const { start, end } = viewportRef.current;
312
+
313
+ if (drag.mode === 'pan') {
314
+ const duration = end - start;
315
+ onViewportChange(
316
+ clamp({
317
+ start: t - drag.grabOffsetMs,
318
+ end: t - drag.grabOffsetMs + duration,
319
+ })
320
+ );
321
+ } else if (drag.mode === 'resize-left') {
322
+ onViewportChange({ start: Math.min(t, end - minViewportMs), end });
323
+ } else if (drag.mode === 'resize-right') {
324
+ onViewportChange({ start, end: Math.max(t, start + minViewportMs) });
325
+ } else if (
326
+ selection ||
327
+ Math.abs(x - drag.originX) > CLICK_DRAG_THRESHOLD_PX
328
+ ) {
329
+ setSelection({
330
+ x0: Math.min(drag.originX, x),
331
+ x1: Math.max(drag.originX, x),
332
+ });
333
+ }
334
+ },
335
+ [
336
+ drag,
337
+ selection,
338
+ pointerFrame,
339
+ xToTime,
340
+ onViewportChange,
341
+ clamp,
342
+ minViewportMs,
343
+ ]
344
+ );
345
+
346
+ const endDrag = useCallback(
347
+ (e: React.PointerEvent<HTMLDivElement>) => {
348
+ if (!drag) return;
349
+ if (drag.mode === 'select') {
350
+ const { x, cw } = pointerFrame(e);
351
+ const t = xToTime(x, cw);
352
+ if (selection) {
353
+ onAnimateTo(
354
+ clamp({
355
+ start: Math.min(drag.originMs, t),
356
+ end: Math.max(drag.originMs, t),
357
+ })
358
+ );
359
+ } else {
360
+ // Plain click: keep the current zoom, center the window there.
361
+ const { start, end } = viewportRef.current;
362
+ const duration = end - start;
363
+ onAnimateTo(
364
+ clamp({ start: t - duration / 2, end: t + duration / 2 })
365
+ );
366
+ }
367
+ }
368
+ setSelection(null);
369
+ setDrag(null);
370
+ },
371
+ [drag, selection, pointerFrame, xToTime, onAnimateTo, clamp]
372
+ );
373
+
374
+ const handleLostPointerCapture = useCallback(() => {
375
+ setSelection(null);
376
+ setDrag(null);
377
+ }, []);
378
+
379
+ const handleDoubleClick = useCallback(() => {
380
+ onAnimateTo({ start: rootStartMs, end: rootEndMs });
381
+ }, [onAnimateTo, rootStartMs, rootEndMs]);
382
+
383
+ // Wheel: same gestures as the timeline, mapped through the full extent.
384
+ useEffect(() => {
385
+ const el = containerRef.current;
386
+ if (!el || rootDurationMs <= 0) return;
387
+
388
+ // The minimap's x axis is the full run, so zoom around the absolute time
389
+ // under the cursor rather than a viewport fraction.
390
+ const zoomAtCursor = (e: WheelEvent, cw: number, left: number): void => {
391
+ const scaleFactor = wheelZoomScaleFactor(e);
392
+ const { start, end } = viewportRef.current;
393
+ const duration = end - start;
394
+ const cursorFraction = Math.min(
395
+ Math.max((e.clientX - left - TIMELINE_PADDING_PX) / cw, 0),
396
+ 1
397
+ );
398
+ const cursorMs = rootStartMs + cursorFraction * rootDurationMs;
399
+ const anchor = Math.min(
400
+ Math.max(duration > 0 ? (cursorMs - start) / duration : 0.5, 0),
401
+ 1
402
+ );
403
+ const newDuration = duration * scaleFactor;
404
+ onViewportChange(
405
+ clamp({
406
+ start: cursorMs - anchor * newDuration,
407
+ end: cursorMs + (1 - anchor) * newDuration,
408
+ })
409
+ );
410
+ };
411
+
412
+ // Pan in map coordinates: one map pixel is one full-extent fraction.
413
+ const panByMapPixels = (e: WheelEvent, cw: number): void => {
414
+ const dx = wheelDeltaToPixels(e.deltaX, e.deltaMode);
415
+ const panMs = (dx / cw) * rootDurationMs;
416
+ const { start, end } = viewportRef.current;
417
+ onViewportChange(clamp({ start: start + panMs, end: end + panMs }));
418
+ };
419
+
420
+ const onWheel = (e: WheelEvent): void => {
421
+ const isZoomGesture = e.ctrlKey || e.metaKey;
422
+ const hasDeltaX = Math.abs(e.deltaX) > Math.abs(e.deltaY);
423
+ if (!isZoomGesture && !hasDeltaX) return;
424
+ e.preventDefault();
425
+
426
+ const rect = el.getBoundingClientRect();
427
+ const cw = rect.width - TIMELINE_PADDING_PX * 2;
428
+ if (cw <= 0) return;
429
+ if (isZoomGesture) zoomAtCursor(e, cw, rect.left);
430
+ else panByMapPixels(e, cw);
431
+ };
432
+
433
+ el.addEventListener('wheel', onWheel, { passive: false });
434
+ return () => el.removeEventListener('wheel', onWheel);
435
+ }, [rootStartMs, rootDurationMs, onViewportChange, clamp]);
436
+
437
+ // Arrow keys pan the focused brush; Home/End jump to the ends.
438
+ const handleKeyDown = useCallback(
439
+ (e: React.KeyboardEvent<HTMLDivElement>) => {
440
+ const { start, end } = viewportRef.current;
441
+ const duration = end - start;
442
+ const pan = duration * KEYBOARD_PAN_FRACTION;
443
+ if (e.key === 'ArrowLeft') {
444
+ onViewportChange(clamp({ start: start - pan, end: end - pan }));
445
+ } else if (e.key === 'ArrowRight') {
446
+ onViewportChange(clamp({ start: start + pan, end: end + pan }));
447
+ } else if (e.key === 'Home') {
448
+ onViewportChange(
449
+ clamp({ start: rootStartMs, end: rootStartMs + duration })
450
+ );
451
+ } else if (e.key === 'End') {
452
+ onViewportChange(
453
+ clamp({ start: rootEndMs - duration, end: rootEndMs })
454
+ );
455
+ } else {
456
+ return;
457
+ }
458
+ e.preventDefault();
459
+ },
460
+ [onViewportChange, clamp, rootStartMs, rootEndMs]
461
+ );
462
+
463
+ // Brush geometry in CSS calc() from time fractions, so the thumb reflows in
464
+ // the same frame as any container resize instead of lagging a state update.
465
+ const startFraction = (viewport.start - rootStartMs) / rootDurationMs;
466
+ const endFraction = (viewport.end - rootStartMs) / rootDurationMs;
467
+ const trackWidth = `(100% - ${TIMELINE_PADDING_PX * 2}px)`;
468
+ const brushStyle = {
469
+ left: `calc(${TIMELINE_PADDING_PX}px + ${startFraction} * ${trackWidth})`,
470
+ width: `max(${MIN_BRUSH_WIDTH_PX}px, calc(${
471
+ endFraction - startFraction
472
+ } * ${trackWidth}))`,
473
+ };
474
+
475
+ const viewDuration = viewport.end - viewport.start;
476
+ const scrollableMs = Math.max(rootDurationMs - viewDuration, 0);
477
+ const scrollPercent =
478
+ scrollableMs > 0
479
+ ? Math.round(((viewport.start - rootStartMs) / scrollableMs) * 100)
480
+ : 0;
481
+
482
+ return (
483
+ // biome-ignore lint/a11y/noStaticElementInteractions: pointer gestures are a mouse-only enhancement; the brush inside is keyboard-operable
484
+ <div
485
+ ref={containerRef}
486
+ className={cn(
487
+ 'relative h-10 min-h-10 w-full border-b border-gray-alpha-400 bg-background-100 select-none overflow-hidden touch-none',
488
+ drag?.mode === 'select' && 'cursor-crosshair'
489
+ )}
490
+ onPointerDown={handlePointerDown}
491
+ onPointerMove={handlePointerMove}
492
+ onPointerUp={endDrag}
493
+ onPointerCancel={endDrag}
494
+ onLostPointerCapture={handleLostPointerCapture}
495
+ onDoubleClick={handleDoubleClick}
496
+ >
497
+ {/* Multiply on light themes / screen on dark, so overlapping span lines
498
+ deepen instead of occluding and the glass thumb reads through them. */}
499
+ <canvas
500
+ ref={canvasRef}
501
+ aria-hidden
502
+ className="pointer-events-none absolute inset-0 h-full w-full mix-blend-multiply [.dark-theme_&]:mix-blend-screen [.dark_&]:mix-blend-screen [[data-theme=dark]_&]:mix-blend-screen"
503
+ />
504
+ {selection ? (
505
+ <div
506
+ aria-hidden
507
+ className="pointer-events-none absolute inset-y-0 border border-blue-500 bg-blue-300 opacity-40"
508
+ style={{ left: selection.x0, width: selection.x1 - selection.x0 }}
509
+ />
510
+ ) : null}
511
+ <div
512
+ data-minimap-brush
513
+ role="scrollbar"
514
+ aria-controls="trace-timeline"
515
+ aria-orientation="horizontal"
516
+ aria-valuemin={0}
517
+ aria-valuemax={100}
518
+ aria-valuenow={scrollPercent}
519
+ aria-valuetext={`Viewing ${formatDurationPrecise(
520
+ viewport.start - rootStartMs
521
+ )} to ${formatDurationPrecise(
522
+ viewport.end - rootStartMs
523
+ )} of ${formatDurationPrecise(rootDurationMs)}`}
524
+ aria-label="Timeline overview"
525
+ tabIndex={0}
526
+ onKeyDown={handleKeyDown}
527
+ className={cn(
528
+ // Glass thumb, matching the legacy trace viewer's map window.
529
+ 'group/minimap-brush absolute top-0.5 bottom-0.5 rounded-[3px] border border-gray-alpha-400 bg-gray-alpha-200 shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[var(--ds-focus-color)]',
530
+ drag?.mode === 'pan' ? 'cursor-grabbing' : 'cursor-grab'
531
+ )}
532
+ style={brushStyle}
533
+ >
534
+ <div
535
+ data-minimap-handle="left"
536
+ className="absolute inset-y-0 -left-1 flex w-2.5 cursor-ew-resize items-center justify-center"
537
+ >
538
+ <span
539
+ className={cn(
540
+ 'h-3.5 w-[3px] rounded-full bg-gray-600 opacity-0 transition-opacity group-hover/minimap-brush:opacity-100 group-focus-visible/minimap-brush:opacity-100',
541
+ drag && 'opacity-100'
542
+ )}
543
+ />
544
+ </div>
545
+ <div
546
+ data-minimap-handle="right"
547
+ className="absolute inset-y-0 -right-1 flex w-2.5 cursor-ew-resize items-center justify-center"
548
+ >
549
+ <span
550
+ className={cn(
551
+ 'h-3.5 w-[3px] rounded-full bg-gray-600 opacity-0 transition-opacity group-hover/minimap-brush:opacity-100 group-focus-visible/minimap-brush:opacity-100',
552
+ drag && 'opacity-100'
553
+ )}
554
+ />
555
+ </div>
556
+ </div>
557
+ </div>
558
+ );
559
+ });
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Layout constants shared by SplitPane and TraceViewerSkeleton. Kept in a
3
+ * module without 'use client' so the skeleton's import chain stays
4
+ * server-component-safe for external consumers.
5
+ */
6
+
7
+ /** Width (px) of the draggable gutter column between the list and timeline. */
8
+ export const GUTTER_PX = 1;
9
+
10
+ /** Minimum width (px) of either SplitPane pane. */
11
+ export const MIN_PX = 50;
12
+
13
+ /** Default width (px) of the start (event list) pane. */
14
+ export const DEFAULT_START_PX = 340;
15
+
16
+ /** Grid column template for the [list | gutter | timeline] split. */
17
+ export function paneColTemplate(startPx: number): string {
18
+ return `${startPx}px ${GUTTER_PX}px minmax(${MIN_PX}px, 1fr)`;
19
+ }