@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.
- package/dist/components/new-trace-viewer/components/detail-panel-width.d.ts +34 -0
- package/dist/components/new-trace-viewer/components/detail-panel-width.d.ts.map +1 -0
- package/dist/components/new-trace-viewer/components/detail-panel-width.js +67 -0
- package/dist/components/new-trace-viewer/components/detail-panel.d.ts +14 -0
- package/dist/components/new-trace-viewer/components/detail-panel.d.ts.map +1 -0
- package/dist/components/new-trace-viewer/components/detail-panel.js +122 -0
- package/dist/components/new-trace-viewer/components/draggable-border.d.ts +31 -0
- package/dist/components/new-trace-viewer/components/draggable-border.d.ts.map +1 -0
- package/dist/components/new-trace-viewer/components/draggable-border.js +140 -0
- package/dist/components/new-trace-viewer/components/event-list.d.ts +1 -1
- package/dist/components/new-trace-viewer/components/event-list.d.ts.map +1 -1
- package/dist/components/new-trace-viewer/components/event-list.js +3 -5
- package/dist/components/new-trace-viewer/components/middle-truncate/truncate.d.ts +1 -1
- package/dist/components/new-trace-viewer/components/middle-truncate/truncate.d.ts.map +1 -1
- package/dist/components/new-trace-viewer/components/middle-truncate/truncate.js +2 -2
- package/dist/components/new-trace-viewer/components/minimap.d.ts +14 -0
- package/dist/components/new-trace-viewer/components/minimap.d.ts.map +1 -0
- package/dist/components/new-trace-viewer/components/minimap.js +337 -0
- package/dist/components/new-trace-viewer/components/pane-constants.d.ts +14 -0
- package/dist/components/new-trace-viewer/components/pane-constants.d.ts.map +1 -0
- package/dist/components/new-trace-viewer/components/pane-constants.js +16 -0
- package/dist/components/new-trace-viewer/components/split-pane.d.ts +1 -4
- package/dist/components/new-trace-viewer/components/split-pane.d.ts.map +1 -1
- package/dist/components/new-trace-viewer/components/split-pane.js +18 -16
- package/dist/components/new-trace-viewer/components/timeline.d.ts +2 -2
- package/dist/components/new-trace-viewer/components/timeline.d.ts.map +1 -1
- package/dist/components/new-trace-viewer/components/timeline.js +5 -7
- package/dist/components/new-trace-viewer/components/trace-viewer-skeleton.d.ts.map +1 -1
- package/dist/components/new-trace-viewer/components/trace-viewer-skeleton.js +10 -3
- package/dist/components/new-trace-viewer/components/use-element-width.d.ts +8 -0
- package/dist/components/new-trace-viewer/components/use-element-width.d.ts.map +1 -0
- package/dist/components/new-trace-viewer/components/use-element-width.js +25 -0
- package/dist/components/new-trace-viewer/context.d.ts +2 -0
- package/dist/components/new-trace-viewer/context.d.ts.map +1 -1
- package/dist/components/new-trace-viewer/context.js +3 -2
- package/dist/components/new-trace-viewer/trace-viewer.d.ts.map +1 -1
- package/dist/components/new-trace-viewer/trace-viewer.js +53 -181
- package/dist/components/new-trace-viewer/utils.d.ts +21 -0
- package/dist/components/new-trace-viewer/utils.d.ts.map +1 -1
- package/dist/components/new-trace-viewer/utils.js +44 -3
- package/dist/components/sidebar/attribute-panel.d.ts.map +1 -1
- package/dist/components/sidebar/attribute-panel.js +38 -35
- package/dist/components/sidebar/attributes-block.d.ts +18 -7
- package/dist/components/sidebar/attributes-block.d.ts.map +1 -1
- package/dist/components/sidebar/attributes-block.js +76 -18
- package/dist/components/sidebar/events-list.d.ts +1 -1
- package/dist/components/sidebar/events-list.d.ts.map +1 -1
- package/dist/components/sidebar/events-list.js +9 -10
- package/dist/components/trace-viewer/components/map.d.ts +2 -2
- package/dist/components/trace-viewer/components/map.d.ts.map +1 -1
- package/dist/components/trace-viewer/components/node.d.ts +7 -7
- package/dist/components/trace-viewer/components/node.d.ts.map +1 -1
- package/dist/components/ui/collapsible.d.ts +46 -0
- package/dist/components/ui/collapsible.d.ts.map +1 -0
- package/dist/components/ui/collapsible.js +66 -0
- package/dist/components/ui/data-inspector.js +2 -7
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/lib/hydration.d.ts +12 -2
- package/dist/lib/hydration.d.ts.map +1 -1
- package/dist/lib/hydration.js +41 -18
- package/dist/lib/utils.d.ts +8 -5
- package/dist/lib/utils.d.ts.map +1 -1
- package/dist/lib/utils.js +13 -16
- package/package.json +6 -6
- package/src/components/new-trace-viewer/components/detail-panel-width.test.ts +123 -0
- package/src/components/new-trace-viewer/components/detail-panel-width.ts +76 -0
- package/src/components/new-trace-viewer/components/detail-panel.tsx +272 -0
- package/src/components/new-trace-viewer/components/draggable-border.tsx +216 -0
- package/src/components/new-trace-viewer/components/event-list.tsx +3 -5
- package/src/components/new-trace-viewer/components/middle-truncate/truncate.ts +1 -1
- package/src/components/new-trace-viewer/components/minimap.tsx +559 -0
- package/src/components/new-trace-viewer/components/pane-constants.ts +19 -0
- package/src/components/new-trace-viewer/components/split-pane.tsx +44 -55
- package/src/components/new-trace-viewer/components/timeline.tsx +3 -5
- package/src/components/new-trace-viewer/components/trace-viewer-skeleton.tsx +21 -1
- package/src/components/new-trace-viewer/components/use-element-width.ts +29 -0
- package/src/components/new-trace-viewer/context.tsx +4 -1
- package/src/components/new-trace-viewer/trace-viewer.tsx +93 -312
- package/src/components/new-trace-viewer/utils.test.ts +65 -0
- package/src/components/new-trace-viewer/utils.ts +76 -2
- package/src/components/sidebar/attribute-panel.tsx +106 -105
- package/src/components/sidebar/attributes-block.tsx +170 -50
- package/src/components/sidebar/events-list.tsx +107 -92
- package/src/components/ui/collapsible.tsx +219 -0
- package/src/index.ts +8 -7
- package/src/lib/hydration.ts +48 -25
- package/src/lib/utils.test.ts +33 -0
- package/src/lib/utils.ts +12 -16
- package/dist/components/sidebar/detail-card.d.ts +0 -13
- package/dist/components/sidebar/detail-card.d.ts.map +0 -1
- package/dist/components/sidebar/detail-card.js +0 -36
- 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
|
+
}
|