@workflow/web-shared 5.0.0-beta.24 → 5.0.0-beta.26

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 (95) 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 -4
  49. package/dist/components/workflow-traces/trace-span-construction.d.ts.map +1 -1
  50. package/dist/components/workflow-traces/trace-span-construction.js +74 -53
  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 +0 -3
  55. package/dist/lib/event-materialization.d.ts.map +1 -1
  56. package/dist/lib/event-materialization.js +28 -27
  57. package/dist/lib/trace-builder.d.ts.map +1 -1
  58. package/dist/lib/trace-builder.js +3 -3
  59. package/dist/lib/utils.d.ts +0 -2
  60. package/dist/lib/utils.d.ts.map +1 -1
  61. package/dist/lib/utils.js +1 -6
  62. package/package.json +3 -3
  63. package/src/components/event-list-view.tsx +76 -13
  64. package/src/components/index.ts +10 -6
  65. package/src/components/new-trace-viewer/components/copy-button.tsx +1 -1
  66. package/src/components/new-trace-viewer/components/event-list.tsx +1 -1
  67. package/src/components/new-trace-viewer/components/middle-truncate/middle-truncate.tsx +1 -1
  68. package/src/components/new-trace-viewer/components/span-markers.tsx +2 -2
  69. package/src/components/new-trace-viewer/components/split-pane.tsx +1 -1
  70. package/src/components/new-trace-viewer/components/timeline.tsx +1 -1
  71. package/src/components/new-trace-viewer/trace-viewer.tsx +6 -6
  72. package/src/components/run-trace-view.tsx +10 -14
  73. package/src/components/sidebar/attribute-panel.tsx +2 -4
  74. package/src/components/sidebar/detail-card.tsx +1 -1
  75. package/src/components/sidebar/entity-detail-panel.tsx +23 -94
  76. package/src/components/sidebar/events-list.tsx +58 -8
  77. package/src/components/sidebar/sidebar-data-context.tsx +6 -7
  78. package/src/components/sidebar/span-detail-merge.ts +69 -0
  79. package/src/components/sidebar/use-selected-span-detail.ts +146 -0
  80. package/src/components/ui/alert.tsx +1 -1
  81. package/src/components/ui/button.tsx +1 -1
  82. package/src/components/ui/card.tsx +1 -1
  83. package/src/components/ui/context-card.tsx +1 -1
  84. package/src/components/ui/error-card.tsx +1 -1
  85. package/src/components/ui/icon-button.tsx +1 -1
  86. package/src/components/ui/kbd.tsx +1 -1
  87. package/src/components/ui/skeleton.tsx +1 -1
  88. package/src/components/ui/tooltip.tsx +1 -1
  89. package/src/components/workflow-trace-view.tsx +11 -21
  90. package/src/components/workflow-traces/trace-span-construction.test.ts +85 -0
  91. package/src/components/workflow-traces/trace-span-construction.ts +113 -89
  92. package/src/lib/cn.ts +93 -0
  93. package/src/lib/event-materialization.ts +56 -76
  94. package/src/lib/trace-builder.ts +2 -1
  95. package/src/lib/utils.ts +0 -6
