@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,15 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { parseStepName, parseWorkflowName } from '@workflow/utils/parse-name';
4
- import {
5
- ChevronDown,
6
- ChevronUp,
7
- RotateCcw,
8
- Search,
9
- X,
10
- ZoomIn,
11
- ZoomOut,
12
- } from 'lucide-react';
3
+ import { RotateCcw, Search, ZoomIn, ZoomOut } from 'lucide-react';
13
4
  import {
14
5
  type ReactNode,
15
6
  useCallback,
@@ -21,24 +12,17 @@ import {
21
12
  } from 'react';
22
13
  import { useLoadMoreOnScroll } from '../../hooks/use-load-more-on-scroll';
23
14
  import { useReducedMotion } from '../../hooks/use-reduced-motion';
24
- import { filterSpanRawEvents } from '../../lib/trace-builder';
25
- import { ErrorBoundary } from '../error-boundary';
26
15
  import {
27
- EntityDetailPanel,
28
- type SelectedSpanInfo,
29
- } from '../sidebar/entity-detail-panel';
30
- import { useSidebarData } from '../sidebar/sidebar-data-context';
31
- import { formatDuration, getHighResInMs } from '../trace-viewer/util/timing';
16
+ formatDurationPrecise,
17
+ getHighResInMs,
18
+ } from '../trace-viewer/util/timing';
32
19
  import { IconButton } from '../ui/icon-button';
33
20
  import { Kbd } from '../ui/kbd';
34
21
  import { Spinner } from '../ui/spinner';
35
- import {
36
- Tooltip,
37
- TooltipContent,
38
- TooltipProvider,
39
- TooltipTrigger,
40
- } from '../ui/tooltip';
22
+ import { TooltipProvider } from '../ui/tooltip';
23
+ import { TraceDetailPanel } from './components/detail-panel';
41
24
  import EventList from './components/event-list';
25
+ import { Minimap } from './components/minimap';
42
26
  import { SplitPane } from './components/split-pane';
