@workflow/web-shared 4.1.11 → 4.1.13

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 (52) hide show
  1. package/dist/components/event-list-view.d.ts +1 -1
  2. package/dist/components/event-list-view.d.ts.map +1 -1
  3. package/dist/components/event-list-view.js +6 -2
  4. package/dist/components/sidebar/attribute-panel.d.ts.map +1 -1
  5. package/dist/components/sidebar/attribute-panel.js +29 -33
  6. package/dist/components/trace-viewer/components/map.d.ts +2 -2
  7. package/dist/components/trace-viewer/components/map.d.ts.map +1 -1
  8. package/dist/components/trace-viewer/components/node.d.ts +7 -7
  9. package/dist/components/trace-viewer/components/node.d.ts.map +1 -1
  10. package/dist/components/trace-viewer/trace-viewer.module.css +4 -4
  11. package/dist/components/ui/alert.js +2 -2
  12. package/dist/components/ui/card.js +2 -2
  13. package/dist/components/ui/context-card.d.ts +47 -0
  14. package/dist/components/ui/context-card.d.ts.map +1 -0
  15. package/dist/components/ui/context-card.js +471 -0
  16. package/dist/components/ui/error-card.js +2 -2
  17. package/dist/components/ui/skeleton.js +2 -2
  18. package/dist/components/ui/timestamp-tooltip.d.ts +34 -1
  19. package/dist/components/ui/timestamp-tooltip.d.ts.map +1 -1
  20. package/dist/components/ui/timestamp-tooltip.js +80 -132
  21. package/dist/components/workflow-traces/trace-span-construction.d.ts +4 -1
  22. package/dist/components/workflow-traces/trace-span-construction.d.ts.map +1 -1
  23. package/dist/components/workflow-traces/trace-span-construction.js +30 -9
  24. package/dist/hooks/use-reduced-motion.d.ts +10 -0
  25. package/dist/hooks/use-reduced-motion.d.ts.map +1 -0
  26. package/dist/hooks/use-reduced-motion.js +29 -0
  27. package/dist/lib/cn.d.ts +10 -0
  28. package/dist/lib/cn.d.ts.map +1 -0
  29. package/dist/lib/cn.js +83 -0
  30. package/dist/lib/event-materialization.d.ts +3 -0
  31. package/dist/lib/event-materialization.d.ts.map +1 -1
  32. package/dist/lib/event-materialization.js +13 -7
  33. package/dist/lib/utils.d.ts +0 -2
  34. package/dist/lib/utils.d.ts.map +1 -1
  35. package/dist/lib/utils.js +1 -6
  36. package/dist/styles.css +9 -0
  37. package/package.json +8 -6
  38. package/src/components/event-list-view.tsx +10 -1
  39. package/src/components/sidebar/attribute-panel.tsx +122 -121
  40. package/src/components/trace-viewer/trace-viewer.module.css +4 -4
  41. package/src/components/ui/alert.tsx +1 -1
  42. package/src/components/ui/card.tsx +1 -1
  43. package/src/components/ui/context-card.tsx +784 -0
  44. package/src/components/ui/error-card.tsx +1 -1
  45. package/src/components/ui/skeleton.tsx +1 -1
  46. package/src/components/ui/timestamp-tooltip.tsx +132 -194
  47. package/src/components/workflow-traces/trace-span-construction.ts +72 -30
  48. package/src/hooks/use-reduced-motion.ts +34 -0
  49. package/src/lib/cn.ts +93 -0
  50. package/src/lib/event-materialization.ts +68 -40
  51. package/src/lib/utils.ts +0 -6
  52. package/src/styles.css +9 -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,4 +1,4 @@
1
- import { cn } from '../../lib/utils';
1
+ import { cn } from '../../lib/cn';
2
2
 
