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

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 (86) hide show
  1. package/dist/components/new-trace-viewer/components/event-list.d.ts.map +1 -1
  2. package/dist/components/new-trace-viewer/components/event-list.js +7 -4
  3. package/dist/components/new-trace-viewer/components/event-list.js.map +1 -1
  4. package/dist/components/new-trace-viewer/components/middle-truncate/copy-selection.d.ts +18 -0
  5. package/dist/components/new-trace-viewer/components/middle-truncate/copy-selection.d.ts.map +1 -0
  6. package/dist/components/new-trace-viewer/components/middle-truncate/copy-selection.js +54 -0
  7. package/dist/components/new-trace-viewer/components/middle-truncate/copy-selection.js.map +1 -0
  8. package/dist/components/new-trace-viewer/components/middle-truncate/middle-truncate.d.ts +20 -0
  9. package/dist/components/new-trace-viewer/components/middle-truncate/middle-truncate.d.ts.map +1 -0
  10. package/dist/components/new-trace-viewer/components/middle-truncate/middle-truncate.js +81 -0
  11. package/dist/components/new-trace-viewer/components/middle-truncate/middle-truncate.js.map +1 -0
  12. package/dist/components/new-trace-viewer/components/middle-truncate/truncate.d.ts +25 -0
  13. package/dist/components/new-trace-viewer/components/middle-truncate/truncate.d.ts.map +1 -0
  14. package/dist/components/new-trace-viewer/components/middle-truncate/truncate.js +101 -0
  15. package/dist/components/new-trace-viewer/components/middle-truncate/truncate.js.map +1 -0
  16. package/dist/components/new-trace-viewer/components/middle-truncate/use-middle-truncate.d.ts +20 -0
  17. package/dist/components/new-trace-viewer/components/middle-truncate/use-middle-truncate.d.ts.map +1 -0
  18. package/dist/components/new-trace-viewer/components/middle-truncate/use-middle-truncate.js +114 -0
  19. package/dist/components/new-trace-viewer/components/middle-truncate/use-middle-truncate.js.map +1 -0
  20. package/dist/components/new-trace-viewer/components/split-pane.d.ts.map +1 -1
  21. package/dist/components/new-trace-viewer/components/split-pane.js +2 -3
  22. package/dist/components/new-trace-viewer/components/split-pane.js.map +1 -1
  23. package/dist/components/new-trace-viewer/components/timeline.d.ts +10 -11
  24. package/dist/components/new-trace-viewer/components/timeline.d.ts.map +1 -1
  25. package/dist/components/new-trace-viewer/components/timeline.js +57 -53
  26. package/dist/components/new-trace-viewer/components/timeline.js.map +1 -1
  27. package/dist/components/new-trace-viewer/context.d.ts.map +1 -1
  28. package/dist/components/new-trace-viewer/context.js +5 -7
  29. package/dist/components/new-trace-viewer/context.js.map +1 -1
  30. package/dist/components/new-trace-viewer/detail-panel.d.ts.map +1 -1
  31. package/dist/components/new-trace-viewer/detail-panel.js +2 -1
  32. package/dist/components/new-trace-viewer/detail-panel.js.map +1 -1
  33. package/dist/components/new-trace-viewer/trace-viewer.d.ts.map +1 -1
  34. package/dist/components/new-trace-viewer/trace-viewer.js +44 -18
  35. package/dist/components/new-trace-viewer/trace-viewer.js.map +1 -1
  36. package/dist/components/new-trace-viewer/utils.d.ts +3 -8
  37. package/dist/components/new-trace-viewer/utils.d.ts.map +1 -1
  38. package/dist/components/new-trace-viewer/utils.js +12 -65
  39. package/dist/components/new-trace-viewer/utils.js.map +1 -1
  40. package/dist/components/sidebar/attribute-panel.d.ts.map +1 -1
  41. package/dist/components/sidebar/attribute-panel.js +6 -0
  42. package/dist/components/sidebar/attribute-panel.js.map +1 -1
  43. package/dist/components/trace-viewer/util/timing.d.ts +1 -1
  44. package/dist/components/trace-viewer/util/timing.d.ts.map +1 -1
  45. package/dist/components/trace-viewer/util/timing.js +5 -8
  46. package/dist/components/trace-viewer/util/timing.js.map +1 -1
  47. package/dist/components/ui/error-stack-block.d.ts.map +1 -1
  48. package/dist/components/ui/error-stack-block.js +24 -2
  49. package/dist/components/ui/error-stack-block.js.map +1 -1
  50. package/dist/components/workflow-trace-view.js +1 -1
  51. package/dist/components/workflow-trace-view.js.map +1 -1
  52. package/dist/components/workflow-traces/trace-span-construction.d.ts +2 -2
  53. package/dist/components/workflow-traces/trace-span-construction.d.ts.map +1 -1
  54. package/dist/components/workflow-traces/trace-span-construction.js +17 -4
  55. package/dist/components/workflow-traces/trace-span-construction.js.map +1 -1
  56. package/dist/lib/hydration.d.ts +6 -0
  57. package/dist/lib/hydration.d.ts.map +1 -1
  58. package/dist/lib/hydration.js +124 -1
  59. package/dist/lib/hydration.js.map +1 -1
  60. package/dist/lib/trace-builder.d.ts.map +1 -1
  61. package/dist/lib/trace-builder.js +2 -1
  62. package/dist/lib/trace-builder.js.map +1 -1
  63. package/dist/lib/utils.d.ts +9 -7
  64. package/dist/lib/utils.d.ts.map +1 -1
  65. package/dist/lib/utils.js +27 -27
  66. package/dist/lib/utils.js.map +1 -1
  67. package/package.json +5 -4
  68. package/src/components/new-trace-viewer/components/event-list.tsx +26 -19
  69. package/src/components/new-trace-viewer/components/middle-truncate/copy-selection.ts +114 -0
  70. package/src/components/new-trace-viewer/components/middle-truncate/middle-truncate.tsx +157 -0
  71. package/src/components/new-trace-viewer/components/middle-truncate/truncate.ts +132 -0
  72. package/src/components/new-trace-viewer/components/middle-truncate/use-middle-truncate.ts +167 -0
  73. package/src/components/new-trace-viewer/components/split-pane.tsx +2 -3
  74. package/src/components/new-trace-viewer/components/timeline.tsx +119 -115
  75. package/src/components/new-trace-viewer/context.tsx +7 -9
  76. package/src/components/new-trace-viewer/detail-panel.tsx +2 -1
  77. package/src/components/new-trace-viewer/trace-viewer.tsx +100 -32
  78. package/src/components/new-trace-viewer/utils.ts +19 -93
  79. package/src/components/sidebar/attribute-panel.tsx +5 -0
  80. package/src/components/trace-viewer/util/timing.ts +5 -9
  81. package/src/components/ui/error-stack-block.tsx +32 -4
  82. package/src/components/workflow-trace-view.tsx +1 -1
  83. package/src/components/workflow-traces/trace-span-construction.ts +23 -5
  84. package/src/lib/hydration.ts +132 -1
  85. package/src/lib/trace-builder.ts +2 -1
  86. package/src/lib/utils.ts +29 -29