43
27
  import {
44
28
  TIMELINE_PADDING_PX,
@@ -50,7 +34,14 @@ import { ROW_HEIGHT_PX, scrollRowIntoView } from './components/use-row-window';
50
34
  import { ActiveSpanProvider, useActiveSpan } from './context';
51
35
  import { searchSpans } from './search';
52
36
  import type { TraceWithMeta } from './types';
53
- import { computeRootBounds, computeTimeMarkers } from './utils';
37
+ import {
38
+ clampViewportToRoot,
39
+ computeRootBounds,
40
+ computeTimeMarkers,
41
+ type ViewportRange,
42
+ wheelDeltaToPixels,
43
+ wheelZoomScaleFactor,
44
+ } from './utils';
54
45
 
55
46
  interface NewTraceViewerProps {
56
47
  trace: TraceWithMeta;
@@ -63,17 +54,12 @@ const MIN_VIEWPORT_MS = 0.001;
63
54
 
64
55
  const ZOOM_DEBOUNCE_MS = 150;
65
56
 
66
- interface Viewport {
67
- start: number;
68
- end: number;
69
- }
70
-
71
- function useAnimatedViewport(initial: Viewport) {
72
- const [viewport, setViewportState] = useState<Viewport>(initial);
57
+ function useAnimatedViewport(initial: ViewportRange) {
58
+ const [viewport, setViewportState] = useState<ViewportRange>(initial);
73
59
  const animRef = useRef<{
74
60
  raf: number;
75
- from: Viewport;
76
- to: Viewport;
61
+ from: ViewportRange;
62
+ to: ViewportRange;
77
63
  start: number;
78
64
  } | null>(null);
79
65
  const currentRef = useRef(initial);
@@ -88,7 +74,7 @@ function useAnimatedViewport(initial: Viewport) {
88
74
  }, []);
89
75
 
90
76
  const animateTo = useCallback(
91
- (target: Viewport) => {
77
+ (target: ViewportRange) => {
92
78
  cancel();
93
79
 
94
80
  if (reducedMotion) {
@@ -118,7 +104,7 @@ function useAnimatedViewport(initial: Viewport) {
118
104
  );
119
105
 
120
106
  const setViewport = useCallback(
121
- (update: Viewport | ((prev: Viewport) => Viewport)) => {
107
+ (update: ViewportRange | ((prev: ViewportRange) => ViewportRange)) => {
122
108
  cancel();
123
109
  if (typeof update === 'function') {
124
110
  setViewportState((prev) => {
@@ -139,33 +125,6 @@ function useAnimatedViewport(initial: Viewport) {
139
125
  return { viewport, setViewport, animateTo };
140
126
  }
141
127
 
142
- // ---------------------------------------------------------------------------
143
- // Hook: bridge ActiveSpanContext + SidebarDataContext → SelectedSpanInfo
144
- // ---------------------------------------------------------------------------
145
-
146
- function useSelectedSpanInfo(): SelectedSpanInfo | null {
147
- const { activeSpan } = useActiveSpan();
148
- const sidebar = useSidebarData();
149
-
150
- return useMemo(() => {
151
- if (!activeSpan) return null;
152
-
153
- const resource = activeSpan.attributes?.resource as string | undefined;
154
- const rawEvents = filterSpanRawEvents(
155
- sidebar.events,
156
- resource,
157
- activeSpan.spanId
158
- );
159
-
160
- return {
161
- data: activeSpan.attributes?.data,
162
- resource,
163
- spanId: activeSpan.spanId,
164
- rawEvents,
165
- };
166
- }, [activeSpan, sidebar]);
167
- }
168
-
169
128
  // ---------------------------------------------------------------------------
170
129
  // Root component
171
130
  // ---------------------------------------------------------------------------
@@ -196,16 +155,15 @@ function NewTraceViewerContent({
196
155
  hasMore,
197
156
  isLoadingMore,
198
157
  }: NewTraceViewerProps): ReactNode {
199
- const { activeSpan, activeSpanId, setActiveSpan, clearActiveSpan } =
200
- useActiveSpan();
158
+ const { activeSpanId, setActiveSpan, clearActiveSpan } = useActiveSpan();
201
159
 
202
- const sidebar = useSidebarData();
203
- const selectedSpan = useSelectedSpanInfo();
204
160
  const reducedMotion = useReducedMotion();
205
161
 
206
162
  const [searchQuery, setSearchQuery] = useState('');
207
163
  const deferredSearchQuery = useDeferredValue(searchQuery);
208
164
 
165
+ const paneRootRef = useRef<HTMLDivElement>(null);
166
+
209
167
  const searchResult = useMemo(
210
168
  () => searchSpans(trace.spans, deferredSearchQuery),
211
169
  [trace.spans, deferredSearchQuery]
@@ -228,7 +186,7 @@ function NewTraceViewerContent({
228
186
  end: root.startTime + root.duration,
229
187
  });
230
188
 
231
- const prevRootRef = useRef<Viewport>({
189
+ const prevRootRef = useRef<ViewportRange>({
232
190
  start: root.startTime,
233
191
  end: root.startTime + root.duration,
234
192
  });
@@ -269,64 +227,51 @@ function NewTraceViewerContent({
269
227
  animateTo({ start: root.startTime, end: root.startTime + root.duration });
270
228
  }, [animateTo, root.startTime, root.duration]);
271
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
+
272
236
  // Pan (keeping the current zoom) so `timeMs` is centered in view — used by the
273
237
  // off-screen marker indicators to scroll their marker into view.
274
238
  const handleRevealTime = useCallback(
275
239
  (timeMs: number) => {
276
- const rootS = root.startTime;
277
- const rootE = root.startTime + root.duration;
278
240
  const { start, end } = viewportRef.current;
279
241
  const duration = end - start;
280
- let newStart = timeMs - duration / 2;
281
- let newEnd = timeMs + duration / 2;
282
- if (newStart < rootS) {
283
- newStart = rootS;
284
- newEnd = rootS + duration;
285
- }
286
- if (newEnd > rootE) {
287
- newEnd = rootE;
288
- newStart = Math.max(rootS, rootE - duration);
289
- }
290
- animateTo({ start: newStart, end: newEnd });
242
+ animateTo(
243
+ clampToRoot({
244
+ start: timeMs - duration / 2,
245
+ end: timeMs + duration / 2,
246
+ })
247
+ );
291
248
  },
292
- [animateTo, root.startTime, root.duration]
249
+ [animateTo, clampToRoot]
293
250
  );
294
251
 
295
252
  const ZOOM_FACTOR = 0.5;
296
253
 
297
254
  const zoomBy = useCallback(
298
255
  (factor: number) => {
299
- const rootS = root.startTime;
300
- const rootE = root.startTime + root.duration;
301
- const rootD = root.duration;
302
-
303
256
  setViewport((prev) => {
304
- const prevDuration = prev.end - prev.start;
305
257
  const center = (prev.start + prev.end) / 2;
306
258
  const newDuration = Math.max(
307
259
  MIN_VIEWPORT_MS,
308
- Math.min(rootD, prevDuration * factor)
260
+ (prev.end - prev.start) * factor
309
261
  );
310
- let newStart = center - newDuration / 2;
311
- let newEnd = center + newDuration / 2;
312
-
313
- if (newStart < rootS) {
314
- newStart = rootS;
315
- newEnd = rootS + newDuration;
316
- }
317
- if (newEnd > rootE) {
318
- newEnd = rootE;
319
- newStart = Math.max(rootS, rootE - newDuration);
320
- }
321
-
322
- return { start: newStart, end: newEnd };
262
+ return clampToRoot({
263
+ start: center - newDuration / 2,
264
+ end: center + newDuration / 2,
265
+ });
323
266
  });
324
267
  },
325
- [setViewport, root.startTime, root.duration]
268
+ [setViewport, clampToRoot]
326
269
  );
327
270
 
328
271
  const zoomIn = useCallback(() => zoomBy(ZOOM_FACTOR), [zoomBy]);
329
272
  const zoomOut = useCallback(() => zoomBy(1 / ZOOM_FACTOR), [zoomBy]);
273
+ const isAtMinZoom = viewDuration >= root.duration;
274
+ const isAtMaxZoom = viewDuration <= MIN_VIEWPORT_MS;
330
275
 
331
276
  const focusViewportOnSpan = useCallback(
332
277
  (spanId: string) => {
@@ -337,12 +282,8 @@ function NewTraceViewerContent({
337
282
  const spanEnd = getHighResInMs(span.endTime);
338
283
  const spanDuration = spanEnd - spanStart;
339
284
 
340
- const rootS = root.startTime;
341
- const rootE = root.startTime + root.duration;
342
- const rootD = root.duration;
343
-
344
- if (spanDuration > rootD * 0.8) {
345
- animateTo({ start: rootS, end: rootE });
285
+ if (spanDuration > root.duration * 0.8) {
286
+ animateTo({ start: root.startTime, end: root.endTime });
346
287
  return;
347
288
  }
348
289
 
@@ -356,20 +297,16 @@ function NewTraceViewerContent({
356
297
  newEnd = center + MIN_VIEWPORT_MS / 2;
357
298
  }
358
299
 
359
- if (newStart < rootS) {
360
- const duration = newEnd - newStart;
361
- newStart = rootS;
362
- newEnd = Math.min(rootE, rootS + duration);
363
- }
364
- if (newEnd > rootE) {
365
- const duration = newEnd - newStart;
366
- newEnd = rootE;
367
- newStart = Math.max(rootS, rootE - duration);
368
- }
369
-
370
- animateTo({ start: newStart, end: newEnd });
300
+ animateTo(clampToRoot({ start: newStart, end: newEnd }));
371
301
  },
372
- [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
+ ]
373
310
  );
374
311
 
375
312
  // Bring a row into view when keyboard/button navigation lands on a span that
@@ -440,30 +377,11 @@ function NewTraceViewerContent({
440
377
  const [altHeld, setAltHeld] = useState(false);
441
378
 
442
379
  useEffect(() => {
443
- const handleSidebarNavKey = (e: KeyboardEvent): void => {
444
- const target = e.target as HTMLElement | null;
445
- if (
446
- target instanceof HTMLInputElement ||
447
- target instanceof HTMLTextAreaElement ||
448
- target?.isContentEditable
449
- ) {
450
- return;
451
- }
452
- const targetId =
453
- e.key === 'k' ? prevSpanIdRef.current : nextSpanIdRef.current;
454
- if (targetId) {
455
- e.preventDefault();
456
- navigateToSpanRef.current(targetId);
457
- }
458
- };
459
-
460
380
  const onKeyDown = (e: KeyboardEvent): void => {
461
381
  if (e.key === 'Escape') {
462
382
  handleClearActiveSpan();
463
383
  } else if (e.key === 'Alt') {
464
384
  setAltHeld(true);
465
- } else if (e.key === 'j' || e.key === 'k') {
466
- handleSidebarNavKey(e);
467
385
  }
468
386
  };
469
387
  const onKeyUp = (e: KeyboardEvent): void => {
@@ -488,7 +406,7 @@ function NewTraceViewerContent({
488
406
  if (hoverFraction == null) return null;
489
407
  const absTime = viewport.start + hoverFraction * viewDuration;
490
408
  const offset = absTime - root.startTime;
491
- return { fraction: hoverFraction, label: formatDuration(offset, true) };
409
+ return { fraction: hoverFraction, label: formatDurationPrecise(offset) };
492
410
  }, [hoverFraction, viewport.start, viewDuration, root.startTime]);
493
411
 
494
412
  const handleTimelineMouseMove = useCallback(
@@ -516,12 +434,7 @@ function NewTraceViewerContent({
516
434
 
517
435
  useEffect(() => {
518
436
  const el = timelineRef.current;
519
- if (!el) return;
520
-
521
- const rootS = root.startTime;
522
- const rootE = root.startTime + root.duration;
523
- const rootD = root.duration;
524
- if (rootD <= 0) return;
437
+ if (!el || root.duration <= 0) return;
525
438
 
526
439
  const onWheel = (e: WheelEvent): void => {
527
440
  const isZoomGesture = e.ctrlKey || e.metaKey;
@@ -535,9 +448,6 @@ function NewTraceViewerContent({
535
448
  if (contentWidth <= 0) return;
536
449
 
537
450
  if (isZoomGesture) {
538
- let dy = e.deltaY;
539
- if (e.deltaMode === 1) dy *= 16;
540
-
541
451
  const cursorFraction = Math.max(
542
452
  0,
543
453
  Math.min(
@@ -545,115 +455,56 @@ function NewTraceViewerContent({
545
455
  (e.clientX - rect.left - TIMELINE_PADDING_PX) / contentWidth
546
456
  )
547
457
  );
548
- const isMouseWheel = e.deltaMode === 1 || Math.abs(e.deltaY) >= 50;
549
- const scaleFactor = 2 ** (dy / (isMouseWheel ? 200 : 60));
458
+ const scaleFactor = wheelZoomScaleFactor(e);
550
459
 
551
460
  setViewport((prev) => {
552
461
  const prevDuration = prev.end - prev.start;
553
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.
554
465
  const newDuration = Math.max(
555
466
  MIN_VIEWPORT_MS,
556
- Math.min(rootD, prevDuration * scaleFactor)
467
+ Math.min(root.duration, prevDuration * scaleFactor)
557
468
  );
558
-
559
- let newStart = cursorTime - cursorFraction * newDuration;
560
- let newEnd = newStart + newDuration;
561
-
562
- if (newStart < rootS) {
563
- newStart = rootS;
564
- newEnd = rootS + newDuration;
565
- }
566
- if (newEnd > rootE) {
567
- newEnd = rootE;
568
- newStart = Math.max(rootS, rootE - newDuration);
569
- }
570
-
571
- return { start: newStart, end: newEnd };
469
+ return clampToRoot({
470
+ start: cursorTime - cursorFraction * newDuration,
471
+ end: cursorTime + (1 - cursorFraction) * newDuration,
472
+ });
572
473
  });
573
474
  } else {
574
- let dx = e.deltaX;
575
- if (e.deltaMode === 1) dx *= 16;
576
-
475
+ const dx = wheelDeltaToPixels(e.deltaX, e.deltaMode);
577
476
  setViewport((prev) => {
578
- const prevDuration = prev.end - prev.start;
579
- const panAmount = (dx / contentWidth) * prevDuration;
580
-
581
- let newStart = prev.start + panAmount;
582
- let newEnd = prev.end + panAmount;
583
-
584
- if (newStart < rootS) {
585
- newStart = rootS;
586
- newEnd = rootS + prevDuration;
587
- }
588
- if (newEnd > rootE) {
589
- newEnd = rootE;
590
- newStart = Math.max(rootS, rootE - prevDuration);
591
- }
592
-
593
- 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
+ });
594
482
  });
595
483
  }
596
484
  };
597
485
 
598
486
  el.addEventListener('wheel', onWheel, { passive: false });
599
487
  return () => el.removeEventListener('wheel', onWheel);
600
- }, [root.startTime, root.duration, setViewport]);
601
-
602
- // Derive the selected span name and metadata for the panel header
603
- const selectedSpanName = useMemo(() => {
604
- if (!selectedSpan?.data) return 'Details';
605
- const data = selectedSpan.data as Record<string, unknown>;
606
- if (selectedSpan.resource === 'hook') {
607
- return (data.token as string | undefined) ?? (data.hookId as string);
608
- }
609
-
610
- const stepName = data.stepName as string | undefined;
611
- const workflowName = data.workflowName as string | undefined;
612
- return (
613
- (stepName ? parseStepName(stepName)?.shortName : undefined) ??
614
- (workflowName ? parseWorkflowName(workflowName)?.shortName : undefined) ??
615
- stepName ??
616
- workflowName ??
617
- (data.hookId as string) ??
618
- 'Details'
619
- );
620
- }, [selectedSpan?.data, selectedSpan?.resource]);
621
-
622
- const { prevSpanId, nextSpanId } = useMemo(() => {
623
- if (!activeSpanId) return { prevSpanId: null, nextSpanId: null };
624
- const i = trace.spans.findIndex((s) => s.spanId === activeSpanId);
625
- if (i === -1) return { prevSpanId: null, nextSpanId: null };
626
- return {
627
- prevSpanId: trace.spans[i - 1]?.spanId ?? null,
628
- nextSpanId: trace.spans[i + 1]?.spanId ?? null,
629
- };
630
- }, [activeSpanId, trace.spans]);
631
-
632
- const handleSelectPrevSpan = useCallback(() => {
633
- if (prevSpanId) navigateToSpan(prevSpanId);
634
- }, [prevSpanId, navigateToSpan]);
635
-
636
- const handleSelectNextSpan = useCallback(() => {
637
- if (nextSpanId) navigateToSpan(nextSpanId);
638
- }, [nextSpanId, navigateToSpan]);
639
-
640
- const prevSpanIdRef = useRef(prevSpanId);
641
- const nextSpanIdRef = useRef(nextSpanId);
642
- const navigateToSpanRef = useRef(navigateToSpan);
643
- prevSpanIdRef.current = prevSpanId;
644
- nextSpanIdRef.current = nextSpanId;
645
- navigateToSpanRef.current = navigateToSpan;
488
+ }, [root.duration, setViewport, clampToRoot]);
646
489
 
647
490
  return (
648
491
  <div
492
+ ref={paneRootRef}
649
493
  data-pane="pane-root"
650
- data-has-detail={activeSpan ? '' : undefined}
651
- className="relative grid w-full h-full max-h-full grid-cols-[minmax(100px,1fr)] data-[has-detail]:grid-cols-[minmax(100px,1fr)_clamp(280px,360px,100%)]"
494
+ className="relative flex w-full h-full max-h-full"
652
495
  >
653
496
  <div
654
497
  id="trace-parent"
655
- className="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"
656
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
+ />
657
508
  <SplitPane
658
509
  scrollContainerRef={scrollContainerRef}
659
510
  startHeader={
@@ -713,6 +564,7 @@ function NewTraceViewerContent({
713
564
  {/* biome-ignore lint/a11y/noStaticElementInteractions: timeline hover and wheel gestures are pointer-only annotations */}
714
565
  <div
715
566
  ref={timelineRef}
567
+ id="trace-timeline"
716
568
  className="block min-h-0 overflow-visible relative"
717
569
  onDoubleClick={resetZoom}
718
570
  onMouseMove={handleTimelineMouseMove}
@@ -737,6 +589,7 @@ function NewTraceViewerContent({
737
589
  variant="muted"
738
590
  size="small"
739
591
  onClick={zoomOut}
592
+ disabled={isAtMinZoom}
740
593
  aria-label="Zoom out"
741
594
  >
742
595
  <ZoomOut className="w-4 h-4" />
@@ -753,6 +606,7 @@ function NewTraceViewerContent({
753
606
  variant="muted"
754
607
  size="small"
755
608
  onClick={zoomIn}
609
+ disabled={isAtMaxZoom}
756
610
  aria-label="Zoom in"
757
611
  >
758
612
  <ZoomIn className="w-4 h-4" />
@@ -760,84 +614,11 @@ function NewTraceViewerContent({
760
614
  </div>
761
615
  </div>
762
616
 
763
- {/* Detail panel */}
764
- {activeSpan ? (
765
- <aside className="flex flex-col h-full max-h-full bg-background-100 border-l border-gray-alpha-400 overflow-auto">
766
- {/* Panel header */}
767
- <div className="flex items-center justify-between gap-2 shrink-0 px-4 pt-3 pb-3">
768
- <span className="text-label-14 font-medium text-gray-1000 truncate block">
769
- {selectedSpanName}
770
- </span>
771
- <div className="flex items-center gap-0.5 shrink-0">
772
- <Tooltip>
773
- <TooltipTrigger asChild>
774
- <IconButton
775
- aria-label="Navigate up"
776
- aria-keyshortcuts="K"
777
- onClick={handleSelectPrevSpan}
778
- disabled={!prevSpanId}
779
- >
780
- <ChevronUp className="w-4 h-4" />
781
- </IconButton>
782
- </TooltipTrigger>
783
- {prevSpanId ? (
784
- <TooltipContent>
785
- Navigate up
786
- <Kbd>K</Kbd>
787
- </TooltipContent>
788
- ) : null}
789
- </Tooltip>
790
- <Tooltip>
791
- <TooltipTrigger asChild>
792
- <IconButton
793
- aria-label="Navigate down"
794
- aria-keyshortcuts="J"
795
- onClick={handleSelectNextSpan}
796
- disabled={!nextSpanId}
797
- >
798
- <ChevronDown className="w-4 h-4" />
799
- </IconButton>
800
- </TooltipTrigger>
801
- {nextSpanId ? (
802
- <TooltipContent>
803
- Navigate down
804
- <Kbd>J</Kbd>
805
- </TooltipContent>
806
- ) : null}
807
- </Tooltip>
808
- <div aria-hidden className="w-px h-4 bg-gray-alpha-400 mx-1" />
809
- <IconButton
810
- aria-label="Close span details"
811
- aria-keyshortcuts="Escape"
812
- onClick={handleClearActiveSpan}
813
- >
814
- <X className="w-4 h-4" />
815
- </IconButton>
816
- </div>
817
- </div>
818
- {/* Panel body */}
819
- <div className="flex-1 overflow-y-auto">
820
- <ErrorBoundary>
821
- <EntityDetailPanel
822
- run={sidebar.run}
823
- onStreamClick={sidebar.onStreamClick}
824
- onRunClick={sidebar.onRunClick}
825
- fetchSpanDetail={sidebar.fetchSpanDetail}
826
- onWakeUpSleep={sidebar.onWakeUpSleep}
827
- onLoadEventData={sidebar.onLoadEventData}
828
- onResolveHook={sidebar.onResolveHook}
829
- encryptionKey={sidebar.encryptionKey}
830
- onDecrypt={sidebar.onDecrypt}
831
- isDecrypting={sidebar.isDecrypting}
832
- selectedSpan={selectedSpan}
833
- showSeparateEventOccurrenceTimestamps={
834
- sidebar.showSeparateEventOccurrenceTimestamps
835
- }
836
- />
837
- </ErrorBoundary>
838
- </div>
839
- </aside>
840
- ) : null}
617
+ <TraceDetailPanel
618
+ containerRef={paneRootRef}
619
+ onNavigateToSpan={navigateToSpan}
620
+ onClose={handleClearActiveSpan}
621
+ />
841
622
 
842
623
  <TraceShortcutHelper
843
624
  hasMultipleSpans={trace.spans.length > 1}
@@ -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
+ });