@workflow/web-shared 4.1.0-beta.52 → 4.1.0-beta.53
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/error-boundary.d.ts +15 -20
- package/dist/components/error-boundary.d.ts.map +1 -1
- package/dist/components/error-boundary.js +17 -31
- package/dist/components/error-boundary.js.map +1 -1
- package/dist/components/event-list-view.d.ts +7 -6
- package/dist/components/event-list-view.d.ts.map +1 -1
- package/dist/components/event-list-view.js +492 -109
- package/dist/components/event-list-view.js.map +1 -1
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +1 -0
- package/dist/components/index.js.map +1 -1
- package/dist/components/run-trace-view.d.ts +2 -1
- package/dist/components/run-trace-view.d.ts.map +1 -1
- package/dist/components/run-trace-view.js +2 -2
- package/dist/components/run-trace-view.js.map +1 -1
- package/dist/components/sidebar/attribute-panel.d.ts +2 -1
- package/dist/components/sidebar/attribute-panel.d.ts.map +1 -1
- package/dist/components/sidebar/attribute-panel.js +53 -142
- package/dist/components/sidebar/attribute-panel.js.map +1 -1
- package/dist/components/sidebar/conversation-view.d.ts.map +1 -1
- package/dist/components/sidebar/conversation-view.js +3 -17
- package/dist/components/sidebar/conversation-view.js.map +1 -1
- package/dist/components/sidebar/entity-detail-panel.d.ts +3 -1
- package/dist/components/sidebar/entity-detail-panel.d.ts.map +1 -1
- package/dist/components/sidebar/entity-detail-panel.js +63 -10
- package/dist/components/sidebar/entity-detail-panel.js.map +1 -1
- package/dist/components/sidebar/events-list.d.ts.map +1 -1
- package/dist/components/sidebar/events-list.js +4 -8
- package/dist/components/sidebar/events-list.js.map +1 -1
- package/dist/components/sidebar/resolve-hook-modal.d.ts +3 -0
- package/dist/components/sidebar/resolve-hook-modal.d.ts.map +1 -1
- package/dist/components/sidebar/resolve-hook-modal.js +152 -3
- package/dist/components/sidebar/resolve-hook-modal.js.map +1 -1
- package/dist/components/stream-viewer.d.ts +7 -5
- package/dist/components/stream-viewer.d.ts.map +1 -1
- package/dist/components/stream-viewer.js +54 -22
- package/dist/components/stream-viewer.js.map +1 -1
- package/dist/components/trace-viewer/components/markers.d.ts +2 -1
- package/dist/components/trace-viewer/components/markers.d.ts.map +1 -1
- package/dist/components/trace-viewer/components/markers.js +59 -20
- package/dist/components/trace-viewer/components/markers.js.map +1 -1
- package/dist/components/trace-viewer/components/node.d.ts +5 -1
- package/dist/components/trace-viewer/components/node.d.ts.map +1 -1
- package/dist/components/trace-viewer/components/node.js +250 -68
- package/dist/components/trace-viewer/components/node.js.map +1 -1
- package/dist/components/trace-viewer/components/span-content.d.ts +19 -0
- package/dist/components/trace-viewer/components/span-content.d.ts.map +1 -0
- package/dist/components/trace-viewer/components/span-content.js +137 -0
- package/dist/components/trace-viewer/components/span-content.js.map +1 -0
- package/dist/components/trace-viewer/components/span-detail-panel.d.ts.map +1 -1
- package/dist/components/trace-viewer/components/span-detail-panel.js +3 -2
- package/dist/components/trace-viewer/components/span-detail-panel.js.map +1 -1
- package/dist/components/trace-viewer/components/span-segments.d.ts +50 -0
- package/dist/components/trace-viewer/components/span-segments.d.ts.map +1 -0
- package/dist/components/trace-viewer/components/span-segments.js +392 -0
- package/dist/components/trace-viewer/components/span-segments.js.map +1 -0
- package/dist/components/trace-viewer/components/span-strategies.d.ts +46 -0
- package/dist/components/trace-viewer/components/span-strategies.d.ts.map +1 -0
- package/dist/components/trace-viewer/components/span-strategies.js +108 -0
- package/dist/components/trace-viewer/components/span-strategies.js.map +1 -0
- package/dist/components/trace-viewer/context.d.ts +7 -6
- package/dist/components/trace-viewer/context.d.ts.map +1 -1
- package/dist/components/trace-viewer/context.js +47 -18
- package/dist/components/trace-viewer/context.js.map +1 -1
- package/dist/components/trace-viewer/trace-viewer.d.ts +5 -1
- package/dist/components/trace-viewer/trace-viewer.d.ts.map +1 -1
- package/dist/components/trace-viewer/trace-viewer.js +87 -11
- package/dist/components/trace-viewer/trace-viewer.js.map +1 -1
- package/dist/components/trace-viewer/trace-viewer.module.css +179 -6
- package/dist/components/trace-viewer/util/timing.d.ts +5 -0
- package/dist/components/trace-viewer/util/timing.d.ts.map +1 -1
- package/dist/components/trace-viewer/util/timing.js +12 -0
- package/dist/components/trace-viewer/util/timing.js.map +1 -1
- package/dist/components/trace-viewer/util/use-streaming-spans.d.ts +1 -1
- package/dist/components/trace-viewer/util/use-streaming-spans.d.ts.map +1 -1
- package/dist/components/trace-viewer/util/use-streaming-spans.js +29 -17
- package/dist/components/trace-viewer/util/use-streaming-spans.js.map +1 -1
- package/dist/components/trace-viewer/worker.js +3 -1
- package/dist/components/trace-viewer/worker.js.map +1 -1
- package/dist/components/ui/alert.js +3 -3
- package/dist/components/ui/alert.js.map +1 -1
- package/dist/components/ui/card.d.ts.map +1 -1
- package/dist/components/ui/card.js +2 -2
- package/dist/components/ui/card.js.map +1 -1
- package/dist/components/ui/data-inspector.d.ts +17 -0
- package/dist/components/ui/data-inspector.d.ts.map +1 -0
- package/dist/components/ui/data-inspector.js +184 -0
- package/dist/components/ui/data-inspector.js.map +1 -0
- package/dist/components/ui/error-card.d.ts.map +1 -1
- package/dist/components/ui/error-card.js +4 -1
- package/dist/components/ui/error-card.js.map +1 -1
- package/dist/components/ui/inspector-theme.d.ts +39 -24
- package/dist/components/ui/inspector-theme.d.ts.map +1 -1
- package/dist/components/ui/inspector-theme.js +90 -38
- package/dist/components/ui/inspector-theme.js.map +1 -1
- package/dist/components/ui/skeleton.d.ts +1 -1
- package/dist/components/ui/skeleton.d.ts.map +1 -1
- package/dist/components/ui/skeleton.js +2 -2
- package/dist/components/ui/skeleton.js.map +1 -1
- package/dist/components/workflow-trace-view.d.ts +3 -1
- package/dist/components/workflow-trace-view.d.ts.map +1 -1
- package/dist/components/workflow-trace-view.js +435 -21
- package/dist/components/workflow-trace-view.js.map +1 -1
- package/dist/components/workflow-traces/trace-span-construction.d.ts +1 -1
- package/dist/components/workflow-traces/trace-span-construction.d.ts.map +1 -1
- package/dist/components/workflow-traces/trace-span-construction.js +2 -2
- package/dist/components/workflow-traces/trace-span-construction.js.map +1 -1
- package/dist/lib/hydration.d.ts.map +1 -1
- package/dist/lib/hydration.js +17 -3
- package/dist/lib/hydration.js.map +1 -1
- package/dist/styles.css +186 -0
- package/package.json +8 -7
- package/src/components/error-boundary.tsx +29 -40
- package/src/components/event-list-view.tsx +1000 -287
- package/src/components/index.ts +1 -0
- package/src/components/run-trace-view.tsx +3 -0
- package/src/components/sidebar/attribute-panel.tsx +58 -258
- package/src/components/sidebar/conversation-view.tsx +30 -27
- package/src/components/sidebar/entity-detail-panel.tsx +86 -20
- package/src/components/sidebar/events-list.tsx +4 -11
- package/src/components/sidebar/resolve-hook-modal.tsx +206 -47
- package/src/components/stream-viewer.tsx +138 -61
- package/src/components/trace-viewer/components/markers.tsx +69 -21
- package/src/components/trace-viewer/components/node.tsx +346 -100
- package/src/components/trace-viewer/components/span-content.tsx +247 -0
- package/src/components/trace-viewer/components/span-detail-panel.tsx +7 -2
- package/src/components/trace-viewer/components/span-segments.ts +516 -0
- package/src/components/trace-viewer/components/span-strategies.ts +205 -0
- package/src/components/trace-viewer/context.tsx +92 -40
- package/src/components/trace-viewer/trace-viewer.module.css +179 -6
- package/src/components/trace-viewer/trace-viewer.tsx +115 -11
- package/src/components/trace-viewer/util/timing.ts +13 -0
- package/src/components/trace-viewer/util/use-streaming-spans.ts +28 -17
- package/src/components/trace-viewer/worker.ts +4 -1
- package/src/components/ui/alert.tsx +3 -3
- package/src/components/ui/card.tsx +3 -5
- package/src/components/ui/data-inspector.tsx +318 -0
- package/src/components/ui/error-card.tsx +17 -6
- package/src/components/ui/inspector-theme.ts +127 -39
- package/src/components/ui/skeleton.tsx +3 -1
- package/src/components/workflow-trace-view.tsx +625 -26
- package/src/components/workflow-traces/trace-span-construction.ts +3 -2
- package/src/lib/hydration.ts +17 -8
- package/src/styles.css +186 -0
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
+
import { parseStepName, parseWorkflowName } from '@workflow/utils/parse-name';
|
|
3
4
|
import type { Event, Hook, Step, WorkflowRun } from '@workflow/world';
|
|
4
|
-
import { X } from 'lucide-react';
|
|
5
|
+
import { Clock, Copy, Info, Send, Type, X, XCircle } from 'lucide-react';
|
|
6
|
+
import type { MouseEvent as ReactMouseEvent, ReactNode } from 'react';
|
|
5
7
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
8
|
+
import { createPortal } from 'react-dom';
|
|
6
9
|
import { toast } from 'sonner';
|
|
7
10
|
import { ErrorBoundary } from './error-boundary';
|
|
8
11
|
import {
|
|
@@ -10,6 +13,7 @@ import {
|
|
|
10
13
|
type SelectedSpanInfo,
|
|
11
14
|
type SpanSelectionInfo,
|
|
12
15
|
} from './sidebar/entity-detail-panel';
|
|
16
|
+
import { ResolveHookModal } from './sidebar/resolve-hook-modal';
|
|
13
17
|
import {
|
|
14
18
|
TraceViewerContextProvider,
|
|
15
19
|
TraceViewerTimeline,
|
|
@@ -30,10 +34,535 @@ import {
|
|
|
30
34
|
} from './workflow-traces/trace-span-construction';
|
|
31
35
|
import { otelTimeToMs } from './workflow-traces/trace-time-utils';
|
|
32
36
|
|
|
33
|
-
|
|
37
|
+
/**
|
|
38
|
+
* While a run is live, continuously grow root.duration and rescale so the
|
|
39
|
+
* trace always fits within the viewport. Individual span widths are grown
|
|
40
|
+
* by each SpanComponent's own useEffect (see node.tsx).
|
|
41
|
+
*/
|
|
42
|
+
function useLiveTick(isLive: boolean): void {
|
|
43
|
+
const { state, dispatch } = useTraceViewer();
|
|
44
|
+
const stateRef = useRef(state);
|
|
45
|
+
stateRef.current = state;
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (!isLive) return;
|
|
49
|
+
|
|
50
|
+
// Grow root.duration on every frame so span rAFs see the latest time
|
|
51
|
+
let rafId = 0;
|
|
52
|
+
const tick = (): void => {
|
|
53
|
+
const { root } = stateRef.current;
|
|
54
|
+
if (root.startTime) {
|
|
55
|
+
const nowMs = Date.now();
|
|
56
|
+
if (nowMs > root.endTime) {
|
|
57
|
+
root.endTime = nowMs;
|
|
58
|
+
root.duration = root.endTime - root.startTime;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
rafId = requestAnimationFrame(tick);
|
|
62
|
+
};
|
|
63
|
+
rafId = requestAnimationFrame(tick);
|
|
64
|
+
|
|
65
|
+
// Re-scale smoothly so the trace fits the viewport as it grows.
|
|
66
|
+
// We dispatch detectBaseScale only when the computed baseScale has
|
|
67
|
+
// changed enough to matter visually (>0.1% shift), avoiding
|
|
68
|
+
// unnecessary React re-renders while keeping things smooth.
|
|
69
|
+
let scaleRafId = 0;
|
|
70
|
+
let lastBaseScale = 0;
|
|
71
|
+
const scaleTick = (): void => {
|
|
72
|
+
const s = stateRef.current;
|
|
73
|
+
if (s.root.duration > 0) {
|
|
74
|
+
const newBaseScale = (s.width - s.scrollbarWidth) / s.root.duration;
|
|
75
|
+
const delta = Math.abs(newBaseScale - lastBaseScale);
|
|
76
|
+
if (delta > lastBaseScale * 0.001 || lastBaseScale === 0) {
|
|
77
|
+
lastBaseScale = newBaseScale;
|
|
78
|
+
dispatch({ type: 'detectBaseScale' });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
scaleRafId = requestAnimationFrame(scaleTick);
|
|
82
|
+
};
|
|
83
|
+
scaleRafId = requestAnimationFrame(scaleTick);
|
|
84
|
+
|
|
85
|
+
return () => {
|
|
86
|
+
cancelAnimationFrame(rafId);
|
|
87
|
+
cancelAnimationFrame(scaleRafId);
|
|
88
|
+
};
|
|
89
|
+
}, [isLive, dispatch]);
|
|
90
|
+
}
|
|
91
|
+
|
|
34
92
|
const DEFAULT_PANEL_WIDTH = 380;
|
|
35
93
|
const MIN_PANEL_WIDTH = 240;
|
|
36
94
|
|
|
95
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
96
|
+
// Right-click context menu for spans
|
|
97
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
type ResourceType = 'sleep' | 'step' | 'hook' | 'run' | 'unknown';
|
|
100
|
+
|
|
101
|
+
interface ContextMenuState {
|
|
102
|
+
x: number;
|
|
103
|
+
y: number;
|
|
104
|
+
spanId: string;
|
|
105
|
+
spanName: string;
|
|
106
|
+
resourceType: ResourceType;
|
|
107
|
+
/** Whether the span represents an active/pending resource (not yet completed) */
|
|
108
|
+
isActive: boolean;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
interface ContextMenuItem {
|
|
112
|
+
label: string;
|
|
113
|
+
icon?: ReactNode;
|
|
114
|
+
action: () => void;
|
|
115
|
+
destructive?: boolean;
|
|
116
|
+
disabled?: boolean;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function SpanContextMenu({
|
|
120
|
+
menu,
|
|
121
|
+
items,
|
|
122
|
+
onClose,
|
|
123
|
+
}: {
|
|
124
|
+
menu: ContextMenuState;
|
|
125
|
+
items: ContextMenuItem[];
|
|
126
|
+
onClose: () => void;
|
|
127
|
+
}): ReactNode {
|
|
128
|
+
const menuRef = useRef<HTMLDivElement>(null);
|
|
129
|
+
|
|
130
|
+
// Close on outside click
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
const handleClick = (e: MouseEvent): void => {
|
|
133
|
+
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
|
134
|
+
onClose();
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
const handleKeyDown = (e: KeyboardEvent): void => {
|
|
138
|
+
if (e.key === 'Escape') {
|
|
139
|
+
onClose();
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
const handleScroll = (): void => {
|
|
143
|
+
onClose();
|
|
144
|
+
};
|
|
145
|
+
// Use a timeout so we don't close immediately from the same event
|
|
146
|
+
const timeout = setTimeout(() => {
|
|
147
|
+
window.addEventListener('mousedown', handleClick);
|
|
148
|
+
window.addEventListener('keydown', handleKeyDown);
|
|
149
|
+
window.addEventListener('scroll', handleScroll, true);
|
|
150
|
+
}, 0);
|
|
151
|
+
return () => {
|
|
152
|
+
clearTimeout(timeout);
|
|
153
|
+
window.removeEventListener('mousedown', handleClick);
|
|
154
|
+
window.removeEventListener('keydown', handleKeyDown);
|
|
155
|
+
window.removeEventListener('scroll', handleScroll, true);
|
|
156
|
+
};
|
|
157
|
+
}, [onClose]);
|
|
158
|
+
|
|
159
|
+
// Adjust position if menu would overflow viewport
|
|
160
|
+
const [adjustedPos, setAdjustedPos] = useState({ x: menu.x, y: menu.y });
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
const el = menuRef.current;
|
|
163
|
+
if (!el) return;
|
|
164
|
+
const rect = el.getBoundingClientRect();
|
|
165
|
+
let { x, y } = menu;
|
|
166
|
+
if (rect.right > window.innerWidth) {
|
|
167
|
+
x = window.innerWidth - rect.width - 8;
|
|
168
|
+
}
|
|
169
|
+
if (rect.bottom > window.innerHeight) {
|
|
170
|
+
y = window.innerHeight - rect.height - 8;
|
|
171
|
+
}
|
|
172
|
+
if (x !== menu.x || y !== menu.y) {
|
|
173
|
+
setAdjustedPos({ x, y });
|
|
174
|
+
}
|
|
175
|
+
}, [menu]);
|
|
176
|
+
|
|
177
|
+
if (items.length === 0) return null;
|
|
178
|
+
|
|
179
|
+
return createPortal(
|
|
180
|
+
<div
|
|
181
|
+
ref={menuRef}
|
|
182
|
+
style={{
|
|
183
|
+
position: 'fixed',
|
|
184
|
+
left: adjustedPos.x,
|
|
185
|
+
top: adjustedPos.y,
|
|
186
|
+
zIndex: 99999,
|
|
187
|
+
boxShadow: 'var(--ds-shadow-menu)',
|
|
188
|
+
borderRadius: 12,
|
|
189
|
+
background: 'var(--ds-background-100)',
|
|
190
|
+
padding: 'var(--geist-space-gap-quarter, 4px)',
|
|
191
|
+
fontSize: 14,
|
|
192
|
+
minWidth: 180,
|
|
193
|
+
overflowX: 'hidden',
|
|
194
|
+
overflowY: 'auto',
|
|
195
|
+
}}
|
|
196
|
+
>
|
|
197
|
+
<div
|
|
198
|
+
style={{
|
|
199
|
+
display: 'block',
|
|
200
|
+
color: 'var(--ds-gray-900)',
|
|
201
|
+
fontSize: '0.75rem',
|
|
202
|
+
padding: 'var(--geist-gap-quarter, 4px) var(--geist-space-2x, 8px)',
|
|
203
|
+
fontWeight: 500,
|
|
204
|
+
overflow: 'hidden',
|
|
205
|
+
textOverflow: 'ellipsis',
|
|
206
|
+
whiteSpace: 'nowrap',
|
|
207
|
+
maxWidth: 240,
|
|
208
|
+
borderBottom: '1px solid var(--ds-gray-alpha-400)',
|
|
209
|
+
marginBottom: 4,
|
|
210
|
+
}}
|
|
211
|
+
>
|
|
212
|
+
{menu.spanName}
|
|
213
|
+
</div>
|
|
214
|
+
{items.map((item) => (
|
|
215
|
+
<button
|
|
216
|
+
key={item.label}
|
|
217
|
+
type="button"
|
|
218
|
+
style={{
|
|
219
|
+
outline: 'none',
|
|
220
|
+
cursor: item.disabled ? 'not-allowed' : 'pointer',
|
|
221
|
+
display: 'flex',
|
|
222
|
+
alignItems: 'center',
|
|
223
|
+
gap: 8,
|
|
224
|
+
padding: '0 var(--geist-space-2x, 8px)',
|
|
225
|
+
height: 40,
|
|
226
|
+
textDecoration: 'none',
|
|
227
|
+
borderRadius: 6,
|
|
228
|
+
color: item.destructive
|
|
229
|
+
? 'var(--ds-red-900)'
|
|
230
|
+
: 'var(--ds-gray-1000)',
|
|
231
|
+
width: '100%',
|
|
232
|
+
background: 'transparent',
|
|
233
|
+
border: 'none',
|
|
234
|
+
fontSize: 14,
|
|
235
|
+
textAlign: 'left',
|
|
236
|
+
opacity: item.disabled ? 0.4 : 1,
|
|
237
|
+
transition: 'background 0.15s',
|
|
238
|
+
}}
|
|
239
|
+
disabled={item.disabled}
|
|
240
|
+
onMouseEnter={(e) => {
|
|
241
|
+
(e.currentTarget as HTMLButtonElement).style.background =
|
|
242
|
+
'var(--ds-gray-alpha-100)';
|
|
243
|
+
}}
|
|
244
|
+
onMouseLeave={(e) => {
|
|
245
|
+
(e.currentTarget as HTMLButtonElement).style.background =
|
|
246
|
+
'transparent';
|
|
247
|
+
}}
|
|
248
|
+
onClick={() => {
|
|
249
|
+
item.action();
|
|
250
|
+
onClose();
|
|
251
|
+
}}
|
|
252
|
+
>
|
|
253
|
+
{item.icon ?? null}
|
|
254
|
+
{item.label}
|
|
255
|
+
</button>
|
|
256
|
+
))}
|
|
257
|
+
</div>,
|
|
258
|
+
document.body
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Inner wrapper that has access to the TraceViewer context */
|
|
263
|
+
function TraceViewerWithContextMenu({
|
|
264
|
+
trace,
|
|
265
|
+
run,
|
|
266
|
+
hooks,
|
|
267
|
+
isLive,
|
|
268
|
+
onWakeUpSleep,
|
|
269
|
+
onCancelRun,
|
|
270
|
+
onResolveHook,
|
|
271
|
+
children,
|
|
272
|
+
}: {
|
|
273
|
+
trace: { spans: Span[] };
|
|
274
|
+
run: WorkflowRun;
|
|
275
|
+
hooks: Hook[];
|
|
276
|
+
isLive: boolean;
|
|
277
|
+
onWakeUpSleep?: (
|
|
278
|
+
runId: string,
|
|
279
|
+
correlationId: string
|
|
280
|
+
) => Promise<{ stoppedCount: number }>;
|
|
281
|
+
onCancelRun?: (runId: string) => Promise<void>;
|
|
282
|
+
onResolveHook?: (
|
|
283
|
+
hookToken: string,
|
|
284
|
+
payload: unknown,
|
|
285
|
+
hook?: Hook
|
|
286
|
+
) => Promise<void>;
|
|
287
|
+
children: ReactNode;
|
|
288
|
+
}): ReactNode {
|
|
289
|
+
const { dispatch } = useTraceViewer();
|
|
290
|
+
|
|
291
|
+
// Drive active span widths at 60fps without React re-renders
|
|
292
|
+
useLiveTick(isLive);
|
|
293
|
+
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
|
294
|
+
const [resolveHookTarget, setResolveHookTarget] = useState<Hook | null>(null);
|
|
295
|
+
const [resolvingHook, setResolvingHook] = useState(false);
|
|
296
|
+
// Track hooks resolved in this session so the context menu item hides immediately
|
|
297
|
+
const [resolvedHookIds, setResolvedHookIds] = useState<Set<string>>(
|
|
298
|
+
new Set()
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
// Build a lookup map: spanId -> span
|
|
302
|
+
const spanLookup = useMemo(() => {
|
|
303
|
+
const map = new Map<string, Span>();
|
|
304
|
+
for (const span of trace.spans) {
|
|
305
|
+
map.set(span.spanId, span);
|
|
306
|
+
}
|
|
307
|
+
return map;
|
|
308
|
+
}, [trace.spans]);
|
|
309
|
+
|
|
310
|
+
// Build a lookup map: hookId -> Hook
|
|
311
|
+
const hookLookup = useMemo(() => {
|
|
312
|
+
const map = new Map<string, Hook>();
|
|
313
|
+
for (const hook of hooks) {
|
|
314
|
+
map.set(hook.hookId, hook);
|
|
315
|
+
}
|
|
316
|
+
return map;
|
|
317
|
+
}, [hooks]);
|
|
318
|
+
|
|
319
|
+
const handleResolveHook = useCallback(
|
|
320
|
+
async (payload: unknown) => {
|
|
321
|
+
if (resolvingHook || !resolveHookTarget || !onResolveHook) return;
|
|
322
|
+
if (!resolveHookTarget.token) {
|
|
323
|
+
toast.error('Unable to resolve hook', {
|
|
324
|
+
description:
|
|
325
|
+
'Missing hook token. Try refreshing the run data and retry.',
|
|
326
|
+
});
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
try {
|
|
330
|
+
setResolvingHook(true);
|
|
331
|
+
await onResolveHook(
|
|
332
|
+
resolveHookTarget.token,
|
|
333
|
+
payload,
|
|
334
|
+
resolveHookTarget
|
|
335
|
+
);
|
|
336
|
+
toast.success('Hook resolved', {
|
|
337
|
+
description: 'The payload has been sent and the hook resolved.',
|
|
338
|
+
});
|
|
339
|
+
// Mark this hook as resolved locally so the menu item hides immediately
|
|
340
|
+
setResolvedHookIds((prev) =>
|
|
341
|
+
new Set(prev).add(resolveHookTarget.hookId)
|
|
342
|
+
);
|
|
343
|
+
setResolveHookTarget(null);
|
|
344
|
+
} catch (err) {
|
|
345
|
+
console.error('Failed to resolve hook:', err);
|
|
346
|
+
toast.error('Failed to resolve hook', {
|
|
347
|
+
description:
|
|
348
|
+
err instanceof Error ? err.message : 'An unknown error occurred',
|
|
349
|
+
});
|
|
350
|
+
} finally {
|
|
351
|
+
setResolvingHook(false);
|
|
352
|
+
}
|
|
353
|
+
},
|
|
354
|
+
[onResolveHook, resolveHookTarget, resolvingHook]
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
const handleContextMenu = useCallback(
|
|
358
|
+
(e: ReactMouseEvent | MouseEvent): void => {
|
|
359
|
+
const target = e.target as HTMLElement;
|
|
360
|
+
const $button = target.closest<HTMLButtonElement>('[data-span-id]');
|
|
361
|
+
if (!$button) return;
|
|
362
|
+
|
|
363
|
+
const spanId = $button.dataset.spanId;
|
|
364
|
+
if (!spanId) return;
|
|
365
|
+
|
|
366
|
+
e.preventDefault();
|
|
367
|
+
e.stopPropagation();
|
|
368
|
+
|
|
369
|
+
const span = spanLookup.get(spanId);
|
|
370
|
+
if (!span) return;
|
|
371
|
+
|
|
372
|
+
const resourceType =
|
|
373
|
+
(span.attributes.resource as ResourceType) ?? 'unknown';
|
|
374
|
+
const spanData = span.attributes.data as
|
|
375
|
+
| Record<string, unknown>
|
|
376
|
+
| undefined;
|
|
377
|
+
const isActive = !spanData?.completedAt && !spanData?.disposedAt;
|
|
378
|
+
|
|
379
|
+
setContextMenu({
|
|
380
|
+
x: e.clientX,
|
|
381
|
+
y: e.clientY,
|
|
382
|
+
spanId,
|
|
383
|
+
spanName: span.name,
|
|
384
|
+
resourceType,
|
|
385
|
+
isActive,
|
|
386
|
+
});
|
|
387
|
+
},
|
|
388
|
+
[spanLookup]
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
392
|
+
useEffect(() => {
|
|
393
|
+
const $container = containerRef.current;
|
|
394
|
+
if (!$container) return;
|
|
395
|
+
|
|
396
|
+
const onContextMenu = (event: MouseEvent): void => {
|
|
397
|
+
handleContextMenu(event);
|
|
398
|
+
};
|
|
399
|
+
$container.addEventListener('contextmenu', onContextMenu);
|
|
400
|
+
|
|
401
|
+
return () => {
|
|
402
|
+
$container.removeEventListener('contextmenu', onContextMenu);
|
|
403
|
+
};
|
|
404
|
+
}, [handleContextMenu]);
|
|
405
|
+
|
|
406
|
+
const closeMenu = useCallback(() => {
|
|
407
|
+
setContextMenu(null);
|
|
408
|
+
}, []);
|
|
409
|
+
|
|
410
|
+
const getMenuItems = useCallback(
|
|
411
|
+
(menu: ContextMenuState): ContextMenuItem[] => {
|
|
412
|
+
const items: ContextMenuItem[] = [];
|
|
413
|
+
|
|
414
|
+
// Sleep-specific: Wake Up (only on active runs)
|
|
415
|
+
const isRunActive = !run.completedAt;
|
|
416
|
+
if (
|
|
417
|
+
menu.resourceType === 'sleep' &&
|
|
418
|
+
menu.isActive &&
|
|
419
|
+
isRunActive &&
|
|
420
|
+
onWakeUpSleep
|
|
421
|
+
) {
|
|
422
|
+
items.push({
|
|
423
|
+
label: 'Wake Up Sleep',
|
|
424
|
+
icon: <Clock className="h-3.5 w-3.5" />,
|
|
425
|
+
action: () => {
|
|
426
|
+
onWakeUpSleep(run.runId, menu.spanId)
|
|
427
|
+
.then((result) => {
|
|
428
|
+
if (result.stoppedCount > 0) {
|
|
429
|
+
toast.success('Sleep woken up', {
|
|
430
|
+
description: `Woke up ${String(result.stoppedCount)} sleep${result.stoppedCount > 1 ? 's' : ''}`,
|
|
431
|
+
});
|
|
432
|
+
} else {
|
|
433
|
+
toast.info('No active sleeps found', {
|
|
434
|
+
description: 'The sleep may have already completed.',
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
})
|
|
438
|
+
.catch((err: unknown) => {
|
|
439
|
+
toast.error('Failed to wake up sleep', {
|
|
440
|
+
description:
|
|
441
|
+
err instanceof Error
|
|
442
|
+
? err.message
|
|
443
|
+
: 'An unknown error occurred',
|
|
444
|
+
});
|
|
445
|
+
});
|
|
446
|
+
},
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Hook-specific: Resolve Hook (only on active, unresolved hooks)
|
|
451
|
+
if (menu.resourceType === 'hook' && isRunActive && onResolveHook) {
|
|
452
|
+
const hook = hookLookup.get(menu.spanId);
|
|
453
|
+
const span = spanLookup.get(menu.spanId);
|
|
454
|
+
// Check data-level disposedAt, span events, AND local resolved state
|
|
455
|
+
const hookData = span?.attributes?.data as
|
|
456
|
+
| { disposedAt?: unknown }
|
|
457
|
+
| undefined;
|
|
458
|
+
const isDisposed =
|
|
459
|
+
Boolean(hookData?.disposedAt) ||
|
|
460
|
+
Boolean(span?.events?.some((e) => e.name === 'hook_disposed')) ||
|
|
461
|
+
resolvedHookIds.has(menu.spanId);
|
|
462
|
+
if (hook?.token && !isDisposed) {
|
|
463
|
+
items.push({
|
|
464
|
+
label: 'Resolve Hook',
|
|
465
|
+
icon: <Send className="h-3.5 w-3.5" />,
|
|
466
|
+
action: () => {
|
|
467
|
+
setResolveHookTarget(hook);
|
|
468
|
+
},
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Run-specific: Cancel (only on active runs)
|
|
474
|
+
if (menu.resourceType === 'run' && isRunActive && onCancelRun) {
|
|
475
|
+
items.push({
|
|
476
|
+
label: 'Cancel Run',
|
|
477
|
+
icon: <XCircle className="h-3.5 w-3.5" />,
|
|
478
|
+
destructive: true,
|
|
479
|
+
action: () => {
|
|
480
|
+
onCancelRun(run.runId).catch((err: unknown) => {
|
|
481
|
+
toast.error('Failed to cancel run', {
|
|
482
|
+
description:
|
|
483
|
+
err instanceof Error
|
|
484
|
+
? err.message
|
|
485
|
+
: 'An unknown error occurred',
|
|
486
|
+
});
|
|
487
|
+
});
|
|
488
|
+
},
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Common actions
|
|
493
|
+
items.push({
|
|
494
|
+
label: 'View Details',
|
|
495
|
+
icon: <Info className="h-3.5 w-3.5" />,
|
|
496
|
+
action: () => {
|
|
497
|
+
dispatch({ type: 'select', id: menu.spanId });
|
|
498
|
+
},
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
items.push({
|
|
502
|
+
label: 'Copy Name',
|
|
503
|
+
icon: <Type className="h-3.5 w-3.5" />,
|
|
504
|
+
action: () => {
|
|
505
|
+
navigator.clipboard
|
|
506
|
+
.writeText(menu.spanName)
|
|
507
|
+
.then(() => {
|
|
508
|
+
toast.success('Name copied to clipboard');
|
|
509
|
+
})
|
|
510
|
+
.catch(() => {
|
|
511
|
+
toast.error('Failed to copy name');
|
|
512
|
+
});
|
|
513
|
+
},
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
items.push({
|
|
517
|
+
label: 'Copy ID',
|
|
518
|
+
icon: <Copy className="h-3.5 w-3.5" />,
|
|
519
|
+
action: () => {
|
|
520
|
+
navigator.clipboard
|
|
521
|
+
.writeText(menu.spanId)
|
|
522
|
+
.then(() => {
|
|
523
|
+
toast.success('ID copied to clipboard');
|
|
524
|
+
})
|
|
525
|
+
.catch(() => {
|
|
526
|
+
toast.error('Failed to copy ID');
|
|
527
|
+
});
|
|
528
|
+
},
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
return items;
|
|
532
|
+
},
|
|
533
|
+
[
|
|
534
|
+
dispatch,
|
|
535
|
+
onWakeUpSleep,
|
|
536
|
+
onCancelRun,
|
|
537
|
+
onResolveHook,
|
|
538
|
+
hookLookup,
|
|
539
|
+
spanLookup,
|
|
540
|
+
resolvedHookIds,
|
|
541
|
+
run.runId,
|
|
542
|
+
run.completedAt,
|
|
543
|
+
]
|
|
544
|
+
);
|
|
545
|
+
|
|
546
|
+
return (
|
|
547
|
+
<div className="relative w-full h-full" ref={containerRef}>
|
|
548
|
+
{children}
|
|
549
|
+
{contextMenu ? (
|
|
550
|
+
<SpanContextMenu
|
|
551
|
+
menu={contextMenu}
|
|
552
|
+
items={getMenuItems(contextMenu)}
|
|
553
|
+
onClose={closeMenu}
|
|
554
|
+
/>
|
|
555
|
+
) : null}
|
|
556
|
+
<ResolveHookModal
|
|
557
|
+
isOpen={resolveHookTarget !== null}
|
|
558
|
+
onClose={() => setResolveHookTarget(null)}
|
|
559
|
+
onSubmit={handleResolveHook}
|
|
560
|
+
isSubmitting={resolvingHook}
|
|
561
|
+
/>
|
|
562
|
+
</div>
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
|
|
37
566
|
type GroupedEvents = {
|
|
38
567
|
eventsByStepId: Map<string, Event[]>;
|
|
39
568
|
eventsByHookId: Map<string, Event[]>;
|
|
@@ -121,9 +650,10 @@ const buildSpans = (
|
|
|
121
650
|
groupedEvents: GroupedEvents,
|
|
122
651
|
now: Date
|
|
123
652
|
) => {
|
|
653
|
+
const viewerEndTime = new Date(run.completedAt || now);
|
|
124
654
|
const stepSpans = steps.map((step) => {
|
|
125
655
|
const stepEvents = groupedEvents.eventsByStepId.get(step.stepId) || [];
|
|
126
|
-
return stepToSpan(step, stepEvents, now);
|
|
656
|
+
return stepToSpan(step, stepEvents, now, viewerEndTime);
|
|
127
657
|
});
|
|
128
658
|
|
|
129
659
|
const hookSpans = Array.from(groupedEvents.hookEvents.values())
|
|
@@ -290,6 +820,7 @@ export const WorkflowTraceViewer = ({
|
|
|
290
820
|
spanDetailError,
|
|
291
821
|
onWakeUpSleep,
|
|
292
822
|
onResolveHook,
|
|
823
|
+
onCancelRun,
|
|
293
824
|
onStreamClick,
|
|
294
825
|
onSpanSelect,
|
|
295
826
|
onLoadEventData,
|
|
@@ -312,6 +843,8 @@ export const WorkflowTraceViewer = ({
|
|
|
312
843
|
payload: unknown,
|
|
313
844
|
hook?: Hook
|
|
314
845
|
) => Promise<void>;
|
|
846
|
+
/** Callback to cancel the current run */
|
|
847
|
+
onCancelRun?: (runId: string) => Promise<void>;
|
|
315
848
|
/** Callback when a stream reference is clicked in the detail panel */
|
|
316
849
|
onStreamClick?: (streamId: string) => void;
|
|
317
850
|
/** Callback when a span is selected. */
|
|
@@ -322,29 +855,23 @@ export const WorkflowTraceViewer = ({
|
|
|
322
855
|
eventId: string
|
|
323
856
|
) => Promise<unknown | null>;
|
|
324
857
|
}) => {
|
|
325
|
-
const [now, setNow] = useState(() => new Date());
|
|
326
858
|
const [selectedSpan, setSelectedSpan] = useState<SelectedSpanInfo | null>(
|
|
327
859
|
null
|
|
328
860
|
);
|
|
329
861
|
const [panelWidth, setPanelWidth] = useState(DEFAULT_PANEL_WIDTH);
|
|
330
862
|
const [deselectTrigger, setDeselectTrigger] = useState(0);
|
|
331
863
|
|
|
332
|
-
|
|
333
|
-
if (!run?.completedAt) {
|
|
334
|
-
const interval = setInterval(() => {
|
|
335
|
-
setNow(new Date());
|
|
336
|
-
}, RE_RENDER_INTERVAL_MS);
|
|
337
|
-
return () => clearInterval(interval);
|
|
338
|
-
}
|
|
339
|
-
return undefined;
|
|
340
|
-
}, [run?.completedAt]);
|
|
864
|
+
const isLive = Boolean(run && !run.completedAt);
|
|
341
865
|
|
|
866
|
+
// Build trace only when actual data changes — no timer-driven rebuilds.
|
|
867
|
+
// Active span widths are animated imperatively by useLiveTick at 60fps.
|
|
342
868
|
const trace = useMemo(() => {
|
|
343
869
|
if (!run) {
|
|
344
870
|
return undefined;
|
|
345
871
|
}
|
|
346
|
-
return buildTrace(run, steps, hooks, events,
|
|
347
|
-
|
|
872
|
+
return buildTrace(run, steps, hooks, events, new Date());
|
|
873
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- `new Date()` is intentionally not a dep; useLiveTick handles live growth
|
|
874
|
+
}, [run, steps, hooks, events]);
|
|
348
875
|
|
|
349
876
|
useEffect(() => {
|
|
350
877
|
if (error && !isLoading) {
|
|
@@ -379,6 +906,36 @@ export const WorkflowTraceViewer = ({
|
|
|
379
906
|
[events]
|
|
380
907
|
);
|
|
381
908
|
|
|
909
|
+
// Keep selected span raw events live as polling updates arrive.
|
|
910
|
+
useEffect(() => {
|
|
911
|
+
const correlationId = selectedSpan?.spanId;
|
|
912
|
+
if (!correlationId) return;
|
|
913
|
+
|
|
914
|
+
const nextRawEvents = events.filter(
|
|
915
|
+
(e) => e.correlationId === correlationId
|
|
916
|
+
);
|
|
917
|
+
setSelectedSpan((prev) => {
|
|
918
|
+
if (!prev || prev.spanId !== correlationId) {
|
|
919
|
+
return prev;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
const prevRawEvents = prev.rawEvents ?? [];
|
|
923
|
+
const isSame =
|
|
924
|
+
prevRawEvents.length === nextRawEvents.length &&
|
|
925
|
+
prevRawEvents.every(
|
|
926
|
+
(event, index) => event.eventId === nextRawEvents[index]?.eventId
|
|
927
|
+
);
|
|
928
|
+
if (isSame) {
|
|
929
|
+
return prev;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
return {
|
|
933
|
+
...prev,
|
|
934
|
+
rawEvents: nextRawEvents,
|
|
935
|
+
};
|
|
936
|
+
});
|
|
937
|
+
}, [events, selectedSpan?.spanId]);
|
|
938
|
+
|
|
382
939
|
const handleClose = useCallback(() => {
|
|
383
940
|
setSelectedSpan(null);
|
|
384
941
|
setDeselectTrigger((n) => n + 1);
|
|
@@ -388,13 +945,17 @@ export const WorkflowTraceViewer = ({
|
|
|
388
945
|
setPanelWidth((w) => Math.max(MIN_PANEL_WIDTH, w + deltaX));
|
|
389
946
|
}, []);
|
|
390
947
|
|
|
391
|
-
// Get the selected span name
|
|
948
|
+
// Get the selected span name for the panel header
|
|
392
949
|
const selectedSpanName = useMemo(() => {
|
|
393
950
|
if (!selectedSpan?.data) return undefined;
|
|
394
951
|
const data = selectedSpan.data as Record<string, unknown>;
|
|
952
|
+
const stepName = data.stepName as string | undefined;
|
|
953
|
+
const workflowName = data.workflowName as string | undefined;
|
|
395
954
|
return (
|
|
396
|
-
(
|
|
397
|
-
(
|
|
955
|
+
(stepName ? parseStepName(stepName)?.shortName : undefined) ??
|
|
956
|
+
(workflowName ? parseWorkflowName(workflowName)?.shortName : undefined) ??
|
|
957
|
+
stepName ??
|
|
958
|
+
workflowName ??
|
|
398
959
|
(data.hookId as string) ??
|
|
399
960
|
'Details'
|
|
400
961
|
);
|
|
@@ -425,7 +986,22 @@ export const WorkflowTraceViewer = ({
|
|
|
425
986
|
>
|
|
426
987
|
<SelectionBridge onSelectionChange={handleSelectionChange} />
|
|
427
988
|
<DeselectBridge triggerDeselect={deselectTrigger} />
|
|
428
|
-
<
|
|
989
|
+
<TraceViewerWithContextMenu
|
|
990
|
+
trace={trace}
|
|
991
|
+
run={run}
|
|
992
|
+
hooks={hooks}
|
|
993
|
+
isLive={isLive}
|
|
994
|
+
onWakeUpSleep={onWakeUpSleep}
|
|
995
|
+
onCancelRun={onCancelRun}
|
|
996
|
+
onResolveHook={onResolveHook}
|
|
997
|
+
>
|
|
998
|
+
<TraceViewerTimeline
|
|
999
|
+
eagerRender
|
|
1000
|
+
height="100%"
|
|
1001
|
+
isLive={isLive}
|
|
1002
|
+
trace={trace}
|
|
1003
|
+
/>
|
|
1004
|
+
</TraceViewerWithContextMenu>
|
|
429
1005
|
</TraceViewerContextProvider>
|
|
430
1006
|
</div>
|
|
431
1007
|
|
|
@@ -445,17 +1021,39 @@ export const WorkflowTraceViewer = ({
|
|
|
445
1021
|
className="flex items-center justify-between px-3 py-2 border-b flex-shrink-0"
|
|
446
1022
|
style={{ borderColor: 'var(--ds-gray-200)' }}
|
|
447
1023
|
>
|
|
448
|
-
<
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
1024
|
+
<div className="min-w-0 flex-1">
|
|
1025
|
+
<div
|
|
1026
|
+
className="text-sm font-medium truncate"
|
|
1027
|
+
style={{ color: 'var(--ds-gray-1000)' }}
|
|
1028
|
+
title={selectedSpanName}
|
|
1029
|
+
>
|
|
1030
|
+
{selectedSpanName}
|
|
1031
|
+
</div>
|
|
1032
|
+
</div>
|
|
454
1033
|
<button
|
|
455
1034
|
type="button"
|
|
456
1035
|
aria-label="Close panel"
|
|
457
1036
|
onClick={handleClose}
|
|
458
|
-
|
|
1037
|
+
style={{
|
|
1038
|
+
display: 'flex',
|
|
1039
|
+
alignItems: 'center',
|
|
1040
|
+
justifyContent: 'center',
|
|
1041
|
+
marginLeft: 8,
|
|
1042
|
+
padding: 4,
|
|
1043
|
+
borderRadius: 6,
|
|
1044
|
+
border: 'none',
|
|
1045
|
+
background: 'transparent',
|
|
1046
|
+
color: 'var(--ds-gray-900)',
|
|
1047
|
+
cursor: 'pointer',
|
|
1048
|
+
flexShrink: 0,
|
|
1049
|
+
transition: 'background 0.15s',
|
|
1050
|
+
}}
|
|
1051
|
+
onMouseEnter={(e) => {
|
|
1052
|
+
e.currentTarget.style.background = 'var(--ds-gray-alpha-200)';
|
|
1053
|
+
}}
|
|
1054
|
+
onMouseLeave={(e) => {
|
|
1055
|
+
e.currentTarget.style.background = 'transparent';
|
|
1056
|
+
}}
|
|
459
1057
|
>
|
|
460
1058
|
<X size={16} />
|
|
461
1059
|
</button>
|
|
@@ -465,6 +1063,7 @@ export const WorkflowTraceViewer = ({
|
|
|
465
1063
|
<ErrorBoundary title="Failed to load entity details">
|
|
466
1064
|
<EntityDetailPanel
|
|
467
1065
|
run={run}
|
|
1066
|
+
hooks={hooks}
|
|
468
1067
|
onStreamClick={onStreamClick}
|
|
469
1068
|
spanDetailData={spanDetailData ?? null}
|
|
470
1069
|
spanDetailError={spanDetailError}
|