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

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