@workflow/web-shared 5.0.0-beta.26 → 5.0.0-beta.28

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 (94) 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/event-list.d.ts +1 -1
  11. package/dist/components/new-trace-viewer/components/event-list.d.ts.map +1 -1
  12. package/dist/components/new-trace-viewer/components/event-list.js +3 -5
  13. package/dist/components/new-trace-viewer/components/middle-truncate/truncate.d.ts +1 -1
  14. package/dist/components/new-trace-viewer/components/middle-truncate/truncate.d.ts.map +1 -1
  15. package/dist/components/new-trace-viewer/components/middle-truncate/truncate.js +2 -2
  16. package/dist/components/new-trace-viewer/components/minimap.d.ts +14 -0
  17. package/dist/components/new-trace-viewer/components/minimap.d.ts.map +1 -0
  18. package/dist/components/new-trace-viewer/components/minimap.js +337 -0
  19. package/dist/components/new-trace-viewer/components/pane-constants.d.ts +14 -0
  20. package/dist/components/new-trace-viewer/components/pane-constants.d.ts.map +1 -0
  21. package/dist/components/new-trace-viewer/components/pane-constants.js +16 -0
  22. package/dist/components/new-trace-viewer/components/split-pane.d.ts +1 -4
  23. package/dist/components/new-trace-viewer/components/split-pane.d.ts.map +1 -1
  24. package/dist/components/new-trace-viewer/components/split-pane.js +18 -16
  25. package/dist/components/new-trace-viewer/components/timeline.d.ts +2 -2
  26. package/dist/components/new-trace-viewer/components/timeline.d.ts.map +1 -1
  27. package/dist/components/new-trace-viewer/components/timeline.js +5 -7
  28. package/dist/components/new-trace-viewer/components/trace-viewer-skeleton.d.ts.map +1 -1
  29. package/dist/components/new-trace-viewer/components/trace-viewer-skeleton.js +10 -3
  30. package/dist/components/new-trace-viewer/components/use-element-width.d.ts +8 -0
  31. package/dist/components/new-trace-viewer/components/use-element-width.d.ts.map +1 -0
  32. package/dist/components/new-trace-viewer/components/use-element-width.js +25 -0
  33. package/dist/components/new-trace-viewer/context.d.ts +2 -0
  34. package/dist/components/new-trace-viewer/context.d.ts.map +1 -1
  35. package/dist/components/new-trace-viewer/context.js +3 -2
  36. package/dist/components/new-trace-viewer/trace-viewer.d.ts.map +1 -1
  37. package/dist/components/new-trace-viewer/trace-viewer.js +53 -181
  38. package/dist/components/new-trace-viewer/utils.d.ts +21 -0
  39. package/dist/components/new-trace-viewer/utils.d.ts.map +1 -1
  40. package/dist/components/new-trace-viewer/utils.js +44 -3
  41. package/dist/components/sidebar/attribute-panel.d.ts.map +1 -1
  42. package/dist/components/sidebar/attribute-panel.js +38 -35
  43. package/dist/components/sidebar/attributes-block.d.ts +18 -7
  44. package/dist/components/sidebar/attributes-block.d.ts.map +1 -1
  45. package/dist/components/sidebar/attributes-block.js +76 -18
  46. package/dist/components/sidebar/events-list.d.ts +1 -1
  47. package/dist/components/sidebar/events-list.d.ts.map +1 -1
  48. package/dist/components/sidebar/events-list.js +9 -10
  49. package/dist/components/trace-viewer/components/map.d.ts +2 -2
  50. package/dist/components/trace-viewer/components/map.d.ts.map +1 -1
  51. package/dist/components/trace-viewer/components/node.d.ts +7 -7
  52. package/dist/components/trace-viewer/components/node.d.ts.map +1 -1
  53. package/dist/components/ui/collapsible.d.ts +46 -0
  54. package/dist/components/ui/collapsible.d.ts.map +1 -0
  55. package/dist/components/ui/collapsible.js +66 -0
  56. package/dist/components/ui/data-inspector.js +2 -7
  57. package/dist/index.d.ts +2 -2
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js +3 -3
  60. package/dist/lib/hydration.d.ts +12 -2
  61. package/dist/lib/hydration.d.ts.map +1 -1
  62. package/dist/lib/hydration.js +41 -18
  63. package/dist/lib/utils.d.ts +8 -5
  64. package/dist/lib/utils.d.ts.map +1 -1
  65. package/dist/lib/utils.js +13 -16
  66. package/package.json +6 -6
  67. package/src/components/new-trace-viewer/components/detail-panel-width.test.ts +123 -0
  68. package/src/components/new-trace-viewer/components/detail-panel-width.ts +76 -0
  69. package/src/components/new-trace-viewer/components/detail-panel.tsx +272 -0
  70. package/src/components/new-trace-viewer/components/draggable-border.tsx +216 -0
  71. package/src/components/new-trace-viewer/components/event-list.tsx +3 -5
  72. package/src/components/new-trace-viewer/components/middle-truncate/truncate.ts +1 -1
  73. package/src/components/new-trace-viewer/components/minimap.tsx +559 -0
  74. package/src/components/new-trace-viewer/components/pane-constants.ts +19 -0
  75. package/src/components/new-trace-viewer/components/split-pane.tsx +44 -55
  76. package/src/components/new-trace-viewer/components/timeline.tsx +3 -5
  77. package/src/components/new-trace-viewer/components/trace-viewer-skeleton.tsx +21 -1
  78. package/src/components/new-trace-viewer/components/use-element-width.ts +29 -0
  79. package/src/components/new-trace-viewer/context.tsx +4 -1
  80. package/src/components/new-trace-viewer/trace-viewer.tsx +93 -312
  81. package/src/components/new-trace-viewer/utils.test.ts +65 -0
  82. package/src/components/new-trace-viewer/utils.ts +76 -2
  83. package/src/components/sidebar/attribute-panel.tsx +106 -105
  84. package/src/components/sidebar/attributes-block.tsx +170 -50
  85. package/src/components/sidebar/events-list.tsx +107 -92
  86. package/src/components/ui/collapsible.tsx +219 -0
  87. package/src/index.ts +8 -7
  88. package/src/lib/hydration.ts +48 -25
  89. package/src/lib/utils.test.ts +33 -0
  90. package/src/lib/utils.ts +12 -16
  91. package/dist/components/sidebar/detail-card.d.ts +0 -13
  92. package/dist/components/sidebar/detail-card.d.ts.map +0 -1
  93. package/dist/components/sidebar/detail-card.js +0 -36
  94. package/src/components/sidebar/detail-card.tsx +0 -143
