@workflow/web-shared 5.0.0-beta.2 → 5.0.0-beta.4

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 (161) hide show
  1. package/dist/components/index.d.ts +2 -0
  2. package/dist/components/index.d.ts.map +1 -1
  3. package/dist/components/index.js +2 -0
  4. package/dist/components/index.js.map +1 -1
  5. package/dist/components/new-trace-viewer/components/copy-button.d.ts +9 -0
  6. package/dist/components/new-trace-viewer/components/copy-button.d.ts.map +1 -0
  7. package/dist/components/new-trace-viewer/components/copy-button.js +27 -0
  8. package/dist/components/new-trace-viewer/components/copy-button.js.map +1 -0
  9. package/dist/components/new-trace-viewer/components/event-list.d.ts +8 -0
  10. package/dist/components/new-trace-viewer/components/event-list.d.ts.map +1 -0
  11. package/dist/components/new-trace-viewer/components/event-list.js +38 -0
  12. package/dist/components/new-trace-viewer/components/event-list.js.map +1 -0
  13. package/dist/components/new-trace-viewer/components/middle-truncate/copy-selection.d.ts +18 -0
  14. package/dist/components/new-trace-viewer/components/middle-truncate/copy-selection.d.ts.map +1 -0
  15. package/dist/components/new-trace-viewer/components/middle-truncate/copy-selection.js +54 -0
  16. package/dist/components/new-trace-viewer/components/middle-truncate/copy-selection.js.map +1 -0
  17. package/dist/components/new-trace-viewer/components/middle-truncate/middle-truncate.d.ts +20 -0
  18. package/dist/components/new-trace-viewer/components/middle-truncate/middle-truncate.d.ts.map +1 -0
  19. package/dist/components/new-trace-viewer/components/middle-truncate/middle-truncate.js +81 -0
  20. package/dist/components/new-trace-viewer/components/middle-truncate/middle-truncate.js.map +1 -0
  21. package/dist/components/new-trace-viewer/components/middle-truncate/truncate.d.ts +25 -0
  22. package/dist/components/new-trace-viewer/components/middle-truncate/truncate.d.ts.map +1 -0
  23. package/dist/components/new-trace-viewer/components/middle-truncate/truncate.js +101 -0
  24. package/dist/components/new-trace-viewer/components/middle-truncate/truncate.js.map +1 -0
  25. package/dist/components/new-trace-viewer/components/middle-truncate/use-middle-truncate.d.ts +20 -0
  26. package/dist/components/new-trace-viewer/components/middle-truncate/use-middle-truncate.d.ts.map +1 -0
  27. package/dist/components/new-trace-viewer/components/middle-truncate/use-middle-truncate.js +114 -0
  28. package/dist/components/new-trace-viewer/components/middle-truncate/use-middle-truncate.js.map +1 -0
  29. package/dist/components/new-trace-viewer/components/split-pane.d.ts +14 -0
  30. package/dist/components/new-trace-viewer/components/split-pane.d.ts.map +1 -0
  31. package/dist/components/new-trace-viewer/components/split-pane.js +99 -0
  32. package/dist/components/new-trace-viewer/components/split-pane.js.map +1 -0
  33. package/dist/components/new-trace-viewer/components/timeline.d.ts +31 -0
  34. package/dist/components/new-trace-viewer/components/timeline.d.ts.map +1 -0
  35. package/dist/components/new-trace-viewer/components/timeline.js +138 -0
  36. package/dist/components/new-trace-viewer/components/timeline.js.map +1 -0
  37. package/dist/components/new-trace-viewer/context.d.ts +15 -0
  38. package/dist/components/new-trace-viewer/context.d.ts.map +1 -0
  39. package/dist/components/new-trace-viewer/context.js +47 -0
  40. package/dist/components/new-trace-viewer/context.js.map +1 -0
  41. package/dist/components/new-trace-viewer/detail-panel.d.ts +10 -0
  42. package/dist/components/new-trace-viewer/detail-panel.d.ts.map +1 -0
  43. package/dist/components/new-trace-viewer/detail-panel.js +12 -0
  44. package/dist/components/new-trace-viewer/detail-panel.js.map +1 -0
  45. package/dist/components/new-trace-viewer/icons.d.ts +6 -0
  46. package/dist/components/new-trace-viewer/icons.d.ts.map +1 -0
  47. package/dist/components/new-trace-viewer/icons.js +15 -0
  48. package/dist/components/new-trace-viewer/icons.js.map +1 -0
  49. package/dist/components/new-trace-viewer/trace-viewer.d.ts +8 -0
  50. package/dist/components/new-trace-viewer/trace-viewer.d.ts.map +1 -0
  51. package/dist/components/new-trace-viewer/trace-viewer.js +349 -0
  52. package/dist/components/new-trace-viewer/trace-viewer.js.map +1 -0
  53. package/dist/components/new-trace-viewer/types.d.ts +1 -0
  54. package/dist/components/new-trace-viewer/types.d.ts.map +1 -0
  55. package/dist/components/new-trace-viewer/types.js +2 -0
  56. package/dist/components/new-trace-viewer/types.js.map +1 -0
  57. package/dist/components/new-trace-viewer/util/constants.d.ts +1 -0
  58. package/dist/components/new-trace-viewer/util/constants.d.ts.map +1 -0
  59. package/dist/components/new-trace-viewer/util/constants.js +2 -0
  60. package/dist/components/new-trace-viewer/util/constants.js.map +1 -0
  61. package/dist/components/new-trace-viewer/utils.d.ts +42 -0
  62. package/dist/components/new-trace-viewer/utils.d.ts.map +1 -0
  63. package/dist/components/new-trace-viewer/utils.js +305 -0
  64. package/dist/components/new-trace-viewer/utils.js.map +1 -0
  65. package/dist/components/sidebar/attribute-panel.d.ts.map +1 -1
  66. package/dist/components/sidebar/attribute-panel.js +42 -23
  67. package/dist/components/sidebar/attribute-panel.js.map +1 -1
  68. package/dist/components/sidebar/copyable-data-block.js +1 -1
  69. package/dist/components/sidebar/copyable-data-block.js.map +1 -1
  70. package/dist/components/sidebar/detail-card.d.ts.map +1 -1
  71. package/dist/components/sidebar/detail-card.js +1 -1
  72. package/dist/components/sidebar/detail-card.js.map +1 -1
  73. package/dist/components/sidebar/entity-detail-panel.d.ts +1 -3
  74. package/dist/components/sidebar/entity-detail-panel.d.ts.map +1 -1
  75. package/dist/components/sidebar/entity-detail-panel.js +3 -25
  76. package/dist/components/sidebar/entity-detail-panel.js.map +1 -1
  77. package/dist/components/sidebar/events-list.d.ts.map +1 -1
  78. package/dist/components/sidebar/events-list.js +2 -2
  79. package/dist/components/sidebar/events-list.js.map +1 -1
  80. package/dist/components/sidebar/sidebar-data-context.d.ts +29 -0
  81. package/dist/components/sidebar/sidebar-data-context.d.ts.map +1 -0
  82. package/dist/components/sidebar/sidebar-data-context.js +19 -0
  83. package/dist/components/sidebar/sidebar-data-context.js.map +1 -0
  84. package/dist/components/stream-viewer.d.ts +1 -1
  85. package/dist/components/stream-viewer.d.ts.map +1 -1
  86. package/dist/components/stream-viewer.js +2 -26
  87. package/dist/components/stream-viewer.js.map +1 -1
  88. package/dist/components/trace-viewer/util/timing.d.ts +1 -1
  89. package/dist/components/trace-viewer/util/timing.d.ts.map +1 -1
  90. package/dist/components/trace-viewer/util/timing.js +5 -8
  91. package/dist/components/trace-viewer/util/timing.js.map +1 -1
  92. package/dist/components/trace-viewer-new.d.ts +10 -0
  93. package/dist/components/trace-viewer-new.d.ts.map +1 -0
  94. package/dist/components/trace-viewer-new.js +23 -0
  95. package/dist/components/trace-viewer-new.js.map +1 -0
  96. package/dist/components/ui/data-inspector.d.ts +21 -0
  97. package/dist/components/ui/data-inspector.d.ts.map +1 -1
  98. package/dist/components/ui/data-inspector.js +99 -4
  99. package/dist/components/ui/data-inspector.js.map +1 -1
  100. package/dist/components/ui/inspector-theme.d.ts +9 -10
  101. package/dist/components/ui/inspector-theme.d.ts.map +1 -1
  102. package/dist/components/ui/inspector-theme.js +38 -71
  103. package/dist/components/ui/inspector-theme.js.map +1 -1
  104. package/dist/components/workflow-trace-view.d.ts +1 -3
  105. package/dist/components/workflow-trace-view.d.ts.map +1 -1
  106. package/dist/components/workflow-trace-view.js +4 -4
  107. package/dist/components/workflow-trace-view.js.map +1 -1
  108. package/dist/components/workflow-traces/trace-span-construction.d.ts +2 -2
  109. package/dist/components/workflow-traces/trace-span-construction.d.ts.map +1 -1
  110. package/dist/components/workflow-traces/trace-span-construction.js +16 -3
  111. package/dist/components/workflow-traces/trace-span-construction.js.map +1 -1
  112. package/dist/index.d.ts +1 -0
  113. package/dist/index.d.ts.map +1 -1
  114. package/dist/index.js.map +1 -1
  115. package/dist/lib/stream-display.d.ts +18 -0
  116. package/dist/lib/stream-display.d.ts.map +1 -0
  117. package/dist/lib/stream-display.js +30 -0
  118. package/dist/lib/stream-display.js.map +1 -0
  119. package/dist/lib/trace-builder.d.ts.map +1 -1
  120. package/dist/lib/trace-builder.js +2 -1
  121. package/dist/lib/trace-builder.js.map +1 -1
  122. package/dist/lib/utils.d.ts +9 -9
  123. package/dist/lib/utils.d.ts.map +1 -1
  124. package/dist/lib/utils.js +26 -31
  125. package/dist/lib/utils.js.map +1 -1
  126. package/dist/styles.css +470 -1
  127. package/package.json +3 -2
  128. package/src/components/index.ts +5 -0
  129. package/src/components/new-trace-viewer/components/copy-button.tsx +68 -0
  130. package/src/components/new-trace-viewer/components/event-list.tsx +116 -0
  131. package/src/components/new-trace-viewer/components/middle-truncate/copy-selection.ts +114 -0
  132. package/src/components/new-trace-viewer/components/middle-truncate/middle-truncate.tsx +157 -0
  133. package/src/components/new-trace-viewer/components/middle-truncate/truncate.ts +132 -0
  134. package/src/components/new-trace-viewer/components/middle-truncate/use-middle-truncate.ts +167 -0
  135. package/src/components/new-trace-viewer/components/split-pane.tsx +185 -0
  136. package/src/components/new-trace-viewer/components/timeline.tsx +382 -0
  137. package/src/components/new-trace-viewer/context.tsx +92 -0
  138. package/src/components/new-trace-viewer/detail-panel.tsx +58 -0
  139. package/src/components/new-trace-viewer/icons.tsx +79 -0
  140. package/src/components/new-trace-viewer/trace-viewer.tsx +645 -0
  141. package/src/components/new-trace-viewer/types.ts +0 -0
  142. package/src/components/new-trace-viewer/util/constants.ts +0 -0
  143. package/src/components/new-trace-viewer/utils.ts +450 -0
  144. package/src/components/sidebar/attribute-panel.tsx +43 -32
  145. package/src/components/sidebar/copyable-data-block.tsx +1 -1
  146. package/src/components/sidebar/detail-card.tsx +1 -19
  147. package/src/components/sidebar/entity-detail-panel.tsx +0 -71
  148. package/src/components/sidebar/events-list.tsx +9 -6
  149. package/src/components/sidebar/sidebar-data-context.tsx +62 -0
  150. package/src/components/stream-viewer.tsx +18 -40
  151. package/src/components/trace-viewer/util/timing.ts +5 -9
  152. package/src/components/trace-viewer-new.tsx +49 -0
  153. package/src/components/ui/data-inspector.tsx +235 -4
  154. package/src/components/ui/inspector-theme.ts +39 -81
  155. package/src/components/workflow-trace-view.tsx +2 -6
  156. package/src/components/workflow-traces/trace-span-construction.ts +22 -4
  157. package/src/index.ts +1 -0
  158. package/src/lib/stream-display.ts +48 -0
  159. package/src/lib/trace-builder.ts +2 -1
  160. package/src/lib/utils.ts +27 -32
  161. package/src/styles.css +470 -1
