@workflow/web-shared 5.0.0-beta.10 → 5.0.0-beta.12

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 (58) hide show
  1. package/dist/components/index.d.ts +1 -0
  2. package/dist/components/index.d.ts.map +1 -1
  3. package/dist/components/index.js +2 -1
  4. package/dist/components/new-trace-viewer/components/event-list.d.ts.map +1 -1
  5. package/dist/components/new-trace-viewer/components/event-list.js +9 -4
  6. package/dist/components/new-trace-viewer/components/split-pane.d.ts +3 -2
  7. package/dist/components/new-trace-viewer/components/split-pane.d.ts.map +1 -1
  8. package/dist/components/new-trace-viewer/components/split-pane.js +10 -4
  9. package/dist/components/new-trace-viewer/components/timeline.d.ts +1 -1
  10. package/dist/components/new-trace-viewer/components/timeline.d.ts.map +1 -1
  11. package/dist/components/new-trace-viewer/components/timeline.js +6 -8
  12. package/dist/components/new-trace-viewer/components/trace-viewer-skeleton.d.ts +2 -0
  13. package/dist/components/new-trace-viewer/components/trace-viewer-skeleton.d.ts.map +1 -0
  14. package/dist/components/new-trace-viewer/components/trace-viewer-skeleton.js +19 -0
  15. package/dist/components/new-trace-viewer/components/use-row-window.d.ts +20 -0
  16. package/dist/components/new-trace-viewer/components/use-row-window.d.ts.map +1 -0
  17. package/dist/components/new-trace-viewer/components/use-row-window.js +69 -0
  18. package/dist/components/new-trace-viewer/context.d.ts.map +1 -1
  19. package/dist/components/new-trace-viewer/context.js +2 -2
  20. package/dist/components/new-trace-viewer/trace-viewer.d.ts +4 -1
  21. package/dist/components/new-trace-viewer/trace-viewer.d.ts.map +1 -1
  22. package/dist/components/new-trace-viewer/trace-viewer.js +19 -6
  23. package/dist/components/trace-viewer-new.d.ts +5 -1
  24. package/dist/components/trace-viewer-new.d.ts.map +1 -1
  25. package/dist/components/trace-viewer-new.js +7 -8
  26. package/dist/components/ui/icon-button.d.ts +8 -2
  27. package/dist/components/ui/icon-button.d.ts.map +1 -1
  28. package/dist/components/ui/icon-button.js +4 -4
  29. package/dist/components/ui/kbd.d.ts +6 -0
  30. package/dist/components/ui/kbd.d.ts.map +1 -0
  31. package/dist/components/ui/kbd.js +6 -0
  32. package/dist/components/ui/tooltip.d.ts +10 -0
  33. package/dist/components/ui/tooltip.d.ts.map +1 -0
  34. package/dist/components/ui/tooltip.js +12 -0
  35. package/dist/hooks/use-intersection-observer.d.ts +17 -0
  36. package/dist/hooks/use-intersection-observer.d.ts.map +1 -0
  37. package/dist/hooks/use-intersection-observer.js +29 -0
  38. package/dist/hooks/use-load-more-on-scroll.d.ts +21 -0
  39. package/dist/hooks/use-load-more-on-scroll.d.ts.map +1 -0
  40. package/dist/hooks/use-load-more-on-scroll.js +28 -0
  41. package/dist/lib/hydration.d.ts.map +1 -1
  42. package/dist/lib/hydration.js +26 -2
  43. package/package.json +4 -4
  44. package/src/components/index.ts +1 -0
  45. package/src/components/new-trace-viewer/components/event-list.tsx +19 -11
  46. package/src/components/new-trace-viewer/components/split-pane.tsx +15 -2
  47. package/src/components/new-trace-viewer/components/timeline.tsx +27 -19
  48. package/src/components/new-trace-viewer/components/trace-viewer-skeleton.tsx +94 -0
  49. package/src/components/new-trace-viewer/components/use-row-window.ts +93 -0
  50. package/src/components/new-trace-viewer/context.tsx +1 -3
  51. package/src/components/new-trace-viewer/trace-viewer.tsx +90 -22
  52. package/src/components/trace-viewer-new.tsx +20 -12
  53. package/src/components/ui/icon-button.tsx +7 -11
  54. package/src/components/ui/kbd.tsx +21 -0
  55. package/src/components/ui/tooltip.tsx +47 -0
  56. package/src/hooks/use-intersection-observer.ts +57 -0
  57. package/src/hooks/use-load-more-on-scroll.ts +49 -0
  58. package/src/lib/hydration.ts +28 -1
