@workflow/web-shared 5.0.0-beta.16 → 5.0.0-beta.18

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 (37) hide show
  1. package/dist/components/new-trace-viewer/components/span-markers.d.ts +46 -0
  2. package/dist/components/new-trace-viewer/components/span-markers.d.ts.map +1 -0
  3. package/dist/components/new-trace-viewer/components/span-markers.js +85 -0
  4. package/dist/components/new-trace-viewer/components/timeline.d.ts +3 -1
  5. package/dist/components/new-trace-viewer/components/timeline.d.ts.map +1 -1
  6. package/dist/components/new-trace-viewer/components/timeline.js +34 -51
  7. package/dist/components/new-trace-viewer/components/use-row-window.d.ts +14 -0
  8. package/dist/components/new-trace-viewer/components/use-row-window.d.ts.map +1 -1
  9. package/dist/components/new-trace-viewer/components/use-row-window.js +39 -1
  10. package/dist/components/new-trace-viewer/trace-viewer.d.ts.map +1 -1
  11. package/dist/components/new-trace-viewer/trace-viewer.js +87 -23
  12. package/dist/components/new-trace-viewer/utils.d.ts +15 -0
  13. package/dist/components/new-trace-viewer/utils.d.ts.map +1 -1
  14. package/dist/components/new-trace-viewer/utils.js +37 -31
  15. package/dist/components/sidebar/attribute-panel.d.ts.map +1 -1
  16. package/dist/components/sidebar/attribute-panel.js +3 -25
  17. package/dist/components/sidebar/attributes-block.d.ts +1 -1
  18. package/dist/components/sidebar/attributes-block.d.ts.map +1 -1
  19. package/dist/components/sidebar/attributes-block.js +5 -7
  20. package/dist/components/trace-viewer/trace-viewer.module.css +4 -4
  21. package/dist/lib/hydration.d.ts.map +1 -1
  22. package/dist/lib/hydration.js +6 -1
  23. package/dist/lib/zstd-browser-decoder.d.ts +7 -0
  24. package/dist/lib/zstd-browser-decoder.d.ts.map +1 -0
  25. package/dist/lib/zstd-browser-decoder.js +41 -0
  26. package/package.json +6 -5
  27. package/src/components/new-trace-viewer/components/span-markers.tsx +173 -0
  28. package/src/components/new-trace-viewer/components/timeline.tsx +142 -95
  29. package/src/components/new-trace-viewer/components/use-row-window.ts +47 -0
  30. package/src/components/new-trace-viewer/trace-viewer.tsx +108 -23
  31. package/src/components/new-trace-viewer/utils.test.ts +150 -0
  32. package/src/components/new-trace-viewer/utils.ts +61 -34
  33. package/src/components/sidebar/attribute-panel.tsx +5 -43
  34. package/src/components/sidebar/attributes-block.tsx +9 -18
  35. package/src/components/trace-viewer/trace-viewer.module.css +4 -4
  36. package/src/lib/hydration.ts +7 -0
  37. package/src/lib/zstd-browser-decoder.ts +42 -0
@@ -2,23 +2,43 @@
2
2
 
3
3
  import { ArrowLeft, ArrowRight } from 'lucide-react';
4
4
  import type { CSSProperties, ReactNode } from 'react';
5
- import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
5
+ import {
6
+ Fragment,
7
+ memo,
8
+ useCallback,
9
+ useEffect,
10
+ useMemo,
11
+ useRef,
12
+ useState,
13
+ } from 'react';
6
14
  import { cn } from '../../../lib/utils';
7
15
  import {
8
16
  formatDuration,
9
17
  formatDurationPrecise,
10
18
  getHighResInMs,
11
19
  } from '../../trace-viewer/util/timing';
12
- import { Tooltip, TooltipContent, TooltipTrigger } from '../../ui/tooltip';
13
20
  import { isSpanDimmedBySearch, type SpanSearchResult } from '../search';
14
21
  import type { Span } from '../types';
15
- import type { Segment, SegmentStatus, TimeMarker } from '../utils';
22
+ import type {
23
+ OffscreenMarkers,
24
+ Segment,
25
+ SegmentStatus,
26
+ TimeMarker,
27
+ } from '../utils';
16
28
  import {
29
+ computeOffscreenMarkers,
17
30
  computeSpanGaps,
31
+ computeSpanMarkers,
18
32
  computeSpanSegments,
19
33
  getResourceColor,
20
34
  getSpanDurationMs,
21
35
  } from '../utils';
