@workflow/web-shared 5.0.0-beta.23 → 5.0.0-beta.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +2 -10
  2. package/dist/components/event-list-view.d.ts +30 -0
  3. package/dist/components/event-list-view.d.ts.map +1 -1
  4. package/dist/components/event-list-view.js +27 -12
  5. package/dist/components/index.d.ts +4 -3
  6. package/dist/components/index.d.ts.map +1 -1
  7. package/dist/components/index.js +3 -3
  8. package/dist/components/new-trace-viewer/components/copy-button.js +2 -2
  9. package/dist/components/new-trace-viewer/components/event-list.js +2 -2
  10. package/dist/components/new-trace-viewer/components/middle-truncate/middle-truncate.js +2 -2
  11. package/dist/components/new-trace-viewer/components/span-markers.js +3 -3
  12. package/dist/components/new-trace-viewer/components/split-pane.js +2 -2
  13. package/dist/components/new-trace-viewer/components/timeline.js +2 -2
  14. package/dist/components/new-trace-viewer/trace-viewer.js +4 -4
  15. package/dist/components/run-trace-view.d.ts +5 -7
  16. package/dist/components/run-trace-view.d.ts.map +1 -1
  17. package/dist/components/run-trace-view.js +3 -3
  18. package/dist/components/sidebar/attribute-panel.d.ts.map +1 -1
  19. package/dist/components/sidebar/attribute-panel.js +3 -5
  20. package/dist/components/sidebar/detail-card.js +2 -2
  21. package/dist/components/sidebar/entity-detail-panel.d.ts +6 -10
  22. package/dist/components/sidebar/entity-detail-panel.d.ts.map +1 -1
  23. package/dist/components/sidebar/entity-detail-panel.js +8 -60
  24. package/dist/components/sidebar/events-list.d.ts +3 -1
  25. package/dist/components/sidebar/events-list.d.ts.map +1 -1
  26. package/dist/components/sidebar/events-list.js +31 -9
  27. package/dist/components/sidebar/sidebar-data-context.d.ts +5 -6
  28. package/dist/components/sidebar/sidebar-data-context.d.ts.map +1 -1
  29. package/dist/components/sidebar/sidebar-data-context.js +1 -1
  30. package/dist/components/sidebar/span-detail-merge.d.ts +15 -0
  31. package/dist/components/sidebar/span-detail-merge.d.ts.map +1 -1
  32. package/dist/components/sidebar/span-detail-merge.js +40 -1
  33. package/dist/components/sidebar/use-selected-span-detail.d.ts +16 -0
  34. package/dist/components/sidebar/use-selected-span-detail.d.ts.map +1 -0
  35. package/dist/components/sidebar/use-selected-span-detail.js +93 -0
  36. package/dist/components/ui/alert.js +2 -2
  37. package/dist/components/ui/button.js +2 -2
  38. package/dist/components/ui/card.js +2 -2
  39. package/dist/components/ui/context-card.js +2 -2
  40. package/dist/components/ui/error-card.js +2 -2
  41. package/dist/components/ui/icon-button.js +2 -2
  42. package/dist/components/ui/kbd.js +2 -2
  43. package/dist/components/ui/skeleton.js +2 -2
  44. package/dist/components/ui/tooltip.js +2 -2
  45. package/dist/components/workflow-trace-view.d.ts +6 -7
  46. package/dist/components/workflow-trace-view.d.ts.map +1 -1
  47. package/dist/components/workflow-trace-view.js +3 -6
  48. package/dist/components/workflow-traces/trace-span-construction.d.ts +4 -1
  49. package/dist/components/workflow-traces/trace-span-construction.d.ts.map +1 -1
  50. package/dist/components/workflow-traces/trace-span-construction.js +69 -29
  51. package/dist/lib/cn.d.ts +10 -0
  52. package/dist/lib/cn.d.ts.map +1 -0
  53. package/dist/lib/cn.js +83 -0
  54. package/dist/lib/event-materialization.d.ts.map +1 -1
  55. package/dist/lib/event-materialization.js +22 -15
  56. package/dist/lib/trace-builder.d.ts.map +1 -1
  57. package/dist/lib/trace-builder.js +3 -3
  58. package/dist/lib/utils.d.ts +0 -2
  59. package/dist/lib/utils.d.ts.map +1 -1
  60. package/dist/lib/utils.js +1 -6
  61. package/package.json +3 -3
  62. package/src/components/event-list-view.tsx +76 -13
  63. package/src/components/index.ts +10 -6
  64. package/src/components/new-trace-viewer/components/copy-button.tsx +1 -1
  65. package/src/components/new-trace-viewer/components/event-list.tsx +1 -1
  66. package/src/components/new-trace-viewer/components/middle-truncate/middle-truncate.tsx +1 -1
  67. package/src/components/new-trace-viewer/components/span-markers.tsx +2 -2
  68. package/src/components/new-trace-viewer/components/split-pane.tsx +1 -1
  69. package/src/components/new-trace-viewer/components/timeline.tsx +1 -1
  70. package/src/components/new-trace-viewer/trace-viewer.tsx +6 -6
  71. package/src/components/run-trace-view.tsx +10 -14
  72. package/src/components/sidebar/attribute-panel.tsx +2 -4
  73. package/src/components/sidebar/detail-card.tsx +1 -1
  74. package/src/components/sidebar/entity-detail-panel.tsx +23 -94
  75. package/src/components/sidebar/events-list.tsx +58 -8
  76. package/src/components/sidebar/sidebar-data-context.tsx +6 -7
  77. package/src/components/sidebar/span-detail-merge.ts +69 -0
  78. package/src/components/sidebar/use-selected-span-detail.ts +146 -0
  79. package/src/components/ui/alert.tsx +1 -1
  80. package/src/components/ui/button.tsx +1 -1
  81. package/src/components/ui/card.tsx +1 -1
  82. package/src/components/ui/context-card.tsx +1 -1
  83. package/src/components/ui/error-card.tsx +1 -1
  84. package/src/components/ui/icon-button.tsx +1 -1
  85. package/src/components/ui/kbd.tsx +1 -1
  86. package/src/components/ui/skeleton.tsx +1 -1
  87. package/src/components/ui/tooltip.tsx +1 -1
  88. package/src/components/workflow-trace-view.tsx +11 -21
  89. package/src/components/workflow-traces/trace-span-construction.test.ts +85 -0
  90. package/src/components/workflow-traces/trace-span-construction.ts +93 -29
  91. package/src/lib/cn.ts +93 -0
  92. package/src/lib/event-materialization.ts +22 -14
  93. package/src/lib/trace-builder.ts +2 -1
  94. package/src/lib/utils.ts +0 -6
