@workflow/web-shared 4.1.0-beta.51 → 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,16 +1,28 @@
1
1
  'use client';
2
2
 
3
- import type { Event } from '@workflow/world';
4
- import { ChevronRight, Loader2 } from 'lucide-react';
5
- import { useCallback, useMemo, useState } from 'react';
6
- import { ObjectInspector } from 'react-inspector';
7
- import { useDarkMode } from '../hooks/use-dark-mode';
8
- import { inspectorThemeDark, inspectorThemeLight } from './ui/inspector-theme';
9
- import { getEventColor } from './workflow-traces/event-colors';
3
+ import { parseStepName, parseWorkflowName } from '@workflow/utils/parse-name';
4
+ import type { Event, Step, WorkflowRun } from '@workflow/world';
5
+ import { Check, ChevronRight, Copy } from 'lucide-react';
6
+ import type { MouseEvent as ReactMouseEvent, ReactNode } from 'react';
7
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
8
+ import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso';
9
+ import { formatDuration } from '../lib/utils';
10
+ import { DataInspector } from './ui/data-inspector';
11
+ import { Skeleton } from './ui/skeleton';
12
+
13
+ const BUTTON_RESET_STYLE: React.CSSProperties = {
14
+ appearance: 'none',
15
+ WebkitAppearance: 'none',
16
+ border: 'none',
17
+ background: 'transparent',
18
+ };
19
+ const DOT_PULSE_ANIMATION =
20
+ 'workflow-dot-pulse 1.25s cubic-bezier(0, 0, 0.2, 1) infinite';
21
+
22
+ // ──────────────────────────────────────────────────────────────────────────
23
+ // Helpers
24
+ // ──────────────────────────────────────────────────────────────────────────
10
25
 