@@ -0,0 +1,123 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import {
3
+ clampPanelWidth,
4
+ computeMaxPanelWidth,
5
+ MAIN_MIN_WIDTH,
6
+ PANEL_DEFAULT_WIDTH,
7
+ PANEL_HARD_MAX_WIDTH,
8
+ PANEL_MIN_WIDTH,
9
+ PANEL_WIDTH_STORAGE_KEY,
10
+ readStoredPanelWidth,
11
+ writeStoredPanelWidth,
12
+ } from './detail-panel-width';
13
+
14
+ function stubLocalStorage(overrides: Partial<Storage> = {}) {
15
+ const storage = {
16
+ getItem: vi.fn().mockReturnValue(null),
17
+ setItem: vi.fn(),
18
+ ...overrides,
19
+ };
20
+ vi.stubGlobal('window', { localStorage: storage });
21
+ return storage;
22
+ }
23
+
24
+ afterEach(() => {
25
+ vi.unstubAllGlobals();
26
+ });
27
+
28
+ describe('computeMaxPanelWidth', () => {
29
+ it('falls back to the hard max when the container is unmeasured', () => {
30
+ expect(computeMaxPanelWidth(0)).toBe(PANEL_HARD_MAX_WIDTH);
31
+ expect(computeMaxPanelWidth(-5)).toBe(PANEL_HARD_MAX_WIDTH);
32
+ });
33
+
34
+ it('reserves the main-area minimum', () => {
35
+ expect(computeMaxPanelWidth(1000)).toBe(1000 - MAIN_MIN_WIDTH);
36
+ });
37
+
38
+ it('caps at the hard max on very wide containers', () => {
39
+ expect(computeMaxPanelWidth(5000)).toBe(PANEL_HARD_MAX_WIDTH);
40
+ });
41
+
42
+ it('bottoms out at 0 when the container is smaller than the main minimum', () => {
43
+ expect(computeMaxPanelWidth(MAIN_MIN_WIDTH - 50)).toBe(0);
44
+ });
45
+ });
46
+
47
+ describe('clampPanelWidth', () => {
48
+ it('passes through widths within bounds', () => {
49
+ expect(clampPanelWidth(360, 1000)).toBe(360);
50
+ });
51
+
52
+ it('clamps to the panel minimum', () => {
53
+ expect(clampPanelWidth(100, 1000)).toBe(PANEL_MIN_WIDTH);
54
+ });
55
+
56
+ it('clamps to the container-relative maximum', () => {
57
+ expect(clampPanelWidth(900, 1000)).toBe(1000 - MAIN_MIN_WIDTH);
58
+ });
59
+
60
+ it('lets the max win below the crossover so the panel gives way, not the main area', () => {
61
+ // Container too small for both floors: panel compresses below its own min.
62
+ const available = PANEL_MIN_WIDTH + MAIN_MIN_WIDTH - 100;
63
+ expect(clampPanelWidth(360, available)).toBe(available - MAIN_MIN_WIDTH);
64
+ });
65
+ });
66
+
67
+ describe('readStoredPanelWidth', () => {
68
+ it('returns the default when window is unavailable (SSR)', () => {
69
+ expect(readStoredPanelWidth()).toBe(PANEL_DEFAULT_WIDTH);
70
+ });
71
+
72
+ it('returns the stored value when valid', () => {
73
+ stubLocalStorage({ getItem: vi.fn().mockReturnValue('512') });
74
+ expect(readStoredPanelWidth()).toBe(512);
75
+ });
76
+
77
+ it('returns the default for missing or unparseable values', () => {
78
+ stubLocalStorage({ getItem: vi.fn().mockReturnValue(null) });
79
+ expect(readStoredPanelWidth()).toBe(PANEL_DEFAULT_WIDTH);
80
+ stubLocalStorage({ getItem: vi.fn().mockReturnValue('not-a-number') });
81
+ expect(readStoredPanelWidth()).toBe(PANEL_DEFAULT_WIDTH);
82
+ });
83
+
84
+ it('clamps stored values to absolute bounds only', () => {
85
+ stubLocalStorage({ getItem: vi.fn().mockReturnValue('-20') });
86
+ expect(readStoredPanelWidth()).toBe(PANEL_MIN_WIDTH);
87
+ stubLocalStorage({ getItem: vi.fn().mockReturnValue('99999') });
88
+ expect(readStoredPanelWidth()).toBe(PANEL_HARD_MAX_WIDTH);
89
+ });
90
+
91
+ it('returns the default when storage access throws', () => {
92
+ stubLocalStorage({
93
+ getItem: vi.fn().mockImplementation(() => {
94
+ throw new Error('denied');
95
+ }),
96
+ });
97
+ expect(readStoredPanelWidth()).toBe(PANEL_DEFAULT_WIDTH);
98
+ });
99
+ });
100
+
101
+ describe('writeStoredPanelWidth', () => {
102
+ it('is a no-op when window is unavailable (SSR)', () => {
103
+ expect(() => writeStoredPanelWidth(400)).not.toThrow();
104
+ });
105
+
106
+ it('rounds and writes the value', () => {
107
+ const storage = stubLocalStorage();
108
+ writeStoredPanelWidth(412.6);
109
+ expect(storage.setItem).toHaveBeenCalledWith(
110
+ PANEL_WIDTH_STORAGE_KEY,
111
+ '413'
112
+ );
113
+ });
114
+
115
+ it('swallows storage errors (private mode, quota)', () => {
116
+ stubLocalStorage({
117
+ setItem: vi.fn().mockImplementation(() => {
118
+ throw new Error('quota');
119
+ }),
120
+ });
121
+ expect(() => writeStoredPanelWidth(400)).not.toThrow();
122
+ });
123
+ });
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Width model for the span detail panel: the stored value is the user's
3
+ * preferred width (absolute px, persisted to localStorage on explicit
4
+ * interaction only), while the container-relative maximum is applied at
5
+ * render time — so a width preferred on a wide screen survives a narrower
6
+ * session and restores when the viewer grows again.
7
+ */
8
+
9
+ /** Floor for the detail panel — matches the previous `clamp(280px, …)` floor. */
10
+ export const PANEL_MIN_WIDTH = 280;
11
+
12
+ /**
13
+ * Absolute ceiling enforced at the storage layer so a corrupt or stale stored
14
+ * value can never wedge the layout open.
15
+ */
16
+ export const PANEL_HARD_MAX_WIDTH = 1300;
17
+
18
+ /** Default width — matches the previous fixed 360px column. */
19
+ export const PANEL_DEFAULT_WIDTH = 360;
20
+
21
+ /**
22
+ * Width the main (event list + timeline) area keeps while the panel is
23
+ * resized or the container shrinks. Below `PANEL_MIN_WIDTH + MAIN_MIN_WIDTH`
24
+ * of container the panel gives way first, compressing below its own floor —
25
+ * the list and timeline stay usable at the panel's expense.
26
+ */
27
+ export const MAIN_MIN_WIDTH = 400;
28
+
29
+ export const PANEL_WIDTH_STORAGE_KEY = 'workflow-trace-detail-panel-width';
30
+
31
+ function clamp(value: number, min: number, max: number): number {
32
+ return Math.min(Math.max(value, min), max);
33
+ }
34
+
35
+ export function computeMaxPanelWidth(availableWidth: number): number {
36
+ if (availableWidth <= 0) return PANEL_HARD_MAX_WIDTH;
37
+ return Math.min(
38
+ Math.max(availableWidth - MAIN_MIN_WIDTH, 0),
39
+ PANEL_HARD_MAX_WIDTH
40
+ );
41
+ }
42
+
43
+ /**
44
+ * Note: when the container is too small to honor both floors
45
+ * (`availableWidth < PANEL_MIN_WIDTH + MAIN_MIN_WIDTH`), the max wins and the
46
+ * panel compresses below `PANEL_MIN_WIDTH`.
47
+ */
48
+ export function clampPanelWidth(width: number, availableWidth: number): number {
49
+ return clamp(width, PANEL_MIN_WIDTH, computeMaxPanelWidth(availableWidth));
50
+ }
51
+
52
+ export function readStoredPanelWidth(): number {
53
+ if (typeof window === 'undefined') return PANEL_DEFAULT_WIDTH;
54
+ try {
55
+ const raw = window.localStorage.getItem(PANEL_WIDTH_STORAGE_KEY);
56
+ const value = raw ? Number(raw) : Number.NaN;
57
+ if (!Number.isFinite(value)) return PANEL_DEFAULT_WIDTH;
58
+ // Only enforce absolute bounds at the storage layer; the container-relative
59
+ // max is applied at render time (see module docs).
60
+ return clamp(value, PANEL_MIN_WIDTH, PANEL_HARD_MAX_WIDTH);
61
+ } catch {
62
+ return PANEL_DEFAULT_WIDTH;
63
+ }
64
+ }
65
+
66
+ export function writeStoredPanelWidth(width: number): void {
67
+ if (typeof window === 'undefined') return;
68
+ try {
69
+ window.localStorage.setItem(
70
+ PANEL_WIDTH_STORAGE_KEY,
71
+ String(Math.round(width))
72
+ );
73
+ } catch {
74
+ // Best-effort (private mode, quota).
75
+ }
76
+ }
@@ -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
+ }