@@ -768,10 +768,8 @@ export const AttributePanel = ({
768
768
 
769
769
  if (resource === 'sleep') return present;
770
770
 
771
- // During loading, ensure sections appear so their skeletons render
772
- // in the correct position (above the events section).
773
- const loadingDefaults = ['input', 'output'];
774
- for (const key of loadingDefaults) {
771
+ const loadingPlaceholders = ['input', 'output'];
772
+ for (const key of loadingPlaceholders) {
775
773
  if (!present.includes(key)) {
776
774
  present.push(key);
777
775
  }
@@ -1,6 +1,6 @@
1
1
  import { ChevronDown, ChevronRight } from 'lucide-react';
2
2
  import { type ReactNode, useState } from 'react';
3
- import { cn } from '../../lib/utils';
3
+ import { cn } from '../../lib/cn';
4
4
 
5
5
  export function DetailCard({
6
6
  summary,
@@ -1,9 +1,9 @@
1
1
  'use client';
2
2
 
3
- import type { Event, Hook, Step, WorkflowRun } from '@workflow/world';
3
+ import type { Event, Hook, WorkflowRun } from '@workflow/world';
4
4
  import clsx from 'clsx';
5
5
  import { Send, Zap } from 'lucide-react';
6
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
6
+ import { useCallback, useEffect, useMemo, useState } from 'react';
7
7
  import { useToast } from '../../lib/toast';
8
8
  import { DecryptClickContext } from '../ui/data-inspector';
9
9
  import { AttributePanel } from './attribute-panel';
@@ -11,19 +11,11 @@ import { EventsList } from './events-list';
11
11
  import { ResolveHookModal } from './resolve-hook-modal';
12
12
  import { useSidebarDataOptional } from './sidebar-data-context';
13
13
  import {
14
- mergeSpanDetail,
15
- spanDetailMatchesSelection,
16
- } from './span-detail-merge';
17
-
18
- // Type guards for runtime validation of span attribute data
19
- function isStep(data: unknown): data is Step {
20
- return data !== null && typeof data === 'object' && 'stepId' in data;
21
- }
22
-
23
- function isWorkflowRun(data: unknown): data is WorkflowRun {
24
- return data !== null && typeof data === 'object' && 'runId' in data;
25
- }
14
+ type FetchSpanDetail,
15
+ useSelectedSpanDetail,
16
+ } from './use-selected-span-detail';
26
17
 
18
+ // Type guard for runtime validation of span attribute data
27
19
  function isHook(data: unknown): data is Hook {
28
20
  return data !== null && typeof data === 'object' && 'hookId' in data;
29
21
  }
@@ -61,10 +53,7 @@ export function EntityDetailPanel({
61
53
  run,
62
54
  onStreamClick,
63
55
  onRunClick,
64
- spanDetailData,
65
- spanDetailError,
66
- spanDetailLoading,
67
- onSpanSelect,
56
+ fetchSpanDetail,
68
57
  onWakeUpSleep,
69
58
  onLoadEventData,
70
59
  onResolveHook,
@@ -72,20 +61,14 @@ export function EntityDetailPanel({
72
61
  onDecrypt,
73
62
  isDecrypting = false,
74
63
  selectedSpan,
64
+ showSeparateEventOccurrenceTimestamps = false,
75
65
  }: {
76
66
  run: WorkflowRun;
77
67
  /** Callback when a stream reference is clicked */
78
68
  onStreamClick?: (streamId: string) => void;
79
69
  /** Callback when a run reference is clicked */
80
70
  onRunClick?: (runId: string) => void;
81
- /** Pre-fetched span detail data for the selected span. */
82
- spanDetailData: WorkflowRun | Step | Hook | Event | null;
83
- /** Error from external span detail fetch. */
84
- spanDetailError?: Error | null;
85
- /** Loading state from external span detail fetch. */
86
- spanDetailLoading?: boolean;
87
- /** Callback when a span is selected. Use this to fetch data externally and pass via spanDetailData. */
88
- onSpanSelect: (info: SpanSelectionInfo) => void;
71
+ fetchSpanDetail: FetchSpanDetail;
89
72
  /** Callback to wake up a pending sleep call. */
90
73
  onWakeUpSleep?: (
91
74
  runId: string,
@@ -110,6 +93,8 @@ export function EntityDetailPanel({
110
93
  isDecrypting?: boolean;
111
94
  /** Info about the currently selected span from the trace viewer */
112
95
  selectedSpan: SelectedSpanInfo | null;
96
+ /** Show occurredAt separately instead of folding it into the Created timestamp. */
97
+ showSeparateEventOccurrenceTimestamps?: boolean;
113
98
  }): React.JSX.Element | null {
114
99
  const toast = useToast();
115
100
  const [stoppingSleep, setStoppingSleep] = useState(false);
@@ -128,53 +113,15 @@ export function EntityDetailPanel({
128
113
  const rawEvents = selectedSpan?.rawEvents;
129
114
  const rawEventsLength = rawEvents?.length ?? 0;
130
115
 
131
- // Determine resource type, ID, and runId from the selected span
132
- const { resource, resourceId, runId } = useMemo(() => {
133
- if (!selectedSpan) {
134
- return { resource: undefined, resourceId: undefined, runId: undefined };
135
- }
136
-
137
- const res = selectedSpan.resource;
138
- if (res === 'step' && isStep(data)) {
139
- return { resource: 'step', resourceId: data.stepId, runId: data.runId };
140
- }
141
- if (res === 'run' && isWorkflowRun(data)) {
142
- return { resource: 'run', resourceId: data.runId, runId: undefined };
143
- }
144
- if (res === 'hook' && isHook(data)) {
145
- return { resource: 'hook', resourceId: data.hookId, runId: undefined };
146
- }
147
- if (res === 'sleep') {
148
- const waitData = data as { runId?: string } | undefined;
149
- return {
150
- resource: 'sleep',
151
- resourceId: selectedSpan.spanId,
152
- runId: waitData?.runId,
153
- };
154
- }
155
- return { resource: undefined, resourceId: undefined, runId: undefined };
156
- }, [selectedSpan, data]);
157
-
158
- // Notify parent when span selection changes.
159
- // Use a ref for the callback so the effect only fires when the actual
160
- // selection values change, not when the callback identity changes due to
161
- // parent re-renders from polling.
162
- const onSpanSelectRef = useRef(onSpanSelect);
163
- onSpanSelectRef.current = onSpanSelect;
164
-
165
- useEffect(() => {
166
- if (
167
- resource &&
168
- resourceId &&
169
- ['run', 'step', 'hook', 'sleep'].includes(resource)
170
- ) {
171
- onSpanSelectRef.current({
172
- resource: resource as 'run' | 'step' | 'hook' | 'sleep',
173
- resourceId,
174
- runId,
175
- });
176
- }
177
- }, [resource, resourceId, runId]);
116
+ const {
117
+ status,
118
+ resource,
119
+ resourceId,
120
+ displayData,
121
+ detail: matchedSpanDetailData,
122
+ error,
123
+ } = useSelectedSpanDetail(selectedSpan, fetchSpanDetail);
124
+ const loading = status === 'loading';
178
125
 
179
126
  // Check if this sleep is still pending and can be woken up
180
127
  const canWakeUp = useMemo(() => {
@@ -216,17 +163,6 @@ export function EntityDetailPanel({
216
163
  resolvedHookIds,
217
164
  ]);
218
165
 
219
- const error = spanDetailError ?? undefined;
220
- const loading = spanDetailLoading ?? false;
221
-
222
- const matchedSpanDetailData = useMemo(
223
- () =>
224
- spanDetailMatchesSelection(spanDetailData, resource, resourceId)
225
- ? spanDetailData
226
- : null,
227
- [spanDetailData, resource, resourceId]
228
- );
229
-
230
166
  // Get the hook token for resolving (prefer fetched data, then hooks array fallback)
231
167
  const hookToken = useMemo(() => {
232
168
  if (resource !== 'hook' || !resourceId) return undefined;
@@ -325,16 +261,6 @@ export function EntityDetailPanel({
325
261
  [onResolveHook, hookToken, resolvingHook, matchedSpanDetailData, data]
326
262
  );
327
263
 
328
- const displayData = useMemo(
329
- () =>
330
- mergeSpanDetail(data, matchedSpanDetailData) as
331
- | WorkflowRun
332
- | Step
333
- | Hook
334
- | Event,
335
- [data, matchedSpanDetailData]
336
- );
337
-
338
264
  const moduleSpecifier = useMemo(() => {
339
265
  const displayRecord = displayData as Record<string, unknown>;
340
266
  const displayStepName = displayRecord.stepName;
@@ -451,6 +377,9 @@ export function EntityDetailPanel({
451
377
  onStreamClick={onStreamClick}
452
378
  onRunClick={onRunClick}
453
379
  encryptionKey={encryptionKey}
380
+ showSeparateEventOccurrenceTimestamps={
381
+ showSeparateEventOccurrenceTimestamps
382
+ }
454
383
  />
455
384
  )}
456
385
  </div>
@@ -7,6 +7,7 @@ import { RunClickContext, StreamClickContext } from '../ui/data-inspector';
7
7
  import { ErrorCard } from '../ui/error-card';
8
8
  import { ErrorStackBlock, isStructuredError } from '../ui/error-stack-block';
9
9
  import { Skeleton } from '../ui/skeleton';
10
+ import { TimestampTooltip } from '../ui/timestamp-tooltip';
10
11
  import { AttrSetEventBlock } from './attributes-block';
11
12
  import { CopyableDataBlock, EncryptedDataBlock } from './copyable-data-block';
12
13
  import { DetailCard } from './detail-card';
@@ -34,6 +35,28 @@ const DATA_EVENT_TYPES = new Set([
34
35
  'attr_set',
35
36
  ]);
36
37
 
38
+ const parseDateValue = (value: unknown): Date | null => {
39
+ if (value == null) return null;
40
+
41
+ const date = value instanceof Date ? value : new Date(String(value));
42
+ return Number.isNaN(date.getTime()) ? null : date;
43
+ };
44
+
45
+ const getEffectiveEventDate = (
46
+ event: Pick<Event, 'createdAt' | 'occurredAt'>
47
+ ): Date => parseDateValue(event.occurredAt) ?? new Date(event.createdAt);
48
+
49
+ const formatEventTimestamp = (date: Date): string =>
50
+ date.toLocaleString(undefined, {
51
+ month: 'short',
52
+ day: 'numeric',
53
+ hour: 'numeric',
54
+ minute: 'numeric',
55
+ second: 'numeric',
56
+ fractionalSecondDigits: 3,
57
+ timeZoneName: 'short',
58
+ });
59
+
37
60
  /**
38
61
  * A single event row that can lazy-load its eventData when expanded.
39
62
  */
@@ -41,6 +64,7 @@ function EventItem({
41
64
  event,
42
65
  onLoadEventData,
43
66
  encryptionKey,
67
+ showSeparateEventOccurrenceTimestamps = false,
44
68
  }: {
45
69
  event: Event;
46
70
  onLoadEventData?: (
@@ -49,6 +73,8 @@ function EventItem({
49
73
  ) => Promise<unknown | null>;
50
74
  /** When this changes (e.g., Decrypt was clicked), invalidate cached data */
51
75
  encryptionKey?: Uint8Array;
76
+ /** Show occurredAt separately instead of folding it into the Created timestamp. */
77
+ showSeparateEventOccurrenceTimestamps?: boolean;
52
78
  }) {
53
79
  const [loadedData, setLoadedData] = useState<unknown | null>(null);
54
80
  const [isLoading, setIsLoading] = useState(false);
@@ -101,11 +127,18 @@ function EventItem({
101
127
  }, [encryptionKey, loadEventData]);
102
128
 
103
129
  const createdAt = new Date(event.createdAt);
104
- const createdAtTime = createdAt.toLocaleTimeString(undefined, {
105
- hour: 'numeric',
106
- minute: 'numeric',
107
- second: 'numeric',
108
- });
130
+ const occurredAt = parseDateValue(event.occurredAt);
131
+ const displayedCreatedAt = showSeparateEventOccurrenceTimestamps
132
+ ? createdAt
133
+ : getEffectiveEventDate(event);
134
+ const displayedCreatedAtTime = displayedCreatedAt.toLocaleTimeString(
135
+ undefined,
136
+ {
137
+ hour: 'numeric',
138
+ minute: 'numeric',
139
+ second: 'numeric',
140
+ }
141
+ );
109
142
 
110
143
  const displayPayload = isLoading ? loadedData : mergedDisplay;
111
144
 
@@ -119,7 +152,7 @@ function EventItem({
119
152
  {event.eventType}
120
153
  </span>
121
154
  <span className="shrink-0 text-label-13 text-gray-900">
122
- {createdAtTime}
155
+ {displayedCreatedAtTime}
123
156
  </span>
124
157
  </div>
125
158
  }
@@ -133,6 +166,16 @@ function EventItem({
133
166
  >
134
167
  {/* Event attributes */}
135
168
  <div className="flex flex-col bg-background-200 [&:has(+_*)]:border-b [&:has(+_*)]:border-gray-alpha-400">
169
+ {showSeparateEventOccurrenceTimestamps && occurredAt && (
170
+ <div className="flex items-center justify-between gap-2 py-2 px-3">
171
+ <span className="text-label-12 text-gray-900">Occurred</span>
172
+ <TimestampTooltip date={occurredAt}>
173
+ <span className="max-w-[70%] truncate text-right text-label-12 font-mono">
174
+ {formatEventTimestamp(occurredAt)}
175
+ </span>
176
+ </TimestampTooltip>
177
+ </div>
178
+ )}
136
179
  <div className="flex items-center justify-between gap-2 py-2 px-3">
137
180
  <span className="text-label-12 text-gray-900">Event ID</span>
138
181
  <span className="max-w-[70%] truncate text-right text-label-12 font-mono">
@@ -268,6 +311,7 @@ export function EventsList({
268
311
  onStreamClick,
269
312
  onRunClick,
270
313
  encryptionKey,
314
+ showSeparateEventOccurrenceTimestamps = false,
271
315
  }: {
272
316
  events: Event[];
273
317
  isLoading?: boolean;
@@ -280,13 +324,16 @@ export function EventsList({
280
324
  onRunClick?: (runId: string) => void;
281
325
  /** When provided, signals that decryption is active (triggers re-load of expanded events) */
282
326
  encryptionKey?: Uint8Array;
327
+ /** Show occurredAt separately instead of folding it into the Created timestamp. */
328
+ showSeparateEventOccurrenceTimestamps?: boolean;
283
329
  }) {
284
- // Sort by createdAt
330
+ // Sort by the timestamp shown as Created by default.
285
331
  const sortedEvents = useMemo(
286
332
  () =>
287
333
  [...events].sort(
288
334
  (a, b) =>
289
- new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
335
+ getEffectiveEventDate(a).getTime() -
336
+ getEffectiveEventDate(b).getTime()
290
337
  ),
291
338
  [events]
292
339
  );
@@ -321,6 +368,9 @@ export function EventsList({
321
368
  event={event}
322
369
  onLoadEventData={onLoadEventData}
323
370
  encryptionKey={encryptionKey}
371
+ showSeparateEventOccurrenceTimestamps={
372
+ showSeparateEventOccurrenceTimestamps
373
+ }
324
374
  />
325
375
  ))}
326
376
  </div>
@@ -1,16 +1,13 @@
1
1
  'use client';
2
2
 
3
- import { createContext, useContext, type ReactNode } from 'react';
4
- import type { Event, Hook, Step, WorkflowRun } from '@workflow/world';
5
- import type { SpanSelectionInfo } from './entity-detail-panel';
3
+ import type { Event, Hook, WorkflowRun } from '@workflow/world';
4
+ import { createContext, type ReactNode, useContext } from 'react';
5
+ import type { FetchSpanDetail } from './use-selected-span-detail';
6
6
 
7
7
  export interface SidebarDataContextValue {
8
8
  run: WorkflowRun;
9
9
  events: Event[];
10
- spanDetailData: WorkflowRun | Step | Hook | Event | null;
11
- spanDetailError?: Error | null;
12
- spanDetailLoading?: boolean;
13
- onSpanSelect: (info: SpanSelectionInfo) => void;
10
+ fetchSpanDetail: FetchSpanDetail;
14
11
  onStreamClick?: (streamId: string) => void;
15
12
  onRunClick?: (runId: string) => void;
16
13
  onWakeUpSleep?: (
@@ -30,6 +27,8 @@ export interface SidebarDataContextValue {
30
27
  onDecrypt?: () => void;
31
28
  isDecrypting?: boolean;
32
29
  hasEncryptedData?: boolean;
30
+ /** Show occurredAt separately instead of folding it into the Created timestamp. */
31
+ showSeparateEventOccurrenceTimestamps?: boolean;
33
32
  }
34
33
 
35
34
  const SidebarDataContext = createContext<SidebarDataContextValue | null>(null);
@@ -3,6 +3,75 @@ const hasField = (
3
3
  key: string
4
4
  ): value is Record<string, unknown> => key in value;
5
5
 
6
+ const RESOURCES_WITH_FETCHED_DETAIL = new Set(['run', 'step', 'sleep']);
7
+
8
+ export function resourceNeedsFetchedDetail(
9
+ resource: string | undefined
10
+ ): boolean {
11
+ return resource !== undefined && RESOURCES_WITH_FETCHED_DETAIL.has(resource);
12
+ }
13
+
14
+ export type SpanDetailStatus = 'idle' | 'loading' | 'ready' | 'error';
15
+
16
+ export interface SpanDetailView {
17
+ status: SpanDetailStatus;
18
+ displayData: Record<string, unknown>;
19
+ detail: unknown;
20
+ error: Error | undefined;
21
+ }
22
+
23
+ export function deriveSpanDetailView(args: {
24
+ resource: string | undefined;
25
+ resourceId: string | undefined;
26
+ inlineData: unknown;
27
+ fetchedDetail: unknown;
28
+ fetchedError: Error | null;
29
+ }): SpanDetailView {
30
+ const { resource, resourceId, inlineData, fetchedDetail, fetchedError } =
31
+ args;
32
+
33
+ const matchedDetail = spanDetailMatchesSelection(
34
+ fetchedDetail,
35
+ resource,
36
+ resourceId
37
+ )
38
+ ? fetchedDetail
39
+ : null;
40
+ const displayData = mergeSpanDetail(inlineData, matchedDetail) as Record<
41
+ string,
42
+ unknown
43
+ >;
44
+
45
+ if (!resource || !resourceId) {
46
+ return { status: 'idle', displayData, detail: null, error: undefined };
47
+ }
48
+ if (fetchedError) {
49
+ return {
50
+ status: 'error',
51
+ displayData,
52
+ detail: matchedDetail,
53
+ error: fetchedError,
54
+ };
55
+ }
56
+ if (!resourceNeedsFetchedDetail(resource)) {
57
+ return {
58
+ status: 'ready',
59
+ displayData,
60
+ detail: matchedDetail,
61
+ error: undefined,
62
+ };
63
+ }
64
+ if (matchedDetail !== null) {
65
+ return {
66
+ status: 'ready',
67
+ displayData,
68
+ detail: matchedDetail,
69
+ error: undefined,
70
+ };
71
+ }
72
+ return { status: 'loading', displayData, detail: null, error: undefined };
73
+ }
74
+
6
75
  /**
7
76
  * Returns true when the fetched `detail` belongs to the current selection.
8
77
  * The fetch lags selection, so it can briefly hold a previously selected span
@@ -0,0 +1,146 @@
1
+ 'use client';
2
+
3
+ import type { Event, Hook, Step, WorkflowRun } from '@workflow/world';
4
+ import { useEffect, useMemo, useRef, useState } from 'react';
5
+ import type {
6
+ SelectedSpanInfo,
7
+ SpanSelectionInfo,
8
+ } from './entity-detail-panel';
9
+ import {
10
+ deriveSpanDetailView,
11
+ resourceNeedsFetchedDetail,
12
+ type SpanDetailStatus,
13
+ } from './span-detail-merge';
14
+
15
+ export type DetailResource = WorkflowRun | Step | Hook | Event;
16
+
17
+ export type FetchSpanDetail = (
18
+ selection: SpanSelectionInfo
19
+ ) => Promise<DetailResource>;
20
+
21
+ function deriveSpanSelection(
22
+ selectedSpan: SelectedSpanInfo | null
23
+ ): SpanSelectionInfo | null {
24
+ if (!selectedSpan) return null;
25
+ const { resource, data } = selectedSpan;
26
+
27
+ if (
28
+ resource === 'step' &&
29
+ data &&
30
+ typeof data === 'object' &&
31
+ 'stepId' in data
32
+ ) {
33
+ const step = data as Step;
34
+ return { resource: 'step', resourceId: step.stepId, runId: step.runId };
35
+ }
36
+ if (
37
+ resource === 'run' &&
38
+ data &&
39
+ typeof data === 'object' &&
40
+ 'runId' in data
41
+ ) {
42
+ return { resource: 'run', resourceId: (data as WorkflowRun).runId };
43
+ }
44
+ if (
45
+ resource === 'hook' &&
46
+ data &&
47
+ typeof data === 'object' &&
48
+ 'hookId' in data
49
+ ) {
50
+ return { resource: 'hook', resourceId: (data as Hook).hookId };
51
+ }
52
+ if (resource === 'sleep') {
53
+ if (!selectedSpan.spanId) return null;
54
+ const waitData = data as { runId?: string } | undefined;
55
+ return {
56
+ resource: 'sleep',
57
+ resourceId: selectedSpan.spanId,
58
+ runId: waitData?.runId,
59
+ };
60
+ }
61
+ return null;
62
+ }
63
+
64
+ export interface SelectedSpanDetailResult {
65
+ status: SpanDetailStatus;
66
+ resource: SpanSelectionInfo['resource'] | undefined;
67
+ resourceId: string | undefined;
68
+ runId: string | undefined;
69
+ displayData: Record<string, unknown>;
70
+ detail: DetailResource | null;
71
+ error: Error | undefined;
72
+ }
73
+
74
+ export function useSelectedSpanDetail(
75
+ selectedSpan: SelectedSpanInfo | null,
76
+ fetchSpanDetail: FetchSpanDetail
77
+ ): SelectedSpanDetailResult {
78
+ const selection = useMemo(
79
+ () => deriveSpanSelection(selectedSpan),
80
+ [selectedSpan]
81
+ );
82
+ const resource = selection?.resource;
83
+ const resourceId = selection?.resourceId;
84
+ const runId = selection?.runId;
85
+ const selectionKey =
86
+ resource && resourceId ? `${resource}:${resourceId}` : null;
87
+ const needsFetch = resourceNeedsFetchedDetail(resource);
88
+
89
+ const [fetched, setFetched] = useState<{
90
+ detail: DetailResource | null;
91
+ error: Error | null;
92
+ errorKey: string | null;
93
+ }>({ detail: null, error: null, errorKey: null });
94
+
95
+ const tokenRef = useRef(0);
96
+ const selectionRef = useRef(selection);
97
+ selectionRef.current = selection;
98
+
99
+ useEffect(() => {
100
+ if (!selectionKey || !needsFetch) {
101
+ return;
102
+ }
103
+ const activeSelection = selectionRef.current;
104
+ if (!activeSelection) {
105
+ return;
106
+ }
107
+ const token = ++tokenRef.current;
108
+ fetchSpanDetail(activeSelection)
109
+ .then((detail) => {
110
+ if (tokenRef.current !== token) return;
111
+ setFetched({ detail, error: null, errorKey: null });
112
+ })
113
+ .catch((err: unknown) => {
114
+ if (tokenRef.current !== token) return;
115
+ setFetched({
116
+ detail: null,
117
+ error: err instanceof Error ? err : new Error(String(err)),
118
+ errorKey: selectionKey,
119
+ });
120
+ });
121
+ }, [selectionKey, runId, needsFetch, fetchSpanDetail]);
122
+
123
+ const scopedError = fetched.errorKey === selectionKey ? fetched.error : null;
124
+
125
+ const view = useMemo(
126
+ () =>
127
+ deriveSpanDetailView({
128
+ resource,
129
+ resourceId,
130
+ inlineData: selectedSpan?.data,
131
+ fetchedDetail: fetched.detail,
132
+ fetchedError: scopedError,
133
+ }),
134
+ [resource, resourceId, selectedSpan?.data, fetched.detail, scopedError]
135
+ );
136
+
137
+ return {
138
+ status: view.status,
139
+ resource,
140
+ resourceId,
141
+ runId,
142
+ displayData: view.displayData,
143
+ detail: view.detail as DetailResource | null,
144
+ error: view.error,
145
+ };
146
+ }
@@ -1,7 +1,7 @@
1
1
  import { cva, type VariantProps } from 'class-variance-authority';
2
2
  import * as React from 'react';
3
3
 
4
- import { cn } from '../../lib/utils';
4
+ import { cn } from '../../lib/cn';
5
5
 
6
6
  const alertVariants = cva(
7
7
  'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4',
@@ -1,6 +1,6 @@
1
1
  import * as React from 'react';
2
2
  import { cva, type VariantProps } from 'class-variance-authority';
3
- import { cn } from '../../lib/utils';
3
+ import { cn } from '../../lib/cn';
4
4
 
5
5
  const buttonVariants = cva(
6
6
  [
@@ -2,7 +2,7 @@
2
2
 
3
3
  import * as React from 'react';
4
4
 
5
- import { cn } from '../../lib/utils';
5
+ import { cn } from '../../lib/cn';
6
6
 
7
7
  const Card = React.forwardRef<
8
8
  HTMLDivElement,
@@ -14,7 +14,7 @@ import {
14
14
  import { createPortal } from 'react-dom';
15
15
  import useMeasure from 'react-use-measure';
16
16
  import { useReducedMotion } from '../../hooks/use-reduced-motion';
17
- import { cn } from '../../lib/utils';
17
+ import { cn } from '../../lib/cn';
18
18
 
19
19
  const INACTIVE_TIMEOUT_MS = 250;
20
20
  const ENTER_DELAY_MS = 0;
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { AlertCircle, ChevronDown } from 'lucide-react';
4
4
  import { useState } from 'react';
5
- import { cn } from '../../lib/utils';
5
+ import { cn } from '../../lib/cn';
6
6
 
7
7
  interface ErrorCardProps {
8
8
  /** Title shown in the header */
@@ -1,6 +1,6 @@
1
1
  import { cva, type VariantProps } from 'class-variance-authority';
2
2
  import * as React from 'react';
3
- import { cn } from '../../lib/utils';
3
+ import { cn } from '../../lib/cn';
4
4
 
5
5
  const iconButtonVariants = cva(
6
6
  [
@@ -1,6 +1,6 @@
1
1
  import { cva, type VariantProps } from 'class-variance-authority';
2
2
  import type { ComponentProps, ReactNode } from 'react';
3
- import { cn } from '../../lib/utils';
3
+ import { cn } from '../../lib/cn';
4
4
 
5
5
  const kbdVariants = cva(
6
6
  'inline-flex items-center justify-center rounded px-1 text-center font-sans',
@@ -1,4 +1,4 @@
1
- import { cn } from '../../lib/utils';
1
+ import { cn } from '../../lib/cn';
2
2
 
3
3
  function Skeleton({
4
4
  className,
@@ -2,7 +2,7 @@
2
2
 
3
3
  import * as TooltipPrimitive from '@radix-ui/react-tooltip';
4
4
  import * as React from 'react';
5
- import { cn } from '../../lib/utils';
5
+ import { cn } from '../../lib/cn';
6
6
 
7
7
  const TooltipProvider = TooltipPrimitive.Provider;
8
8