3
3
  function Skeleton({
4
4
  className,
@@ -1,15 +1,20 @@
1
1
  'use client';
2
2
 
3
3
  import type { ReactNode } from 'react';
4
- import { useEffect, useRef, useState } from 'react';
5
- import { createPortal } from 'react-dom';
4
+ import { useEffect, useState } from 'react';
5
+ import {
6
+ ContextCardProvider,
7
+ ContextCardTrigger,
8
+ type ContextCardTriggerProps,
9
+ useHasContextCardProvider,
10
+ } from './context-card';
6
11
 
7
12
  // ---------------------------------------------------------------------------
8
13
  // Time formatting helpers
9
14
  // ---------------------------------------------------------------------------
10
15
 
11
16
  interface TimeUnit {
12
- unit: string;
17
+ unit: Intl.RelativeTimeFormatUnit;
13
18
  ms: number;
14
19
  }
15
20
 
@@ -38,25 +43,69 @@ function formatTimeDifference(diff: number): string {
38
43
  return result.join(', ');
39
44
  }
40
45
 
41
- function useTimeAgo(date: number): string {
46
+ /**
47
+ * Detailed relative time string that auto-updates every second
48
+ * (e.g. "2 hours, 15 minutes, 30 seconds ago"). Used inside the hover card.
49
+ */
50
+ export function useTimeAgo(date: number): string {
42
51
  const [timeAgo, setTimeAgo] = useState<string>('');
43
52
 
44
53
  useEffect(() => {
45
- const update = (): void => {
54
+ const updateTimeAgo = (): void => {
46
55
  const diff = Date.now() - date;
47
- const formatted = formatTimeDifference(diff);
48
- setTimeAgo(formatted ? `${formatted} ago` : 'Just now');
56
+ const formattedDiff = formatTimeDifference(diff);
57
+ setTimeAgo(formattedDiff ? `${formattedDiff} ago` : 'Just now');
49
58
  };
50
- update();
51
- const timer = setInterval(update, 1000);
59
+
60
+ updateTimeAgo();
61
+ const timer = setInterval(updateTimeAgo, 1000);
52
62
  return () => clearInterval(timer);
53
63
  }, [date]);
54
64
 
55
65
  return timeAgo;
56
66
  }
57
67
 
68
+ /**
69
+ * Short relative time string that auto-updates every minute
70
+ * (e.g. "3 days ago", "5 hours ago"). Returns an empty string if `date` is
71
+ * nullish.
72
+ */
73
+ export function useShortTimeAgo(date: number | null | undefined): string {
74
+ const [shortTimeAgo, setShortTimeAgo] = useState<string>('');
75
+
76
+ useEffect(() => {
77
+ if (!date) {
78
+ setShortTimeAgo('');
79
+ return;
80
+ }
81
+
82
+ const updateShortTimeAgo = (): void => {
83
+ const diff = Date.now() - date;
84
+ const days = Math.floor(diff / (1000 * 60 * 60 * 24));
85
+ const hours = Math.floor(diff / (1000 * 60 * 60));
86
+ const minutes = Math.floor(diff / (1000 * 60));
87
+
88
+ if (days > 0) {
89
+ setShortTimeAgo(`${days} day${days > 1 ? 's' : ''} ago`);
90
+ } else if (hours > 0) {
91
+ setShortTimeAgo(`${hours} hour${hours > 1 ? 's' : ''} ago`);
92
+ } else if (minutes > 0) {
93
+ setShortTimeAgo(`${minutes} minute${minutes > 1 ? 's' : ''} ago`);
94
+ } else {
95
+ setShortTimeAgo('Just now');
96
+ }
97
+ };
98
+
99
+ updateShortTimeAgo();
100
+ const timer = setInterval(updateShortTimeAgo, 60000);
101
+ return () => clearInterval(timer);
102
+ }, [date]);
103
+
104
+ return shortTimeAgo;
105
+ }
106
+
58
107
  // ---------------------------------------------------------------------------
59
- // Timezone row
108
+ // Hover card content
60
109
  // ---------------------------------------------------------------------------
61
110
 
62
111
  function ZoneDateTimeRow({
@@ -91,86 +140,34 @@ function ZoneDateTimeRow({
91
140
  });
92
141
 
93
142
  return (
94
- <div
95
- style={{
96
- display: 'flex',
97
- alignItems: 'center',
98
- justifyContent: 'space-between',
99
- gap: 12,
100
- }}
101
- >
102
- <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
103
- <div
104
- style={{
105
- display: 'inline-flex',
106
- alignItems: 'center',
107
- justifyContent: 'center',
108
- height: 16,
109
- padding: '0 6px',
110
- backgroundColor: 'var(--ds-gray-200)',
111
- borderRadius: 3,
112
- fontSize: 11,
113
- fontFamily: 'var(--font-mono, monospace)',
114
- fontWeight: 500,
115
- color: 'var(--ds-gray-900)',
116
- whiteSpace: 'nowrap',
117
- }}
118
- >
119
- {formattedZone}
143
+ <div className="flex items-center justify-between gap-3">
144
+ <div className="flex items-center gap-1.5">
145
+ <div className="flex items-center justify-center h-4 px-1.5 bg-gray-200 rounded-xs">
146
+ <span className="text-[12px] font-mono text-gray-900">
147
+ {formattedZone}
148
+ </span>
120
149
  </div>
121
- <span
122
- style={{
123
- fontSize: 13,
124
- color: 'var(--ds-gray-1000)',
125
- whiteSpace: 'nowrap',
126
- }}
127
- >
128
- {formattedDate}
129
- </span>
150
+ <span className="text-[13px] text-gray-1000">{formattedDate}</span>
130
151
  </div>
131
- <span
132
- style={{
133
- fontSize: 11,
134
- fontFamily: 'var(--font-mono, monospace)',
135
- fontVariantNumeric: 'tabular-nums',
136
- color: 'var(--ds-gray-900)',
137
- whiteSpace: 'nowrap',
138
- }}
139
- >
152
+ <span className="tabular-nums text-[12px] font-mono text-gray-900">
140
153
  {formattedTime}
141
154
  </span>
142
155
  </div>
143
156
  );
144
157
  }
145
158
 
146
- // ---------------------------------------------------------------------------
147
- // Tooltip card content
148
- // ---------------------------------------------------------------------------
149
-
150
- function TimestampTooltipContent({ date }: { date: number }): ReactNode {
159
+ function RelativeTimeContextCardContent({ date }: { date: number }): ReactNode {
151
160
  const localTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
152
161
  const timeAgo = useTimeAgo(date);
153
162
 
154
163
  return (
155
- <div
156
- style={{
157
- display: 'flex',
158
- flexDirection: 'column',
159
- gap: 12,
160
- minWidth: 300,
161
- padding: '12px 14px',
162
- }}
163
- >
164
- <span
165
- style={{
166
- fontSize: 13,
167
- fontVariantNumeric: 'tabular-nums',
168
- color: 'var(--ds-gray-900)',
169
- }}
170
- >
171
- {timeAgo}
172
- </span>
173
- <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
164
+ <div className="flex flex-col gap-3 min-w-[300px]">
165
+ <div className="flex flex-col gap-3">
166
+ <span className="tabular-nums text-[13px] text-gray-900">
167
+ {timeAgo}
168
+ </span>
169
+ </div>
170
+ <div className="flex flex-col gap-2">
174
171
  <ZoneDateTimeRow date={date} zone="UTC" />
175
172
  <ZoneDateTimeRow date={date} zone={localTimezone} />
176
173
  </div>
@@ -178,102 +175,75 @@ function TimestampTooltipContent({ date }: { date: number }): ReactNode {
178
175
  );
179
176
  }
180
177
 
178
+ function DefaultTimeText({
179
+ date,
180
+ }: {
181
+ date: number | null | undefined;
182
+ }): ReactNode {
183
+ const shortTimeAgo = useShortTimeAgo(date);
184
+ return <span className="text-label-14 text-gray-900">{shortTimeAgo}</span>;
185
+ }
186
+
181
187
  // ---------------------------------------------------------------------------
182
- // Hover tooltip wrapper
188
+ // RelativeTimeCard
183
189
  // ---------------------------------------------------------------------------
184
190
 
185
- const TOOLTIP_WIDTH = 330;
186
- const VIEWPORT_PAD = 8;
187
-
188
- function TooltipPortal({
189
- triggerRect,
190
- onMouseEnter,
191
- onMouseLeave,
191
+ type RelativeTimeCardProps = Omit<
192
+ ContextCardTriggerProps,
193
+ 'content' | 'children'
194
+ > & {
195
+ /** Timestamp in milliseconds to display as a relative time. */
196
+ date?: number | null;
197
+ /** Custom content to render instead of the default relative time text. */
198
+ children?: ReactNode;
199
+ };
200
+
201
+ /**
202
+ * Relative time label that reveals a context card with detailed UTC and local
203
+ * timestamps on hover. Renders a default short relative time label (e.g.
204
+ * "3 days ago") when `children` is omitted; renders just the children without
205
+ * the hover card when `date` is nullish.
206
+ */
207
+ export function RelativeTimeCard({
192
208
  date,
193
- }: {
194
- triggerRect: DOMRect;
195
- onMouseEnter: () => void;
196
- onMouseLeave: () => void;
197
- date: number;
198
- }): ReactNode {
199
- const tooltipRef = useRef<HTMLDivElement>(null);
200
- const [style, setStyle] = useState<React.CSSProperties>({
201
- position: 'fixed',
202
- zIndex: 9999,
203
- visibility: 'hidden',
204
- });
209
+ children: _children,
210
+ ...props
211
+ }: RelativeTimeCardProps): ReactNode {
212
+ const children =
213
+ _children === undefined ? <DefaultTimeText date={date} /> : _children;
205
214
 
206
- useEffect(() => {
207
- const placement = triggerRect.top > 240 ? 'above' : 'below';
208
- const centerX = triggerRect.left + triggerRect.width / 2;
209
-
210
- const el = tooltipRef.current;
211
- const w = el ? el.offsetWidth : TOOLTIP_WIDTH;
212
- const h = el ? el.offsetHeight : 100;
213
-
214
- let left = centerX - w / 2;
215
- left = Math.max(
216
- VIEWPORT_PAD,
217
- Math.min(left, window.innerWidth - w - VIEWPORT_PAD)
218
- );
219
-
220
- let top: number;
221
- if (placement === 'above') {
222
- top = triggerRect.top - h - 6;
223
- if (top < VIEWPORT_PAD) {
224
- top = triggerRect.bottom + 6;
225
- }
226
- } else {
227
- top = triggerRect.bottom + 6;
228
- if (top + h > window.innerHeight - VIEWPORT_PAD) {
229
- top = triggerRect.top - h - 6;
230
- }
231
- }
215
+ if (!date) return children;
232
216
 
233
- setStyle({
234
- position: 'fixed',
235
- left,
236
- top,
237
- zIndex: 9999,
238
- borderRadius: 10,
239
- border: '1px solid var(--ds-gray-alpha-200)',
240
- backgroundColor: 'var(--ds-background-100)',
241
- boxShadow: '0 4px 12px rgba(0,0,0,0.08), 0 1px 3px rgba(0,0,0,0.06)',
242
- visibility: 'visible',
243
- });
244
- }, [triggerRect]);
245
-
246
- return createPortal(
247
- // biome-ignore lint/a11y/noStaticElementInteractions: tooltip hover zone
248
- <div
249
- ref={tooltipRef}
250
- onMouseEnter={onMouseEnter}
251
- onMouseLeave={onMouseLeave}
252
- style={style}
217
+ return (
218
+ <ContextCardTrigger
219
+ content={<RelativeTimeContextCardContent date={date} />}
220
+ {...props}
253
221
  >
254
- <TimestampTooltipContent date={date} />
255
- </div>,
256
- document.body
222
+ {children}
223
+ </ContextCardTrigger>
257
224
  );
258
225
  }
259
226
 
227
+ // ---------------------------------------------------------------------------
228
+ // TimestampTooltip — convenience wrapper used across the observability UI
229
+ // ---------------------------------------------------------------------------
230
+
231
+ /**
232
+ * Wraps an already-formatted timestamp display with a relative-time hover
233
+ * card. Self-mounts a {@link ContextCardProvider} when one isn't already
234
+ * present so it works anywhere, but shares a provider (enabling the animated
235
+ * card morph between adjacent timestamps) when rendered inside one.
236
+ */
260
237
  export function TimestampTooltip({
261
238
  date,
262
239
  children,
240
+ side = 'top',
263
241
  }: {
264
242
  date: number | Date | string | null | undefined;
265
243
  children: ReactNode;
244
+ side?: ContextCardTriggerProps['side'];
266
245
  }): ReactNode {
267
- const [open, setOpen] = useState(false);
268
- const [triggerRect, setTriggerRect] = useState<DOMRect | null>(null);
269
- const triggerRef = useRef<HTMLSpanElement>(null);
270
- const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
271
-
272
- useEffect(() => {
273
- return () => {
274
- if (closeTimer.current) clearTimeout(closeTimer.current);
275
- };
276
- }, []);
246
+ const hasProvider = useHasContextCardProvider();
277
247
 
278
248
  const ts =
279
249
  date == null
@@ -284,43 +254,11 @@ export function TimestampTooltip({
284
254
 
285
255
  if (ts == null || Number.isNaN(ts)) return <>{children}</>;
286
256
 
287
- const cancelClose = () => {
288
- if (closeTimer.current) {
289
- clearTimeout(closeTimer.current);
290
- closeTimer.current = null;
291
- }
292
- };
293
-
294
- const scheduleClose = () => {
295
- cancelClose();
296
- closeTimer.current = setTimeout(() => setOpen(false), 120);
297
- };
298
-
299
- const handleOpen = () => {
300
- cancelClose();
301
- if (triggerRef.current) {
302
- setTriggerRect(triggerRef.current.getBoundingClientRect());
303
- }
304
- setOpen(true);
305
- };
306
-
307
- return (
308
- // biome-ignore lint/a11y/noStaticElementInteractions: tooltip trigger
309
- <span
310
- ref={triggerRef}
311
- onMouseEnter={handleOpen}
312
- onMouseLeave={scheduleClose}
313
- style={{ display: 'inline-flex' }}
314
- >
257
+ const card = (
258
+ <RelativeTimeCard date={ts} side={side} asChild>
315
259
  {children}
316
- {open && triggerRect && (
317
- <TooltipPortal
318
- triggerRect={triggerRect}
319
- onMouseEnter={cancelClose}
320
- onMouseLeave={scheduleClose}
321
- date={ts}
322
- />
323
- )}
324
- </span>
260
+ </RelativeTimeCard>
325
261
  );
262
+
263
+ return hasProvider ? card : <ContextCardProvider>{card}</ContextCardProvider>;
326
264
  }
@@ -28,6 +28,27 @@ const MARKER_EVENT_TYPES: Set<Event['eventType']> = new Set([
28
28
  'wait_completed',
29
29
  ]);
30
30
 
31
+ const findEventByType = (
32
+ events: Event[],
33
+ eventTypes: Event['eventType'][]
34
+ ): Event | undefined => {
35
+ for (const eventType of eventTypes) {
36
+ const event = events.find((e) => e.eventType === eventType);
37
+ if (event) return event;
38
+ }
39
+ return undefined;
40
+ };
41
+
42
+ const withOccurredAt = <T extends object>(
43
+ entity: T,
44
+ occurredAt: Event['occurredAt'] | undefined
45
+ ): T & { occurredAt?: Date } => {
46
+ if (!occurredAt || (entity as { occurredAt?: unknown }).occurredAt != null) {
47
+ return entity;
48
+ }
49
+ return { ...entity, occurredAt };
50
+ };
51
+
31
52
  /**
32
53
  * Convert workflow events to span events
33
54
  * Only includes events that should be displayed as markers
@@ -59,7 +80,8 @@ export const waitEventsToWaitEntity = (
59
80
  waitId: string;
60
81
  runId: string;
61
82
  createdAt: Date;
62
- resumeAt: Date;
83
+ occurredAt?: Date;
84
+ resumeAt?: Date;
63
85
  completedAt?: Date;
64
86
  } | null => {
65
87
  const startEvent = events.find((event) => event.eventType === 'wait_created');
@@ -69,13 +91,18 @@ export const waitEventsToWaitEntity = (
69
91
  const completedEvent = events.find(
70
92
  (event) => event.eventType === 'wait_completed'
71
93
  );
72
- return {
73
- waitId: startEvent.correlationId,
74
- runId: startEvent.runId,
75
- createdAt: startEvent.createdAt,
76
- resumeAt: startEvent.eventData?.resumeAt,
77
- completedAt: completedEvent?.createdAt,
78
- };
94
+ return withOccurredAt(
95
+ {
96
+ waitId: startEvent.correlationId,
97
+ runId: startEvent.runId,
98
+ createdAt: startEvent.createdAt,
99
+ resumeAt: startEvent.eventData?.resumeAt
100
+ ? new Date(startEvent.eventData.resumeAt)
101
+ : undefined,
102
+ completedAt: completedEvent?.createdAt,
103
+ },
104
+ startEvent.occurredAt
105
+ );
79
106
  };
80
107
 
81
108
  /**
@@ -122,6 +149,7 @@ export const stepEventsToStepEntity = (
122
149
  attempt: number;
123
150
  createdAt: Date;
124
151
  updatedAt: Date;
152
+ occurredAt?: Date;
125
153
  startedAt?: Date;
126
154
  completedAt?: Date;
127
155
  specVersion?: number;
@@ -172,18 +200,24 @@ export const stepEventsToStepEntity = (
172
200
  if (attempt === 0) attempt = 1;
173
201
 
174
202
  const lastEvent = events[events.length - 1];
175
- return {
176
- stepId: anchorEvent.correlationId ?? '',
177
- runId: anchorEvent.runId,
178
- stepName: createdEvent?.eventData?.stepName ?? '',
179
- status,
180
- attempt,
181
- createdAt: anchorEvent.createdAt,
182
- updatedAt: lastEvent?.createdAt ?? anchorEvent.createdAt,
183
- startedAt,
184
- completedAt,
185
- specVersion: anchorEvent.specVersion,
186
- };
203
+ const occurrenceEvent =
204
+ findEventByType(events, ['step_created', 'step_started']) ?? anchorEvent;
205
+
206
+ return withOccurredAt(
207
+ {
208
+ stepId: anchorEvent.correlationId ?? '',
209
+ runId: anchorEvent.runId,
210
+ stepName: createdEvent?.eventData?.stepName ?? '',
211
+ status,
212
+ attempt,
213
+ createdAt: anchorEvent.createdAt,
214
+ updatedAt: lastEvent?.createdAt ?? anchorEvent.createdAt,
215
+ startedAt,
216
+ completedAt,
217
+ specVersion: anchorEvent.specVersion,
218
+ },
219
+ occurrenceEvent.occurredAt
220
+ );
187
221
  };
188
222
 
189
223
  /**
@@ -252,6 +286,7 @@ export const hookEventsToHookEntity = (
252
286
  runId: string;
253
287
  token?: string;
254
288
  createdAt: Date;
289
+ occurredAt?: Date;
255
290
  receivedCount: number;
256
291
  lastReceivedAt?: Date;
257
292
  disposedAt?: Date;
@@ -269,15 +304,18 @@ export const hookEventsToHookEntity = (
269
304
  (event) => event.eventType === 'hook_disposed'
270
305
  );
271
306
  const lastReceivedEvent = receivedEvents.at(-1);
272
- return {
273
- hookId: createdEvent.correlationId,
274
- runId: createdEvent.runId,
275
- token: createdEvent.eventData?.token,
276
- createdAt: createdEvent.createdAt,
277
- receivedCount: receivedEvents.length,
278
- lastReceivedAt: lastReceivedEvent?.createdAt || undefined,
279
- disposedAt: disposedEvents.at(-1)?.createdAt || undefined,
280
- };
307
+ return withOccurredAt(
308
+ {
309
+ hookId: createdEvent.correlationId,
310
+ runId: createdEvent.runId,
311
+ token: createdEvent.eventData?.token,
312
+ createdAt: createdEvent.createdAt,
313
+ receivedCount: receivedEvents.length,
314
+ lastReceivedAt: lastReceivedEvent?.createdAt || undefined,
315
+ disposedAt: disposedEvents.at(-1)?.createdAt || undefined,
316
+ },
317
+ createdEvent.occurredAt
318
+ );
281
319
  };
282
320
 
283
321
  /**
@@ -327,9 +365,13 @@ export function runToSpan(
327
365
  // Only embed identification fields — not the full object with
328
366
  // input/output/error which may contain non-cloneable types.
329
367
  const { input: _i, output: _o, error: _e, ...runIdentity } = run;
368
+ const occurrenceEvent = findEventByType(runEvents, [
369
+ 'run_created',
370
+ 'run_started',
371
+ ]);
330
372
  const attributes = {
331
373
  resource: 'run' as const,
332
- data: runIdentity,
374
+ data: withOccurredAt(runIdentity, occurrenceEvent?.occurredAt),
333
375
  };
334
376
 
335
377
  // Use createdAt as span start time, with activeStartTime for when execution began
@@ -0,0 +1,34 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+
5
+ const QUERY = '(prefers-reduced-motion: reduce)';
6
+
7
+ /**
8
+ * Hook that detects whether the user has requested reduced motion and reacts
9
+ * to changes. Mirrors the `prefers-reduced-motion: reduce` media query so
10
+ * components can skip or shorten animations for users with motion
11
+ * sensitivities (e.g. vestibular disorders).
12
+ *
13
+ * @returns `true` if the user prefers reduced motion, `false` otherwise
14
+ */
15
+ export const useReducedMotion = (): boolean => {
16
+ const [reduced, setReduced] = useState(() => {
17
+ if (typeof window === 'undefined' || !window.matchMedia) return false;
18
+ return window.matchMedia(QUERY).matches;
19
+ });
20
+
21
+ useEffect(() => {
22
+ if (typeof window === 'undefined' || !window.matchMedia) return;
23
+
24
+ const media = window.matchMedia(QUERY);
25
+ const onChange = (): void => setReduced(media.matches);
26
+
27
+ media.addEventListener('change', onChange);
28
+ onChange();
29
+
30
+ return () => media.removeEventListener('change', onChange);
31
+ }, []);
32
+
33
+ return reduced;
34
+ };