36
+ import {
37
+ cullCollidingMarkers,
38
+ MarkerLayer,
39
+ OffscreenMarkerIndicator,
40
+ projectMarkers,
41
+ } from './span-markers';
22
42
  import styles from './timeline.module.css';
23
43
  import { ROW_HEIGHT_PX, useRowWindow } from './use-row-window';
24
44
 
@@ -33,7 +53,7 @@ export const TIMELINE_PADDING_PX = 16;
33
53
  const SEGMENT_CLASSES: Record<SegmentStatus, string> = {
34
54
  queued: 'bg-gray-400 border border-gray-500',
35
55
  retrying: 'bg-gray-400 border border-gray-500',
36
- waiting: 'bg-gray-400 border border-gray-500',
56
+ waiting: 'bg-gray-200 border border-gray-500',
37
57
  running: 'bg-blue-200 border border-blue-500',
38
58
  completed: 'bg-blue-200 border border-blue-500',
39
59
  failed: 'bg-red-200 border border-red-500',
@@ -194,9 +214,20 @@ function projectSegments(
194
214
  // Small render helpers
195
215
  // ---------------------------------------------------------------------------
196
216
 
197
- function DurationLabel({ label }: { label: string }): ReactNode {
217
+ function DurationLabel({
218
+ label,
219
+ className,
220
+ }: {
221
+ label: string;
222
+ className?: string;
223
+ }): ReactNode {
198
224
  return (
199
- <span className="pointer-events-none absolute inset-0 flex items-center justify-start overflow-hidden px-1 text-[10px] font-mono font-medium leading-none whitespace-nowrap text-left text-gray-1000 tabular-nums opacity-0 group-hover/timeline-row:opacity-100">
225
+ <span
226
+ className={cn(
227
+ 'pointer-events-none absolute inset-0 flex items-center justify-start overflow-hidden px-1 text-[10px] font-mono font-medium leading-none whitespace-nowrap text-left text-gray-1000 tabular-nums opacity-0 group-hover/timeline-row:opacity-100',
228
+ className
229
+ )}
230
+ >
200
231
  {label}
201
232
  </span>
202
233
  );
@@ -258,27 +289,50 @@ function LeadInConnector({
258
289
  );
259
290
  }
260
291
 
261
- function SegmentBar({ segments }: { segments: VisibleSegment[] }): ReactNode {
292
+ function SegmentBar({
293
+ segments,
294
+ showLabels = true,
295
+ }: {
296
+ segments: VisibleSegment[];
297
+ /**
298
+ * When false, segment duration labels are suppressed. Used when a separate
299
+ * top layer (e.g. the resumption-marker duration overlay) renders the label
300
+ * above the markers instead, so we don't draw it twice.
301
+ */
302
+ showLabels?: boolean;
303
+ }): ReactNode {
262
304
  return (
263
305
  <div className="relative h-6 w-full">
264
306
  {segments.map((seg, i) => {
265
307
  if (seg.status === 'queued') {
266
308
  const leadInLabel = formatDurationPrecise(seg.fullDurationMs);
267
309
  const showLeadInLabel =
310
+ showLabels &&
268
311
  seg.pixelWidth >= Math.max(40, leadInLabel.length * 6 + 12);
312
+ const isFullWidthQueued = segments.length === 1;
269
313
  return (
270
- <LeadInConnector
271
- key={i}
272
- leftPct={seg.leftPct}
273
- widthPct={seg.widthPct}
274
- label={showLeadInLabel ? leadInLabel : null}
275
- />
314
+ <Fragment key={i}>
315
+ <LeadInConnector
316
+ leftPct={seg.leftPct}
317
+ widthPct={seg.widthPct}
318
+ label={showLeadInLabel ? leadInLabel : null}
319
+ />
320
+ {isFullWidthQueued ? (
321
+ <div
322
+ className="absolute top-1/2 h-4 w-px -translate-y-1/2 bg-gray-500"
323
+ style={{
324
+ left: `calc(${seg.leftPct + seg.widthPct}% - 1.5px)`,
325
+ }}
326
+ />
327
+ ) : null}
328
+ </Fragment>
276
329
  );
277
330
  }
278
331
 
279
332
  const label = formatDurationPrecise(seg.fullDurationMs);
280
333
  // Only render the label when there's enough room for it without clipping.
281
- const showLabel = seg.pixelWidth >= Math.max(40, label.length * 6 + 12);
334
+ const showLabel =
335
+ showLabels && seg.pixelWidth >= Math.max(40, label.length * 6 + 12);
282
336
 
283
337
  return (
284
338
  <div
@@ -305,80 +359,6 @@ function SegmentBar({ segments }: { segments: VisibleSegment[] }): ReactNode {
305
359
  );
306
360
  }
307
361
 
308
- // ---------------------------------------------------------------------------
309
- // Event markers (attr_set)
310
- // ---------------------------------------------------------------------------
311
-
312
- function formatMarkerTime(ms: number): string {
313
- const date = new Date(ms);
314
- return (
315
- date.toLocaleTimeString('en-US', {
316
- hour: '2-digit',
317
- minute: '2-digit',
318
- second: '2-digit',
319
- hour12: false,
320
- }) +
321
- '.' +
322
- date.getMilliseconds().toString().padStart(3, '0')
323
- );
324
- }
325
-
326
- /**
327
- * Diamond markers for `attr_set` events on a span's timeline row. Attribute
328
- * changes are point-in-time events with no duration, so they don't get a
329
- * status segment — instead they render as small teal diamonds at the moment
330
- * the attributes were written.
331
- */
332
- function AttrSetMarkers({
333
- span,
334
- viewStart,
335
- viewDuration,
336
- }: {
337
- span: Span;
338
- viewStart: number;
339
- viewDuration: number;
340
- }): ReactNode {
341
- const markers = useMemo(
342
- () => span.events.filter((e) => e.name === 'attr_set'),
343
- [span.events]
344
- );
345
-
346
- if (markers.length === 0 || viewDuration <= 0) return null;
347
-
348
- return (
349
- <>
350
- {markers.map((event, index) => {
351
- const timeMs = getHighResInMs(event.timestamp);
352
- const frac = (timeMs - viewStart) / viewDuration;
353
- if (frac < 0 || frac > 1) return null;
354
-
355
- return (
356
- <Tooltip key={`attr-set-${index}`}>
357
- <TooltipTrigger asChild>
358
- <div
359
- aria-label="Attributes set"
360
- className="absolute top-1/2 z-10 flex h-4 w-4 -translate-x-1/2 -translate-y-1/2 cursor-pointer items-center justify-center"
361
- style={{ left: `${frac * 100}%` }}
362
- >
363
- <div
364
- className="h-2 w-2 rotate-45 rounded-[1px] border"
365
- style={{
366
- backgroundColor: 'var(--ds-teal-400)',
367
- borderColor: 'var(--ds-teal-900)',
368
- }}
369
- />
370
- </div>
371
- </TooltipTrigger>
372
- <TooltipContent>
373
- Attributes set · {formatMarkerTime(timeMs)}
374
- </TooltipContent>
375
- </Tooltip>
376
- );
377
- })}
378
- </>
379
- );
380
- }
381
-
382
362
  // ---------------------------------------------------------------------------
383
363
  // TimelineBar
384
364
  // ---------------------------------------------------------------------------
@@ -391,6 +371,7 @@ const TimelineBar = memo(function TimelineBar({
391
371
  isSelected,
392
372
  isDimmed,
393
373
  onSelect,
374
+ onRevealTime,
394
375
  }: {
395
376
  span: Span;
396
377
  viewStart: number;
@@ -399,6 +380,7 @@ const TimelineBar = memo(function TimelineBar({
399
380
  isSelected: boolean;
400
381
  isDimmed?: boolean;
401
382
  onSelect: (spanId: string) => void;
383
+ onRevealTime?: (timeMs: number) => void;
402
384
  }): ReactNode {
403
385
  const startMs = getHighResInMs(span.startTime);
404
386
  const endMs = getHighResInMs(span.endTime);
@@ -425,6 +407,41 @@ const TimelineBar = memo(function TimelineBar({
425
407
  [geometry, baseSegments, startMs, totalDurationMs]
426
408
  );
427
409
 
410
+ const baseMarkers = useMemo(() => computeSpanMarkers(span), [span]);
411
+ const markers = useMemo(
412
+ () =>
413
+ geometry.mode.kind === 'full'
414
+ ? cullCollidingMarkers(
415
+ projectMarkers(
416
+ baseMarkers,
417
+ geometry.visibleStartMs,
418
+ geometry.visibleEndMs
419
+ ),
420
+ geometry.visiblePixelWidth
421
+ )
422
+ : [],
423
+ [geometry, baseMarkers]
424
+ );
425
+
426
+ // Markers that fall outside the visible window (scrolled off while zoomed in)
427
+ // — surfaced as edge indicators so they aren't silently lost.
428
+ const offscreen = useMemo<OffscreenMarkers>(
429
+ () =>
430
+ geometry.mode.kind === 'full'
431
+ ? computeOffscreenMarkers(
432
+ baseMarkers,
433
+ geometry.visibleStartMs,
434
+ geometry.visibleEndMs
435
+ )
436
+ : { left: null, right: null },
437
+ [geometry, baseMarkers]
438
+ );
439
+
440
+ // Markers (visible or off-screen) move the duration label into the overlay,
441
+ // so the in-bar segment label is suppressed.
442
+ const hasMarkers =
443
+ markers.length > 0 || offscreen.left !== null || offscreen.right !== null;
444
+
428
445
  const workflowStatus = (span.attributes.data as Record<string, unknown>)
429
446
  ?.status as string | undefined;
430
447
  const isErrored = span.status.code === 2 || workflowStatus === 'failed';
@@ -458,7 +475,7 @@ const TimelineBar = memo(function TimelineBar({
458
475
  >
459
476
  <div className="absolute inset-y-0" style={TIMELINE_INSET_STYLE}>
460
477
  <div
461
- className="absolute top-1/2 h-6 -translate-y-1/2 rounded-[0.25rem]"
478
+ className="absolute top-1/2 h-6 -translate-y-1/2 overflow-hidden rounded-[0.25rem]"
462
479
  style={getBarPositionStyle(geometry)}
463
480
  >
464
481
  {geometry.mode.kind === 'arrow' ? (
@@ -469,7 +486,7 @@ const TimelineBar = memo(function TimelineBar({
469
486
  style={{ background: fallbackBg, borderColor: fallbackBorder }}
470
487
  />
471
488
  ) : segments.length > 0 ? (
472
- <SegmentBar segments={segments} />
489
+ <SegmentBar segments={segments} showLabels={!hasMarkers} />
473
490
  ) : (
474
491
  <PlainBar
475
492
  bg={fallbackBg}
@@ -478,11 +495,38 @@ const TimelineBar = memo(function TimelineBar({
478
495
  />
479
496
  )}
480
497
  </div>
481
- <AttrSetMarkers
482
- span={span}
483
- viewStart={viewStart}
484
- viewDuration={viewDuration}
485
- />
498
+ {/* Overlay (not clipped): ticks, off-screen indicators, then the duration label on top. */}
499
+ {hasMarkers ? (
500
+ <div
501
+ className="pointer-events-none absolute top-1/2 h-6 -translate-y-1/2"
502
+ style={getBarPositionStyle(geometry)}
503
+ >
504
+ {markers.length > 0 ? <MarkerLayer markers={markers} /> : null}
505
+ {offscreen.left ? (
506
+ <OffscreenMarkerIndicator
507
+ direction="left"
508
+ count={offscreen.left.count}
509
+ targetMs={offscreen.left.nearestMs}
510
+ onReveal={onRevealTime}
511
+ />
512
+ ) : null}
513
+ {offscreen.right ? (
514
+ <OffscreenMarkerIndicator
515
+ direction="right"
516
+ count={offscreen.right.count}
517
+ targetMs={offscreen.right.nearestMs}
518
+ onReveal={onRevealTime}
519
+ />
520
+ ) : null}
521
+ {showTotalLabel ? (
522
+ <DurationLabel
523
+ label={totalLabel}
524
+ // Shift clear of the left edge indicator so it isn't covered.
525
+ className={offscreen.left ? 'pl-10' : undefined}
526
+ />
527
+ ) : null}
528
+ </div>
529
+ ) : null}
486
530
  </div>
487
531
  </div>
488
532
  );
@@ -581,6 +625,7 @@ export function Timeline({
581
625
  selectedId,
582
626
  searchResult,
583
627
  onSelect,
628
+ onRevealTime,
584
629
  hoverFraction,
585
630
  altHeld = false,
586
631
  }: {
@@ -591,6 +636,7 @@ export function Timeline({
591
636
  selectedId: string | null;
592
637
  searchResult: SpanSearchResult;
593
638
  onSelect: (spanId: string) => void;
639
+ onRevealTime?: (timeMs: number) => void;
594
640
  hoverFraction?: number | null;
595
641
  altHeld?: boolean;
596
642
  }): ReactNode {
@@ -663,6 +709,7 @@ export function Timeline({
663
709
  isSelected={selectedId === span.spanId}
664
710
  isDimmed={isSpanDimmedBySearch(span.spanId, searchResult)}
665
711
  onSelect={onSelect}
712
+ onRevealTime={onRevealTime}
666
713
  />
667
714
  ))}
668
715
  </div>
@@ -91,3 +91,50 @@ export function useRowWindow(
91
91
 
92
92
  /** Row height (px) shared by the events list and timeline rows (`h-10`). */
93
93
  export const ROW_HEIGHT_PX = 40;
94
+
95
+ /**
96
+ * Scroll a windowed row into view by index.
97
+ *
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
100
+ * instead. Walks up from `listEl` to the shared scrollable ancestor (the same
101
+ * one `useRowWindow` measures against), only scrolls when the row sits outside
102
+ * the visible area, leaves a one-row `margin` of breathing room past it, and
103
+ * clamps the result to `[0, scrollHeight - clientHeight]`.
104
+ */
105
+ export function scrollRowIntoView(
106
+ listEl: HTMLElement | null,
107
+ index: number,
108
+ rowHeight: number,
109
+ opts?: { margin?: number; behavior?: ScrollBehavior }
110
+ ): void {
111
+ const scroller = listEl ? getScrollParent(listEl) : null;
112
+ if (!listEl || !scroller) return;
113
+
114
+ const margin = opts?.margin ?? rowHeight;
115
+
116
+ // Offset of the list's top within the scroll container's content.
117
+ const listOffset =
118
+ listEl.getBoundingClientRect().top -
119
+ scroller.getBoundingClientRect().top +
120
+ scroller.scrollTop;
121
+
122
+ const rowTop = listOffset + index * rowHeight;
123
+ const rowBottom = rowTop + rowHeight;
124
+ const viewTop = scroller.scrollTop;
125
+ const viewBottom = viewTop + scroller.clientHeight;
126
+
127
+ let top: number | null = null;
128
+ if (rowTop < viewTop) {
129
+ top = rowTop - margin;
130
+ } else if (rowBottom > viewBottom) {
131
+ top = rowBottom + margin - scroller.clientHeight;
132
+ }
133
+ if (top === null) return;
134
+
135
+ const max = scroller.scrollHeight - scroller.clientHeight;
136
+ scroller.scrollTo({
137
+ top: Math.max(0, Math.min(top, max)),
138
+ behavior: opts?.behavior,
139
+ });
140
+ }
@@ -39,6 +39,7 @@ import {
39
39
  TooltipTrigger,
40
40
  } from '../ui/tooltip';
41
41
  import EventList from './components/event-list';
42
+ import { ROW_HEIGHT_PX, scrollRowIntoView } from './components/use-row-window';
42
43
  import { SplitPane } from './components/split-pane';
43
44
  import {
44
45
  TIMELINE_PADDING_PX,
@@ -59,6 +60,8 @@ interface NewTraceViewerProps {
59
60
 
60
61
  const MIN_VIEWPORT_MS = 0.001;
61
62
 
63
+ const ZOOM_DEBOUNCE_MS = 150;
64
+
62
65
  interface Viewport {
63
66
  start: number;
64
67
  end: number;
@@ -197,6 +200,7 @@ function NewTraceViewerContent({
197
200
 
198
201
  const sidebar = useSidebarData();
199
202
  const selectedSpan = useSelectedSpanInfo();
203
+ const reducedMotion = useReducedMotion();
200
204
 
201
205
  const [searchQuery, setSearchQuery] = useState('');
202
206
  const deferredSearchQuery = useDeferredValue(searchQuery);
@@ -249,6 +253,12 @@ function NewTraceViewerContent({
249
253
 
250
254
  const viewDuration = viewport.end - viewport.start;
251
255
 
256
+ // Keep a ref to the live viewport so the reveal callback can read the current
257
+ // zoom without being recreated on every pan (which would bust TimelineBar's
258
+ // memo and re-render every row each animation frame).
259
+ const viewportRef = useRef(viewport);
260
+ viewportRef.current = viewport;
261
+
252
262
  const timeMarkers = useMemo(
253
263
  () => computeTimeMarkers(viewDuration, viewport.start - root.startTime),
254
264
  [viewDuration, viewport.start, root.startTime]
@@ -258,6 +268,29 @@ function NewTraceViewerContent({
258
268
  animateTo({ start: root.startTime, end: root.startTime + root.duration });
259
269
  }, [animateTo, root.startTime, root.duration]);
260
270
 
271
+ // Pan (keeping the current zoom) so `timeMs` is centered in view — used by the
272
+ // off-screen marker indicators to scroll their marker into view.
273
+ const handleRevealTime = useCallback(
274
+ (timeMs: number) => {
275
+ const rootS = root.startTime;
276
+ const rootE = root.startTime + root.duration;
277
+ const { start, end } = viewportRef.current;
278
+ const duration = end - start;
279
+ let newStart = timeMs - duration / 2;
280
+ let newEnd = timeMs + duration / 2;
281
+ if (newStart < rootS) {
282
+ newStart = rootS;
283
+ newEnd = rootS + duration;
284
+ }
285
+ if (newEnd > rootE) {
286
+ newEnd = rootE;
287
+ newStart = Math.max(rootS, rootE - duration);
288
+ }
289
+ animateTo({ start: newStart, end: newEnd });
290
+ },
291
+ [animateTo, root.startTime, root.duration]
292
+ );
293
+
261
294
  const ZOOM_FACTOR = 0.5;
262
295
 
263
296
  const zoomBy = useCallback(
@@ -294,14 +327,8 @@ function NewTraceViewerContent({
294
327
  const zoomIn = useCallback(() => zoomBy(ZOOM_FACTOR), [zoomBy]);
295
328
  const zoomOut = useCallback(() => zoomBy(1 / ZOOM_FACTOR), [zoomBy]);
296
329
 
297
- const handleSelectSpan = useCallback(
330
+ const focusViewportOnSpan = useCallback(
298
331
  (spanId: string) => {
299
- if (spanId === activeSpanId) {
300
- clearActiveSpan();
301
- return;
302
- }
303
- setActiveSpan(spanId);
304
-
305
332
  const span = trace.spans.find((s) => s.spanId === spanId);
306
333
  if (!span) return;
307
334
 
@@ -341,17 +368,74 @@ function NewTraceViewerContent({
341
368
 
342
369
  animateTo({ start: newStart, end: newEnd });
343
370
  },
371
+ [animateTo, trace.spans, root.startTime, root.duration]
372
+ );
373
+
374
+ // Bring a row into view when keyboard/button navigation lands on a span that
375
+ // sits outside the shared scroll container's visible area. The list is
376
+ // windowed, so an off-screen row has no DOM node to `scrollIntoView` —
377
+ // `scrollRowIntoView` computes the target offset from the span's index.
378
+ const scrollSpanIntoView = useCallback(
379
+ (spanId: string) => {
380
+ const index = trace.spans.findIndex((s) => s.spanId === spanId);
381
+ if (index === -1) return;
382
+
383
+ const list =
384
+ scrollContainerRef.current?.querySelector<HTMLElement>('#event-list') ??
385
+ null;
386
+ scrollRowIntoView(list, index, ROW_HEIGHT_PX, {
387
+ behavior: reducedMotion ? 'auto' : 'smooth',
388
+ });
389
+ },
390
+ [trace.spans, reducedMotion]
391
+ );
392
+
393
+ const zoomTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
394
+ const cancelPendingZoom = useCallback(() => {
395
+ if (zoomTimerRef.current !== null) {
396
+ clearTimeout(zoomTimerRef.current);
397
+ zoomTimerRef.current = null;
398
+ }
399
+ }, []);
400
+ useEffect(() => cancelPendingZoom, [cancelPendingZoom]);
401
+
402
+ const handleClearActiveSpan = useCallback(() => {
403
+ cancelPendingZoom();
404
+ clearActiveSpan();
405
+ }, [cancelPendingZoom, clearActiveSpan]);
406
+
407
+ const handleSelectSpan = useCallback(
408
+ (spanId: string) => {
409
+ cancelPendingZoom();
410
+ if (spanId === activeSpanId) {
411
+ clearActiveSpan();
412
+ return;
413
+ }
414
+ setActiveSpan(spanId);
415
+ focusViewportOnSpan(spanId);
416
+ },
344
417
  [
345
- animateTo,
346
- setActiveSpan,
347
- clearActiveSpan,
418
+ cancelPendingZoom,
348
419
  activeSpanId,
349
- trace.spans,
350
- root.startTime,
351
- root.duration,
420
+ clearActiveSpan,
421
+ setActiveSpan,
422
+ focusViewportOnSpan,
352
423
  ]
353
424
  );
354
425
 
426
+ const navigateToSpan = useCallback(
427
+ (spanId: string) => {
428
+ setActiveSpan(spanId);
429
+ scrollSpanIntoView(spanId);
430
+ cancelPendingZoom();
431
+ zoomTimerRef.current = setTimeout(() => {
432
+ zoomTimerRef.current = null;
433
+ focusViewportOnSpan(spanId);
434
+ }, ZOOM_DEBOUNCE_MS);
435
+ },
436
+ [setActiveSpan, scrollSpanIntoView, cancelPendingZoom, focusViewportOnSpan]
437
+ );
438
+
355
439
  const [altHeld, setAltHeld] = useState(false);
356
440
 
357
441
  useEffect(() => {
@@ -368,13 +452,13 @@ function NewTraceViewerContent({
368
452
  e.key === 'k' ? prevSpanIdRef.current : nextSpanIdRef.current;
369
453
  if (targetId) {
370
454
  e.preventDefault();
371
- handleSelectSpanRef.current(targetId);
455
+ navigateToSpanRef.current(targetId);
372
456
  }
373
457
  };
374
458
 
375
459
  const onKeyDown = (e: KeyboardEvent): void => {
376
460
  if (e.key === 'Escape') {
377
- clearActiveSpan();
461
+ handleClearActiveSpan();
378
462
  } else if (e.key === 'Alt') {
379
463
  e.preventDefault();
380
464
  setAltHeld(true);
@@ -395,7 +479,7 @@ function NewTraceViewerContent({
395
479
  window.removeEventListener('keyup', onKeyUp);
396
480
  window.removeEventListener('blur', onBlur);
397
481
  };
398
- }, [clearActiveSpan]);
482
+ }, [handleClearActiveSpan]);
399
483
 
400
484
  const timelineRef = useRef<HTMLDivElement>(null);
401
485
  const [hoverFraction, setHoverFraction] = useState<number | null>(null);
@@ -546,19 +630,19 @@ function NewTraceViewerContent({
546
630
  }, [activeSpanId, trace.spans]);
547
631
 
548
632
  const handleSelectPrevSpan = useCallback(() => {
549
- if (prevSpanId) handleSelectSpan(prevSpanId);
550
- }, [prevSpanId, handleSelectSpan]);
633
+ if (prevSpanId) navigateToSpan(prevSpanId);
634
+ }, [prevSpanId, navigateToSpan]);
551
635
 
552
636
  const handleSelectNextSpan = useCallback(() => {
553
- if (nextSpanId) handleSelectSpan(nextSpanId);
554
- }, [nextSpanId, handleSelectSpan]);
637
+ if (nextSpanId) navigateToSpan(nextSpanId);
638
+ }, [nextSpanId, navigateToSpan]);
555
639
 
556
640
  const prevSpanIdRef = useRef(prevSpanId);
557
641
  const nextSpanIdRef = useRef(nextSpanId);
558
- const handleSelectSpanRef = useRef(handleSelectSpan);
642
+ const navigateToSpanRef = useRef(navigateToSpan);
559
643
  prevSpanIdRef.current = prevSpanId;
560
644
  nextSpanIdRef.current = nextSpanId;
561
- handleSelectSpanRef.current = handleSelectSpan;
645
+ navigateToSpanRef.current = navigateToSpan;
562
646
 
563
647
  return (
564
648
  <div
@@ -642,6 +726,7 @@ function NewTraceViewerContent({
642
726
  selectedId={activeSpanId}
643
727
  searchResult={searchResult}
644
728
  onSelect={handleSelectSpan}
729
+ onRevealTime={handleRevealTime}
645
730
  hoverFraction={hoverFraction}
646
731
  altHeld={altHeld}
647
732
  />
@@ -724,7 +809,7 @@ function NewTraceViewerContent({
724
809
  <IconButton
725
810
  aria-label="Close span details"
726
811
  aria-keyshortcuts="Escape"
727
- onClick={clearActiveSpan}
812
+ onClick={handleClearActiveSpan}
728
813
  >
729
814
  <X className="w-4 h-4" />
730
815
  </IconButton>