@workflow/web-shared 5.0.0-beta.25 → 5.0.0-beta.27

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 (74) hide show
  1. package/dist/components/new-trace-viewer/components/detail-panel-width.d.ts +34 -0
  2. package/dist/components/new-trace-viewer/components/detail-panel-width.d.ts.map +1 -0
  3. package/dist/components/new-trace-viewer/components/detail-panel-width.js +67 -0
  4. package/dist/components/new-trace-viewer/components/detail-panel.d.ts +14 -0
  5. package/dist/components/new-trace-viewer/components/detail-panel.d.ts.map +1 -0
  6. package/dist/components/new-trace-viewer/components/detail-panel.js +122 -0
  7. package/dist/components/new-trace-viewer/components/draggable-border.d.ts +31 -0
  8. package/dist/components/new-trace-viewer/components/draggable-border.d.ts.map +1 -0
  9. package/dist/components/new-trace-viewer/components/draggable-border.js +140 -0
  10. package/dist/components/new-trace-viewer/components/middle-truncate/truncate.d.ts +1 -1
  11. package/dist/components/new-trace-viewer/components/middle-truncate/truncate.d.ts.map +1 -1
  12. package/dist/components/new-trace-viewer/components/middle-truncate/truncate.js +2 -2
  13. package/dist/components/new-trace-viewer/components/pane-constants.d.ts +14 -0
  14. package/dist/components/new-trace-viewer/components/pane-constants.d.ts.map +1 -0
  15. package/dist/components/new-trace-viewer/components/pane-constants.js +16 -0
  16. package/dist/components/new-trace-viewer/components/split-pane.d.ts +1 -4
  17. package/dist/components/new-trace-viewer/components/split-pane.d.ts.map +1 -1
  18. package/dist/components/new-trace-viewer/components/split-pane.js +18 -16
  19. package/dist/components/new-trace-viewer/components/timeline.d.ts +2 -2
  20. package/dist/components/new-trace-viewer/components/timeline.d.ts.map +1 -1
  21. package/dist/components/new-trace-viewer/components/trace-viewer-skeleton.d.ts.map +1 -1
  22. package/dist/components/new-trace-viewer/components/trace-viewer-skeleton.js +5 -2
  23. package/dist/components/new-trace-viewer/components/use-element-width.d.ts +8 -0
  24. package/dist/components/new-trace-viewer/components/use-element-width.d.ts.map +1 -0
  25. package/dist/components/new-trace-viewer/components/use-element-width.js +25 -0
  26. package/dist/components/new-trace-viewer/context.d.ts +2 -0
  27. package/dist/components/new-trace-viewer/context.d.ts.map +1 -1
  28. package/dist/components/new-trace-viewer/context.js +3 -2
  29. package/dist/components/new-trace-viewer/trace-viewer.d.ts.map +1 -1
  30. package/dist/components/new-trace-viewer/trace-viewer.js +10 -90
  31. package/dist/components/sidebar/attribute-panel.d.ts.map +1 -1
  32. package/dist/components/sidebar/attribute-panel.js +38 -35
  33. package/dist/components/sidebar/attributes-block.d.ts +18 -7
  34. package/dist/components/sidebar/attributes-block.d.ts.map +1 -1
  35. package/dist/components/sidebar/attributes-block.js +76 -18
  36. package/dist/components/sidebar/events-list.d.ts +1 -1
  37. package/dist/components/sidebar/events-list.d.ts.map +1 -1
  38. package/dist/components/sidebar/events-list.js +9 -10
  39. package/dist/components/trace-viewer/components/map.d.ts +2 -2
  40. package/dist/components/trace-viewer/components/map.d.ts.map +1 -1
  41. package/dist/components/trace-viewer/components/node.d.ts +7 -7
  42. package/dist/components/trace-viewer/components/node.d.ts.map +1 -1
  43. package/dist/components/ui/collapsible.d.ts +46 -0
  44. package/dist/components/ui/collapsible.d.ts.map +1 -0
  45. package/dist/components/ui/collapsible.js +66 -0
  46. package/dist/components/ui/data-inspector.js +2 -7
  47. package/dist/index.d.ts +2 -2
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +3 -3
  50. package/dist/lib/hydration.d.ts +12 -2
  51. package/dist/lib/hydration.d.ts.map +1 -1
  52. package/dist/lib/hydration.js +41 -18
  53. package/package.json +6 -6
  54. package/src/components/new-trace-viewer/components/detail-panel-width.test.ts +123 -0
  55. package/src/components/new-trace-viewer/components/detail-panel-width.ts +76 -0
  56. package/src/components/new-trace-viewer/components/detail-panel.tsx +272 -0
  57. package/src/components/new-trace-viewer/components/draggable-border.tsx +216 -0
  58. package/src/components/new-trace-viewer/components/middle-truncate/truncate.ts +1 -1
  59. package/src/components/new-trace-viewer/components/pane-constants.ts +19 -0
  60. package/src/components/new-trace-viewer/components/split-pane.tsx +44 -55
  61. package/src/components/new-trace-viewer/components/trace-viewer-skeleton.tsx +4 -1
  62. package/src/components/new-trace-viewer/components/use-element-width.ts +29 -0
  63. package/src/components/new-trace-viewer/context.tsx +4 -1
  64. package/src/components/new-trace-viewer/trace-viewer.tsx +18 -199
  65. package/src/components/sidebar/attribute-panel.tsx +106 -105
  66. package/src/components/sidebar/attributes-block.tsx +170 -50
  67. package/src/components/sidebar/events-list.tsx +107 -92
  68. package/src/components/ui/collapsible.tsx +219 -0
  69. package/src/index.ts +8 -7
  70. package/src/lib/hydration.ts +48 -25
  71. package/dist/components/sidebar/detail-card.d.ts +0 -13
  72. package/dist/components/sidebar/detail-card.d.ts.map +0 -1
  73. package/dist/components/sidebar/detail-card.js +0 -36
  74. package/src/components/sidebar/detail-card.tsx +0 -143