@@ -4,6 +4,7 @@ import {
4
4
  Children,
5
5
  type ReactNode,
6
6
  type PointerEvent as ReactPointerEvent,
7
+ type RefObject,
7
8
  useCallback,
8
9
  useEffect,
9
10
  useRef,
@@ -28,6 +29,7 @@ export interface SplitPaneProps {
28
29
  startHeader?: ReactNode;
29
30
  /** Fixed (non-scrolling) header rendered above the end pane. */
30
31
  endHeader?: ReactNode;
32
+ scrollContainerRef?: RefObject<HTMLDivElement | null>;
31
33
  }
32
34
 
33
35
  export function SplitPane({
@@ -36,6 +38,7 @@ export function SplitPane({
36
38
  defaultStartWidth = DEFAULT_START_PX,
37
39
  startHeader,
38
40
  endHeader,
41
+ scrollContainerRef,
39
42
  }: SplitPaneProps) {
40
43
  const parts = Children.toArray(children);
41
44
  if (parts.length !== 2) {
@@ -57,6 +60,16 @@ export function SplitPane({
57
60
  };
58
61
  }, []);
59
62
 
63
+ const setContainerRef = useCallback(
64
+ (node: HTMLDivElement | null) => {
65
+ containerRef.current = node;
66
+ if (scrollContainerRef) {
67
+ scrollContainerRef.current = node;
68
+ }
69
+ },
70
+ [scrollContainerRef]
71
+ );
72
+
60
73
  const clampPx = useCallback((px: number) => {
61
74
  const el = containerRef.current;
62
75
  if (!el) return px;
@@ -152,7 +165,7 @@ export function SplitPane({
152
165
  <div>{endHeader}</div>
153
166
  </div>
154
167
  <div
155
- ref={containerRef}
168
+ ref={setContainerRef}
156
169
  className={cn(
157
170
  'grid flex-1 min-h-0 overflow-x-hidden overflow-y-auto',
158
171
  isDragging && 'select-none'
@@ -169,7 +182,7 @@ export function SplitPane({
169
182
 
170
183
  return (
171
184
  <div
172
- ref={containerRef}
185
+ ref={setContainerRef}
173
186
  className={cn(
174
187
  'grid h-full min-h-0 content-start overflow-x-hidden overflow-y-auto',
175
188
  isDragging && 'select-none',
@@ -6,14 +6,15 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
6
6
  import { cn } from '../../../lib/utils';
7
7
  import type { Span } from '../../trace-viewer/types';
8
8
  import { formatDuration, getHighResInMs } from '../../trace-viewer/util/timing';
9
- import type { Segment, SegmentStatus, TimeMarker } from '../utils';
10
9
  import { isSpanDimmedBySearch, type SpanSearchResult } from '../search';
10
+ import type { Segment, SegmentStatus, TimeMarker } from '../utils';
11
11
  import {
12
12
  computeSpanGaps,
13
13
  computeSpanSegments,
14
14
  getResourceColor,
15
15
  getSpanDurationMs,
16
16
  } from '../utils';
17
+ import { ROW_HEIGHT_PX, useRowWindow } from './use-row-window';
17
18
 
18
19
  // ---------------------------------------------------------------------------
19
20
  // Constants
@@ -365,10 +366,6 @@ export { TimelineBar };
365
366
  // DeltaIndicator (Alt-key gap overlay)
366
367
  // ---------------------------------------------------------------------------
367
368
 
368
- // Row and indicator sizes are local to DeltaIndicator since it's the only
369
- // place that needs to compute Y positions from a row index. ROW_HEIGHT must
370
- // match the `h-10` (40px) row used in TimelineBar.
371
- const DELTA_ROW_HEIGHT_PX = 40;
372
369
  const DELTA_CAP_HEIGHT_PX = 8;
373
370
  // Vertical offset to sit the indicator inside the gap between row N and N+1,
374
371
  // aligned with where the bar starts in the next row (rows center a 24px bar
@@ -386,7 +383,7 @@ const DeltaIndicator = memo(function DeltaIndicator({
386
383
  label: string;
387
384
  rowIndex: number;
388
385
  }) {
389
- const centerY = DELTA_ROW_OFFSET_PX + (rowIndex + 1) * DELTA_ROW_HEIGHT_PX;
386
+ const centerY = DELTA_ROW_OFFSET_PX + (rowIndex + 1) * ROW_HEIGHT_PX;
390
387
 
391
388
  return (
392
389
  <div
@@ -473,6 +470,11 @@ export function Timeline({
473
470
  const [containerWidth, setContainerWidth] = useState(0);
474
471
  const viewDuration = viewEnd - viewStart;
475
472
  const timelineWidth = Math.max(0, containerWidth - TIMELINE_PADDING_PX * 2);
473
+ const { start, end } = useRowWindow(
474
+ containerRef,
475
+ spans.length,
476
+ ROW_HEIGHT_PX
477
+ );
476
478
 
477
479
  useEffect(() => {
478
480
  const el = containerRef.current;
@@ -490,7 +492,11 @@ export function Timeline({
490
492
  );
491
493
 
492
494
  return (
493
- <div ref={containerRef} className="relative h-full overflow-hidden">
495
+ <div
496
+ ref={containerRef}
497
+ className="relative h-full overflow-hidden"
498
+ style={{ minHeight: spans.length * ROW_HEIGHT_PX }}
499
+ >
494
500
  <div
495
501
  aria-hidden
496
502
  className="absolute inset-y-0 pointer-events-none"
@@ -518,18 +524,20 @@ export function Timeline({
518
524
  />
519
525
  </div>
520
526
  )}
521
- {spans.map((span) => (
522
- <TimelineBar
523
- key={span.spanId}
524
- span={span}
525
- viewStart={viewStart}
526
- viewDuration={viewDuration}
527
- containerWidth={timelineWidth}
528
- isSelected={selectedId === span.spanId}
529
- isDimmed={isSpanDimmedBySearch(span.spanId, searchResult)}
530
- onSelect={onSelect}
531
- />
532
- ))}
527
+ <div style={{ transform: `translateY(${start * ROW_HEIGHT_PX}px)` }}>
528
+ {spans.slice(start, end).map((span) => (
529
+ <TimelineBar
530
+ key={span.spanId}
531
+ span={span}
532
+ viewStart={viewStart}
533
+ viewDuration={viewDuration}
534
+ containerWidth={timelineWidth}
535
+ isSelected={selectedId === span.spanId}
536
+ isDimmed={isSpanDimmedBySearch(span.spanId, searchResult)}
537
+ onSelect={onSelect}
538
+ />
539
+ ))}
540
+ </div>
533
541
  {altHeld && (
534
542
  <div
535
543
  aria-hidden
@@ -0,0 +1,94 @@
1
+ import { Skeleton } from '../../ui/skeleton';
2
+
3
+ const COL_TEMPLATE = '340px 1px minmax(50px, 1fr)';
4
+
5
+ const ROWS: { id: string; name: number; off: number; bar: number }[] = [
6
+ { id: 'r0', name: 62, off: 0, bar: 72 },
7
+ { id: 'r1', name: 78, off: 6, bar: 48 },
8
+ { id: 'r2', name: 50, off: 10, bar: 55 },
9
+ { id: 'r3', name: 84, off: 18, bar: 30 },
10
+ { id: 'r4', name: 45, off: 18, bar: 42 },
11
+ { id: 'r5', name: 66, off: 34, bar: 38 },
12
+ { id: 'r6', name: 55, off: 41, bar: 25 },
13
+ { id: 'r7', name: 40, off: 50, bar: 30 },
14
+ ];
15
+
16
+ const HEADER_MARKERS = ['m0', 'm1', 'm2', 'm3'];
17
+
18
+ const HeaderDivider = () => (
19
+ <div className="flex justify-center">
20
+ <span aria-hidden className="h-full w-px bg-gray-alpha-400" />
21
+ </div>
22
+ );
23
+
24
+ export function TraceViewerSkeleton() {
25
+ return (
26
+ <output
27
+ aria-busy="true"
28
+ aria-label="Loading trace"
29
+ className="flex flex-col w-full h-full min-h-0 bg-background-100"
30
+ >
31
+ <span className="sr-only">Loading trace…</span>
32
+
33
+ {/* Header row: search header | divider | timeline header */}
34
+ <div
35
+ className="shrink-0 grid"
36
+ style={{ gridTemplateColumns: COL_TEMPLATE }}
37
+ >
38
+ <div className="h-10 min-h-10 flex items-center border-b border-gray-alpha-400 pl-4 pr-2 gap-1.5">
39
+ <Skeleton className="w-3.5 h-3.5 shrink-0 rounded-sm" />
40
+ <Skeleton className="h-3.5 w-40" />
41
+ </div>
42
+ <HeaderDivider />
43
+ <div className="h-10 min-h-10 flex items-end border-b border-gray-alpha-400 px-4 pb-1 gap-2">
44
+ <div className="relative flex-1 flex items-end justify-between">
45
+ {HEADER_MARKERS.map((id) => (
46
+ <Skeleton key={id} className="h-3.5 w-9" />
47
+ ))}
48
+ </div>
49
+ </div>
50
+ </div>
51
+
52
+ {/* Content row: event list | gutter | timeline */}
53
+ <div
54
+ className="grid flex-1 min-h-0 overflow-hidden"
55
+ style={{ gridTemplateColumns: COL_TEMPLATE }}
56
+ >
57
+ {/* Sidebar event rows */}
58
+ <div className="block overflow-visible">
59
+ <ul className="block divide-y divide-gray-alpha-400 border-b border-gray-alpha-400">
60
+ {ROWS.map((row) => (
61
+ <li key={row.id} className="h-10 flex items-center pl-4 pr-2">
62
+ <div className="flex min-w-0 flex-1 items-center gap-2">
63
+ <Skeleton className="w-4 h-4 shrink-0 rounded-sm" />
64
+ <Skeleton
65
+ className="h-3.5"
66
+ style={{ width: `${row.name}%` }}
67
+ />
68
+ </div>
69
+ <Skeleton className="ml-2 h-3.5 w-10 shrink-0" />
70
+ </li>
71
+ ))}
72
+ </ul>
73
+ </div>
74
+
75
+ {/* Gutter */}
76
+ <span aria-hidden className="h-full w-px bg-gray-alpha-400" />
77
+
78
+ {/* Timeline bars */}
79
+ <div className="relative min-h-0">
80
+ {ROWS.map((row) => (
81
+ <div key={row.id} className="relative h-10">
82
+ <div className="absolute inset-x-4 inset-y-0">
83
+ <Skeleton
84
+ className="absolute top-1/2 -translate-y-1/2 h-6 rounded-[0.25rem]"
85
+ style={{ left: `${row.off}%`, width: `${row.bar}%` }}
86
+ />
87
+ </div>
88
+ </div>
89
+ ))}
90
+ </div>
91
+ </div>
92
+ </output>
93
+ );
94
+ }
@@ -0,0 +1,93 @@
1
+ 'use client';
2
+
3
+ import { type RefObject, useEffect, useState } from 'react';
4
+
5
+ /** Rows rendered on the first paint, before the container has been measured. */
6
+ const INITIAL_VISIBLE_ROWS = 60;
7
+
8
+ export interface RowWindow {
9
+ /** First visible row index (inclusive), already padded by overscan. */
10
+ start: number;
11
+ /** Last visible row index (exclusive), already padded by overscan. */
12
+ end: number;
13
+ }
14
+
15
+ /** Nearest scrollable ancestor of `el` (the shared `SplitPane` container). */
16
+ function getScrollParent(el: HTMLElement | null): HTMLElement | null {
17
+ let node = el?.parentElement ?? null;
18
+ while (node) {
19
+ const overflowY = getComputedStyle(node).overflowY;
20
+ if (overflowY === 'auto' || overflowY === 'scroll') return node;
21
+ node = node.parentElement;
22
+ }
23
+ return null;
24
+ }
25
+
26
+ /**
27
+ * Fixed-height windowing over a shared scroll container.
28
+ *
29
+ * The events list and timeline both render one `rowHeight`-tall row per span
30
+ * and scroll together inside the same `SplitPane`. Each caller passes a ref to
31
+ * its own root; the hook walks up to the shared scrollable ancestor, so the
32
+ * scroll container doesn't have to be threaded through props. Same scroll
33
+ * position + same row height means both panes window to the same range.
34
+ */
35
+ export function useRowWindow(
36
+ ref: RefObject<HTMLElement | null>,
37
+ rowCount: number,
38
+ rowHeight: number,
39
+ overscan = 12
40
+ ): RowWindow {
41
+ // Bound the pre-measurement window so the first paint of a large trace
42
+ // doesn't render every row before the effect narrows it down.
43
+ const [range, setRange] = useState<RowWindow>(() => ({
44
+ start: 0,
45
+ end: Math.min(rowCount, INITIAL_VISIBLE_ROWS),
46
+ }));
47
+
48
+ useEffect(() => {
49
+ const self = ref.current;
50
+ const el = getScrollParent(self);
51
+ if (!self || !el) return;
52
+
53
+ let raf = 0;
54
+ const measure = () => {
55
+ raf = 0;
56
+ // Offset of the list's top within the scroll container's content, so the
57
+ // window stays correct even if the list isn't flush with the top.
58
+ const offset =
59
+ self.getBoundingClientRect().top -
60
+ el.getBoundingClientRect().top +
61
+ el.scrollTop;
62
+ const top = el.scrollTop - offset;
63
+ const start = Math.max(0, Math.floor(top / rowHeight) - overscan);
64
+ const end = Math.min(
65
+ rowCount,
66
+ Math.ceil((top + el.clientHeight) / rowHeight) + overscan
67
+ );
68
+ setRange((prev) =>
69
+ prev.start === start && prev.end === end ? prev : { start, end }
70
+ );
71
+ };
72
+
73
+ const onScroll = () => {
74
+ if (!raf) raf = requestAnimationFrame(measure);
75
+ };
76
+
77
+ measure();
78
+ el.addEventListener('scroll', onScroll, { passive: true });
79
+ const ro = new ResizeObserver(measure);
80
+ ro.observe(el);
81
+
82
+ return () => {
83
+ el.removeEventListener('scroll', onScroll);
84
+ ro.disconnect();
85
+ if (raf) cancelAnimationFrame(raf);
86
+ };
87
+ }, [ref, rowCount, rowHeight, overscan]);
88
+
89
+ return range;
90
+ }
91
+
92
+ /** Row height (px) shared by the events list and timeline rows (`h-10`). */
93
+ export const ROW_HEIGHT_PX = 40;
@@ -28,9 +28,7 @@ export function ActiveSpanProvider({
28
28
  spans: Span[];
29
29
  children: ReactNode;
30
30
  }) {
31
- const [activeSpanId, setActiveSpanId] = useState<string | null>(
32
- spans[0]?.spanId ?? null
33
- );
31
+ const [activeSpanId, setActiveSpanId] = useState<string | null>(null);
34
32
 
35
33
  useEffect(() => {
36
34
  setActiveSpanId((currentSpanId) => {
@@ -19,6 +19,7 @@ import {
19
19
  useRef,
20
20
  useState,
21
21
  } from 'react';
22
+ import { useLoadMoreOnScroll } from '../../hooks/use-load-more-on-scroll';
22
23
  import { useReducedMotion } from '../../hooks/use-reduced-motion';
23
24
  import { ErrorBoundary } from '../error-boundary';
24
25
  import {
@@ -29,6 +30,8 @@ import { useSidebarDataOptional } from '../sidebar/sidebar-data-context';
29
30
  import type { Trace } from '../trace-viewer/types';
30
31
  import { formatDuration, getHighResInMs } from '../trace-viewer/util/timing';
31
32
  import { IconButton } from '../ui/icon-button';
33
+ import { Spinner } from '../ui/spinner';
34
+ import { Kbd } from '../ui/kbd';
32
35
  import EventList from './components/event-list';
33
36
  import { SplitPane } from './components/split-pane';
34
37
  import {
@@ -36,6 +39,12 @@ import {
36
39
  Timeline,
37
40
  TimelineHeader,
38
41
  } from './components/timeline';
42
+ import {
43
+ Tooltip,
44
+ TooltipContent,
45
+ TooltipProvider,
46
+ TooltipTrigger,
47
+ } from '../ui/tooltip';
39
48
  import { ActiveSpanProvider, useActiveSpan } from './context';
40
49
  import { DetailPanel } from './detail-panel';
41
50
  import { searchSpans } from './search';
@@ -43,6 +52,9 @@ import { computeRootBounds, computeTimeMarkers } from './utils';
43
52
 
44
53
  interface NewTraceViewerProps {
45
54
  trace: Trace;
55
+ onLoadMore?: () => void | Promise<void>;
56
+ hasMore?: boolean;
57
+ isLoadingMore?: boolean;
46
58
  }
47
59
 
48
60
  const MIN_VIEWPORT_MS = 0.001;
@@ -152,15 +164,32 @@ function useSelectedSpanInfo(): SelectedSpanInfo | null {
152
164
  // Root component
153
165
  // ---------------------------------------------------------------------------
154
166
 
155
- export function NewTraceViewer({ trace }: NewTraceViewerProps): ReactNode {
167
+ export function NewTraceViewer({
168
+ trace,
169
+ onLoadMore,
170
+ hasMore,
171
+ isLoadingMore,
172
+ }: NewTraceViewerProps): ReactNode {
156
173
  return (
157
- <ActiveSpanProvider spans={trace.spans}>
158
- <NewTraceViewerContent trace={trace} />
159
- </ActiveSpanProvider>
174
+ <TooltipProvider delayDuration={300}>
175
+ <ActiveSpanProvider spans={trace.spans}>
176
+ <NewTraceViewerContent
177
+ trace={trace}
178
+ onLoadMore={onLoadMore}
179
+ hasMore={hasMore}
180
+ isLoadingMore={isLoadingMore}
181
+ />
182
+ </ActiveSpanProvider>
183
+ </TooltipProvider>
160
184
  );
161
185
  }
162
186
 
163
- function NewTraceViewerContent({ trace }: NewTraceViewerProps): ReactNode {
187
+ function NewTraceViewerContent({
188
+ trace,
189
+ onLoadMore,
190
+ hasMore,
191
+ isLoadingMore,
192
+ }: NewTraceViewerProps): ReactNode {
164
193
  const { activeSpan, activeSpanId, setActiveSpan, clearActiveSpan } =
165
194
  useActiveSpan();
166
195
 
@@ -177,6 +206,16 @@ function NewTraceViewerContent({ trace }: NewTraceViewerProps): ReactNode {
177
206
 
178
207
  const root = useMemo(() => computeRootBounds(trace.spans), [trace.spans]);
179
208
 
209
+ const scrollContainerRef = useRef<HTMLDivElement>(null);
210
+ const loadMore = useCallback(() => {
211
+ void onLoadMore?.();
212
+ }, [onLoadMore]);
213
+ const loadMoreSentinelRef = useLoadMoreOnScroll(loadMore, {
214
+ hasMore: Boolean(onLoadMore && hasMore),
215
+ isLoadingMore: Boolean(isLoadingMore),
216
+ rootRef: scrollContainerRef,
217
+ });
218
+
180
219
  const { viewport, setViewport, animateTo } = useAnimatedViewport({
181
220
  start: root.startTime,
182
221
  end: root.startTime + root.duration,
@@ -523,13 +562,14 @@ function NewTraceViewerContent({ trace }: NewTraceViewerProps): ReactNode {
523
562
  <div
524
563
  data-pane="pane-root"
525
564
  data-has-detail={activeSpan ? '' : undefined}
526
- className="grid w-full h-full max-h-full grid-cols-[minmax(100px,1fr)] data-[has-detail]:grid-cols-[minmax(100px,1fr)_clamp(280px,420px,100%)]"
565
+ className="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%)]"
527
566
  >
528
567
  <div
529
568
  id="trace-parent"
530
569
  className="grid grid-rows-[1fr] h-full min-h-0 overflow-hidden relative bg-background-100"
531
570
  >
532
571
  <SplitPane
572
+ scrollContainerRef={scrollContainerRef}
533
573
  startHeader={
534
574
  <div className="bg-background-100 border-b border-gray-alpha-400 h-10 min-h-10 flex items-center pl-4 pr-2 gap-1.5">
535
575
  <Search className="w-3.5 h-3.5 shrink-0 text-gray-800" />
@@ -575,6 +615,14 @@ function NewTraceViewerContent({ trace }: NewTraceViewerProps): ReactNode {
575
615
  searchResult={searchResult}
576
616
  onSelectSpan={handleSelectSpan}
577
617
  />
618
+ <div ref={loadMoreSentinelRef} className="flex justify-center">
619
+ {isLoadingMore ? (
620
+ <div className="flex items-center justify-center gap-2 py-3 text-sm text-gray-800">
621
+ <Spinner size={14} />
622
+ <span>Loading spans…</span>
623
+ </div>
624
+ ) : null}
625
+ </div>
578
626
  </div>
579
627
  {/* biome-ignore lint/a11y/noStaticElementInteractions: timeline hover and wheel gestures are pointer-only annotations */}
580
628
  <div
@@ -634,22 +682,42 @@ function NewTraceViewerContent({ trace }: NewTraceViewerProps): ReactNode {
634
682
  {selectedSpanName}
635
683
  </span>
636
684
  <div className="flex items-center gap-0.5 shrink-0">
637
- <IconButton
638
- aria-label="Navigate to previous span"
639
- aria-keyshortcuts="K"
640
- onClick={handleSelectPrevSpan}
641
- disabled={!prevSpanId}
642
- >
643
- <ChevronUp className="w-4 h-4" />
644
- </IconButton>
645
- <IconButton
646
- aria-label="Navigate to next span"
647
- aria-keyshortcuts="J"
648
- onClick={handleSelectNextSpan}
649
- disabled={!nextSpanId}
650
- >
651
- <ChevronDown className="w-4 h-4" />
652
- </IconButton>
685
+ <Tooltip>
686
+ <TooltipTrigger asChild>
687
+ <IconButton
688
+ aria-label="Navigate up"
689
+ aria-keyshortcuts="K"
690
+ onClick={handleSelectPrevSpan}
691
+ disabled={!prevSpanId}
692
+ >
693
+ <ChevronUp className="w-4 h-4" />
694
+ </IconButton>
695
+ </TooltipTrigger>
696
+ {prevSpanId ? (
697
+ <TooltipContent>
698
+ Navigate up
699
+ <Kbd>K</Kbd>
700
+ </TooltipContent>
701
+ ) : null}
702
+ </Tooltip>
703
+ <Tooltip>
704
+ <TooltipTrigger asChild>
705
+ <IconButton
706
+ aria-label="Navigate down"
707
+ aria-keyshortcuts="J"
708
+ onClick={handleSelectNextSpan}
709
+ disabled={!nextSpanId}
710
+ >
711
+ <ChevronDown className="w-4 h-4" />
712
+ </IconButton>
713
+ </TooltipTrigger>
714
+ {nextSpanId ? (
715
+ <TooltipContent>
716
+ Navigate down
717
+ <Kbd>J</Kbd>
718
+ </TooltipContent>
719
+ ) : null}
720
+ </Tooltip>
653
721
  <div aria-hidden className="w-px h-4 bg-gray-alpha-400 mx-1" />
654
722
  <IconButton
655
723
  aria-label="Close span details"
@@ -1,11 +1,12 @@
1
- import { useMemo } from 'react';
2
1
  import type { WorkflowRun } from '@workflow/core/runtime';
3
2
  import type { Event } from '@workflow/world';
3
+ import { useMemo } from 'react';
4
4
  import { buildTrace, type TraceWithMeta } from '../lib/trace-builder';
5
+ import { TraceViewerSkeleton } from './new-trace-viewer/components/trace-viewer-skeleton';
5
6
  import { NewTraceViewer as NewTraceViewerComponent } from './new-trace-viewer/trace-viewer';
6
7
  import {
7
- SidebarDataProvider,
8
8
  type SidebarDataContextValue,
9
+ SidebarDataProvider,
9
10
  } from './sidebar/sidebar-data-context';
10
11
  import type { Trace } from './trace-viewer/types';
11
12
 
@@ -13,34 +14,41 @@ const NewTraceViewer = ({
13
14
  run,
14
15
  events,
15
16
  sidebarData,
17
+ onLoadMore,
18
+ hasMore,
19
+ isLoadingMore,
20
+ loading = false,
16
21
  }: {
17
22
  run: WorkflowRun;
18
23
  events: Event[];
19
24
  sidebarData: SidebarDataContextValue;
25
+ onLoadMore?: () => void | Promise<void>;
26
+ hasMore?: boolean;
27
+ isLoadingMore?: boolean;
28
+ loading?: boolean;
20
29
  }) => {
21
- // Build trace only when actual data changes — no timer-driven rebuilds.
22
- // Active span widths are animated imperatively by useLiveTick at 60fps.
23
30
  const traceWithMeta: TraceWithMeta | undefined = useMemo(() => {
24
31
  if (!run?.runId) {
25
32
  return undefined;
26
33
  }
27
34
  return buildTrace(run, events, new Date());
28
- // eslint-disable-next-line react-hooks/exhaustive-deps -- `new Date()` is intentionally not a dep; useLiveTick handles live growth
35
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- `new Date()` is intentionally not a dep
29
36
  }, [run, events]);
30
37
  const trace = traceWithMeta;
31
38
 
32
- if (!trace) {
33
- return (
34
- <div className="relative w-full h-full flex items-center justify-center">
35
- <div className="text-gray-500 text-sm">Loading trace…</div>
36
- </div>
37
- );
39
+ if (!trace || (loading && events.length === 0)) {
40
+ return <TraceViewerSkeleton />;
38
41
  }
39
42
 
40
43
  return (
41
44
  <SidebarDataProvider value={sidebarData}>
42
45
  <div className="relative w-full h-full flex">
43
- <NewTraceViewerComponent trace={trace as Trace} />
46
+ <NewTraceViewerComponent
47
+ trace={trace as Trace}
48
+ onLoadMore={onLoadMore}
49
+ hasMore={hasMore}
50
+ isLoadingMore={isLoadingMore}
51
+ />
44
52
  </div>
45
53
  </SidebarDataProvider>
46
54
  );
@@ -1,5 +1,5 @@
1
1
  import { cva, type VariantProps } from 'class-variance-authority';
2
- import type * as React from 'react';
2
+ import * as React from 'react';
3
3
  import { cn } from '../../lib/utils';
4
4
 
5
5
  const iconButtonVariants = cva(
@@ -38,20 +38,16 @@ export type IconButtonProps = Omit<
38
38
  type?: 'button' | 'submit' | 'reset';
39
39
  };
40
40
 
41
- export function IconButton({
42
- className,
43
- variant,
44
- size,
45
- type = 'button',
46
- ...props
47
- }: IconButtonProps) {
48
- return (
41
+ export const IconButton = React.forwardRef<HTMLButtonElement, IconButtonProps>(
42
+ ({ className, variant, size, type = 'button', ...props }, ref) => (
49
43
  <button
44
+ ref={ref}
50
45
  type={type}
51
46
  className={cn(iconButtonVariants({ variant, size, className }))}
52
47
  {...props}
53
48
  />
54
- );
55
- }
49
+ )
50
+ );
51
+ IconButton.displayName = 'IconButton';
56
52
 
57
53
  export { iconButtonVariants };
@@ -0,0 +1,21 @@
1
+ import type { ReactNode } from 'react';
2
+ import { cn } from '../../lib/utils';
3
+
4
+ export function Kbd({
5
+ children,
6
+ className,
7
+ }: {
8
+ children: ReactNode;
9
+ className?: string;
10
+ }): ReactNode {
11
+ return (
12
+ <kbd
13
+ className={cn(
14
+ 'inline-flex h-5 min-w-5 items-center justify-center rounded bg-background-100/20 px-1 text-center font-sans text-xs leading-none',
15
+ className
16
+ )}
17
+ >
18
+ {children}
19
+ </kbd>
20
+ );
21
+ }