@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.
Files changed (145) hide show
  1. package/dist/components/error-boundary.d.ts +15 -20
  2. package/dist/components/error-boundary.d.ts.map +1 -1
  3. package/dist/components/error-boundary.js +17 -31
  4. package/dist/components/error-boundary.js.map +1 -1
  5. package/dist/components/event-list-view.d.ts +7 -6
  6. package/dist/components/event-list-view.d.ts.map +1 -1
  7. package/dist/components/event-list-view.js +492 -109
  8. package/dist/components/event-list-view.js.map +1 -1
  9. package/dist/components/index.d.ts +1 -0
  10. package/dist/components/index.d.ts.map +1 -1
  11. package/dist/components/index.js +1 -0
  12. package/dist/components/index.js.map +1 -1
  13. package/dist/components/run-trace-view.d.ts +2 -1
  14. package/dist/components/run-trace-view.d.ts.map +1 -1
  15. package/dist/components/run-trace-view.js +2 -2
  16. package/dist/components/run-trace-view.js.map +1 -1
  17. package/dist/components/sidebar/attribute-panel.d.ts +2 -1
  18. package/dist/components/sidebar/attribute-panel.d.ts.map +1 -1
  19. package/dist/components/sidebar/attribute-panel.js +53 -142
  20. package/dist/components/sidebar/attribute-panel.js.map +1 -1
  21. package/dist/components/sidebar/conversation-view.d.ts.map +1 -1
  22. package/dist/components/sidebar/conversation-view.js +3 -17
  23. package/dist/components/sidebar/conversation-view.js.map +1 -1
  24. package/dist/components/sidebar/entity-detail-panel.d.ts +3 -1
  25. package/dist/components/sidebar/entity-detail-panel.d.ts.map +1 -1
  26. package/dist/components/sidebar/entity-detail-panel.js +63 -10
  27. package/dist/components/sidebar/entity-detail-panel.js.map +1 -1
  28. package/dist/components/sidebar/events-list.d.ts.map +1 -1
  29. package/dist/components/sidebar/events-list.js +4 -8
  30. package/dist/components/sidebar/events-list.js.map +1 -1
  31. package/dist/components/sidebar/resolve-hook-modal.d.ts +3 -0
  32. package/dist/components/sidebar/resolve-hook-modal.d.ts.map +1 -1
  33. package/dist/components/sidebar/resolve-hook-modal.js +152 -3
  34. package/dist/components/sidebar/resolve-hook-modal.js.map +1 -1
  35. package/dist/components/stream-viewer.d.ts +7 -5
  36. package/dist/components/stream-viewer.d.ts.map +1 -1
  37. package/dist/components/stream-viewer.js +54 -22
  38. package/dist/components/stream-viewer.js.map +1 -1
  39. package/dist/components/trace-viewer/components/markers.d.ts +2 -1
  40. package/dist/components/trace-viewer/components/markers.d.ts.map +1 -1
  41. package/dist/components/trace-viewer/components/markers.js +59 -20
  42. package/dist/components/trace-viewer/components/markers.js.map +1 -1
  43. package/dist/components/trace-viewer/components/node.d.ts +5 -1
  44. package/dist/components/trace-viewer/components/node.d.ts.map +1 -1
  45. package/dist/components/trace-viewer/components/node.js +250 -68
  46. package/dist/components/trace-viewer/components/node.js.map +1 -1
  47. package/dist/components/trace-viewer/components/span-content.d.ts +19 -0
  48. package/dist/components/trace-viewer/components/span-content.d.ts.map +1 -0
  49. package/dist/components/trace-viewer/components/span-content.js +137 -0
  50. package/dist/components/trace-viewer/components/span-content.js.map +1 -0
  51. package/dist/components/trace-viewer/components/span-detail-panel.d.ts.map +1 -1
  52. package/dist/components/trace-viewer/components/span-detail-panel.js +3 -2
  53. package/dist/components/trace-viewer/components/span-detail-panel.js.map +1 -1
  54. package/dist/components/trace-viewer/components/span-segments.d.ts +50 -0
  55. package/dist/components/trace-viewer/components/span-segments.d.ts.map +1 -0
  56. package/dist/components/trace-viewer/components/span-segments.js +392 -0
  57. package/dist/components/trace-viewer/components/span-segments.js.map +1 -0
  58. package/dist/components/trace-viewer/components/span-strategies.d.ts +46 -0
  59. package/dist/components/trace-viewer/components/span-strategies.d.ts.map +1 -0
  60. package/dist/components/trace-viewer/components/span-strategies.js +108 -0
  61. package/dist/components/trace-viewer/components/span-strategies.js.map +1 -0
  62. package/dist/components/trace-viewer/context.d.ts +7 -6
  63. package/dist/components/trace-viewer/context.d.ts.map +1 -1
  64. package/dist/components/trace-viewer/context.js +47 -18
  65. package/dist/components/trace-viewer/context.js.map +1 -1
  66. package/dist/components/trace-viewer/trace-viewer.d.ts +5 -1
  67. package/dist/components/trace-viewer/trace-viewer.d.ts.map +1 -1
  68. package/dist/components/trace-viewer/trace-viewer.js +87 -11
  69. package/dist/components/trace-viewer/trace-viewer.js.map +1 -1
  70. package/dist/components/trace-viewer/trace-viewer.module.css +179 -6
  71. package/dist/components/trace-viewer/util/timing.d.ts +5 -0
  72. package/dist/components/trace-viewer/util/timing.d.ts.map +1 -1
  73. package/dist/components/trace-viewer/util/timing.js +12 -0
  74. package/dist/components/trace-viewer/util/timing.js.map +1 -1
  75. package/dist/components/trace-viewer/util/use-streaming-spans.d.ts +1 -1
  76. package/dist/components/trace-viewer/util/use-streaming-spans.d.ts.map +1 -1
  77. package/dist/components/trace-viewer/util/use-streaming-spans.js +29 -17
  78. package/dist/components/trace-viewer/util/use-streaming-spans.js.map +1 -1
  79. package/dist/components/trace-viewer/worker.js +3 -1
  80. package/dist/components/trace-viewer/worker.js.map +1 -1
  81. package/dist/components/ui/alert.js +3 -3
  82. package/dist/components/ui/alert.js.map +1 -1
  83. package/dist/components/ui/card.d.ts.map +1 -1
  84. package/dist/components/ui/card.js +2 -2
  85. package/dist/components/ui/card.js.map +1 -1
  86. package/dist/components/ui/data-inspector.d.ts +17 -0
  87. package/dist/components/ui/data-inspector.d.ts.map +1 -0
  88. package/dist/components/ui/data-inspector.js +184 -0
  89. package/dist/components/ui/data-inspector.js.map +1 -0
  90. package/dist/components/ui/error-card.d.ts.map +1 -1
  91. package/dist/components/ui/error-card.js +4 -1
  92. package/dist/components/ui/error-card.js.map +1 -1
  93. package/dist/components/ui/inspector-theme.d.ts +39 -24
  94. package/dist/components/ui/inspector-theme.d.ts.map +1 -1
  95. package/dist/components/ui/inspector-theme.js +90 -38
  96. package/dist/components/ui/inspector-theme.js.map +1 -1
  97. package/dist/components/ui/skeleton.d.ts +1 -1
  98. package/dist/components/ui/skeleton.d.ts.map +1 -1
  99. package/dist/components/ui/skeleton.js +2 -2
  100. package/dist/components/ui/skeleton.js.map +1 -1
  101. package/dist/components/workflow-trace-view.d.ts +3 -1
  102. package/dist/components/workflow-trace-view.d.ts.map +1 -1
  103. package/dist/components/workflow-trace-view.js +435 -21
  104. package/dist/components/workflow-trace-view.js.map +1 -1
  105. package/dist/components/workflow-traces/trace-span-construction.d.ts +1 -1
  106. package/dist/components/workflow-traces/trace-span-construction.d.ts.map +1 -1
  107. package/dist/components/workflow-traces/trace-span-construction.js +2 -2
  108. package/dist/components/workflow-traces/trace-span-construction.js.map +1 -1
  109. package/dist/lib/hydration.d.ts.map +1 -1
  110. package/dist/lib/hydration.js +17 -3
  111. package/dist/lib/hydration.js.map +1 -1
  112. package/dist/styles.css +186 -0
  113. package/package.json +8 -7
  114. package/src/components/error-boundary.tsx +29 -40
  115. package/src/components/event-list-view.tsx +1000 -287
  116. package/src/components/index.ts +1 -0
  117. package/src/components/run-trace-view.tsx +3 -0
  118. package/src/components/sidebar/attribute-panel.tsx +58 -258
  119. package/src/components/sidebar/conversation-view.tsx +30 -27
  120. package/src/components/sidebar/entity-detail-panel.tsx +86 -20
  121. package/src/components/sidebar/events-list.tsx +4 -11
  122. package/src/components/sidebar/resolve-hook-modal.tsx +206 -47
  123. package/src/components/stream-viewer.tsx +138 -61
  124. package/src/components/trace-viewer/components/markers.tsx +69 -21
  125. package/src/components/trace-viewer/components/node.tsx +346 -100
  126. package/src/components/trace-viewer/components/span-content.tsx +247 -0
  127. package/src/components/trace-viewer/components/span-detail-panel.tsx +7 -2
  128. package/src/components/trace-viewer/components/span-segments.ts +516 -0
  129. package/src/components/trace-viewer/components/span-strategies.ts +205 -0
  130. package/src/components/trace-viewer/context.tsx +92 -40
  131. package/src/components/trace-viewer/trace-viewer.module.css +179 -6
  132. package/src/components/trace-viewer/trace-viewer.tsx +115 -11
  133. package/src/components/trace-viewer/util/timing.ts +13 -0
  134. package/src/components/trace-viewer/util/use-streaming-spans.ts +28 -17
  135. package/src/components/trace-viewer/worker.ts +4 -1
  136. package/src/components/ui/alert.tsx +3 -3
  137. package/src/components/ui/card.tsx +3 -5
  138. package/src/components/ui/data-inspector.tsx +318 -0
  139. package/src/components/ui/error-card.tsx +17 -6
  140. package/src/components/ui/inspector-theme.ts +127 -39
  141. package/src/components/ui/skeleton.tsx +3 -1
  142. package/src/components/workflow-trace-view.tsx +625 -26
  143. package/src/components/workflow-traces/trace-span-construction.ts +3 -2
  144. package/src/lib/hydration.ts +17 -8
  145. 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
- const RE_RENDER_INTERVAL_MS = 2000;
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
- useEffect(() => {
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, now);
347
- }, [run, steps, hooks, events, now]);
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 and duration for the panel header
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
- (data.stepName as string) ??
397
- (data.workflowName as string) ??
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
- <TraceViewerTimeline height="100%" trace={trace} />
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
- <span
449
- className="text-sm font-medium truncate"
450
- style={{ color: 'var(--ds-gray-1000)' }}
451
- >
452
- {selectedSpanName}
453
- </span>
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
- className="ml-2 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 flex-shrink-0"
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}