@workflow/web-shared 5.0.0-beta.27 → 5.0.0-beta.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/components/event-list-view.d.ts.map +1 -1
  2. package/dist/components/event-list-view.js +13 -1
  3. package/dist/components/new-trace-viewer/components/detail-panel.d.ts.map +1 -1
  4. package/dist/components/new-trace-viewer/components/detail-panel.js +2 -2
  5. package/dist/components/new-trace-viewer/components/event-list.d.ts +1 -1
  6. package/dist/components/new-trace-viewer/components/event-list.d.ts.map +1 -1
  7. package/dist/components/new-trace-viewer/components/event-list.js +4 -6
  8. package/dist/components/new-trace-viewer/components/minimap.d.ts +14 -0
  9. package/dist/components/new-trace-viewer/components/minimap.d.ts.map +1 -0
  10. package/dist/components/new-trace-viewer/components/minimap.js +337 -0
  11. package/dist/components/new-trace-viewer/components/split-pane.d.ts +0 -2
  12. package/dist/components/new-trace-viewer/components/split-pane.d.ts.map +1 -1
  13. package/dist/components/new-trace-viewer/components/split-pane.js +9 -75
  14. package/dist/components/new-trace-viewer/components/timeline.d.ts.map +1 -1
  15. package/dist/components/new-trace-viewer/components/timeline.js +5 -7
  16. package/dist/components/new-trace-viewer/components/trace-viewer-skeleton.d.ts.map +1 -1
  17. package/dist/components/new-trace-viewer/components/trace-viewer-skeleton.js +6 -2
  18. package/dist/components/new-trace-viewer/trace-viewer.d.ts.map +1 -1
  19. package/dist/components/new-trace-viewer/trace-viewer.js +48 -96
  20. package/dist/components/new-trace-viewer/utils.d.ts +21 -0
  21. package/dist/components/new-trace-viewer/utils.d.ts.map +1 -1
  22. package/dist/components/new-trace-viewer/utils.js +44 -3
  23. package/dist/components/sidebar/attribute-panel.d.ts +4 -1
  24. package/dist/components/sidebar/attribute-panel.d.ts.map +1 -1
  25. package/dist/components/sidebar/attribute-panel.js +32 -24
  26. package/dist/components/sidebar/attributes-block.d.ts +1 -0
  27. package/dist/components/sidebar/attributes-block.d.ts.map +1 -1
  28. package/dist/components/sidebar/attributes-block.js +6 -5
  29. package/dist/components/sidebar/entity-detail-panel.d.ts +5 -1
  30. package/dist/components/sidebar/entity-detail-panel.d.ts.map +1 -1
  31. package/dist/components/sidebar/entity-detail-panel.js +18 -3
  32. package/dist/components/sidebar/sidebar-data-context.d.ts +4 -0
  33. package/dist/components/sidebar/sidebar-data-context.d.ts.map +1 -1
  34. package/dist/components/sidebar/sidebar-data-context.js +1 -1
  35. package/dist/lib/utils.d.ts +8 -5
  36. package/dist/lib/utils.d.ts.map +1 -1
  37. package/dist/lib/utils.js +13 -16
  38. package/package.json +5 -5
  39. package/src/components/event-list-view.tsx +21 -0
  40. package/src/components/new-trace-viewer/components/detail-panel.tsx +1 -0
  41. package/src/components/new-trace-viewer/components/event-list.tsx +4 -6
  42. package/src/components/new-trace-viewer/components/minimap.tsx +559 -0
  43. package/src/components/new-trace-viewer/components/split-pane.tsx +39 -95
  44. package/src/components/new-trace-viewer/components/timeline.tsx +3 -5
  45. package/src/components/new-trace-viewer/components/trace-viewer-skeleton.tsx +17 -0
  46. package/src/components/new-trace-viewer/trace-viewer.tsx +79 -117
  47. package/src/components/new-trace-viewer/utils.test.ts +65 -0
  48. package/src/components/new-trace-viewer/utils.ts +76 -2
  49. package/src/components/sidebar/attribute-panel.tsx +77 -22
  50. package/src/components/sidebar/attributes-block.tsx +43 -10
  51. package/src/components/sidebar/entity-detail-panel.tsx +22 -0
  52. package/src/components/sidebar/sidebar-data-context.tsx +4 -0
  53. package/src/lib/utils.test.ts +33 -0
  54. package/src/lib/utils.ts +12 -16