@@ -0,0 +1,185 @@
1
+ 'use client';
2
+
3
+ import {
4
+ Children,
5
+ type ReactNode,
6
+ type PointerEvent as ReactPointerEvent,
7
+ useCallback,
8
+ useEffect,
9
+ useRef,
10
+ useState,
11
+ } from 'react';
12
+ import { cn } from '../../../lib/utils';
13
+
14
+ const GUTTER_PX = 1;
15
+ const MIN_PX = 50;
16
+ const DEFAULT_START_PX = 340;
17
+
18
+ export function Divider() {
19
+ return <div aria-hidden className="h-full w-px shrink-0 bg-gray-alpha-400" />;
20
+ }
21
+
22
+ export interface SplitPaneProps {
23
+ children: ReactNode;
24
+ className?: string;
25
+ /** Fixed pixel width for the start (left) pane. Default 220. */
26
+ defaultStartWidth?: number;
27
+ /** Fixed (non-scrolling) header rendered above the start pane. */
28
+ startHeader?: ReactNode;
29
+ /** Fixed (non-scrolling) header rendered above the end pane. */
30
+ endHeader?: ReactNode;
31
+ }
32
+
33
+ export function SplitPane({
34
+ children,
35
+ className,
36
+ defaultStartWidth = DEFAULT_START_PX,
37
+ startHeader,
38
+ endHeader,
39
+ }: SplitPaneProps) {
40
+ const parts = Children.toArray(children);
41
+ if (parts.length !== 2) {
42
+ throw new Error('SplitPane expects exactly two children');
43
+ }
44
+ const [start, end] = parts;
45
+
46
+ const [startPx, setStartPx] = useState(defaultStartWidth);
47
+ const [isDragging, setIsDragging] = useState(false);
48
+ const containerRef = useRef<HTMLDivElement>(null);
49
+ const gutterRef = useRef<HTMLDivElement>(null);
50
+ const rafRef = useRef<number | null>(null);
51
+ const pendingPx = useRef(defaultStartWidth);
52
+ const pointerIdRef = useRef<number | null>(null);
53
+
54
+ useEffect(() => {
55
+ return () => {
56
+ if (rafRef.current) cancelAnimationFrame(rafRef.current);
57
+ };
58
+ }, []);
59
+
60
+ const clampPx = useCallback((px: number) => {
61
+ const el = containerRef.current;
62
+ if (!el) return px;
63
+ const maxPx = el.getBoundingClientRect().width - MIN_PX - GUTTER_PX;
64
+ return Math.min(maxPx, Math.max(MIN_PX, px));
65
+ }, []);
66
+
67
+ useEffect(() => {
68
+ if (!isDragging) return;
69
+
70
+ const onPointerMove = (e: globalThis.PointerEvent) => {
71
+ if (e.pointerId !== pointerIdRef.current) return;
72
+ const container = containerRef.current;
73
+ if (!container) return;
74
+ const rect = container.getBoundingClientRect();
75
+ pendingPx.current = clampPx(e.clientX - rect.left);
76
+ if (!rafRef.current) {
77
+ rafRef.current = requestAnimationFrame(() => {
78
+ rafRef.current = null;
79
+ setStartPx(pendingPx.current);
80
+ });
81
+ }
82
+ };
83
+
84
+ const onPointerUp = (e: globalThis.PointerEvent) => {
85
+ if (e.pointerId !== pointerIdRef.current) return;
86
+ const gutter = gutterRef.current;
87
+ if (gutter?.hasPointerCapture(e.pointerId)) {
88
+ gutter.releasePointerCapture(e.pointerId);
89
+ }
90
+ if (rafRef.current) {
91
+ cancelAnimationFrame(rafRef.current);
92
+ rafRef.current = null;
93
+ }
94
+ pointerIdRef.current = null;
95
+ setIsDragging(false);
96
+ };
97
+
98
+ document.addEventListener('pointermove', onPointerMove);
99
+ document.addEventListener('pointerup', onPointerUp);
100
+ document.addEventListener('pointercancel', onPointerUp);
101
+
102
+ return () => {
103
+ document.removeEventListener('pointermove', onPointerMove);
104
+ document.removeEventListener('pointerup', onPointerUp);
105
+ document.removeEventListener('pointercancel', onPointerUp);
106
+ };
107
+ }, [isDragging, clampPx]);
108
+
109
+ const handlePointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
110
+ e.currentTarget.setPointerCapture(e.pointerId);
111
+ pointerIdRef.current = e.pointerId;
112
+ setIsDragging(true);
113
+ };
114
+
115
+ const handleLostPointerCapture = () => {
116
+ pointerIdRef.current = null;
117
+ setIsDragging(false);
118
+ if (rafRef.current) {
119
+ cancelAnimationFrame(rafRef.current);
120
+ rafRef.current = null;
121
+ }
122
+ };
123
+
124
+ const colTemplate = `${startPx}px ${GUTTER_PX}px minmax(${MIN_PX}px, 1fr)`;
125
+ const hasHeaders = startHeader != null || endHeader != null;
126
+
127
+ const gutter = (
128
+ <div
129
+ ref={gutterRef}
130
+ className="relative z-20 isolate flex shrink-0 cursor-col-resize justify-center"
131
+ onPointerDown={handlePointerDown}
132
+ onLostPointerCapture={handleLostPointerCapture}
133
+ >
134
+ <span
135
+ className="pointer-events-none relative z-10 h-full w-px shrink-0 bg-gray-alpha-400"
136
+ aria-hidden
137
+ />
138
+ </div>
139
+ );
140
+
141
+ if (hasHeaders) {
142
+ return (
143
+ <div className={cn('flex flex-col h-full min-h-0', className)}>
144
+ <div
145
+ className="shrink-0 grid"
146
+ style={{ gridTemplateColumns: colTemplate }}
147
+ >
148
+ <div>{startHeader}</div>
149
+ <div className="flex justify-center">
150
+ <span className="h-full w-px bg-gray-alpha-400" aria-hidden />
151
+ </div>
152
+ <div>{endHeader}</div>
153
+ </div>
154
+ <div
155
+ ref={containerRef}
156
+ className={cn(
157
+ 'grid flex-1 min-h-0 overflow-x-hidden overflow-y-auto',
158
+ isDragging && 'select-none'
159
+ )}
160
+ style={{ gridTemplateColumns: colTemplate }}
161
+ >
162
+ {start}
163
+ {gutter}
164
+ {end}
165
+ </div>
166
+ </div>
167
+ );
168
+ }
169
+
170
+ return (
171
+ <div
172
+ ref={containerRef}
173
+ className={cn(
174
+ 'grid h-full min-h-0 content-start overflow-x-hidden overflow-y-auto',
175
+ isDragging && 'select-none',
176
+ className
177
+ )}
178
+ style={{ gridTemplateColumns: colTemplate, height: '100%' }}
179
+ >
180
+ {start}
181
+ {gutter}
182
+ {end}
183
+ </div>
184
+ );
185
+ }
@@ -0,0 +1,382 @@
1
+ 'use client';
2
+
3
+ import { ArrowLeft, ArrowRight } from 'lucide-react';
4
+ import type { ReactNode } from 'react';
5
+ import { memo, useEffect, useMemo, useRef, useState } from 'react';
6
+ import { cn } from '../../../lib/utils';
7
+ import type { Span } from '../../trace-viewer/types';
8
+ import { formatDuration, getHighResInMs } from '../../trace-viewer/util/timing';
9
+ import type { SegmentStatus, TimeMarker } from '../utils';
10
+ import {
11
+ computeSpanGaps,
12
+ computeSpanSegments,
13
+ getSpanDurationMs,
14
+ getResourceColor,
15
+ } from '../utils';
16
+
17
+ const SEGMENT_CONFIG: Record<
18
+ SegmentStatus,
19
+ { className?: string; style?: React.CSSProperties }
20
+ > = {
21
+ queued: { className: 'bg-gray-500' },
22
+ retrying: {
23
+ className: 'box-border bg-gray-500',
24
+ },
25
+ waiting: { className: 'bg-gray-500' },
26
+ running: { className: 'bg-blue-700' },
27
+ failed: { className: 'bg-red-700' },
28
+ succeeded: { className: 'bg-green-700' },
29
+ sleeping: { className: 'bg-gray-500' },
30
+ received: { className: 'bg-blue-700' },
31
+ };
32
+
33
+ const BAR_HEIGHT_PX = 24;
34
+ const TINY_BAR_BOX_SIZE_PX = 24;
35
+ const TINY_BAR_WIDTH_PX = 4;
36
+ const SEGMENT_GAP_PX = 1;
37
+ // Keep this in sync with the rendered row height in the timeline/event list.
38
+ const ROW_HEIGHT = 40;
39
+ const CONTAINER_PAD_Y = 8;
40
+ const END_CAP_HEIGHT = 8;
41
+ export const TIMELINE_PADDING_PX = 16;
42
+ const ORIGIN_MARKER_EPSILON = 0.000001;
43
+
44
+ const DeltaIndicator = memo(function DeltaIndicator({
45
+ leftFrac,
46
+ rightFrac,
47
+ label,
48
+ rowIndex,
49
+ }: {
50
+ leftFrac: number;
51
+ rightFrac: number;
52
+ label: string;
53
+ rowIndex: number;
54
+ }) {
55
+ const centerY = CONTAINER_PAD_Y + (rowIndex + 1) * ROW_HEIGHT;
56
+
57
+ return (
58
+ <div
59
+ className="absolute pointer-events-none"
60
+ style={{
61
+ left: `${leftFrac * 100}%`,
62
+ width: `${(rightFrac - leftFrac) * 100}%`,
63
+ top: centerY - END_CAP_HEIGHT / 2,
64
+ height: END_CAP_HEIGHT,
65
+ }}
66
+ >
67
+ <div className="absolute left-0 top-0 w-px h-full bg-amber-800" />
68
+ <div className="absolute left-0 right-0 top-1/2 h-px bg-amber-800" />
69
+ <div className="absolute right-0 top-0 w-px h-full bg-amber-800" />
70
+ <span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-label-12 leading-none whitespace-nowrap rounded-xs px-1 py-0.5 text-gray-100 bg-amber-800">
71
+ {label}
72
+ </span>
73
+ </div>
74
+ );
75
+ });
76
+
77
+ const TimelineBar = memo(function TimelineBar({
78
+ span,
79
+ viewStart,
80
+ viewDuration,
81
+ containerWidth,
82
+ isSelected,
83
+ onClick,
84
+ }: {
85
+ span: Span;
86
+ viewStart: number;
87
+ viewDuration: number;
88
+ containerWidth: number;
89
+ isSelected: boolean;
90
+ onClick: () => void;
91
+ }): ReactNode {
92
+ const startTime = getHighResInMs(span.startTime);
93
+ const endTime = getHighResInMs(span.endTime);
94
+ const totalDurationMs = getSpanDurationMs(span);
95
+
96
+ const leftFracRaw =
97
+ viewDuration > 0 ? (startTime - viewStart) / viewDuration : 0;
98
+ const rightFracRaw =
99
+ viewDuration > 0 ? (endTime - viewStart) / viewDuration : 0;
100
+ const widthFrac = rightFracRaw - leftFracRaw;
101
+
102
+ const leftPct = leftFracRaw * 100;
103
+ const widthPct = widthFrac * 100;
104
+
105
+ const pixelWidth = widthFrac * containerWidth;
106
+ const visibleLeftFrac = Math.max(0, Math.min(1, leftFracRaw));
107
+ const visibleRightFrac = Math.max(0, Math.min(1, rightFracRaw));
108
+ const visiblePixelWidth =
109
+ Math.max(0, visibleRightFrac - visibleLeftFrac) * containerWidth;
110
+ const isTinyBar =
111
+ containerWidth > 0 && visiblePixelWidth < TINY_BAR_BOX_SIZE_PX;
112
+ const [isRowHovered, setIsRowHovered] = useState(false);
113
+
114
+ const segments = useMemo(() => computeSpanSegments(span), [span]);
115
+
116
+ const workflowStatus = (span.attributes.data as Record<string, unknown>)
117
+ ?.status as string | undefined;
118
+ const isErrored = span.status.code === 2 || workflowStatus === 'failed';
119
+ const colors = getResourceColor(span.resource);
120
+ const fallbackColor = isErrored
121
+ ? (colors.errorBar ?? 'var(--ds-red-700)')
122
+ : colors.bar;
123
+ const renderDurationLabel = (label: string) => (
124
+ <span
125
+ 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-white tabular-nums"
126
+ style={{ textShadow: '0 1px 1px rgba(0, 0, 0, 0.45)' }}
127
+ >
128
+ {label}
129
+ </span>
130
+ );
131
+ const getMinDurationLabelWidthPx = (label: string) =>
132
+ Math.max(40, label.length * 6 + 12);
133
+ const totalDurationLabel = formatDuration(totalDurationMs);
134
+ const showBarDurationLabel =
135
+ isRowHovered &&
136
+ pixelWidth >= getMinDurationLabelWidthPx(totalDurationLabel);
137
+
138
+ const showBoundaryArrow = isTinyBar && (leftFracRaw < 0 || rightFracRaw > 1);
139
+ const BoundaryArrow = leftFracRaw < 0.5 ? ArrowLeft : ArrowRight;
140
+ const barContent = showBoundaryArrow ? (
141
+ <div className="flex h-6 w-6 items-center justify-center rounded-[0.25rem]">
142
+ <BoundaryArrow className="size-3 text-gray-900" />
143
+ </div>
144
+ ) : isTinyBar ? (
145
+ <div
146
+ className="h-6 rounded-[0.25rem]"
147
+ style={{ background: fallbackColor }}
148
+ />
149
+ ) : segments.length > 0 ? (
150
+ <div className="relative h-6 w-full">
151
+ {segments.map((seg, i) => {
152
+ const segPixelWidth =
153
+ (seg.endFraction - seg.startFraction) * pixelWidth;
154
+ const segDurationLabel = formatDuration(
155
+ (seg.endFraction - seg.startFraction) * totalDurationMs
156
+ );
157
+ const showSegmentDurationLabel =
158
+ isRowHovered &&
159
+ segPixelWidth >= getMinDurationLabelWidthPx(segDurationLabel);
160
+ const segStyle =
161
+ seg.status === 'queued' && segPixelWidth < 20
162
+ ? { background: 'var(--ds-gray-500)' }
163
+ : SEGMENT_CONFIG[seg.status].style;
164
+
165
+ return (
166
+ <div
167
+ key={`${seg.status}-${i}`}
168
+ className={cn(
169
+ 'absolute h-full rounded-[0.25rem]',
170
+ SEGMENT_CONFIG[seg.status].className
171
+ )}
172
+ style={{
173
+ left: `calc(${seg.startFraction * 100}% + ${SEGMENT_GAP_PX / 2}px)`,
174
+ width: `calc(${(seg.endFraction - seg.startFraction) * 100}% - ${SEGMENT_GAP_PX}px)`,
175
+ minWidth: 1,
176
+ ...segStyle,
177
+ }}
178
+ >
179
+ {showSegmentDurationLabel
180
+ ? renderDurationLabel(segDurationLabel)
181
+ : null}
182
+ </div>
183
+ );
184
+ })}
185
+ </div>
186
+ ) : (
187
+ <div
188
+ className="relative h-6 rounded-[0.25rem]"
189
+ style={{
190
+ width: '100%',
191
+ minWidth: 4,
192
+ background: fallbackColor,
193
+ }}
194
+ >
195
+ {showBarDurationLabel ? renderDurationLabel(totalDurationLabel) : null}
196
+ </div>
197
+ );
198
+
199
+ return (
200
+ <div
201
+ role="treeitem"
202
+ aria-selected={isSelected}
203
+ aria-expanded={isSelected}
204
+ aria-level={1}
205
+ className={cn(
206
+ 'h-10 relative flex items-center hover:bg-gray-100 aria-selected:bg-gray-100 aria-selected:hover:bg-gray-200'
207
+ )}
208
+ onMouseEnter={() => setIsRowHovered(true)}
209
+ onMouseLeave={() => setIsRowHovered(false)}
210
+ onClick={onClick}
211
+ >
212
+ <div
213
+ className="absolute inset-y-0"
214
+ style={{
215
+ left: TIMELINE_PADDING_PX,
216
+ right: TIMELINE_PADDING_PX,
217
+ }}
218
+ >
219
+ <div
220
+ className="absolute top-1/2 -translate-y-1/2"
221
+ style={{
222
+ left: showBoundaryArrow
223
+ ? `min(max(${leftPct}%, 0px), calc(100% - ${TINY_BAR_BOX_SIZE_PX}px))`
224
+ : isTinyBar
225
+ ? `min(${leftPct}%, calc(100% - ${TINY_BAR_WIDTH_PX}px))`
226
+ : `${leftPct}%`,
227
+ width: showBoundaryArrow
228
+ ? `${TINY_BAR_BOX_SIZE_PX}px`
229
+ : isTinyBar
230
+ ? `${TINY_BAR_WIDTH_PX}px`
231
+ : `max(${widthPct}%, 4px)`,
232
+ height: BAR_HEIGHT_PX,
233
+ }}
234
+ >
235
+ {barContent}
236
+ </div>
237
+ </div>
238
+ </div>
239
+ );
240
+ });
241
+
242
+ export { TimelineBar };
243
+
244
+ export function TimelineHeader({
245
+ markers,
246
+ hoverInfo,
247
+ }: {
248
+ markers: TimeMarker[];
249
+ hoverInfo?: { fraction: number; label: string } | null;
250
+ }): ReactNode {
251
+ return (
252
+ <div className="relative bg-background-100 border-b border-gray-alpha-400 h-10 min-h-10 flex items-end px-4 pb-1">
253
+ <div className="relative h-full flex-1">
254
+ {markers.map((m) => (
255
+ <span
256
+ key={`${m.position}-${m.label}`}
257
+ className="absolute bottom-1 font-mono text-xs font-normal leading-4 text-gray-900 whitespace-nowrap"
258
+ style={{ left: `${m.position * 100}%` }}
259
+ >
260
+ {m.label}
261
+ </span>
262
+ ))}
263
+ {hoverInfo && (
264
+ <span
265
+ className="absolute top-1 pointer-events-none z-10 font-mono text-[11px] leading-4 text-gray-1000 whitespace-nowrap bg-background-100 border border-gray-alpha-400 rounded px-1 -translate-x-1/2"
266
+ style={{ left: `${hoverInfo.fraction * 100}%` }}
267
+ >
268
+ {hoverInfo.label}
269
+ </span>
270
+ )}
271
+ </div>
272
+ </div>
273
+ );
274
+ }
275
+
276
+ export function Timeline({
277
+ spans,
278
+ viewStart,
279
+ viewEnd,
280
+ markers,
281
+ selectedId,
282
+ onSelect,
283
+ hoverFraction,
284
+ altHeld = false,
285
+ }: {
286
+ spans: Span[];
287
+ viewStart: number;
288
+ viewEnd: number;
289
+ markers: TimeMarker[];
290
+ selectedId: string | null;
291
+ onSelect: (spanId: string) => void;
292
+ hoverFraction?: number | null;
293
+ altHeld?: boolean;
294
+ }): ReactNode {
295
+ const containerRef = useRef<HTMLDivElement>(null);
296
+ const [containerWidth, setContainerWidth] = useState(0);
297
+ const viewDuration = viewEnd - viewStart;
298
+ const timelineWidth = Math.max(0, containerWidth - TIMELINE_PADDING_PX * 2);
299
+
300
+ useEffect(() => {
301
+ const el = containerRef.current;
302
+ if (!el) return;
303
+ const ro = new ResizeObserver(([entry]) => {
304
+ setContainerWidth(entry.contentRect.width);
305
+ });
306
+ ro.observe(el);
307
+ return () => ro.disconnect();
308
+ }, []);
309
+
310
+ const gaps = useMemo(
311
+ () => computeSpanGaps(spans, viewStart, viewEnd),
312
+ [spans, viewStart, viewEnd]
313
+ );
314
+
315
+ return (
316
+ <div ref={containerRef} className="relative h-full overflow-hidden">
317
+ <div
318
+ aria-hidden
319
+ className="absolute inset-y-0 pointer-events-none"
320
+ style={{
321
+ left: TIMELINE_PADDING_PX,
322
+ right: TIMELINE_PADDING_PX,
323
+ }}
324
+ >
325
+ {markers.map((marker) =>
326
+ Math.abs(marker.value) > ORIGIN_MARKER_EPSILON ? (
327
+ <div
328
+ key={`${marker.position}-${marker.label}`}
329
+ className="absolute top-0 bottom-0 w-px bg-gray-alpha-300"
330
+ style={{ left: `${marker.position * 100}%` }}
331
+ />
332
+ ) : null
333
+ )}
334
+ </div>
335
+ {hoverFraction != null && (
336
+ <div
337
+ className="absolute inset-y-0 pointer-events-none z-10"
338
+ style={{
339
+ left: TIMELINE_PADDING_PX,
340
+ right: TIMELINE_PADDING_PX,
341
+ }}
342
+ >
343
+ <div
344
+ className="absolute top-0 bottom-0 w-px bg-gray-alpha-400"
345
+ style={{ left: `${hoverFraction * 100}%` }}
346
+ />
347
+ </div>
348
+ )}
349
+ {spans.map((span) => (
350
+ <TimelineBar
351
+ key={span.spanId}
352
+ span={span}
353
+ viewStart={viewStart}
354
+ viewDuration={viewDuration}
355
+ containerWidth={timelineWidth}
356
+ isSelected={selectedId === span.spanId}
357
+ onClick={() => onSelect(span.spanId)}
358
+ />
359
+ ))}
360
+ {altHeld && (
361
+ <div
362
+ aria-hidden
363
+ className="absolute inset-y-0 pointer-events-none"
364
+ style={{
365
+ left: TIMELINE_PADDING_PX,
366
+ right: TIMELINE_PADDING_PX,
367
+ }}
368
+ >
369
+ {gaps.map((gap) => (
370
+ <DeltaIndicator
371
+ key={gap.rowIndex}
372
+ leftFrac={gap.leftFrac}
373
+ rightFrac={gap.rightFrac}
374
+ label={formatDuration(gap.gapMs, true)}
375
+ rowIndex={gap.rowIndex}
376
+ />
377
+ ))}
378
+ </div>
379
+ )}
380
+ </div>
381
+ );
382
+ }
@@ -0,0 +1,92 @@
1
+ 'use client';
2
+
3
+ import {
4
+ createContext,
5
+ type ReactNode,
6
+ useCallback,
7
+ useContext,
8
+ useEffect,
9
+ useMemo,
10
+ useState,
11
+ } from 'react';
12
+ import type { Span } from '../trace-viewer/types';
13
+
14
+ interface ActiveSpanContextValue {
15
+ activeSpanId: string | null;
16
+ activeSpan: Span | null;
17
+ setActiveSpan: (spanId: string) => void;
18
+ clearActiveSpan: () => void;
19
+ }
20
+
21
+ const ActiveSpanContext = createContext<ActiveSpanContextValue | null>(null);
22
+ ActiveSpanContext.displayName = 'ActiveSpanContext';
23
+
24
+ export function ActiveSpanProvider({
25
+ spans,
26
+ children,
27
+ }: {
28
+ spans: Span[];
29
+ children: ReactNode;
30
+ }) {
31
+ const [activeSpanId, setActiveSpanId] = useState<string | null>(
32
+ spans[0]?.spanId ?? null
33
+ );
34
+
35
+ useEffect(() => {
36
+ setActiveSpanId((currentSpanId) => {
37
+ if (!currentSpanId) {
38
+ return null;
39
+ }
40
+
41
+ const hasCurrentSpan = spans.some(
42
+ (span) => span.spanId === currentSpanId
43
+ );
44
+ if (hasCurrentSpan) {
45
+ return currentSpanId;
46
+ }
47
+
48
+ return null;
49
+ });
50
+ }, [spans]);
51
+
52
+ const activeSpan = useMemo(() => {
53
+ if (!activeSpanId) {
54
+ return null;
55
+ }
56
+
57
+ return spans.find((span) => span.spanId === activeSpanId) ?? null;
58
+ }, [activeSpanId, spans]);
59
+
60
+ const setActiveSpan = useCallback((spanId: string) => {
61
+ setActiveSpanId(spanId);
62
+ }, []);
63
+
64
+ const clearActiveSpan = useCallback(() => {
65
+ setActiveSpanId(null);
66
+ }, []);
67
+
68
+ const value = useMemo<ActiveSpanContextValue>(
69
+ () => ({
70
+ activeSpanId,
71
+ activeSpan,
72
+ setActiveSpan,
73
+ clearActiveSpan,
74
+ }),
75
+ [activeSpanId, activeSpan, setActiveSpan, clearActiveSpan]
76
+ );
77
+
78
+ return (
79
+ <ActiveSpanContext.Provider value={value}>
80
+ {children}
81
+ </ActiveSpanContext.Provider>
82
+ );
83
+ }
84
+
85
+ export const useActiveSpan = (): ActiveSpanContextValue => {
86
+ const context = useContext(ActiveSpanContext);
87
+ if (!context) {
88
+ throw new Error('useActiveSpan must be used within ActiveSpanProvider');
89
+ }
90
+
91
+ return context;
92
+ };