@@ -0,0 +1,272 @@
1
+ 'use client';
2
+
3
+ import { parseStepName, parseWorkflowName } from '@workflow/utils/parse-name';
4
+ import { ChevronDown, ChevronUp, X } from 'lucide-react';
5
+ import {
6
+ type ReactNode,
7
+ type RefObject,
8
+ useCallback,
9
+ useEffect,
10
+ useMemo,
11
+ useRef,
12
+ useState,
13
+ } from 'react';
14
+ import { filterSpanRawEvents } from '../../../lib/trace-builder';
15
+ import { ErrorBoundary } from '../../error-boundary';
16
+ import {
17
+ EntityDetailPanel,
18
+ type SelectedSpanInfo,
19
+ } from '../../sidebar/entity-detail-panel';
20
+ import { useSidebarData } from '../../sidebar/sidebar-data-context';
21
+ import { IconButton } from '../../ui/icon-button';
22
+ import { Kbd } from '../../ui/kbd';
23
+ import { Tooltip, TooltipContent, TooltipTrigger } from '../../ui/tooltip';
24
+ import { useActiveSpan } from '../context';
25
+ import {
26
+ clampPanelWidth,
27
+ computeMaxPanelWidth,
28
+ PANEL_DEFAULT_WIDTH,
29
+ PANEL_MIN_WIDTH,
30
+ readStoredPanelWidth,
31
+ writeStoredPanelWidth,
32
+ } from './detail-panel-width';
33
+ import { DraggableBorder } from './draggable-border';
34
+ import { useElementWidth } from './use-element-width';
35
+
36
+ const DETAIL_PANEL_ID = 'trace-detail-panel';
37
+
38
+ /** Bridge ActiveSpanContext + SidebarDataContext → SelectedSpanInfo. */
39
+ function useSelectedSpanInfo(): SelectedSpanInfo | null {
40
+ const { activeSpan } = useActiveSpan();
41
+ const sidebar = useSidebarData();
42
+
43
+ return useMemo(() => {
44
+ if (!activeSpan) return null;
45
+
46
+ const resource = activeSpan.attributes?.resource as string | undefined;
47
+ const rawEvents = filterSpanRawEvents(
48
+ sidebar.events,
49
+ resource,
50
+ activeSpan.spanId
51
+ );
52
+
53
+ return {
54
+ data: activeSpan.attributes?.data,
55
+ resource,
56
+ spanId: activeSpan.spanId,
57
+ rawEvents,
58
+ };
59
+ }, [activeSpan, sidebar]);
60
+ }
61
+
62
+ /**
63
+ * The span detail aside: content, prev/next/close header, J/K navigation, and
64
+ * the resizable left border with its width model (see detail-panel-width.ts).
65
+ *
66
+ * Always mounted — it renders null without a selection — so the panel width
67
+ * survives closing and reopening within a session.
68
+ */
69
+ export function TraceDetailPanel({
70
+ containerRef,
71
+ onNavigateToSpan,
72
+ onClose,
73
+ }: {
74
+ containerRef: RefObject<HTMLDivElement | null>;
75
+ onNavigateToSpan: (spanId: string) => void;
76
+ onClose: () => void;
77
+ }): ReactNode {
78
+ const { activeSpan, activeSpanId, spans } = useActiveSpan();
79
+ const sidebar = useSidebarData();
80
+ const selectedSpan = useSelectedSpanInfo();
81
+ const isOpen = Boolean(activeSpan);
82
+
83
+ const [storedWidth, setStoredWidth] = useState<number>(() =>
84
+ readStoredPanelWidth()
85
+ );
86
+ const containerWidth = useElementWidth(containerRef, isOpen);
87
+ const asideRef = useRef<HTMLDivElement | null>(null);
88
+
89
+ const handleResize = useCallback(
90
+ (next: number) => {
91
+ const clamped = clampPanelWidth(next, containerWidth);
92
+ setStoredWidth(clamped);
93
+ writeStoredPanelWidth(clamped);
94
+ },
95
+ [containerWidth]
96
+ );
97
+
98
+ const panelWidth = clampPanelWidth(storedWidth, containerWidth);
99
+ const panelMaxWidth = Math.max(
100
+ PANEL_MIN_WIDTH,
101
+ computeMaxPanelWidth(containerWidth)
102
+ );
103
+
104
+ const { prevSpanId, nextSpanId } = useMemo(() => {
105
+ if (!activeSpanId) return { prevSpanId: null, nextSpanId: null };
106
+ const i = spans.findIndex((s) => s.spanId === activeSpanId);
107
+ if (i === -1) return { prevSpanId: null, nextSpanId: null };
108
+ return {
109
+ prevSpanId: spans[i - 1]?.spanId ?? null,
110
+ nextSpanId: spans[i + 1]?.spanId ?? null,
111
+ };
112
+ }, [activeSpanId, spans]);
113
+
114
+ const handleSelectPrev = useCallback(() => {
115
+ if (prevSpanId) onNavigateToSpan(prevSpanId);
116
+ }, [prevSpanId, onNavigateToSpan]);
117
+
118
+ const handleSelectNext = useCallback(() => {
119
+ if (nextSpanId) onNavigateToSpan(nextSpanId);
120
+ }, [nextSpanId, onNavigateToSpan]);
121
+
122
+ useEffect(() => {
123
+ if (!isOpen) return;
124
+
125
+ const onKeyDown = (e: KeyboardEvent): void => {
126
+ if (
127
+ e.key !== 'j' &&
128
+ e.key !== 'k' &&
129
+ e.key !== 'ArrowDown' &&
130
+ e.key !== 'ArrowUp'
131
+ ) {
132
+ return;
133
+ }
134
+ const target = e.target as HTMLElement | null;
135
+ if (
136
+ target instanceof HTMLInputElement ||
137
+ target instanceof HTMLTextAreaElement ||
138
+ target?.isContentEditable
139
+ ) {
140
+ return;
141
+ }
142
+ const targetId =
143
+ e.key === 'k' || e.key === 'ArrowUp' ? prevSpanId : nextSpanId;
144
+ if (targetId) {
145
+ e.preventDefault();
146
+ onNavigateToSpan(targetId);
147
+ }
148
+ };
149
+
150
+ window.addEventListener('keydown', onKeyDown);
151
+ return () => window.removeEventListener('keydown', onKeyDown);
152
+ }, [isOpen, prevSpanId, nextSpanId, onNavigateToSpan]);
153
+
154
+ // Derive the selected span name for the panel header
155
+ const selectedSpanName = useMemo(() => {
156
+ if (!selectedSpan?.data) return 'Details';
157
+ const data = selectedSpan.data as Record<string, unknown>;
158
+ if (selectedSpan.resource === 'hook') {
159
+ return (data.token as string | undefined) ?? (data.hookId as string);
160
+ }
161
+
162
+ const stepName = data.stepName as string | undefined;
163
+ const workflowName = data.workflowName as string | undefined;
164
+ return (
165
+ (stepName ? parseStepName(stepName)?.shortName : undefined) ??
166
+ (workflowName ? parseWorkflowName(workflowName)?.shortName : undefined) ??
167
+ stepName ??
168
+ workflowName ??
169
+ (data.hookId as string) ??
170
+ 'Details'
171
+ );
172
+ }, [selectedSpan?.data, selectedSpan?.resource]);
173
+
174
+ if (!activeSpan) return null;
175
+
176
+ return (
177
+ <aside
178
+ ref={asideRef}
179
+ id={DETAIL_PANEL_ID}
180
+ className="relative flex flex-col h-full max-h-full shrink-0 bg-background-100 border-l border-gray-alpha-400"
181
+ style={{ width: panelWidth }}
182
+ >
183
+ <DraggableBorder
184
+ element={asideRef}
185
+ position="left"
186
+ onWidthChange={handleResize}
187
+ onReset={() => handleResize(PANEL_DEFAULT_WIDTH)}
188
+ aria-label="Resize span details panel"
189
+ aria-controls={DETAIL_PANEL_ID}
190
+ aria-valuemin={PANEL_MIN_WIDTH}
191
+ aria-valuemax={panelMaxWidth}
192
+ aria-valuenow={Math.min(
193
+ Math.max(Math.round(panelWidth), PANEL_MIN_WIDTH),
194
+ panelMaxWidth
195
+ )}
196
+ />
197
+ {/* Panel header */}
198
+ <div className="flex items-center justify-between gap-2 shrink-0 px-4 py-[7.5px]">
199
+ <span className="text-label-14 font-medium text-gray-1000 truncate block">
200
+ {selectedSpanName}
201
+ </span>
202
+ <div className="flex items-center gap-0.5 shrink-0">
203
+ <Tooltip>
204
+ <TooltipTrigger asChild>
205
+ <IconButton
206
+ aria-label="Navigate up"
207
+ aria-keyshortcuts="K"
208
+ onClick={handleSelectPrev}
209
+ disabled={!prevSpanId}
210
+ >
211
+ <ChevronUp className="w-4 h-4" />
212
+ </IconButton>
213
+ </TooltipTrigger>
214
+ {prevSpanId ? (
215
+ <TooltipContent>
216
+ Navigate up
217
+ <Kbd>K</Kbd>
218
+ </TooltipContent>
219
+ ) : null}
220
+ </Tooltip>
221
+ <Tooltip>
222
+ <TooltipTrigger asChild>
223
+ <IconButton
224
+ aria-label="Navigate down"
225
+ aria-keyshortcuts="J"
226
+ onClick={handleSelectNext}
227
+ disabled={!nextSpanId}
228
+ >
229
+ <ChevronDown className="w-4 h-4" />
230
+ </IconButton>
231
+ </TooltipTrigger>
232
+ {nextSpanId ? (
233
+ <TooltipContent>
234
+ Navigate down
235
+ <Kbd>J</Kbd>
236
+ </TooltipContent>
237
+ ) : null}
238
+ </Tooltip>
239
+ <div aria-hidden className="w-px h-4 bg-gray-alpha-400 mx-1" />
240
+ <IconButton
241
+ aria-label="Close span details"
242
+ aria-keyshortcuts="Escape"
243
+ onClick={onClose}
244
+ >
245
+ <X className="w-4 h-4" />
246
+ </IconButton>
247
+ </div>
248
+ </div>
249
+ {/* Panel body */}
250
+ <div className="flex-1 overflow-y-auto">
251
+ <ErrorBoundary>
252
+ <EntityDetailPanel
253
+ run={sidebar.run}
254
+ onStreamClick={sidebar.onStreamClick}
255
+ onRunClick={sidebar.onRunClick}
256
+ fetchSpanDetail={sidebar.fetchSpanDetail}
257
+ onWakeUpSleep={sidebar.onWakeUpSleep}
258
+ onLoadEventData={sidebar.onLoadEventData}
259
+ onResolveHook={sidebar.onResolveHook}
260
+ encryptionKey={sidebar.encryptionKey}
261
+ onDecrypt={sidebar.onDecrypt}
262
+ isDecrypting={sidebar.isDecrypting}
263
+ selectedSpan={selectedSpan}
264
+ showSeparateEventOccurrenceTimestamps={
265
+ sidebar.showSeparateEventOccurrenceTimestamps
266
+ }
267
+ />
268
+ </ErrorBoundary>
269
+ </div>
270
+ </aside>
271
+ );
272
+ }
@@ -0,0 +1,216 @@
1
+ 'use client';
2
+
3
+ import {
4
+ type KeyboardEvent as ReactKeyboardEvent,
5
+ type PointerEvent as ReactPointerEvent,
6
+ type RefObject,
7
+ useEffect,
8
+ useRef,
9
+ useState,
10
+ } from 'react';
11
+ import { cn } from '../../../lib/cn';
12
+
13
+ interface DraggableBorderProps {
14
+ /** The panel whose width this border adjusts (measured on interaction). */
15
+ element: RefObject<HTMLElement | null>;
16
+ /** Which edge of the panel the border sits on. */
17
+ position: 'left' | 'right';
18
+ /**
19
+ * Called with the next width (px) while dragging or keyboard-resizing.
20
+ * The owner is responsible for clamping.
21
+ */
22
+ onWidthChange: (width: number) => void;
23
+ /** Double-click reset. */
24
+ onReset?: () => void;
25
+ 'aria-label': string;
26
+ 'aria-controls'?: string;
27
+ 'aria-valuemin'?: number;
28
+ 'aria-valuemax'?: number;
29
+ 'aria-valuenow'?: number;
30
+ }
31
+
32
+ /**
33
+ * Overlay drag handle straddling a panel edge: an invisible strip over the
34
+ * panel's border whose center line highlights on hover/drag, with
35
+ * double-click reset, pointer-capture dragging (works with touch/pen, no
36
+ * ghost image), and keyboard/ARIA window-splitter support.
37
+ *
38
+ * The panel's positioning ancestor must not clip overflow — the strip hangs
39
+ * ~8px past the panel edge.
40
+ */
41
+ export function DraggableBorder({
42
+ element,
43
+ position,
44
+ onWidthChange,
45
+ onReset,
46
+ 'aria-label': ariaLabel,
47
+ 'aria-controls': ariaControls,
48
+ 'aria-valuemin': ariaValueMin,
49
+ 'aria-valuemax': ariaValueMax,
50
+ 'aria-valuenow': ariaValueNow,
51
+ }: DraggableBorderProps) {
52
+ const [isDragging, setIsDragging] = useState(false);
53
+ const pointerIdRef = useRef<number | null>(null);
54
+ const startXRef = useRef(0);
55
+ const startWidthRef = useRef(0);
56
+ const pendingWidthRef = useRef(0);
57
+ const rafRef = useRef<number | null>(null);
58
+ const movedRef = useRef(false);
59
+ const lastDragEndAtRef = useRef(0);
60
+
61
+ useEffect(() => {
62
+ return () => {
63
+ if (rafRef.current) cancelAnimationFrame(rafRef.current);
64
+ };
65
+ }, []);
66
+
67
+ // Suppress text selection for the duration of a drag. Effect cleanup also
68
+ // covers the handle unmounting mid-drag (e.g. Escape closing the panel).
69
+ useEffect(() => {
70
+ if (!isDragging) return;
71
+ const previous = document.body.style.userSelect;
72
+ document.body.style.userSelect = 'none';
73
+ return () => {
74
+ document.body.style.userSelect = previous;
75
+ };
76
+ }, [isDragging]);
77
+
78
+ const stopDrag = () => {
79
+ pointerIdRef.current = null;
80
+ setIsDragging(false);
81
+ if (rafRef.current) {
82
+ cancelAnimationFrame(rafRef.current);
83
+ rafRef.current = null;
84
+ // Flush the final position that was still waiting on the next frame.
85
+ if (movedRef.current) onWidthChange(pendingWidthRef.current);
86
+ }
87
+ if (movedRef.current) {
88
+ movedRef.current = false;
89
+ lastDragEndAtRef.current = performance.now();
90
+ }
91
+ };
92
+
93
+ const handlePointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
94
+ // Ignore secondary pointers (e.g. a second finger) while a drag is active
95
+ // so they can't re-baseline the gesture.
96
+ if (pointerIdRef.current !== null) return;
97
+ const el = element.current;
98
+ if (!el) return;
99
+ if (e.pointerType === 'mouse' && e.button !== 0) return;
100
+ e.currentTarget.setPointerCapture(e.pointerId);
101
+ pointerIdRef.current = e.pointerId;
102
+ startXRef.current = e.clientX;
103
+ startWidthRef.current = el.offsetWidth;
104
+ movedRef.current = false;
105
+ setIsDragging(true);
106
+ };
107
+
108
+ // Pointer capture routes move/up events to the strip itself, so no
109
+ // document-level listeners are needed.
110
+ const handlePointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
111
+ if (pointerIdRef.current !== e.pointerId) return;
112
+ if (Math.abs(e.clientX - startXRef.current) > 4) {
113
+ movedRef.current = true;
114
+ }
115
+ const delta =
116
+ position === 'left'
117
+ ? startXRef.current - e.clientX
118
+ : e.clientX - startXRef.current;
119
+ pendingWidthRef.current = startWidthRef.current + delta;
120
+ if (!rafRef.current) {
121
+ rafRef.current = requestAnimationFrame(() => {
122
+ rafRef.current = null;
123
+ onWidthChange(pendingWidthRef.current);
124
+ });
125
+ }
126
+ };
127
+
128
+ const handlePointerEnd = (e: ReactPointerEvent<HTMLDivElement>) => {
129
+ if (pointerIdRef.current !== e.pointerId) return;
130
+ stopDrag();
131
+ };
132
+
133
+ const handleLostPointerCapture = (e: ReactPointerEvent<HTMLDivElement>) => {
134
+ if (pointerIdRef.current !== null && e.pointerId !== pointerIdRef.current) {
135
+ return;
136
+ }
137
+ stopDrag();
138
+ };
139
+
140
+ const handleDoubleClick = () => {
141
+ // Pointer-capture drags still emit click/dblclick (unlike HTML5 drag), so
142
+ // a dblclick during or right after a real drag is two resize nudges, not
143
+ // a reset request.
144
+ if (
145
+ movedRef.current ||
146
+ performance.now() - lastDragEndAtRef.current < 500
147
+ ) {
148
+ return;
149
+ }
150
+ onReset?.();
151
+ };
152
+
153
+ const handleKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
154
+ const el = element.current;
155
+ if (!el) return;
156
+ const step = e.shiftKey ? 64 : 16;
157
+ // Arrows move the border in screen direction: for a left-edge border,
158
+ // ArrowLeft grows the panel.
159
+ const grow = position === 'left' ? 1 : -1;
160
+ let next: number | null = null;
161
+ if (e.key === 'ArrowLeft') {
162
+ next = el.offsetWidth + grow * step;
163
+ } else if (e.key === 'ArrowRight') {
164
+ next = el.offsetWidth - grow * step;
165
+ } else if (e.key === 'Home' && ariaValueMin !== undefined) {
166
+ next = ariaValueMin;
167
+ } else if (e.key === 'End' && ariaValueMax !== undefined) {
168
+ next = ariaValueMax;
169
+ }
170
+ if (next === null) return;
171
+ e.preventDefault();
172
+ onWidthChange(next);
173
+ };
174
+
175
+ return (
176
+ // biome-ignore lint/a11y/useSemanticElements: the WAI-ARIA window-splitter pattern requires a focusable div with role=separator; <hr> cannot receive focus or drag interactions
177
+ <div
178
+ role="separator"
179
+ aria-orientation="vertical"
180
+ aria-label={ariaLabel}
181
+ aria-controls={ariaControls}
182
+ aria-valuemin={ariaValueMin}
183
+ aria-valuemax={ariaValueMax}
184
+ aria-valuenow={ariaValueNow}
185
+ tabIndex={0}
186
+ className={cn(
187
+ 'group absolute inset-y-0 z-10 w-[17px] cursor-col-resize touch-none outline-none',
188
+ position === 'left' ? '-left-2' : '-right-2'
189
+ )}
190
+ onPointerDown={handlePointerDown}
191
+ onPointerMove={handlePointerMove}
192
+ onPointerUp={handlePointerEnd}
193
+ onPointerCancel={handlePointerEnd}
194
+ onLostPointerCapture={handleLostPointerCapture}
195
+ onDoubleClick={handleDoubleClick}
196
+ onKeyDown={handleKeyDown}
197
+ >
198
+ {/* Center line: sits exactly over the panel's 1px border and highlights
199
+ on hover/drag/focus (delayed so incidental mouse-overs don't flash).
200
+ pointer-events-none is load-bearing: without it this 1px line is a
201
+ hit target, so a double-click aimed at the line lands one click on
202
+ the line and (after sub-pixel jitter) the other on the strip. A
203
+ native dblclick only fires when both clicks share a target, so the
204
+ reset silently fails on the line but works just beside it. Keeping
205
+ the line inert makes the separator div the sole target everywhere. */}
206
+ <span
207
+ aria-hidden
208
+ className={cn(
209
+ 'pointer-events-none absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-transparent transition-colors delay-75 duration-100',
210
+ 'group-hover:bg-gray-500 group-focus-visible:bg-[var(--ds-focus-color)]',
211
+ isDragging && 'bg-gray-500'
212
+ )}
213
+ />
214
+ </div>
215
+ );
216
+ }
@@ -1,4 +1,4 @@
1
- const ELLIPSIS = '...';
1
+ const ELLIPSIS = '';
2
2
  const MIN_START = 3;