@@ -1,47 +1,45 @@
1
1
  'use client';
2
2
 
3
+ import { ArrowLeft, ArrowRight } from 'lucide-react';
3
4
  import type { ReactNode } from 'react';
4
5
  import { memo, useEffect, useMemo, useRef, useState } from 'react';
5
6
  import { cn } from '../../../lib/utils';
6
7
  import type { Span } from '../../trace-viewer/types';
7
8
  import { formatDuration, getHighResInMs } from '../../trace-viewer/util/timing';
8
- import type { SegmentStatus, TimeCompression } from '../utils';
9
+ import type { SegmentStatus, TimeMarker } from '../utils';
9
10
  import {
10
- computeCompressedTimeMarkers,
11
11
  computeSpanGaps,
12
12
  computeSpanSegments,
13
- computeTimeMarkers,
13
+ getSpanDurationMs,
14
14
  getResourceColor,
15
15
  } from '../utils';
16
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
17
  const SEGMENT_CONFIG: Record<
24
18
  SegmentStatus,
25
19
  { className?: string; style?: React.CSSProperties }
26
20
  > = {
27
- queued: { style: { background: QUEUED_BACKGROUND } },
21
+ queued: { className: 'bg-gray-500' },
28
22
  retrying: {
29
23
  className: 'box-border bg-gray-500',
30
24
  },
31
- waiting: { style: { background: HATCHED_BACKGROUND } },
25
+ waiting: { className: 'bg-gray-500' },
32
26
  running: { className: 'bg-blue-700' },
33
27
  failed: { className: 'bg-red-700' },
34
28
  succeeded: { className: 'bg-green-700' },
35
- sleeping: { className: 'bg-amber-700' },
29
+ sleeping: { className: 'bg-gray-500' },
36
30
  received: { className: 'bg-blue-700' },
37
31
  };