@@ -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
 
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { parseStepName, parseWorkflowName } from '@workflow/utils/parse-name';
4
- import type { Event, Hook, Step, WorkflowRun } from '@workflow/world';
4
+ import type { Event, Hook, WorkflowRun } from '@workflow/world';
5
5
  import {
6
6
  ChevronDown,
7
7
  ChevronUp,
@@ -29,6 +29,7 @@ import {
29
29
  type SpanSelectionInfo,
30
30
  } from './sidebar/entity-detail-panel';
31
31
  import { ResolveHookModal } from './sidebar/resolve-hook-modal';
32
+ import type { FetchSpanDetail } from './sidebar/use-selected-span-detail';
32
33
  import {
33
34
  TraceViewerContextProvider,
34
35
  TraceViewerTimeline,
@@ -764,15 +765,12 @@ export const WorkflowTraceViewer = ({
764
765
  events,
765
766
  isLoading,
766
767
  error,
767
- spanDetailData,
768
- spanDetailLoading,
769
- spanDetailError,
768
+ fetchSpanDetail,
770
769
  onWakeUpSleep,
771
770
  onResolveHook,
772
771
  onCancelRun,
773
772
  onStreamClick,
774
773
  onRunClick,
775
- onSpanSelect,
776
774
  onLoadEventData,
777
775
  onLoadMoreSpans,
778
776
  hasMoreSpans = false,
@@ -780,14 +778,13 @@ export const WorkflowTraceViewer = ({
780
778
  encryptionKey,
781
779
  onDecrypt,
782
780
  isDecrypting = false,
781
+ showSeparateEventOccurrenceTimestamps = false,
783
782
  }: {
784
783
  run: WorkflowRun;
785
784
  events: Event[];
786
785
  isLoading?: boolean;
787
786
  error?: Error | null;
788
- spanDetailData?: WorkflowRun | Step | Hook | Event | null;
789
- spanDetailLoading?: boolean;
790
- spanDetailError?: Error | null;
787
+ fetchSpanDetail: FetchSpanDetail;
791
788
  onWakeUpSleep?: (
792
789
  runId: string,
793
790
  correlationId: string
@@ -803,8 +800,6 @@ export const WorkflowTraceViewer = ({
803
800
  onStreamClick?: (streamId: string) => void;
804
801
  /** Callback when a run reference is clicked in the detail panel */
805
802
  onRunClick?: (runId: string) => void;
806
- /** Callback when a span is selected. */
807
- onSpanSelect?: (info: SpanSelectionInfo) => void;
808
803
  /** Callback to load event data for a specific event (lazy loading in sidebar) */
809
804
  onLoadEventData?: (
810
805
  correlationId: string,
@@ -822,6 +817,8 @@ export const WorkflowTraceViewer = ({
822
817
  onDecrypt?: () => void;
823
818
  /** Whether the encryption key is currently being fetched */
824
819
  isDecrypting?: boolean;
820
+ /** Show occurredAt separately instead of folding it into the Created timestamp. */
821
+ showSeparateEventOccurrenceTimestamps?: boolean;
825
822
  }) => {
826
823
  const toast = useToast();
827
824
  const [selectedSpan, setSelectedSpan] = useState<SelectedSpanInfo | null>(
@@ -856,13 +853,6 @@ export const WorkflowTraceViewer = ({
856
853
  }
857
854
  }, [error, isLoading]);
858
855
 
859
- const handleSpanSelect = useCallback(
860
- (info: SpanSelectionInfo) => {
861
- onSpanSelect?.(info);
862
- },
863
- [onSpanSelect]
864
- );
865
-
866
856
  const handleSelectionChange = useCallback(
867
857
  (info: SelectedSpanInfo | null) => {
868
858
  if (info) {
@@ -1158,10 +1148,7 @@ export const WorkflowTraceViewer = ({
1158
1148
  run={run}
1159
1149
  onStreamClick={onStreamClick}
1160
1150
  onRunClick={onRunClick}
1161
- spanDetailData={spanDetailData ?? null}
1162
- spanDetailError={spanDetailError}
1163
- spanDetailLoading={spanDetailLoading}
1164
- onSpanSelect={handleSpanSelect}
1151
+ fetchSpanDetail={fetchSpanDetail}
1165
1152
  onWakeUpSleep={onWakeUpSleep}
1166
1153
  onLoadEventData={onLoadEventData}
1167
1154
  onResolveHook={onResolveHook}
@@ -1169,6 +1156,9 @@ export const WorkflowTraceViewer = ({
1169
1156
  onDecrypt={onDecrypt}
1170
1157
  isDecrypting={isDecrypting}
1171
1158
  selectedSpan={selectedSpan}
1159
+ showSeparateEventOccurrenceTimestamps={
1160
+ showSeparateEventOccurrenceTimestamps
1161
+ }
1172
1162
  />
1173
1163
  </ErrorBoundary>
1174
1164
  </div>
@@ -0,0 +1,85 @@
1
+ import type { Event, WorkflowRun } from '@workflow/world';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { runToSpan } from './trace-span-construction';
4
+ import { otelTimeToMs } from './trace-time-utils';
5
+
6
+ const date = (ms: number) => new Date(ms);
7
+
8
+ function run(overrides: Partial<WorkflowRun> = {}): WorkflowRun {
9
+ return {
10
+ runId: 'run_1',
11
+ status: 'completed',
12
+ deploymentId: 'dpl_1',
13
+ workflowName: 'workflow//./workflow//testWorkflow',
14
+ specVersion: 2,
15
+ attributes: {},
16
+ createdAt: date(1_000),
17
+ updatedAt: date(6_000),
18
+ startedAt: date(2_000),
19
+ completedAt: date(6_000),
20
+ output: null,
21
+ ...overrides,
22
+ } as WorkflowRun;
23
+ }
24
+
25
+ function event(overrides: Partial<Event>): Event {
26
+ return {
27
+ runId: 'run_1',
28
+ eventId: `evt_${overrides.eventType ?? 'unknown'}`,
29
+ eventType: 'run_created',
30
+ createdAt: date(1_000),
31
+ specVersion: 2,
32
+ eventData: {
33
+ deploymentId: 'dpl_1',
34
+ workflowName: 'workflow//./workflow//testWorkflow',
35
+ input: null,
36
+ },
37
+ ...overrides,
38
+ } as Event;
39
+ }
40
+
41
+ describe('runToSpan', () => {
42
+ it('prefers occurredAt for run span timing when available', () => {
43
+ const span = runToSpan(
44
+ run(),
45
+ [
46
+ event({
47
+ eventType: 'run_created',
48
+ createdAt: date(1_000),
49
+ occurredAt: date(100),
50
+ }),
51
+ event({
52
+ eventType: 'run_started',
53
+ createdAt: date(2_000),
54
+ occurredAt: date(200),
55
+ }),
56
+ event({
57
+ eventType: 'run_completed',
58
+ createdAt: date(6_000),
59
+ occurredAt: date(500),
60
+ }),
61
+ ],
62
+ date(10_000)
63
+ );
64
+
65
+ expect(otelTimeToMs(span.startTime)).toBe(100);
66
+ expect(
67
+ span.activeStartTime ? otelTimeToMs(span.activeStartTime) : undefined
68
+ ).toBe(200);
69
+ expect(otelTimeToMs(span.endTime)).toBe(500);
70
+ expect(
71
+ span.events.find((e) => e.name === 'run_created')?.timestamp
72
+ ).toEqual([0, 100_000_000]);
73
+
74
+ const data = span.attributes.data as {
75
+ createdAt: Date;
76
+ startedAt?: Date;
77
+ completedAt?: Date;
78
+ occurredAt?: Date;
79
+ };
80
+ expect(data.createdAt).toEqual(date(100));
81
+ expect(data.startedAt).toEqual(date(200));
82
+ expect(data.completedAt).toEqual(date(500));
83
+ expect(data).not.toHaveProperty('occurredAt');
84
+ });
85
+ });