3
3
  const MIN_END = 3;
4
4
  const MIN_KEPT = MIN_START + MIN_END;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Layout constants shared by SplitPane and TraceViewerSkeleton. Kept in a
3
+ * module without 'use client' so the skeleton's import chain stays
4
+ * server-component-safe for external consumers.
5
+ */
6
+
7
+ /** Width (px) of the draggable gutter column between the list and timeline. */
8
+ export const GUTTER_PX = 1;
9
+
10
+ /** Minimum width (px) of either SplitPane pane. */
11
+ export const MIN_PX = 50;
12
+
13
+ /** Default width (px) of the start (event list) pane. */
14
+ export const DEFAULT_START_PX = 340;
15
+
16
+ /** Grid column template for the [list | gutter | timeline] split. */
17
+ export function paneColTemplate(startPx: number): string {
18
+ return `${startPx}px ${GUTTER_PX}px minmax(${MIN_PX}px, 1fr)`;
19
+ }
@@ -11,20 +11,17 @@ import {
11
11
  useState,
12
12
  } from 'react';
13
13
  import { cn } from '../../../lib/cn';
14
-
15
- const GUTTER_PX = 1;
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
- }
14
+ import {
15
+ DEFAULT_START_PX,
16
+ GUTTER_PX,
17
+ MIN_PX,
18
+ paneColTemplate,
19
+ } from './pane-constants';
20
+ import { useElementWidth } from './use-element-width';
22
21
 