@@ -3,14 +3,13 @@
3
3
  import {
4
4
  Children,
5
5
  type ReactNode,
6
- type PointerEvent as ReactPointerEvent,
7
6
  type RefObject,
8
7
  useCallback,
9
- useEffect,
10
8
  useRef,
11
9
  useState,
12
10
  } from 'react';
13
11
  import { cn } from '../../../lib/cn';
12
+ import { DraggableBorder } from './draggable-border';
14
13
  import {
15
14
  DEFAULT_START_PX,
16
15
  GUTTER_PX,
@@ -19,12 +18,12 @@ import {
19
18
  } from './pane-constants';
20
19
  import { useElementWidth } from './use-element-width';
21
20
 
21
+ const SPLIT_PANE_START_ID = 'trace-split-start';
22
+
22
23
  export interface SplitPaneProps {
23
24
  children: ReactNode;
24
25
  className?: string;
25
- /** Fixed (non-scrolling) header rendered above the start pane. */
26
26
  startHeader?: ReactNode;
27
- /** Fixed (non-scrolling) header rendered above the end pane. */
28
27
  endHeader?: ReactNode;
29
28
  scrollContainerRef?: RefObject<HTMLDivElement | null>;
30
29
  }
@@ -42,25 +41,11 @@ export function SplitPane({
42
41
  }
43
42
  const [start, end] = parts;
44
43
 
45
- // `startPx` is the user's preferred width; the rendered width is derived by
46
- // clamping against the live container width (same model as the detail
47
- // panel), so shrinking the container compresses the pane without destroying
48
- // the preference, and re-growing restores it.
49
44
  const [startPx, setStartPx] = useState(DEFAULT_START_PX);
50
- const [isDragging, setIsDragging] = useState(false);
51
45
  const containerRef = useRef<HTMLDivElement>(null);
52
- const gutterRef = useRef<HTMLDivElement>(null);
53
- const rafRef = useRef<number | null>(null);
54
- const pendingPx = useRef(DEFAULT_START_PX);
55
- const pointerIdRef = useRef<number | null>(null);
46
+ const startRef = useRef<HTMLDivElement>(null);
56
47
  const containerWidth = useElementWidth(containerRef);
57
48
 
58
- useEffect(() => {
59
- return () => {
60
- if (rafRef.current) cancelAnimationFrame(rafRef.current);
61
- };
62
- }, []);
63
-
64
49
  const setContainerRef = useCallback(
65
50
  (node: HTMLDivElement | null) => {
66
51
  containerRef.current = node;
@@ -78,65 +63,15 @@ export function SplitPane({
78
63
  return Math.min(maxPx, Math.max(MIN_PX, px));
79
64
  }, []);
80
65
 
81
- useEffect(() => {
82
- if (!isDragging) return;
83
-
84
- const onPointerMove = (e: globalThis.PointerEvent) => {
85
- if (e.pointerId !== pointerIdRef.current) return;
86
- const container = containerRef.current;
87
- if (!container) return;
88
- const rect = container.getBoundingClientRect();
89
- pendingPx.current = clampPx(e.clientX - rect.left);
90
- if (!rafRef.current) {
91
- rafRef.current = requestAnimationFrame(() => {
92
- rafRef.current = null;
93
- setStartPx(pendingPx.current);
94
- });
95
- }
96
- };
97
-
98
- const onPointerUp = (e: globalThis.PointerEvent) => {
99
- if (e.pointerId !== pointerIdRef.current) return;
100
- const gutter = gutterRef.current;
101
- if (gutter?.hasPointerCapture(e.pointerId)) {
102
- gutter.releasePointerCapture(e.pointerId);
103
- }
104
- if (rafRef.current) {
105
- cancelAnimationFrame(rafRef.current);
106
- rafRef.current = null;
107
- }
108
- pointerIdRef.current = null;
109
- setIsDragging(false);
110
- };
111
-
112
- document.addEventListener('pointermove', onPointerMove);
113
- document.addEventListener('pointerup', onPointerUp);
114
- document.addEventListener('pointercancel', onPointerUp);
115
-
116
- return () => {
117
- document.removeEventListener('pointermove', onPointerMove);
118
- document.removeEventListener('pointerup', onPointerUp);
119
- document.removeEventListener('pointercancel', onPointerUp);
120
- };
121
- }, [isDragging, clampPx]);
122
-
123
- const handlePointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
124
- e.currentTarget.setPointerCapture(e.pointerId);
125
- pointerIdRef.current = e.pointerId;
126
- setIsDragging(true);
127
- };
128
-
129
- const handleLostPointerCapture = () => {
130
- pointerIdRef.current = null;
131
- setIsDragging(false);
132
- if (rafRef.current) {
133
- cancelAnimationFrame(rafRef.current);
134
- rafRef.current = null;
135
- }
136
- };
66
+ const handleWidthChange = useCallback(
67
+ (next: number) => setStartPx(clampPx(next)),
68
+ [clampPx]
69
+ );
70
+ const handleReset = useCallback(
71
+ () => setStartPx(clampPx(DEFAULT_START_PX)),
72
+ [clampPx]
73
+ );
137
74
 
138
- // Floor applied last so the start column can never collapse below MIN_PX
139
- // (which would produce an invalid negative grid track on tiny containers).
140
75
  const effectiveStartPx =
141
76
  containerWidth > 0
142
77
  ? Math.max(MIN_PX, Math.min(containerWidth - MIN_PX - GUTTER_PX, startPx))
@@ -144,18 +79,9 @@ export function SplitPane({
144
79
 
145
80
  const colTemplate = paneColTemplate(effectiveStartPx);
146
81
 
147
- const gutter = (
148
- <div
149
- ref={gutterRef}
150
- className="relative z-20 isolate flex shrink-0 cursor-col-resize justify-center"
151
- onPointerDown={handlePointerDown}
152
- onLostPointerCapture={handleLostPointerCapture}
153
- >
154
- <span
155
- className="pointer-events-none relative z-10 h-full w-px shrink-0 bg-gray-alpha-400"
156
- aria-hidden
157
- />
158
- </div>
82
+ const maxStartPx = Math.max(
83
+ MIN_PX,
84
+ containerWidth > 0 ? containerWidth - MIN_PX - GUTTER_PX : startPx
159
85
  );
160
86
 
161
87
  return (
@@ -172,14 +98,32 @@ export function SplitPane({
172
98
  </div>
173
99
  <div
174
100
  ref={setContainerRef}
175
- className={cn(
176
- 'grid flex-1 min-h-0 overflow-x-hidden overflow-y-auto',
177
- isDragging && 'select-none'
178
- )}
101
+ className="grid flex-1 min-h-0 overflow-x-hidden overflow-y-auto"
179
102
  style={{ gridTemplateColumns: colTemplate }}
180
103
  >
181
- {start}
182
- {gutter}
104
+ <div ref={startRef} id={SPLIT_PANE_START_ID} className="min-w-0">
105
+ {start}
106
+ </div>
107
+ <div className="relative z-20 isolate flex shrink-0 justify-center">
108
+ <span
109
+ className="pointer-events-none relative z-10 h-full w-px shrink-0 bg-gray-alpha-400"
110
+ aria-hidden
111
+ />
112
+ <DraggableBorder
113
+ element={startRef}
114
+ position="right"
115
+ onWidthChange={handleWidthChange}
116
+ onReset={handleReset}
117
+ aria-label="Resize event list"
118
+ aria-controls={SPLIT_PANE_START_ID}
119
+ aria-valuemin={MIN_PX}
120
+ aria-valuemax={maxStartPx}
121
+ aria-valuenow={Math.min(
122
+ Math.max(Math.round(effectiveStartPx), MIN_PX),
123
+ maxStartPx
124
+ )}
125
+ />
126
+ </div>
183
127
  {end}
184
128
  </div>
185
129
  </div>
@@ -13,7 +13,6 @@ import {
13
13
  } from 'react';
14
14
  import { cn } from '../../../lib/cn';
15
15
  import {
16
- formatDuration,
17
16
  formatDurationPrecise,
18
17
  getHighResInMs,
19
18
  } from '../../trace-viewer/util/timing';
@@ -32,6 +31,7 @@ import {
32
31
  computeSpanSegments,
33
32
  getResourceColor,
34
33
  getSpanDurationMs,
34
+ isSpanErrored,
35
35
  } from '../utils';
36
36
  import {
37
37
  cullCollidingMarkers,
@@ -451,9 +451,7 @@ const TimelineBar = memo(function TimelineBar({
451
451
  const hasMarkers =
452
452
  markers.length > 0 || offscreen.left !== null || offscreen.right !== null;
453
453
 
454
- const workflowStatus = (span.attributes.data as Record<string, unknown>)
455
- ?.status as string | undefined;
456
- const isErrored = span.status.code === 2 || workflowStatus === 'failed';
454
+ const isErrored = isSpanErrored(span);
457
455
  const colors = getResourceColor(span.resource);
458
456
  const fallbackBg = isErrored
459
457
  ? (colors.errorBg ?? 'var(--ds-red-200)')
@@ -733,7 +731,7 @@ export function Timeline({
733
731
  key={gap.rowIndex}
734
732
  leftFrac={gap.leftFrac}
735
733
  rightFrac={gap.rightFrac}
736
- label={formatDuration(gap.gapMs, true)}
734
+ label={formatDurationPrecise(gap.gapMs)}
737
735
  rowIndex={gap.rowIndex}
738
736
  />
739
737
  ))}
@@ -33,6 +33,23 @@ export function TraceViewerSkeleton() {
33
33
  >
34
34
  <span className="sr-only">Loading trace…</span>
35
35
 
36
+ {/* Minimap strip: thin density lines tracing the same shape as the bars */}
37
+ <div className="relative h-10 min-h-10 shrink-0 border-b border-gray-alpha-400">
38
+ <div className="absolute inset-x-4 top-[6px]">
39
+ {ROWS.map((row, index) => (
40
+ <Skeleton
41
+ key={row.id}
42
+ className="absolute h-[3px] rounded-full"
43
+ style={{
44
+ left: `${row.off}%`,
45
+ width: `${row.bar}%`,
46
+ top: index * 3.5,
47
+ }}
48
+ />
49
+ ))}
50
+ </div>
51
+ </div>
52
+
36
53
  {/* Header row: search header | divider | timeline header */}
37
54
  <div
38
55
  className="shrink-0 grid"
@@ -12,13 +12,17 @@ import {
12
12
  } from 'react';
13
13
  import { useLoadMoreOnScroll } from '../../hooks/use-load-more-on-scroll';
14
14
  import { useReducedMotion } from '../../hooks/use-reduced-motion';
15
- import { formatDuration, getHighResInMs } from '../trace-viewer/util/timing';
15
+ import {
16
+ formatDurationPrecise,
17
+ getHighResInMs,
18
+ } from '../trace-viewer/util/timing';
16
19
  import { IconButton } from '../ui/icon-button';
17
20
  import { Kbd } from '../ui/kbd';
18
21
  import { Spinner } from '../ui/spinner';
19
22
  import { TooltipProvider } from '../ui/tooltip';
20
23
  import { TraceDetailPanel } from './components/detail-panel';
21
24
  import EventList from './components/event-list';
25
+ import { Minimap } from './components/minimap';
22
26
  import { SplitPane } from './components/split-pane';
23
27
  import {
24
28
  TIMELINE_PADDING_PX,
@@ -30,7 +34,14 @@ import { ROW_HEIGHT_PX, scrollRowIntoView } from './components/use-row-window';
30
34
  import { ActiveSpanProvider, useActiveSpan } from './context';
31
35
  import { searchSpans } from './search';
32
36
  import type { TraceWithMeta } from './types';
33
- import { computeRootBounds, computeTimeMarkers } from './utils';
37
+ import {
38
+ clampViewportToRoot,
39
+ computeRootBounds,
40
+ computeTimeMarkers,
41
+ type ViewportRange,
42
+ wheelDeltaToPixels,
43
+ wheelZoomScaleFactor,
44
+ } from './utils';
34
45
 
35
46
  interface NewTraceViewerProps {
36
47
  trace: TraceWithMeta;
@@ -43,17 +54,12 @@ const MIN_VIEWPORT_MS = 0.001;
43
54
 
44
55
  const ZOOM_DEBOUNCE_MS = 150;
45
56
 
46
- interface Viewport {
47
- start: number;
48
- end: number;
49
- }
50
-
51
- function useAnimatedViewport(initial: Viewport) {
52
- const [viewport, setViewportState] = useState<Viewport>(initial);
57
+ function useAnimatedViewport(initial: ViewportRange) {
58
+ const [viewport, setViewportState] = useState<ViewportRange>(initial);
53
59
  const animRef = useRef<{
54
60
  raf: number;
55
- from: Viewport;
56
- to: Viewport;
61
+ from: ViewportRange;
62
+ to: ViewportRange;
57
63
  start: number;
58
64
  } | null>(null);
59
65
  const currentRef = useRef(initial);
@@ -68,7 +74,7 @@ function useAnimatedViewport(initial: Viewport) {
68
74
  }, []);
69
75
 
70
76
  const animateTo = useCallback(
71
- (target: Viewport) => {
77
+ (target: ViewportRange) => {
72
78
  cancel();
73
79
 
74
80
  if (reducedMotion) {
@@ -81,8 +87,8 @@ function useAnimatedViewport(initial: Viewport) {
81
87
  const anim = { raf: 0, from, to: target, start: performance.now() };
82
88
 
83
89
  const tick = () => {
84
- const t = Math.min((performance.now() - anim.start) / 150, 1);
85
- const e = 1 - (1 - t) * (1 - t);
90
+ const t = Math.min((performance.now() - anim.start) / 240, 1);
91
+ const e = t < 0.5 ? 8 * t * t * t * t : 1 - (-2 * t + 2) ** 4 / 2;
86
92
  setViewportState({
87
93
  start: anim.from.start + (anim.to.start - anim.from.start) * e,
88
94
  end: anim.from.end + (anim.to.end - anim.from.end) * e,
@@ -98,7 +104,7 @@ function useAnimatedViewport(initial: Viewport) {
98
104
  );
99
105
 
100
106
  const setViewport = useCallback(
101
- (update: Viewport | ((prev: Viewport) => Viewport)) => {
107
+ (update: ViewportRange | ((prev: ViewportRange) => ViewportRange)) => {
102
108
  cancel();
103
109
  if (typeof update === 'function') {
104
110
  setViewportState((prev) => {
@@ -180,7 +186,7 @@ function NewTraceViewerContent({
180
186
  end: root.startTime + root.duration,
181
187
  });
182
188
 
183
- const prevRootRef = useRef<Viewport>({
189
+ const prevRootRef = useRef<ViewportRange>({
184
190
  start: root.startTime,
185
191
  end: root.startTime + root.duration,
186
192
  });
@@ -221,60 +227,45 @@ function NewTraceViewerContent({
221
227
  animateTo({ start: root.startTime, end: root.startTime + root.duration });
222
228
  }, [animateTo, root.startTime, root.duration]);
223
229
 
230
+ const clampToRoot = useCallback(
231
+ (next: ViewportRange): ViewportRange =>
232
+ clampViewportToRoot(next, root.startTime, root.endTime, MIN_VIEWPORT_MS),
233
+ [root.startTime, root.endTime]
234
+ );
235
+
224
236
  // Pan (keeping the current zoom) so `timeMs` is centered in view — used by the
225
237
  // off-screen marker indicators to scroll their marker into view.
226
238
  const handleRevealTime = useCallback(
227
239
  (timeMs: number) => {
228
- const rootS = root.startTime;
229
- const rootE = root.startTime + root.duration;
230
240
  const { start, end } = viewportRef.current;
231
241
  const duration = end - start;
232
- let newStart = timeMs - duration / 2;
233
- let newEnd = timeMs + duration / 2;
234
- if (newStart < rootS) {
235
- newStart = rootS;
236
- newEnd = rootS + duration;
237
- }
238
- if (newEnd > rootE) {
239
- newEnd = rootE;
240
- newStart = Math.max(rootS, rootE - duration);
241
- }
242
- animateTo({ start: newStart, end: newEnd });
242
+ animateTo(
243
+ clampToRoot({
244
+ start: timeMs - duration / 2,
245
+ end: timeMs + duration / 2,
246
+ })
247
+ );
243
248
  },
244
- [animateTo, root.startTime, root.duration]
249
+ [animateTo, clampToRoot]
245
250
  );
246
251
 
247
252
  const ZOOM_FACTOR = 0.5;
248
253
 
249
254
  const zoomBy = useCallback(
250
255
  (factor: number) => {
251
- const rootS = root.startTime;
252
- const rootE = root.startTime + root.duration;
253
- const rootD = root.duration;
254
-
255
256
  setViewport((prev) => {
256
- const prevDuration = prev.end - prev.start;
257
257
  const center = (prev.start + prev.end) / 2;
258
258
  const newDuration = Math.max(
259
259
  MIN_VIEWPORT_MS,
260
- Math.min(rootD, prevDuration * factor)
260
+ (prev.end - prev.start) * factor
261
261
  );
262
- let newStart = center - newDuration / 2;
263
- let newEnd = center + newDuration / 2;
264
-
265
- if (newStart < rootS) {
266
- newStart = rootS;
267
- newEnd = rootS + newDuration;
268
- }
269
- if (newEnd > rootE) {
270
- newEnd = rootE;
271
- newStart = Math.max(rootS, rootE - newDuration);
272
- }
273
-
274
- return { start: newStart, end: newEnd };
262
+ return clampToRoot({
263
+ start: center - newDuration / 2,
264
+ end: center + newDuration / 2,
265
+ });
275
266
  });
276
267
  },
277
- [setViewport, root.startTime, root.duration]
268
+ [setViewport, clampToRoot]
278
269
  );
279
270
 
280
271
  const zoomIn = useCallback(() => zoomBy(ZOOM_FACTOR), [zoomBy]);
@@ -291,12 +282,8 @@ function NewTraceViewerContent({
291
282
  const spanEnd = getHighResInMs(span.endTime);
292
283
  const spanDuration = spanEnd - spanStart;
293
284
 
294
- const rootS = root.startTime;
295
- const rootE = root.startTime + root.duration;
296
- const rootD = root.duration;
297
-
298
- if (spanDuration > rootD * 0.8) {
299
- animateTo({ start: rootS, end: rootE });
285
+ if (spanDuration > root.duration * 0.8) {
286
+ animateTo({ start: root.startTime, end: root.endTime });
300
287
  return;
301
288
  }
302
289
 
@@ -310,20 +297,16 @@ function NewTraceViewerContent({
310
297
  newEnd = center + MIN_VIEWPORT_MS / 2;
311
298
  }
312
299
 
313
- if (newStart < rootS) {
314
- const duration = newEnd - newStart;
315
- newStart = rootS;
316
- newEnd = Math.min(rootE, rootS + duration);
317
- }
318
- if (newEnd > rootE) {
319
- const duration = newEnd - newStart;
320
- newEnd = rootE;
321
- newStart = Math.max(rootS, rootE - duration);
322
- }
323
-
324
- animateTo({ start: newStart, end: newEnd });
300
+ animateTo(clampToRoot({ start: newStart, end: newEnd }));
325
301
  },
326
- [animateTo, trace.spans, root.startTime, root.duration]
302
+ [
303
+ animateTo,
304
+ trace.spans,
305
+ root.startTime,
306
+ root.endTime,
307
+ root.duration,
308
+ clampToRoot,
309
+ ]
327
310
  );
328
311
 
329
312
  // Bring a row into view when keyboard/button navigation lands on a span that
@@ -423,7 +406,7 @@ function NewTraceViewerContent({
423
406
  if (hoverFraction == null) return null;
424
407
  const absTime = viewport.start + hoverFraction * viewDuration;
425
408
  const offset = absTime - root.startTime;
426
- return { fraction: hoverFraction, label: formatDuration(offset, true) };
409
+ return { fraction: hoverFraction, label: formatDurationPrecise(offset) };
427
410
  }, [hoverFraction, viewport.start, viewDuration, root.startTime]);
428
411
 
429
412
  const handleTimelineMouseMove = useCallback(
@@ -451,12 +434,7 @@ function NewTraceViewerContent({
451
434
 
452
435
  useEffect(() => {
453
436
  const el = timelineRef.current;
454
- if (!el) return;
455
-
456
- const rootS = root.startTime;
457
- const rootE = root.startTime + root.duration;
458
- const rootD = root.duration;
459
- if (rootD <= 0) return;
437
+ if (!el || root.duration <= 0) return;
460
438
 
461
439
  const onWheel = (e: WheelEvent): void => {
462
440
  const isZoomGesture = e.ctrlKey || e.metaKey;
@@ -470,9 +448,6 @@ function NewTraceViewerContent({
470
448
  if (contentWidth <= 0) return;
471
449
 
472
450
  if (isZoomGesture) {
473
- let dy = e.deltaY;
474
- if (e.deltaMode === 1) dy *= 16;
475
-
476
451
  const cursorFraction = Math.max(
477
452
  0,
478
453
  Math.min(
@@ -480,59 +455,37 @@ function NewTraceViewerContent({
480
455
  (e.clientX - rect.left - TIMELINE_PADDING_PX) / contentWidth
481
456
  )
482
457
  );
483
- const isMouseWheel = e.deltaMode === 1 || Math.abs(e.deltaY) >= 50;
484
- const scaleFactor = 2 ** (dy / (isMouseWheel ? 200 : 60));
458
+ const scaleFactor = wheelZoomScaleFactor(e);
485
459
 
486
460
  setViewport((prev) => {
487
461
  const prevDuration = prev.end - prev.start;
488
462
  const cursorTime = prev.start + cursorFraction * prevDuration;
463
+ // Clamp the duration before anchoring so the cursor keeps its
464
+ // fraction even when the zoom hits the min/max bounds.
489
465
  const newDuration = Math.max(
490
466
  MIN_VIEWPORT_MS,
491
- Math.min(rootD, prevDuration * scaleFactor)
467
+ Math.min(root.duration, prevDuration * scaleFactor)
492
468
  );
493
-
494
- let newStart = cursorTime - cursorFraction * newDuration;
495
- let newEnd = newStart + newDuration;
496
-
497
- if (newStart < rootS) {
498
- newStart = rootS;
499
- newEnd = rootS + newDuration;
500
- }
501
- if (newEnd > rootE) {
502
- newEnd = rootE;
503
- newStart = Math.max(rootS, rootE - newDuration);
504
- }
505
-
506
- return { start: newStart, end: newEnd };
469
+ return clampToRoot({
470
+ start: cursorTime - cursorFraction * newDuration,
471
+ end: cursorTime + (1 - cursorFraction) * newDuration,
472
+ });
507
473
  });
508
474
  } else {
509
- let dx = e.deltaX;
510
- if (e.deltaMode === 1) dx *= 16;
511
-
475
+ const dx = wheelDeltaToPixels(e.deltaX, e.deltaMode);
512
476
  setViewport((prev) => {
513
- const prevDuration = prev.end - prev.start;
514
- const panAmount = (dx / contentWidth) * prevDuration;
515
-
516
- let newStart = prev.start + panAmount;
517
- let newEnd = prev.end + panAmount;
518
-
519
- if (newStart < rootS) {
520
- newStart = rootS;
521
- newEnd = rootS + prevDuration;
522
- }
523
- if (newEnd > rootE) {
524
- newEnd = rootE;
525
- newStart = Math.max(rootS, rootE - prevDuration);
526
- }
527
-
528
- return { start: newStart, end: newEnd };
477
+ const panAmount = (dx / contentWidth) * (prev.end - prev.start);
478
+ return clampToRoot({
479
+ start: prev.start + panAmount,
480
+ end: prev.end + panAmount,
481
+ });
529
482
  });
530
483
  }
531
484
  };
532
485
 
533
486
  el.addEventListener('wheel', onWheel, { passive: false });
534
487
  return () => el.removeEventListener('wheel', onWheel);
535
- }, [root.startTime, root.duration, setViewport]);
488
+ }, [root.duration, setViewport, clampToRoot]);
536
489
 
537
490
  return (
538
491
  <div
@@ -542,8 +495,16 @@ function NewTraceViewerContent({
542
495
  >
543
496
  <div
544
497
  id="trace-parent"
545
- className="flex-1 min-w-0 grid grid-rows-[1fr] h-full min-h-0 overflow-hidden relative bg-background-100"
498
+ className="flex-1 min-w-0 grid grid-rows-[auto_1fr] h-full min-h-0 overflow-hidden relative bg-background-100"
546
499
  >
500
+ <Minimap
501
+ spans={trace.spans}
502
+ root={root}
503
+ viewport={viewport}
504
+ minViewportMs={MIN_VIEWPORT_MS}
505
+ onViewportChange={setViewport}
506
+ onAnimateTo={animateTo}
507
+ />
547
508
  <SplitPane
548
509
  scrollContainerRef={scrollContainerRef}
549
510
  startHeader={
@@ -603,6 +564,7 @@ function NewTraceViewerContent({
603
564
  {/* biome-ignore lint/a11y/noStaticElementInteractions: timeline hover and wheel gestures are pointer-only annotations */}
604
565
  <div
605
566
  ref={timelineRef}
567
+ id="trace-timeline"
606
568
  className="block min-h-0 overflow-visible relative"
607
569
  onDoubleClick={resetZoom}
608
570
  onMouseMove={handleTimelineMouseMove}
@@ -1,9 +1,11 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import type { Span, SpanEvent } from './types';
3
3
  import {
4
+ clampViewportToRoot,
4
5
  computeOffscreenMarkers,
5
6
  computeSpanMarkers,
6
7
  computeSpanSegments,
8
+ computeTimeMarkers,
7
9
  } from './utils';
8
10
 
9
11
  /** Build a high-res timestamp tuple ([seconds, nanoseconds]) for a given ms. */
@@ -180,3 +182,66 @@ describe('computeOffscreenMarkers', () => {
180
182
  });
181
183
  });
182
184
  });
185
+
186
+ describe('clampViewportToRoot', () => {
187
+ const clamp = (next: { start: number; end: number }) =>
188
+ clampViewportToRoot(next, 100, 1100, 10);
189
+
190
+ it('passes through a window already inside the root', () => {
191
+ expect(clamp({ start: 200, end: 400 })).toEqual({ start: 200, end: 400 });
192
+ });
193
+
194
+ it('shifts a window past the left edge without changing its duration', () => {
195
+ expect(clamp({ start: 50, end: 250 })).toEqual({ start: 100, end: 300 });
196
+ });
197
+
198
+ it('shifts a window past the right edge without changing its duration', () => {
199
+ expect(clamp({ start: 1000, end: 1200 })).toEqual({
200
+ start: 900,
201
+ end: 1100,
202
+ });
203
+ });
204
+
205
+ it('clamps a window wider than the root to the full extent', () => {
206
+ expect(clamp({ start: 0, end: 5000 })).toEqual({ start: 100, end: 1100 });
207
+ });
208
+
209
+ it('enforces the minimum duration', () => {
210
+ expect(clamp({ start: 500, end: 502 })).toEqual({ start: 500, end: 510 });
211
+ });
212
+
213
+ it('keeps a minimum-duration window inside the root near the right edge', () => {
214
+ expect(clamp({ start: 1098, end: 1099 })).toEqual({
215
+ start: 1090,
216
+ end: 1100,
217
+ });
218
+ });
219
+ });
220
+
221
+ describe('computeTimeMarkers', () => {
222
+ it('emits distinct, precise labels across a sub-second-step window', () => {
223
+ // A ~3s window drops the tick step to 500ms. Before the fix this rendered
224
+ // duplicate "2s, 2s, 3s, 3s" labels; now each tick is distinct.
225
+ const labels = computeTimeMarkers(3000, 0).map((m) => m.label);
226
+ expect(labels).toEqual(['0s', '500ms', '1s', '1.5s', '2s', '2.5s', '3s']);
227
+ expect(new Set(labels).size).toBe(labels.length);
228
+ });
229
+
230
+ it('keeps clean whole-second labels when the step is >=1s', () => {
231
+ const labels = computeTimeMarkers(10_000, 0).map((m) => m.label);
232
+ expect(labels).toEqual(['0s', '2s', '4s', '6s', '8s', '10s']);
233
+ });
234
+
235
+ it('still reads in ms when super zoomed in', () => {
236
+ const labels = computeTimeMarkers(120, 0).map((m) => m.label);
237
+ expect(labels).toEqual([
238
+ '0s',
239
+ '20ms',
240
+ '40ms',
241
+ '60ms',
242
+ '80ms',
243
+ '100ms',
244
+ '120ms',
245
+ ]);
246
+ });
247
+ });