38
32
 
39
- const FIXED_BAR_WIDTH_PX = 4;
33
+ const BAR_HEIGHT_PX = 24;
34
+ const TINY_BAR_BOX_SIZE_PX = 24;
35
+ const TINY_BAR_WIDTH_PX = 4;
40
36
  const SEGMENT_GAP_PX = 1;
41
37
  // Keep this in sync with the rendered row height in the timeline/event list.
42
- const ROW_HEIGHT = 34;
38
+ const ROW_HEIGHT = 40;
43
39
  const CONTAINER_PAD_Y = 8;
44
40
  const END_CAP_HEIGHT = 8;
41
+ export const TIMELINE_PADDING_PX = 16;
42
+ const ORIGIN_MARKER_EPSILON = 0.000001;
45
43
 
46
44
  const DeltaIndicator = memo(function DeltaIndicator({
47
45
  leftFrac,
@@ -78,34 +76,42 @@ const DeltaIndicator = memo(function DeltaIndicator({
78
76
 
79
77
  const TimelineBar = memo(function TimelineBar({
80
78
  span,
81
- compression,
79
+ viewStart,
80
+ viewDuration,
82
81
  containerWidth,
83
82
  isSelected,
84
83
  onClick,
85
84
  }: {
86
85
  span: Span;
87
- compression: TimeCompression;
86
+ viewStart: number;
87
+ viewDuration: number;
88
88
  containerWidth: number;
89
89
  isSelected: boolean;
90
90
  onClick: () => void;
91
91
  }): ReactNode {
92
92
  const startTime = getHighResInMs(span.startTime);
93
93
  const endTime = getHighResInMs(span.endTime);
94
- const totalDurationMs = endTime - startTime;
94
+ const totalDurationMs = getSpanDurationMs(span);
95
95
 
96
- const leftFrac = compression.toVisual(startTime);
97
- const rightFrac = compression.toVisual(endTime);
98
- const widthFrac = rightFrac - leftFrac;
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;
99
101
 
100
- const leftPct = leftFrac * 100;
102
+ const leftPct = leftFracRaw * 100;
101
103
  const widthPct = widthFrac * 100;
102
104
 
103
105
  const pixelWidth = widthFrac * containerWidth;
104
- const isCompressed = containerWidth > 0 && pixelWidth < FIXED_BAR_WIDTH_PX;
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;
105
112
  const [isRowHovered, setIsRowHovered] = useState(false);
106
113
 
107
114
  const segments = useMemo(() => computeSpanSegments(span), [span]);
108
- const finalSegment = segments[segments.length - 1];
109
115
 
110
116
  const workflowStatus = (span.attributes.data as Record<string, unknown>)
111
117
  ?.status as string | undefined;
@@ -114,24 +120,6 @@ const TimelineBar = memo(function TimelineBar({
114
120
  const fallbackColor = isErrored
115
121
  ? (colors.errorBar ?? 'var(--ds-red-700)')
116
122
  : 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
123
  const renderDurationLabel = (label: string) => (
136
124
  <span
137
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"
@@ -147,22 +135,19 @@ const TimelineBar = memo(function TimelineBar({
147
135
  isRowHovered &&
148
136
  pixelWidth >= getMinDurationLabelWidthPx(totalDurationLabel);
149
137
 
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}
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" />
163
143
  </div>
144
+ ) : isTinyBar ? (
145
+ <div
146
+ className="h-6 rounded-[0.25rem]"
147
+ style={{ background: fallbackColor }}
148
+ />
164
149
  ) : segments.length > 0 ? (
165
- <div className="relative w-full h-4 top-[3px]">
150
+ <div className="relative h-6 w-full">
166
151
  {segments.map((seg, i) => {
167
152
  const segPixelWidth =
168
153
  (seg.endFraction - seg.startFraction) * pixelWidth;
@@ -200,7 +185,7 @@ const TimelineBar = memo(function TimelineBar({
200
185
  </div>
201
186
  ) : (
202
187
  <div
203
- className="relative h-4 rounded-[0.25rem] top-[3px]"
188
+ className="relative h-6 rounded-[0.25rem]"
204
189
  style={{
205
190
  width: '100%',
206
191
  minWidth: 4,
@@ -218,24 +203,37 @@ const TimelineBar = memo(function TimelineBar({
218
203
  aria-expanded={isSelected}
219
204
  aria-level={1}
220
205
  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'
206
+ 'h-10 relative flex items-center hover:bg-gray-100 aria-selected:bg-gray-100 aria-selected:hover:bg-gray-200'
222
207
  )}
223
208
  onMouseEnter={() => setIsRowHovered(true)}
224
209
  onMouseLeave={() => setIsRowHovered(false)}
225
210
  onClick={onClick}
226
211
  >
227
212
  <div
228
- className="absolute top-1.5 h-[22px] rounded-sm"
213
+ className="absolute inset-y-0"
229
214
  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)`,
215
+ left: TIMELINE_PADDING_PX,
216
+ right: TIMELINE_PADDING_PX,
236
217
  }}
237
218
  >
238
- {barContent}
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>
239
237
  </div>
240
238
  </div>
241
239
  );
@@ -244,43 +242,18 @@ const TimelineBar = memo(function TimelineBar({
244
242
  export { TimelineBar };
245
243
 
246
244
  export function TimelineHeader({
247
- viewStart,
248
- viewDuration,
249
- rootStart,
250
- compression,
251
- isZoomed,
252
- onResetZoom,
245
+ markers,
253
246
  hoverInfo,
254
247
  }: {
255
- viewStart: number;
256
- viewDuration: number;
257
- rootStart: number;
258
- compression: TimeCompression;
259
- isZoomed: boolean;
260
- onResetZoom: () => void;
248
+ markers: TimeMarker[];
261
249
  hoverInfo?: { fraction: number; label: string } | null;
262
250
  }): 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
251
  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">
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">
280
253
  <div className="relative h-full flex-1">
281
- {markers.map((m, i) => (
254
+ {markers.map((m) => (
282
255
  <span
283
- key={i}
256
+ key={`${m.position}-${m.label}`}
284
257
  className="absolute bottom-1 font-mono text-xs font-normal leading-4 text-gray-900 whitespace-nowrap"
285
258
  style={{ left: `${m.position * 100}%` }}
286
259
  >
@@ -296,29 +269,24 @@ export function TimelineHeader({
296
269
  </span>
297
270
  )}
298
271
  </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
272
  </div>
309
273
  );
310
274
  }
311
275
 
312
276
  export function Timeline({
313
277
  spans,
314
- compression,
278
+ viewStart,
279
+ viewEnd,
280
+ markers,
315
281
  selectedId,
316
282
  onSelect,
317
283
  hoverFraction,
318
284
  altHeld = false,
319
285
  }: {
320
286
  spans: Span[];
321
- compression: TimeCompression;
287
+ viewStart: number;
288
+ viewEnd: number;
289
+ markers: TimeMarker[];
322
290
  selectedId: string | null;
323
291
  onSelect: (spanId: string) => void;
324
292
  hoverFraction?: number | null;
@@ -326,6 +294,8 @@ export function Timeline({
326
294
  }): ReactNode {
327
295
  const containerRef = useRef<HTMLDivElement>(null);
328
296
  const [containerWidth, setContainerWidth] = useState(0);
297
+ const viewDuration = viewEnd - viewStart;
298
+ const timelineWidth = Math.max(0, containerWidth - TIMELINE_PADDING_PX * 2);
329
299
 
330
300
  useEffect(() => {
331
301
  const el = containerRef.current;
@@ -338,30 +308,64 @@ export function Timeline({
338
308
  }, []);
339
309
 
340
310
  const gaps = useMemo(
341
- () => computeSpanGaps(spans, compression),
342
- [spans, compression]
311
+ () => computeSpanGaps(spans, viewStart, viewEnd),
312
+ [spans, viewStart, viewEnd]
343
313
  );
344
314
 
345
315
  return (
346
- <div ref={containerRef} className="relative py-2">
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>
347
335
  {hoverFraction != null && (
348
336
  <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
- />
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>
352
348
  )}
353
349
  {spans.map((span) => (
354
350
  <TimelineBar
355
351
  key={span.spanId}
356
352
  span={span}
357
- compression={compression}
358
- containerWidth={containerWidth}
353
+ viewStart={viewStart}
354
+ viewDuration={viewDuration}
355
+ containerWidth={timelineWidth}
359
356
  isSelected={selectedId === span.spanId}
360
357
  onClick={() => onSelect(span.spanId)}
361
358
  />
362
359
  ))}
363
360
  {altHeld && (
364
- <div aria-hidden className="absolute inset-0 pointer-events-none">
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
+ >
365
369
  {gaps.map((gap) => (
366
370
  <DeltaIndicator
367
371
  key={gap.rowIndex}
@@ -34,20 +34,18 @@ export function ActiveSpanProvider({
34
34
 
35
35
  useEffect(() => {
36
36
  setActiveSpanId((currentSpanId) => {
37
- if (spans.length === 0) {
37
+ if (!currentSpanId) {
38
38
  return null;
39
39
  }
40
40
 
41
- if (currentSpanId) {
42
- const hasCurrentSpan = spans.some(
43
- (span) => span.spanId === currentSpanId
44
- );
45
- if (hasCurrentSpan) {
46
- return currentSpanId;
47
- }
41
+ const hasCurrentSpan = spans.some(
42
+ (span) => span.spanId === currentSpanId
43
+ );
44
+ if (hasCurrentSpan) {
45
+ return currentSpanId;
48
46
  }
49
47
 
50
- return spans[0].spanId;
48
+ return null;
51
49
  });
52
50
  }, [spans]);
53
51
 
@@ -4,6 +4,7 @@ import { X } from 'lucide-react';
4
4
  import type { ReactNode } from 'react';
5
5
  import type { Span } from '../trace-viewer/types';
6
6
  import { formatDuration, getHighResInMs } from '../trace-viewer/util/timing';
7
+ import { getSpanDurationMs } from './utils';
7
8
 
8
9
  interface DetailPanelProps {
9
10
  span: Span;
@@ -17,7 +18,7 @@ export function DetailPanel({
17
18
  onClose,
18
19
  }: DetailPanelProps): ReactNode {
19
20
  const startMs = getHighResInMs(span.startTime);
20
- const durationMs = getHighResInMs(span.duration);
21
+ const durationMs = getSpanDurationMs(span);
21
22
  const offsetMs = startMs - rootStart;
22
23
 
23
24
  return (
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { parseStepName, parseWorkflowName } from '@workflow/utils/parse-name';
4
- import { Search, X } from 'lucide-react';
4
+ import { RotateCcw, Search, X, ZoomIn, ZoomOut } from 'lucide-react';
5
5
  import {
6
6
  type ReactNode,
7
7
  useCallback,
@@ -19,19 +19,22 @@ import { useSidebarDataOptional } from '../sidebar/sidebar-data-context';
19
19
  import type { Trace } from '../trace-viewer/types';
20
20
  import { formatDuration, getHighResInMs } from '../trace-viewer/util/timing';
21
21
  import { CopyButton } from './components/copy-button';
22
- import { SplitPane } from './components/split-pane';
23
22
  import EventList from './components/event-list';
24
- import { Timeline, TimelineHeader } from './components/timeline';
23
+ import { SplitPane } from './components/split-pane';
24
+ import {
25
+ Timeline,
26
+ TimelineHeader,
27
+ TIMELINE_PADDING_PX,
28
+ } from './components/timeline';
25
29
  import { ActiveSpanProvider, useActiveSpan } from './context';
26
30
  import { DetailPanel } from './detail-panel';
27
- import { buildTimeCompression, computeRootBounds } from './utils';
31
+ import { computeRootBounds, computeTimeMarkers } from './utils';
28
32
 
29
33
  interface NewTraceViewerProps {
30
34
  trace: Trace;
31
35
  }
32
36
 
33
37
  const MIN_VIEWPORT_MS = 0.001;
34
- const TIMELINE_PADDING = 16;
35
38
 
36
39
  interface Viewport {
37
40
  start: number;
@@ -189,19 +192,51 @@ function NewTraceViewerContent({ trace }: NewTraceViewerProps): ReactNode {
189
192
 
190
193
  const viewDuration = viewport.end - viewport.start;
191
194
 
192
- const compression = useMemo(
193
- () => buildTimeCompression(trace.spans, viewport.start, viewport.end),
194
- [trace.spans, viewport.start, viewport.end]
195
+ const timeMarkers = useMemo(
196
+ () => computeTimeMarkers(viewDuration, viewport.start - root.startTime),
197
+ [viewDuration, viewport.start, root.startTime]
195
198
  );
196
199
 
197
- const isZoomed =
198
- viewport.start > root.startTime + 0.01 ||
199
- viewport.end < root.startTime + root.duration - 0.01;
200
-
201
200
  const resetZoom = useCallback(() => {
202
201
  animateTo({ start: root.startTime, end: root.startTime + root.duration });
203
202
  }, [animateTo, root.startTime, root.duration]);
204
203
 
204
+ const ZOOM_FACTOR = 0.5;
205
+
206
+ const zoomBy = useCallback(
207
+ (factor: number) => {
208
+ const rootS = root.startTime;
209
+ const rootE = root.startTime + root.duration;
210
+ const rootD = root.duration;
211
+
212
+ setViewport((prev) => {
213
+ const prevDuration = prev.end - prev.start;
214
+ const center = (prev.start + prev.end) / 2;
215
+ const newDuration = Math.max(
216
+ MIN_VIEWPORT_MS,
217
+ Math.min(rootD, prevDuration * factor)
218
+ );
219
+ let newStart = center - newDuration / 2;
220
+ let newEnd = center + newDuration / 2;
221
+
222
+ if (newStart < rootS) {
223
+ newStart = rootS;
224
+ newEnd = rootS + newDuration;
225
+ }
226
+ if (newEnd > rootE) {
227
+ newEnd = rootE;
228
+ newStart = Math.max(rootS, rootE - newDuration);
229
+ }
230
+
231
+ return { start: newStart, end: newEnd };
232
+ });
233
+ },
234
+ [setViewport, root.startTime, root.duration]
235
+ );
236
+
237
+ const zoomIn = useCallback(() => zoomBy(ZOOM_FACTOR), [zoomBy]);
238
+ const zoomOut = useCallback(() => zoomBy(1 / ZOOM_FACTOR), [zoomBy]);
239
+
205
240
  const handleSelectSpan = useCallback(
206
241
  (spanId: string) => {
207
242
  if (spanId === activeSpanId) {
@@ -292,22 +327,24 @@ function NewTraceViewerContent({ trace }: NewTraceViewerProps): ReactNode {
292
327
 
293
328
  const hoverInfo = useMemo(() => {
294
329
  if (hoverFraction == null) return null;
295
- const absTime = compression.fromVisual(hoverFraction);
330
+ const absTime = viewport.start + hoverFraction * viewDuration;
296
331
  const offset = absTime - root.startTime;
297
332
  return { fraction: hoverFraction, label: formatDuration(offset, true) };
298
- }, [hoverFraction, compression, root.startTime]);
333
+ }, [hoverFraction, viewport.start, viewDuration, root.startTime]);
299
334
 
300
335
  const handleTimelineMouseMove = useCallback(
301
336
  (e: React.MouseEvent<HTMLDivElement>) => {
302
337
  const el = timelineRef.current;
303
338
  if (!el) return;
304
339
  const rect = el.getBoundingClientRect();
305
- const padding = 8;
306
- const contentWidth = rect.width - padding * 2;
340
+ const contentWidth = rect.width - TIMELINE_PADDING_PX * 2;
307
341
  if (contentWidth <= 0) return;
308
342
  const fraction = Math.max(
309
343
  0,
310
- Math.min(1, (e.clientX - rect.left - padding) / contentWidth)
344
+ Math.min(
345
+ 1,
346
+ (e.clientX - rect.left - TIMELINE_PADDING_PX) / contentWidth
347
+ )
311
348
  );
312
349
  setHoverFraction(fraction);
313
350
  },
@@ -335,7 +372,7 @@ function NewTraceViewerContent({ trace }: NewTraceViewerProps): ReactNode {
335
372
  e.preventDefault();
336
373
 
337
374
  const rect = el.getBoundingClientRect();
338
- const contentWidth = rect.width - TIMELINE_PADDING * 2;
375
+ const contentWidth = rect.width - TIMELINE_PADDING_PX * 2;
339
376
  if (contentWidth <= 0) return;
340
377
 
341
378
  if (isZoomGesture) {
@@ -344,7 +381,10 @@ function NewTraceViewerContent({ trace }: NewTraceViewerProps): ReactNode {
344
381
 
345
382
  const cursorFraction = Math.max(
346
383
  0,
347
- Math.min(1, (e.clientX - rect.left - TIMELINE_PADDING) / contentWidth)
384
+ Math.min(
385
+ 1,
386
+ (e.clientX - rect.left - TIMELINE_PADDING_PX) / contentWidth
387
+ )
348
388
  );
349
389
  const scaleFactor = Math.pow(2, dy / 200);
350
390
 
@@ -403,6 +443,10 @@ function NewTraceViewerContent({ trace }: NewTraceViewerProps): ReactNode {
403
443
  const selectedSpanName = useMemo(() => {
404
444
  if (!selectedSpan?.data) return 'Details';
405
445
  const data = selectedSpan.data as Record<string, unknown>;
446
+ if (selectedSpan.resource === 'hook') {
447
+ return (data.token as string | undefined) ?? (data.hookId as string);
448
+ }
449
+
406
450
  const stepName = data.stepName as string | undefined;
407
451
  const workflowName = data.workflowName as string | undefined;
408
452
  return (
@@ -413,19 +457,23 @@ function NewTraceViewerContent({ trace }: NewTraceViewerProps): ReactNode {
413
457
  (data.hookId as string) ??
414
458
  'Details'
415
459
  );
416
- }, [selectedSpan?.data]);
460
+ }, [selectedSpan?.data, selectedSpan?.resource]);
417
461
 
418
462
  const selectedResource = selectedSpan?.resource as string | undefined;
419
463
  const selectedResourceId = useMemo(() => {
420
464
  if (!selectedSpan?.data) return undefined;
421
465
  const data = selectedSpan.data as Record<string, unknown>;
466
+ if (selectedSpan.resource === 'hook') {
467
+ return (data.hookId as string | undefined) ?? selectedSpan.spanId;
468
+ }
469
+
422
470
  return (
423
471
  (data.stepId as string) ??
424
472
  (data.runId as string) ??
425
473
  (data.hookId as string) ??
426
474
  selectedSpan.spanId
427
475
  );
428
- }, [selectedSpan?.data, selectedSpan?.spanId]);
476
+ }, [selectedSpan?.data, selectedSpan?.resource, selectedSpan?.spanId]);
429
477
 
430
478
  return (
431
479
  <div
@@ -463,15 +511,7 @@ function NewTraceViewerContent({ trace }: NewTraceViewerProps): ReactNode {
463
511
  </div>
464
512
  }
465
513
  endHeader={
466
- <TimelineHeader
467
- viewStart={viewport.start}
468
- viewDuration={viewDuration}
469
- rootStart={root.startTime}
470
- compression={compression}
471
- isZoomed={isZoomed}
472
- onResetZoom={resetZoom}
473
- hoverInfo={hoverInfo}
474
- />
514
+ <TimelineHeader markers={timeMarkers} hoverInfo={hoverInfo} />
475
515
  }
476
516
  >
477
517
  <div className="block overflow-visible">
@@ -483,14 +523,16 @@ function NewTraceViewerContent({ trace }: NewTraceViewerProps): ReactNode {
483
523
  </div>
484
524
  <div
485
525
  ref={timelineRef}
486
- className="block min-h-0 overflow-visible relative px-2"
526
+ className="block min-h-0 overflow-visible relative"
487
527
  onDoubleClick={resetZoom}
488
528
  onMouseMove={handleTimelineMouseMove}
489
529
  onMouseLeave={handleTimelineMouseLeave}
490
530
  >
491
531
  <Timeline
492
532
  spans={filteredSpans}
493
- compression={compression}
533
+ viewStart={viewport.start}
534
+ viewEnd={viewport.end}
535
+ markers={timeMarkers}
494
536
  selectedId={activeSpanId}
495
537
  onSelect={handleSelectSpan}
496
538
  hoverFraction={hoverFraction}
@@ -498,6 +540,32 @@ function NewTraceViewerContent({ trace }: NewTraceViewerProps): ReactNode {
498
540
  />
499
541
  </div>
500
542
  </SplitPane>
543
+ <div className="absolute right-3 bottom-3 z-[5] flex items-center border border-gray-alpha-400 rounded-lg bg-background-100 shadow-sm overflow-hidden divide-x divide-gray-alpha-400">
544
+ <button
545
+ type="button"
546
+ className="flex items-center justify-center w-8 h-8 text-gray-900 cursor-pointer transition-colors duration-[120ms] ease-in-out hover:text-gray-1000 hover:bg-gray-alpha-100"
547
+ onClick={zoomOut}
548
+ aria-label="Zoom out"
549
+ >
550
+ <ZoomOut className="w-4 h-4" />
551
+ </button>
552
+ <button
553
+ type="button"
554
+ className="flex items-center justify-center w-8 h-8 text-gray-900 cursor-pointer transition-colors duration-[120ms] ease-in-out hover:text-gray-1000 hover:bg-gray-alpha-100"
555
+ onClick={resetZoom}
556
+ aria-label="Reset zoom"
557
+ >
558
+ <RotateCcw className="w-3.5 h-3.5" />
559
+ </button>
560
+ <button
561
+ type="button"
562
+ className="flex items-center justify-center w-8 h-8 text-gray-900 cursor-pointer transition-colors duration-[120ms] ease-in-out hover:text-gray-1000 hover:bg-gray-alpha-100"
563
+ onClick={zoomIn}
564
+ aria-label="Zoom in"
565
+ >
566
+ <ZoomIn className="w-4 h-4" />
567
+ </button>
568
+ </div>
501
569
  </div>
502
570
 
503
571
  {/* Detail panel */}