23
22
  export interface SplitPaneProps {
24
23
  children: ReactNode;
25
24
  className?: string;
26
- /** Fixed pixel width for the start (left) pane. Default 220. */
27
- defaultStartWidth?: number;
28
25
  /** Fixed (non-scrolling) header rendered above the start pane. */
29
26
  startHeader?: ReactNode;
30
27
  /** Fixed (non-scrolling) header rendered above the end pane. */
@@ -35,7 +32,6 @@ export interface SplitPaneProps {
35
32
  export function SplitPane({
36
33
  children,
37
34
  className,
38
- defaultStartWidth = DEFAULT_START_PX,
39
35
  startHeader,
40
36
  endHeader,
41
37
  scrollContainerRef,
@@ -46,13 +42,18 @@ export function SplitPane({
46
42
  }
47
43
  const [start, end] = parts;
48
44
 
49
- const [startPx, setStartPx] = useState(defaultStartWidth);
45
+ // `startPx` is the user's preferred width; the rendered width is derived by
46
+ // clamping against the live container width (same model as the detail
47
+ // panel), so shrinking the container compresses the pane without destroying
48
+ // the preference, and re-growing restores it.
49
+ const [startPx, setStartPx] = useState(DEFAULT_START_PX);
50
50
  const [isDragging, setIsDragging] = useState(false);
51
51
  const containerRef = useRef<HTMLDivElement>(null);
52
52
  const gutterRef = useRef<HTMLDivElement>(null);
53
53
  const rafRef = useRef<number | null>(null);
54
- const pendingPx = useRef(defaultStartWidth);
54
+ const pendingPx = useRef(DEFAULT_START_PX);
55
55
  const pointerIdRef = useRef<number | null>(null);
56
+ const containerWidth = useElementWidth(containerRef);
56
57
 
57
58
  useEffect(() => {
58
59
  return () => {
@@ -134,8 +135,14 @@ export function SplitPane({
134
135
  }
135
136
  };
136
137
 
137
- const colTemplate = `${startPx}px ${GUTTER_PX}px minmax(${MIN_PX}px, 1fr)`;
138
- const hasHeaders = startHeader != null || endHeader != null;
138
+ // Floor applied last so the start column can never collapse below MIN_PX
139
+ // (which would produce an invalid negative grid track on tiny containers).
140
+ const effectiveStartPx =
141
+ containerWidth > 0
142
+ ? Math.max(MIN_PX, Math.min(containerWidth - MIN_PX - GUTTER_PX, startPx))
143
+ : startPx;
144
+
145
+ const colTemplate = paneColTemplate(effectiveStartPx);
139
146
 
140
147
  const gutter = (
141
148
  <div
@@ -151,48 +158,30 @@ export function SplitPane({
151
158
  </div>
152
159
  );
153
160
 
154
- if (hasHeaders) {
155
- return (
156
- <div className={cn('flex flex-col h-full min-h-0', className)}>
157
- <div
158
- className="shrink-0 grid"
159
- style={{ gridTemplateColumns: colTemplate }}
160
- >
161
- <div>{startHeader}</div>
162
- <div className="flex justify-center">
163
- <span className="h-full w-px bg-gray-alpha-400" aria-hidden />
164
- </div>
165
- <div>{endHeader}</div>
166
- </div>
167
- <div
168
- ref={setContainerRef}
169
- className={cn(
170
- 'grid flex-1 min-h-0 overflow-x-hidden overflow-y-auto',
171
- isDragging && 'select-none'
172
- )}
173
- style={{ gridTemplateColumns: colTemplate }}
174
- >
175
- {start}
176
- {gutter}
177
- {end}
161
+ return (
162
+ <div className={cn('flex flex-col h-full min-h-0', className)}>
163
+ <div
164
+ className="shrink-0 grid"
165
+ style={{ gridTemplateColumns: colTemplate }}
166
+ >
167
+ <div>{startHeader}</div>
168
+ <div className="flex justify-center">
169
+ <span className="h-full w-px bg-gray-alpha-400" aria-hidden />
178
170
  </div>
171
+ <div>{endHeader}</div>
172
+ </div>
173
+ <div
174
+ ref={setContainerRef}
175
+ className={cn(
176
+ 'grid flex-1 min-h-0 overflow-x-hidden overflow-y-auto',
177
+ isDragging && 'select-none'
178
+ )}
179
+ style={{ gridTemplateColumns: colTemplate }}
180
+ >
181
+ {start}
182
+ {gutter}
183
+ {end}
179
184
  </div>
180
- );
181
- }
182
-
183
- return (
184
- <div
185
- ref={setContainerRef}
186
- className={cn(
187
- 'grid h-full min-h-0 content-start overflow-x-hidden overflow-y-auto',
188
- isDragging && 'select-none',
189
- className
190
- )}
191
- style={{ gridTemplateColumns: colTemplate, height: '100%' }}
192
- >
193
- {start}
194
- {gutter}
195
- {end}
196
185
  </div>
197
186
  );
198
187
  }
@@ -1,6 +1,9 @@
1
1
  import { Skeleton } from '../../ui/skeleton';
2
+ import { DEFAULT_START_PX, paneColTemplate } from './pane-constants';
2
3
 
3
- const COL_TEMPLATE = '340px 1px minmax(50px, 1fr)';
4
+ // Mirrors SplitPane's initial column template so the skeleton lines up with
5
+ // the real viewer's first paint.
6
+ const COL_TEMPLATE = paneColTemplate(DEFAULT_START_PX);
4
7
 
5
8
  const ROWS: { id: string; name: number; off: number; bar: number }[] = [
6
9
  { id: 'r0', name: 62, off: 0, bar: 72 },