11
- /**
12
- * Format a date to a human-readable local time string with milliseconds
13
- */
14
26
  function formatEventTime(date: Date): string {
15
27
  return (
16
28
  date.toLocaleTimeString('en-US', {
@@ -24,72 +36,586 @@ function formatEventTime(date: Date): string {
24
36
  );
25
37
  }
26
38
 
39
+ function formatEventType(eventType: Event['eventType']): string {
40
+ return eventType
41
+ .split('_')
42
+ .map((word: string) => word.charAt(0).toUpperCase() + word.slice(1))
43
+ .join(' ');
44
+ }
45
+
46
+ // ──────────────────────────────────────────────────────────────────────────
47
+ // Event type → status color (small dot only)
48
+ // ──────────────────────────────────────────────────────────────────────────
49
+
50
+ /** Returns a CSS color using Geist design tokens for the status dot. */
51
+ function getStatusDotColor(eventType: string): string {
52
+ // Failed → red
53
+ if (
54
+ eventType === 'step_failed' ||
55
+ eventType === 'run_failed' ||
56
+ eventType === 'workflow_failed'
57
+ ) {
58
+ return 'var(--ds-red-700)';
59
+ }
60
+ // Cancelled → amber
61
+ if (eventType === 'run_cancelled') {
62
+ return 'var(--ds-amber-700)';
63
+ }
64
+ // Retrying → amber
65
+ if (eventType === 'step_retrying') {
66
+ return 'var(--ds-amber-700)';
67
+ }
68
+ // Completed/succeeded → green
69
+ if (
70
+ eventType === 'step_completed' ||
71
+ eventType === 'run_completed' ||
72
+ eventType === 'workflow_completed' ||
73
+ eventType === 'hook_disposed' ||
74
+ eventType === 'wait_completed'
75
+ ) {
76
+ return 'var(--ds-green-700)';
77
+ }
78
+ // Started/running → blue
79
+ if (
80
+ eventType === 'step_started' ||
81
+ eventType === 'run_started' ||
82
+ eventType === 'workflow_started' ||
83
+ eventType === 'hook_received'
84
+ ) {
85
+ return 'var(--ds-blue-700)';
86
+ }
87
+ // Created/pending → gray
88
+ return 'var(--ds-gray-600)';
89
+ }
90
+
27
91
  /**
28
- * Format a date to full local datetime string with milliseconds
92
+ * Build a map from correlationId (stepId) display name using step entities,
93
+ * and parse the workflow name from the run.
29
94
  */
30
- function formatEventDateTime(date: Date): string {
31
- return date.toLocaleString(undefined, {
32
- year: 'numeric',
33
- month: 'numeric',
34
- day: 'numeric',
35
- hour: 'numeric',
36
- minute: 'numeric',
37
- second: 'numeric',
38
- fractionalSecondDigits: 3,
39
- });
95
+ function buildNameMaps(
96
+ steps: Step[] | null,
97
+ run: WorkflowRun | null
98
+ ): {
99
+ correlationNameMap: Map<string, string>;
100
+ workflowName: string | null;
101
+ } {
102
+ const correlationNameMap = new Map<string, string>();
103
+
104
+ // Map step correlationId (= stepId) → parsed step name
105
+ if (steps) {
106
+ for (const step of steps) {
107
+ const parsed = parseStepName(String(step.stepName));
108
+ correlationNameMap.set(step.stepId, parsed?.shortName ?? step.stepName);
109
+ }
110
+ }
111
+
112
+ // Parse workflow name from run
113
+ const workflowName = run?.workflowName
114
+ ? (parseWorkflowName(run.workflowName)?.shortName ?? run.workflowName)
115
+ : null;
116
+
117
+ return { correlationNameMap, workflowName };
118
+ }
119
+
120
+ interface DurationInfo {
121
+ /** Time from created → started (ms) */
122
+ queued?: number;
123
+ /** Time from started → completed/failed/cancelled (ms) */
124
+ ran?: number;
40
125
  }
41
126
 
42
127
  /**
43
- * Format event type to a more readable label
128
+ * Build a map from correlationId duration info by diffing
129
+ * created ↔ started (queued) and started ↔ completed/failed/cancelled (ran).
130
+ * Also computes run-level durations under the key '__run__'.
44
131
  */
45
- function formatEventType(eventType: Event['eventType']): string {
46
- return eventType
47
- .split('_')
48
- .map((word: string) => word.charAt(0).toUpperCase() + word.slice(1))
49
- .join(' ');
132
+ function buildDurationMap(events: Event[]): Map<string, DurationInfo> {
133
+ const createdTimes = new Map<string, number>();
134
+ const startedTimes = new Map<string, number>();
135
+ const durations = new Map<string, DurationInfo>();
136
+
137
+ for (const event of events) {
138
+ const ts = new Date(event.createdAt).getTime();
139
+ const key = event.correlationId ?? '__run__';
140
+ const type: string = event.eventType;
141
+
142
+ // Track created times (first event for each correlation)
143
+ if (type === 'step_created' || type === 'run_created') {
144
+ createdTimes.set(key, ts);
145
+ }
146
+
147
+ // Track started times & compute queued duration
148
+ if (
149
+ type === 'step_started' ||
150
+ type === 'run_started' ||
151
+ type === 'workflow_started'
152
+ ) {
153
+ startedTimes.set(key, ts);
154
+ // If no explicit created event was seen, use the started time as created
155
+ if (!createdTimes.has(key)) {
156
+ createdTimes.set(key, ts);
157
+ }
158
+ const createdAt = createdTimes.get(key);
159
+ const info = durations.get(key) ?? {};
160
+ if (createdAt !== undefined) {
161
+ info.queued = ts - createdAt;
162
+ }
163
+ durations.set(key, info);
164
+ }
165
+
166
+ // Compute ran duration on terminal events
167
+ if (
168
+ type === 'step_completed' ||
169
+ type === 'step_failed' ||
170
+ type === 'run_completed' ||
171
+ type === 'run_failed' ||
172
+ type === 'run_cancelled' ||
173
+ type === 'workflow_completed' ||
174
+ type === 'workflow_failed' ||
175
+ type === 'wait_completed' ||
176
+ type === 'hook_disposed'
177
+ ) {
178
+ const startedAt = startedTimes.get(key);
179
+ const info = durations.get(key) ?? {};
180
+ if (startedAt !== undefined) {
181
+ info.ran = ts - startedAt;
182
+ }
183
+ durations.set(key, info);
184
+ }
185
+ }
186
+
187
+ return durations;
188
+ }
189
+
190
+ function isRunLevel(eventType: string): boolean {
191
+ return (
192
+ eventType === 'run_created' ||
193
+ eventType === 'run_started' ||
194
+ eventType === 'run_completed' ||
195
+ eventType === 'run_failed' ||
196
+ eventType === 'run_cancelled' ||
197
+ eventType === 'workflow_started' ||
198
+ eventType === 'workflow_completed' ||
199
+ eventType === 'workflow_failed'
200
+ );
201
+ }
202
+
203
+ // ──────────────────────────────────────────────────────────────────────────
204
+ // Tree gutter — fixed-width, shows branch lines only for the selected group
205
+ // ──────────────────────────────────────────────────────────────────────────
206
+
207
+ /** Fixed gutter width: 20px root area + 16px for one branch lane */
208
+ const GUTTER_WIDTH = 36;
209
+ /** X position of the single branch lane line */
210
+ const LANE_X = 20;
211
+ const ROOT_LINE_COLOR = 'var(--ds-gray-500)';
212
+
213
+ function TreeGutter({
214
+ isFirst,
215
+ isLast,
216
+ isRunLevel: isRun,
217
+ statusDotColor,
218
+ pulse = false,
219
+ hasSelection,
220
+ showBranch,
221
+ showLaneLine,
222
+ isLaneStart,
223
+ isLaneEnd,
224
+ continuationOnly = false,
225
+ }: {
226
+ isFirst: boolean;
227
+ isLast: boolean;
228
+ isRunLevel: boolean;
229
+ statusDotColor?: string;
230
+ pulse?: boolean;
231
+ /** Whether any group is currently active (selected or hovered) */
232
+ hasSelection: boolean;
233
+ /** Whether to show a horizontal branch line for this row (event belongs to active group) */
234
+ showBranch: boolean;
235
+ /** Whether the vertical lane line passes through this row */
236
+ showLaneLine: boolean;
237
+ /** Whether the vertical lane line starts at this row (top clipped to 50%) */
238
+ isLaneStart: boolean;
239
+ /** Whether the vertical lane line ends at this row (bottom clipped to 50%) */
240
+ isLaneEnd: boolean;
241
+ continuationOnly?: boolean;
242
+ }) {
243
+ const dotSize = isRun ? 8 : 6;
244
+ const dotLeft = isRun ? 5 : 6;
245
+ const dotOpacity = hasSelection && !showBranch && !isRun ? 0.3 : 1;
246
+
247
+ return (
248
+ <div
249
+ className="relative flex-shrink-0 self-stretch"
250
+ style={{
251
+ width: GUTTER_WIDTH,
252
+ minHeight: continuationOnly ? 0 : undefined,
253
+ }}
254
+ >
255
+ {/* Root vertical line (leftmost, always visible) */}
256
+ <div
257
+ style={{
258
+ position: 'absolute',
259
+ left: 8,
260
+ top: continuationOnly ? 0 : isFirst ? '50%' : 0,
261
+ bottom: continuationOnly ? 0 : isLast ? '50%' : 0,
262
+ width: 2,
263
+ backgroundColor: ROOT_LINE_COLOR,
264
+ zIndex: 0,
265
+ }}
266
+ />
267
+
268
+ {!continuationOnly && (
269
+ <>
270
+ {/* Status dot on the root line for every event */}
271
+ <div
272
+ style={{
273
+ position: 'absolute',
274
+ left: dotLeft,
275
+ top: '50%',
276
+ transform: 'translateY(-50%)',
277
+ width: dotSize,
278
+ height: dotSize,
279
+ zIndex: 2,
280
+ }}
281
+ >
282
+ {/* Opaque backdrop ensures gutter lines never visually cut through dots */}
283
+ <div
284
+ style={{
285
+ position: 'absolute',
286
+ inset: 0,
287
+ borderRadius: '50%',
288
+ backgroundColor: 'var(--ds-background-100)',
289
+ zIndex: 0,
290
+ }}
291
+ />
292
+ {pulse && (
293
+ <div
294
+ style={{
295
+ position: 'absolute',
296
+ inset: 0,
297
+ borderRadius: '50%',
298
+ backgroundColor: statusDotColor,
299
+ opacity: 0.75 * dotOpacity,
300
+ animation: DOT_PULSE_ANIMATION,
301
+ zIndex: 1,
302
+ }}
303
+ />
304
+ )}
305
+ <div
306
+ style={{
307
+ position: 'relative',
308
+ width: '100%',
309
+ height: '100%',
310
+ borderRadius: '50%',
311
+ backgroundColor: statusDotColor,
312
+ opacity: dotOpacity,
313
+ transition: 'opacity 150ms',
314
+ zIndex: 2,
315
+ }}
316
+ />
317
+ </div>
318
+
319
+ {/* Horizontal branch from root to gutter edge (selected group events only) */}
320
+ {showBranch && (
321
+ <div
322
+ style={{
323
+ position: 'absolute',
324
+ left: 9,
325
+ top: '50%',
326
+ width: GUTTER_WIDTH - 9,
327
+ height: 2,
328
+ backgroundColor: ROOT_LINE_COLOR,
329
+ zIndex: 0,
330
+ }}
331
+ />
332
+ )}
333
+ </>
334
+ )}
335
+
336
+ {/* Vertical lane line connecting the selected group's events */}
337
+ {showLaneLine && (
338
+ <div
339
+ style={{
340
+ position: 'absolute',
341
+ left: LANE_X,
342
+ top: continuationOnly ? 0 : isLaneStart ? '50%' : 0,
343
+ bottom: continuationOnly ? 0 : isLaneEnd ? '50%' : 0,
344
+ width: 2,
345
+ backgroundColor: ROOT_LINE_COLOR,
346
+ zIndex: 0,
347
+ }}
348
+ />
349
+ )}
350
+ </div>
351
+ );
352
+ }
353
+
354
+ // ──────────────────────────────────────────────────────────────────────────
355
+ // Copyable cell — shows a copy button on hover
356
+ // ──────────────────────────────────────────────────────────────────────────
357
+
358
+ function CopyableCell({
359
+ value,
360
+ className,
361
+ }: {
362
+ value: string;
363
+ className?: string;
364
+ }): ReactNode {
365
+ const [copied, setCopied] = useState(false);
366
+ const resetCopiedTimeoutRef = useRef<number | null>(null);
367
+
368
+ useEffect(() => {
369
+ return () => {
370
+ if (resetCopiedTimeoutRef.current !== null) {
371
+ window.clearTimeout(resetCopiedTimeoutRef.current);
372
+ }
373
+ };
374
+ }, []);
375
+
376
+ const handleCopy = useCallback(
377
+ (e: ReactMouseEvent) => {
378
+ e.stopPropagation();
379
+ navigator.clipboard.writeText(value).then(() => {
380
+ setCopied(true);
381
+ if (resetCopiedTimeoutRef.current !== null) {
382
+ window.clearTimeout(resetCopiedTimeoutRef.current);
383
+ }
384
+ resetCopiedTimeoutRef.current = window.setTimeout(() => {
385
+ setCopied(false);
386
+ resetCopiedTimeoutRef.current = null;
387
+ }, 1500);
388
+ });
389
+ },
390
+ [value]
391
+ );
392
+
393
+ return (
394
+ <div
395
+ className={`group/copy flex items-center gap-1 flex-1 min-w-0 px-4 ${className ?? ''}`}
396
+ >
397
+ <span className="overflow-hidden text-ellipsis whitespace-nowrap">
398
+ {value || '-'}
399
+ </span>
400
+ {value ? (
401
+ <button
402
+ type="button"
403
+ onClick={handleCopy}
404
+ className="flex-shrink-0 opacity-0 group-hover/copy:opacity-100 transition-opacity p-0.5 rounded hover:bg-[var(--ds-gray-alpha-200)]"
405
+ style={BUTTON_RESET_STYLE}
406
+ aria-label={`Copy ${value}`}
407
+ >
408
+ {copied ? (
409
+ <Check
410
+ className="h-3 w-3"
411
+ style={{ color: 'var(--ds-green-700)' }}
412
+ />
413
+ ) : (
414
+ <Copy className="h-3 w-3" style={{ color: 'var(--ds-gray-700)' }} />
415
+ )}
416
+ </button>
417
+ ) : null}
418
+ </div>
419
+ );
420
+ }
421
+
422
+ /** Recursively parse stringified JSON values so escaped slashes / quotes are cleaned up */
423
+ function deepParseJson(value: unknown): unknown {
424
+ if (typeof value === 'string') {
425
+ const trimmed = value.trim();
426
+ if (
427
+ (trimmed.startsWith('{') && trimmed.endsWith('}')) ||
428
+ (trimmed.startsWith('[') && trimmed.endsWith(']')) ||
429
+ (trimmed.startsWith('"') && trimmed.endsWith('"'))
430
+ ) {
431
+ try {
432
+ return deepParseJson(JSON.parse(trimmed));
433
+ } catch {
434
+ return value;
435
+ }
436
+ }
437
+ return value;
438
+ }
439
+ if (Array.isArray(value)) {
440
+ return value.map(deepParseJson);
441
+ }
442
+ if (value !== null && typeof value === 'object') {
443
+ const result: Record<string, unknown> = {};
444
+ for (const [k, v] of Object.entries(value)) {
445
+ result[k] = deepParseJson(v);
446
+ }
447
+ return result;
448
+ }
449
+ return value;
450
+ }
451
+
452
+ function PayloadBlock({ data }: { data: unknown }): ReactNode {
453
+ const [copied, setCopied] = useState(false);
454
+ const resetCopiedTimeoutRef = useRef<number | null>(null);
455
+ const cleaned = useMemo(() => deepParseJson(data), [data]);
456
+
457
+ useEffect(() => {
458
+ return () => {
459
+ if (resetCopiedTimeoutRef.current !== null) {
460
+ window.clearTimeout(resetCopiedTimeoutRef.current);
461
+ }
462
+ };
463
+ }, []);
464
+
465
+ const formatted = useMemo(() => {
466
+ try {
467
+ return JSON.stringify(cleaned, null, 2);
468
+ } catch {
469
+ return String(cleaned);
470
+ }
471
+ }, [cleaned]);
472
+
473
+ const handleCopy = useCallback(
474
+ (e: ReactMouseEvent) => {
475
+ e.stopPropagation();
476
+ navigator.clipboard.writeText(formatted).then(() => {
477
+ setCopied(true);
478
+ if (resetCopiedTimeoutRef.current !== null) {
479
+ window.clearTimeout(resetCopiedTimeoutRef.current);
480
+ }
481
+ resetCopiedTimeoutRef.current = window.setTimeout(() => {
482
+ setCopied(false);
483
+ resetCopiedTimeoutRef.current = null;
484
+ }, 1500);
485
+ });
486
+ },
487
+ [formatted]
488
+ );
489
+
490
+ return (
491
+ <div className="relative group/payload">
492
+ <div
493
+ className="overflow-x-auto p-2 text-[11px]"
494
+ style={{ color: 'var(--ds-gray-1000)' }}
495
+ >
496
+ <DataInspector data={cleaned} expandLevel={2} />
497
+ </div>
498
+ <button
499
+ type="button"
500
+ onClick={handleCopy}
501
+ className="absolute bottom-2 right-2 opacity-0 group-hover/payload:opacity-100 transition-opacity flex items-center gap-1 px-2 py-1 rounded-md text-xs hover:bg-[var(--ds-gray-alpha-200)]"
502
+ style={{ ...BUTTON_RESET_STYLE, color: 'var(--ds-gray-700)' }}
503
+ aria-label="Copy payload"
504
+ >
505
+ {copied ? (
506
+ <>
507
+ <Check
508
+ className="h-3 w-3"
509
+ style={{ color: 'var(--ds-green-700)' }}
510
+ />
511
+ <span style={{ color: 'var(--ds-green-700)' }}>Copied</span>
512
+ </>
513
+ ) : (
514
+ <>
515
+ <Copy className="h-3 w-3" />
516
+ <span>Copy</span>
517
+ </>
518
+ )}
519
+ </button>
520
+ </div>
521
+ );
50
522
  }
51
523
 
524
+ // ──────────────────────────────────────────────────────────────────────────
525
+ // Event row
526
+ // ──────────────────────────────────────────────────────────────────────────
527
+
52
528
  interface EventsListProps {
53
529
  events: Event[] | null;
530
+ steps?: Step[] | null;
531
+ run?: WorkflowRun | null;
54
532
  onLoadEventData?: (event: Event) => Promise<unknown | null>;
533
+ hasMoreEvents?: boolean;
534
+ isLoadingMoreEvents?: boolean;
535
+ onLoadMoreEvents?: () => Promise<void> | void;
55
536
  }
56
537
 
57
- /**
58
- * Single event row component with expandable details
59
- */
60
538
  function EventRow({
61
539
  event,
540
+ index,
541
+ isFirst,
542
+ isLast,
543
+ activeGroupKey,
544
+ selectedGroupKey,
545
+ selectedGroupRange,
546
+ correlationNameMap,
547
+ workflowName,
548
+ durationMap,
549
+ onSelectGroup,
550
+ onHoverGroup,
62
551
  onLoadEventData,
63
552
  }: {
64
553
  event: Event;
554
+ index: number;
555
+ isFirst: boolean;
556
+ isLast: boolean;
557
+ activeGroupKey?: string;
558
+ selectedGroupKey?: string;
559
+ selectedGroupRange: { first: number; last: number } | null;
560
+ correlationNameMap: Map<string, string>;
561
+ workflowName: string | null;
562
+ durationMap: Map<string, DurationInfo>;
563
+ onSelectGroup: (groupKey: string | undefined) => void;
564
+ onHoverGroup: (groupKey: string | undefined) => void;
65
565
  onLoadEventData?: (event: Event) => Promise<unknown | null>;
66
566
  }) {
67
- const isDark = useDarkMode();
68
567
  const [isExpanded, setIsExpanded] = useState(false);
69
568
  const [isLoading, setIsLoading] = useState(false);
70
569
  const [loadedEventData, setLoadedEventData] = useState<unknown | null>(null);
71
570
  const [loadError, setLoadError] = useState<string | null>(null);
571
+ const [hasAttemptedLoad, setHasAttemptedLoad] = useState(false);
72
572
 
73
- const colors = getEventColor(event.eventType);
74
- const createdAt = new Date(event.createdAt);
573
+ const rowGroupKey =
574
+ event.correlationId ??
575
+ (isRunLevel(event.eventType) ? '__run__' : undefined);
576
+
577
+ // Collapse when a different group gets selected
578
+ useEffect(() => {
579
+ if (selectedGroupKey !== undefined && selectedGroupKey !== rowGroupKey) {
580
+ setIsExpanded(false);
581
+ }
582
+ }, [selectedGroupKey, rowGroupKey]);
75
583
 
76
- // Check if event already has eventData (from initial fetch)
584
+ const statusDotColor = getStatusDotColor(event.eventType);
585
+ const createdAt = new Date(event.createdAt);
77
586
  const hasExistingEventData = 'eventData' in event && event.eventData != null;
587
+ const isRun = isRunLevel(event.eventType);
588
+ const eventName = isRun
589
+ ? (workflowName ?? '-')
590
+ : event.correlationId
591
+ ? (correlationNameMap.get(event.correlationId) ?? '-')
592
+ : '-';
593
+
594
+ const durationKey = event.correlationId ?? (isRun ? '__run__' : '');
595
+ const durationInfo = durationKey ? durationMap.get(durationKey) : undefined;
596
+
597
+ const hasActive = activeGroupKey !== undefined;
598
+ const isRelated = rowGroupKey !== undefined && rowGroupKey === activeGroupKey;
599
+ const isDimmed = hasActive && !isRelated;
600
+ const isPulsing = hasActive && isRelated;
601
+
602
+ // Gutter state derived from selectedGroupRange
603
+ const showBranch = hasActive && isRelated && !isRun;
604
+ const showLaneLine =
605
+ selectedGroupRange !== null &&
606
+ index >= selectedGroupRange.first &&
607
+ index <= selectedGroupRange.last;
608
+ const isLaneStart =
609
+ selectedGroupRange !== null && index === selectedGroupRange.first;
610
+ const isLaneEnd =
611
+ selectedGroupRange !== null && index === selectedGroupRange.last;
78
612
 
79
- // Load full event details when expanding
80
613
  const loadEventDetails = useCallback(async () => {
81
- // Skip if we already have data or no correlationId
82
- if (
83
- loadedEventData !== null ||
84
- hasExistingEventData ||
85
- !event.correlationId
86
- ) {
614
+ if (loadedEventData !== null || hasExistingEventData) {
87
615
  return;
88
616
  }
89
-
90
617
  setIsLoading(true);
91
618
  setLoadError(null);
92
-
93
619
  try {
94
620
  if (!onLoadEventData) {
95
621
  setLoadError('Event details unavailable');
@@ -105,163 +631,215 @@ function EventRow({
105
631
  );
106
632
  } finally {
107
633
  setIsLoading(false);
634
+ setHasAttemptedLoad(true);
108
635
  }
109
- }, [
110
- event.correlationId,
111
- loadedEventData,
112
- hasExistingEventData,
113
- onLoadEventData,
114
- ]);
115
-
116
- // Handle expand/collapse
117
- const handleToggle = useCallback(() => {
118
- const newExpanded = !isExpanded;
119
- setIsExpanded(newExpanded);
120
-
121
- // Load details when expanding for the first time
122
- if (newExpanded && loadedEventData === null && !hasExistingEventData) {
123
- loadEventDetails();
636
+ }, [event, loadedEventData, hasExistingEventData, onLoadEventData]);
637
+
638
+ const handleExpandToggle = useCallback(
639
+ (e: ReactMouseEvent) => {
640
+ e.stopPropagation();
641
+ const newExpanded = !isExpanded;
642
+ setIsExpanded(newExpanded);
643
+ if (newExpanded && loadedEventData === null && !hasExistingEventData) {
644
+ loadEventDetails();
645
+ }
646
+ },
647
+ [isExpanded, loadedEventData, hasExistingEventData, loadEventDetails]
648
+ );
649
+
650
+ const handleRowClick = useCallback(() => {
651
+ if (selectedGroupKey === rowGroupKey) {
652
+ onSelectGroup(undefined);
653
+ } else {
654
+ onSelectGroup(rowGroupKey);
124
655
  }
125
- }, [isExpanded, loadedEventData, hasExistingEventData, loadEventDetails]);
656
+ }, [selectedGroupKey, rowGroupKey, onSelectGroup]);
126
657
 
127
- // Get the event data to display (either from initial fetch, loaded data, or null)
128
658
  const eventData = hasExistingEventData
129
659
  ? (event as Event & { eventData: unknown }).eventData
130
660
  : loadedEventData;
131
661
 
662
+ const contentOpacity = isDimmed ? 0.3 : 1;
663
+
132
664
  return (
133
665
  <div
134
- className="rounded-lg border overflow-hidden transition-all"
135
- style={{
136
- backgroundColor: 'var(--ds-background-100)',
137
- borderColor: colors.border,
138
- borderLeftWidth: '1px',
139
- borderLeftColor: colors.color,
140
- }}
666
+ data-event-id={event.eventId}
667
+ onMouseEnter={() => onHoverGroup(rowGroupKey)}
668
+ onMouseLeave={() => onHoverGroup(undefined)}
141
669
  >
142
- {/* Clickable row header */}
143
- <button
144
- type="button"
145
- onClick={handleToggle}
146
- className="w-full text-left grid gap-3 items-center px-0 py-2 text-xs hover:brightness-[0.98] transition-all cursor-pointer"
147
- style={{
148
- backgroundColor: 'var(--ds-background-100)',
149
- gridTemplateColumns: '24px 100px minmax(120px, auto) 1fr 1fr',
670
+ {/* Row */}
671
+ <div
672
+ role="button"
673
+ tabIndex={0}
674
+ onClick={handleRowClick}
675
+ onKeyDown={(e) => {
676
+ if (e.key === 'Enter' || e.key === ' ') handleRowClick();
150
677
  }}
678
+ className="w-full text-left flex items-center gap-0 text-sm hover:bg-[var(--ds-gray-alpha-100)] transition-colors cursor-pointer"
679
+ style={{ minHeight: 40 }}
151
680
  >
152
- {/* Expand icon */}
153
- <div className="flex justify-center">
154
- <ChevronRight
155
- className="h-3.5 w-3.5 transition-transform"
156
- style={{
157
- color: colors.secondary,
158
- transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)',
159
- }}
160
- />
161
- </div>
681
+ <TreeGutter
682
+ isFirst={isFirst}
683
+ isLast={isLast && !isExpanded}
684
+ isRunLevel={isRun}
685
+ statusDotColor={statusDotColor}
686
+ pulse={isPulsing}
687
+ hasSelection={hasActive}
688
+ showBranch={showBranch}
689
+ showLaneLine={showLaneLine}
690
+ isLaneStart={isLaneStart}
691
+ isLaneEnd={isLaneEnd}
692
+ />
162
693
 
163
- {/* Time */}
694
+ {/* Content area — dims when unrelated */}
164
695
  <div
165
- className="font-mono tabular-nums"
166
- style={{ color: colors.secondary }}
696
+ className="flex items-center flex-1 min-w-0"
697
+ style={{ opacity: contentOpacity, transition: 'opacity 150ms' }}
167
698
  >
168
- {formatEventTime(createdAt)}
169
- </div>
699
+ {/* Expand chevron button */}
700
+ <button
701
+ type="button"
702
+ onClick={handleExpandToggle}
703
+ className="flex items-center justify-center w-5 h-5 flex-shrink-0 rounded hover:bg-[var(--ds-gray-alpha-200)] transition-colors"
704
+ style={{
705
+ ...BUTTON_RESET_STYLE,
706
+ border: '1px solid var(--ds-gray-alpha-400)',
707
+ }}
708
+ aria-label={isExpanded ? 'Collapse details' : 'Expand details'}
709
+ >
710
+ <ChevronRight
711
+ className="h-3 w-3 transition-transform"
712
+ style={{
713
+ color: 'var(--ds-gray-700)',
714
+ transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)',
715
+ }}
716
+ />
717
+ </button>
718
+
719
+ {/* Time */}
720
+ <div
721
+ className="text-xs tabular-nums flex-1 min-w-0 px-4"
722
+ style={{ color: 'var(--ds-gray-900)' }}
723
+ >
724
+ {formatEventTime(createdAt)}
725
+ </div>
170
726
 
171
- {/* Event Type */}
172
- <div className="font-medium" style={{ color: colors.text }}>
173
- <span className="inline-flex items-center gap-1.5">
727
+ {/* Event Type */}
728
+ <div className="text-xs font-medium flex-1 min-w-0 px-4">
174
729
  <span
175
- className="w-2 h-2 rounded-full flex-shrink-0"
176
- style={{ backgroundColor: colors.color }}
177
- />
178
- {formatEventType(event.eventType)}
179
- </span>
180
- </div>
730
+ className="inline-flex items-center gap-1.5"
731
+ style={{ color: 'var(--ds-gray-900)' }}
732
+ >
733
+ <span
734
+ style={{
735
+ position: 'relative',
736
+ display: 'inline-flex',
737
+ width: 6,
738
+ height: 6,
739
+ flexShrink: 0,
740
+ }}
741
+ >
742
+ {isPulsing && (
743
+ <span
744
+ style={{
745
+ position: 'absolute',
746
+ inset: 0,
747
+ borderRadius: '50%',
748
+ backgroundColor: statusDotColor,
749
+ opacity: 0.75,
750
+ animation: DOT_PULSE_ANIMATION,
751
+ }}
752
+ />
753
+ )}
754
+ <span
755
+ style={{
756
+ position: 'relative',
757
+ width: 6,
758
+ height: 6,
759
+ borderRadius: '50%',
760
+ backgroundColor: statusDotColor,
761
+ }}
762
+ />
763
+ </span>
764
+ {formatEventType(event.eventType)}
765
+ </span>
766
+ </div>
181
767
 
182
- {/* Correlation ID */}
183
- <div
184
- className="font-mono text-[11px] overflow-hidden text-ellipsis whitespace-nowrap"
185
- style={{ color: colors.secondary }}
186
- title={event.correlationId || '-'}
187
- >
188
- {event.correlationId || '-'}
189
- </div>
768
+ {/* Name */}
769
+ <div
770
+ className="text-xs flex-1 min-w-0 px-4 overflow-hidden text-ellipsis whitespace-nowrap"
771
+ title={eventName !== '-' ? eventName : undefined}
772
+ >
773
+ {eventName}
774
+ </div>
190
775
 
191
- {/* Event ID */}
192
- <div
193
- className="font-mono text-[11px] pr-3 overflow-hidden text-ellipsis whitespace-nowrap"
194
- style={{ color: colors.secondary }}
195
- title={event.eventId}
196
- >
197
- {event.eventId}
776
+ {/* Correlation ID */}
777
+ <CopyableCell
778
+ value={event.correlationId || ''}
779
+ className="font-mono text-xs"
780
+ />
781
+
782
+ {/* Event ID */}
783
+ <CopyableCell value={event.eventId} className="font-mono text-xs" />
198
784
  </div>
199
- </button>
785
+ </div>
200
786
 
201
- {/* Expanded details */}
787
+ {/* Expanded details — tree lines continue through this area */}
202
788
  {isExpanded && (
203
- <div
204
- className="border-t px-4 py-3"
205
- style={{
206
- borderColor: colors.border,
207
- backgroundColor: 'var(--ds-background-100)',
208
- }}
209
- >
210
- {/* Event attributes in a structured table */}
789
+ <div className="flex">
790
+ {/* Continuation gutter — lane line continues if not at lane end */}
791
+ <TreeGutter
792
+ isFirst={false}
793
+ isLast={isLast}
794
+ isRunLevel={isRun}
795
+ hasSelection={hasActive}
796
+ showBranch={false}
797
+ showLaneLine={showLaneLine && !isLaneEnd}
798
+ isLaneStart={false}
799
+ isLaneEnd={false}
800
+ continuationOnly
801
+ />
802
+ {/* Spacer for chevron column */}
803
+ <div className="w-5 flex-shrink-0" />
211
804
  <div
212
- className="flex flex-col divide-y rounded-md border overflow-hidden"
805
+ className="flex-1 my-1.5 mr-3 ml-2 py-2 rounded-md border overflow-hidden"
213
806
  style={{
214
- borderColor: 'var(--ds-gray-300)',
215
- backgroundColor: 'var(--ds-gray-100)',
807
+ borderColor: 'var(--ds-gray-alpha-200)',
808
+ opacity: contentOpacity,
809
+ transition: 'opacity 150ms',
216
810
  }}
217
811
  >
218
- <AttributeRow label="Event ID" value={event.eventId} mono />
219
- <AttributeRow label="Event Type" value={event.eventType} />
220
- <AttributeRow
221
- label="Correlation ID"
222
- value={event.correlationId || '-'}
223
- mono
224
- />
225
- <AttributeRow label="Run ID" value={event.runId} mono />
226
- <AttributeRow
227
- label="Created At"
228
- value={formatEventDateTime(createdAt)}
229
- />
230
- </div>
231
-
232
- {/* Event data section */}
233
- <div className="mt-3">
234
- <div
235
- className="text-xs font-medium mb-1.5"
236
- style={{ color: 'var(--ds-gray-700)' }}
237
- >
238
- Event Data
239
- </div>
240
-
241
- {/* Loading state */}
242
- {isLoading && (
812
+ {/* Duration info */}
813
+ {(durationInfo?.queued !== undefined ||
814
+ durationInfo?.ran !== undefined) && (
243
815
  <div
244
- className="flex items-center gap-2 rounded-md border p-3"
245
- style={{
246
- borderColor: 'var(--ds-gray-300)',
247
- backgroundColor: 'var(--ds-gray-100)',
248
- }}
816
+ className="px-2 pb-1.5 text-xs flex gap-3"
817
+ style={{ color: 'var(--ds-gray-900)' }}
249
818
  >
250
- <Loader2
251
- className="h-4 w-4 animate-spin"
252
- style={{ color: 'var(--ds-gray-700)' }}
253
- />
254
- <span
255
- className="text-xs"
256
- style={{ color: 'var(--ds-gray-700)' }}
257
- >
258
- Loading event details...
259
- </span>
819
+ {durationInfo.queued !== undefined &&
820
+ durationInfo.queued > 0 && (
821
+ <span>
822
+ Queued for{' '}
823
+ <span className="font-mono tabular-nums">
824
+ {formatDuration(durationInfo.queued)}
825
+ </span>
826
+ </span>
827
+ )}
828
+ {durationInfo.ran !== undefined && (
829
+ <span>
830
+ Ran for{' '}
831
+ <span className="font-mono tabular-nums">
832
+ {formatDuration(durationInfo.ran)}
833
+ </span>
834
+ </span>
835
+ )}
260
836
  </div>
261
837
  )}
262
838
 
263
- {/* Error state */}
264
- {loadError && !isLoading && (
839
+ {/* Payload */}
840
+ {eventData != null ? (
841
+ <PayloadBlock data={eventData} />
842
+ ) : loadError ? (
265
843
  <div
266
844
  className="rounded-md border p-3 text-xs"
267
845
  style={{
@@ -272,59 +850,23 @@ function EventRow({
272
850
  >
273
851
  {loadError}
274
852
  </div>
275
- )}
276
-
277
- {/* Event data display */}
278
- {!isLoading && !loadError && eventData != null && (
853
+ ) : isLoading ||
854
+ (!hasExistingEventData &&
855
+ !hasAttemptedLoad &&
856
+ event.correlationId) ? (
857
+ <div className="flex flex-col gap-2 p-3">
858
+ <Skeleton className="h-3" style={{ width: '75%' }} />
859
+ <Skeleton className="h-3" style={{ width: '50%' }} />
860
+ <Skeleton className="h-3" style={{ width: '60%' }} />
861
+ </div>
862
+ ) : (
279
863
  <div
280
- className="overflow-x-auto rounded-md border p-3"
281
- style={{ borderColor: 'var(--ds-gray-300)' }}
864
+ className="p-2 text-xs"
865
+ style={{ color: 'var(--ds-gray-900)' }}
282
866
  >
283
- <ObjectInspector
284
- data={eventData}
285
- // @ts-expect-error react-inspector accepts theme objects at runtime
286
- // see https://github.com/storybookjs/react-inspector/blob/main/README.md#theme
287
- theme={isDark ? inspectorThemeDark : inspectorThemeLight}
288
- expandLevel={2}
289
- />
867
+ No data
290
868
  </div>
291
869
  )}
292
-
293
- {/* No event data */}
294
- {!isLoading &&
295
- !loadError &&
296
- eventData == null &&
297
- !event.correlationId && (
298
- <div
299
- className="rounded-md border p-3 text-xs"
300
- style={{
301
- borderColor: 'var(--ds-gray-300)',
302
- backgroundColor: 'var(--ds-gray-100)',
303
- color: 'var(--ds-gray-700)',
304
- }}
305
- >
306
- No event data available
307
- </div>
308
- )}
309
-
310
- {/* No correlation ID - can't load data */}
311
- {!isLoading &&
312
- !loadError &&
313
- eventData == null &&
314
- event.correlationId &&
315
- !hasExistingEventData &&
316
- loadedEventData === null && (
317
- <div
318
- className="rounded-md border p-3 text-xs"
319
- style={{
320
- borderColor: 'var(--ds-gray-300)',
321
- backgroundColor: 'var(--ds-gray-100)',
322
- color: 'var(--ds-gray-700)',
323
- }}
324
- >
325
- No event data for this event type
326
- </div>
327
- )}
328
870
  </div>
329
871
  </div>
330
872
  )}
@@ -332,45 +874,19 @@ function EventRow({
332
874
  );
333
875
  }
334
876
 
335
- /**
336
- * Helper component for attribute rows in the expanded details
337
- */
338
- function AttributeRow({
339
- label,
340
- value,
341
- mono = false,
342
- }: {
343
- label: string;
344
- value: string;
345
- mono?: boolean;
346
- }) {
347
- return (
348
- <div
349
- className="flex items-center justify-between px-2.5 py-1.5"
350
- style={{ borderColor: 'var(--ds-gray-300)' }}
351
- >
352
- <span
353
- className="text-[11px] font-medium"
354
- style={{ color: 'var(--ds-gray-700)' }}
355
- >
356
- {label}
357
- </span>
358
- <span
359
- className={`text-[11px] ${mono ? 'font-mono' : ''} text-right max-w-[70%] break-all`}
360
- style={{ color: 'var(--ds-gray-1000)' }}
361
- >
362
- {value}
363
- </span>
364
- </div>
365
- );
366
- }
877
+ // ──────────────────────────────────────────────────────────────────────────
878
+ // Main component
879
+ // ──────────────────────────────────────────────────────────────────────────
367
880
 
368
- /**
369
- * Displays a list of all events for a workflow run as colored cards in a pseudo-table.
370
- * Events are sorted by createdAt (oldest first).
371
- */
372
- export function EventListView({ events, onLoadEventData }: EventsListProps) {
373
- // Sort events by createdAt (oldest first)
881
+ export function EventListView({
882
+ events,
883
+ steps,
884
+ run,
885
+ onLoadEventData,
886
+ hasMoreEvents = false,
887
+ isLoadingMoreEvents = false,
888
+ onLoadMoreEvents,
889
+ }: EventsListProps) {
374
890
  const sortedEvents = useMemo(() => {
375
891
  if (!events || events.length === 0) return [];
376
892
  return [...events].sort(
@@ -379,6 +895,87 @@ export function EventListView({ events, onLoadEventData }: EventsListProps) {
379
895
  );
380
896
  }, [events]);
381
897
 
898
+ const { correlationNameMap, workflowName } = useMemo(
899
+ () => buildNameMaps(steps ?? null, run ?? null),
900
+ [steps, run]
901
+ );
902
+
903
+ const durationMap = useMemo(
904
+ () => buildDurationMap(sortedEvents),
905
+ [sortedEvents]
906
+ );
907
+
908
+ const [selectedGroupKey, setSelectedGroupKey] = useState<string | undefined>(
909
+ undefined
910
+ );
911
+ const [hoveredGroupKey, setHoveredGroupKey] = useState<string | undefined>(
912
+ undefined
913
+ );
914
+ const onSelectGroup = useCallback((groupKey: string | undefined) => {
915
+ setSelectedGroupKey(groupKey);
916
+ }, []);
917
+ const onHoverGroup = useCallback((groupKey: string | undefined) => {
918
+ setHoveredGroupKey(groupKey);
919
+ }, []);
920
+
921
+ const activeGroupKey = selectedGroupKey ?? hoveredGroupKey;
922
+
923
+ // Compute the row-index range for the active group's connecting lane line.
924
+ // Only applies to non-run groups (step/hook/wait correlations).
925
+ const selectedGroupRange = useMemo(() => {
926
+ if (!activeGroupKey || activeGroupKey === '__run__') return null;
927
+ let first = -1;
928
+ let last = -1;
929
+ for (let i = 0; i < sortedEvents.length; i++) {
930
+ if (sortedEvents[i].correlationId === activeGroupKey) {
931
+ if (first === -1) first = i;
932
+ last = i;
933
+ }
934
+ }
935
+ return first >= 0 ? { first, last } : null;
936
+ }, [activeGroupKey, sortedEvents]);
937
+
938
+ const [searchQuery, setSearchQuery] = useState('');
939
+ const virtuosoRef = useRef<VirtuosoHandle>(null);
940
+
941
+ const searchIndex = useMemo(() => {
942
+ const entries: {
943
+ text: string;
944
+ groupKey?: string;
945
+ eventId: string;
946
+ index: number;
947
+ }[] = [];
948
+ for (let i = 0; i < sortedEvents.length; i++) {
949
+ const ev = sortedEvents[i];
950
+ entries.push({
951
+ text: [ev.eventId, ev.correlationId ?? ''].join(' ').toLowerCase(),
952
+ groupKey:
953
+ ev.correlationId ??
954
+ (isRunLevel(ev.eventType) ? '__run__' : undefined),
955
+ eventId: ev.eventId,
956
+ index: i,
957
+ });
958
+ }
959
+ return entries;
960
+ }, [sortedEvents]);
961
+
962
+ useEffect(() => {
963
+ const q = searchQuery.trim().toLowerCase();
964
+ if (!q) {
965
+ setSelectedGroupKey(undefined);
966
+ return;
967
+ }
968
+ const match = searchIndex.find((entry) => entry.text.includes(q));
969
+ if (match) {
970
+ setSelectedGroupKey(match.groupKey);
971
+ virtuosoRef.current?.scrollToIndex({
972
+ index: match.index,
973
+ align: 'center',
974
+ behavior: 'smooth',
975
+ });
976
+ }
977
+ }, [searchQuery, searchIndex]);
978
+
382
979
  if (!events || events.length === 0) {
383
980
  return (
384
981
  <div
@@ -391,45 +988,161 @@ export function EventListView({ events, onLoadEventData }: EventsListProps) {
391
988
  }
392
989
 
393
990
  return (
394
- <div className="h-full overflow-auto m-2">
395
- {/* Header row */}
396
- <div
397
- className="grid gap-3 pb-2 mb-2 border-b text-xs font-medium sticky top-0 z-10"
398
- style={{
399
- gridTemplateColumns: '24px 100px minmax(120px, auto) 1fr 1fr',
400
- borderColor: 'var(--ds-gray-300)',
401
- backgroundColor: 'transparent',
402
- color: 'var(--ds-gray-700)',
403
- }}
404
- >
405
- <div>{/* Expand icon column */}</div>
406
- <div>Time</div>
407
- <div>Event Type</div>
408
- <div>Correlation ID</div>
409
- <div>Event ID</div>
410
- </div>
411
-
412
- {/* Event rows */}
413
- <div className="flex flex-col gap-2">
414
- {sortedEvents.map((event) => (
415
- <EventRow
416
- key={event.eventId}
417
- event={event}
418
- onLoadEventData={onLoadEventData}
991
+ <div className="h-full flex flex-col overflow-hidden">
992
+ <style>{`@keyframes workflow-dot-pulse{0%{transform:scale(1);opacity:.7}70%,100%{transform:scale(2.2);opacity:0}}`}</style>
993
+ {/* Search bar */}
994
+ <div style={{ padding: 6, backgroundColor: 'var(--ds-background-100)' }}>
995
+ <label
996
+ style={{
997
+ display: 'flex',
998
+ alignItems: 'center',
999
+ justifyContent: 'center',
1000
+ borderRadius: 6,
1001
+ boxShadow: '0 0 0 1px var(--ds-gray-alpha-400)',
1002
+ background: 'var(--ds-background-100)',
1003
+ height: 40,
1004
+ }}
1005
+ >
1006
+ <div
1007
+ style={{
1008
+ width: 40,
1009
+ height: 40,
1010
+ display: 'flex',
1011
+ alignItems: 'center',
1012
+ justifyContent: 'center',
1013
+ color: 'var(--ds-gray-800)',
1014
+ flexShrink: 0,
1015
+ }}
1016
+ >
1017
+ <svg
1018
+ width={16}
1019
+ height={16}
1020
+ viewBox="0 0 16 16"
1021
+ fill="none"
1022
+ aria-hidden="true"
1023
+ focusable="false"
1024
+ >
1025
+ <circle
1026
+ cx="7"
1027
+ cy="7"
1028
+ r="4.5"
1029
+ stroke="currentColor"
1030
+ strokeWidth="1.5"
1031
+ />
1032
+ <path
1033
+ d="M11.5 11.5L14 14"
1034
+ stroke="currentColor"
1035
+ strokeWidth="1.5"
1036
+ strokeLinecap="round"
1037
+ />
1038
+ </svg>
1039
+ </div>
1040
+ <input
1041
+ type="search"
1042
+ placeholder="Search by event ID or correlation ID…"
1043
+ value={searchQuery}
1044
+ onChange={(e) => setSearchQuery(e.target.value)}
1045
+ style={{
1046
+ marginLeft: -16,
1047
+ paddingInline: 12,
1048
+ fontFamily: 'inherit',
1049
+ fontSize: 14,
1050
+ background: 'transparent',
1051
+ border: 'none',
1052
+ outline: 'none',
1053
+ height: 40,
1054
+ width: '100%',
1055
+ }}
419
1056
  />
420
- ))}
1057
+ </label>
421
1058
  </div>
422
1059
 
423
- {/* Summary */}
1060
+ {/* Header */}
424
1061
  <div
425
- className="mt-4 pt-3 border-t text-xs"
1062
+ className="flex items-center gap-0 text-sm font-medium h-10 border-b flex-shrink-0"
426
1063
  style={{
427
- borderColor: 'var(--ds-gray-300)',
428
- color: 'var(--ds-gray-700)',
1064
+ borderColor: 'var(--ds-gray-alpha-200)',
1065
+ color: 'var(--ds-gray-900)',
1066
+ backgroundColor: 'var(--ds-background-100)',
429
1067
  }}
430
1068
  >
431
- {sortedEvents.length} event{sortedEvents.length !== 1 ? 's' : ''} total
1069
+ <div className="flex-shrink-0" style={{ width: GUTTER_WIDTH }} />
1070
+ <div className="w-5 flex-shrink-0" />
1071
+ <div className="flex-1 min-w-0 px-4">Time</div>
1072
+ <div className="flex-1 min-w-0 px-4">Event Type</div>
1073
+ <div className="flex-1 min-w-0 px-4">Name</div>
1074
+ <div className="flex-1 min-w-0 px-4">Correlation ID</div>
1075
+ <div className="flex-1 min-w-0 px-4">Event ID</div>
432
1076
  </div>
1077
+
1078
+ {/* Virtualized event rows */}
1079
+ <Virtuoso
1080
+ ref={virtuosoRef}
1081
+ totalCount={sortedEvents.length}
1082
+ overscan={20}
1083
+ defaultItemHeight={40}
1084
+ endReached={() => {
1085
+ if (!hasMoreEvents || isLoadingMoreEvents) {
1086
+ return;
1087
+ }
1088
+ void onLoadMoreEvents?.();
1089
+ }}
1090
+ itemContent={(index: number) => {
1091
+ return (
1092
+ <EventRow
1093
+ event={sortedEvents[index]}
1094
+ index={index}
1095
+ isFirst={index === 0}
1096
+ isLast={index === sortedEvents.length - 1}
1097
+ activeGroupKey={activeGroupKey}
1098
+ selectedGroupKey={selectedGroupKey}
1099
+ selectedGroupRange={selectedGroupRange}
1100
+ correlationNameMap={correlationNameMap}
1101
+ workflowName={workflowName}
1102
+ durationMap={durationMap}
1103
+ onSelectGroup={onSelectGroup}
1104
+ onHoverGroup={onHoverGroup}
1105
+ onLoadEventData={onLoadEventData}
1106
+ />
1107
+ );
1108
+ }}
1109
+ components={{
1110
+ Footer: () => (
1111
+ <>
1112
+ {hasMoreEvents && (
1113
+ <div className="px-3 pt-3 flex justify-center">
1114
+ <button
1115
+ type="button"
1116
+ onClick={() => void onLoadMoreEvents?.()}
1117
+ disabled={isLoadingMoreEvents}
1118
+ className="h-8 px-3 text-xs rounded-md border transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
1119
+ style={{
1120
+ borderColor: 'var(--ds-gray-alpha-400)',
1121
+ color: 'var(--ds-gray-900)',
1122
+ backgroundColor: 'var(--ds-background-100)',
1123
+ }}
1124
+ >
1125
+ {isLoadingMoreEvents
1126
+ ? 'Loading more events...'
1127
+ : 'Load more'}
1128
+ </button>
1129
+ </div>
1130
+ )}
1131
+ <div
1132
+ className="mt-4 pt-3 border-t text-xs px-3"
1133
+ style={{
1134
+ borderColor: 'var(--ds-gray-alpha-200)',
1135
+ color: 'var(--ds-gray-900)',
1136
+ }}
1137
+ >
1138
+ {sortedEvents.length} event
1139
+ {sortedEvents.length !== 1 ? 's' : ''} total
1140
+ </div>
1141
+ </>
1142
+ ),
1143
+ }}
1144
+ style={{ flex: 1, minHeight: 0 }}
1145
+ />
433
1146
  </div>
434
1147
  );
435
1148
  }