@workflow/web-shared 4.1.0-beta.65 → 4.1.0-beta.66

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 (57) hide show
  1. package/dist/components/event-list-view.d.ts.map +1 -1
  2. package/dist/components/event-list-view.js +52 -16
  3. package/dist/components/event-list-view.js.map +1 -1
  4. package/dist/components/hook-actions.d.ts.map +1 -1
  5. package/dist/components/hook-actions.js +2 -1
  6. package/dist/components/hook-actions.js.map +1 -1
  7. package/dist/components/sidebar/attribute-panel.d.ts +3 -1
  8. package/dist/components/sidebar/attribute-panel.d.ts.map +1 -1
  9. package/dist/components/sidebar/attribute-panel.js +55 -38
  10. package/dist/components/sidebar/attribute-panel.js.map +1 -1
  11. package/dist/components/sidebar/copyable-data-block.d.ts.map +1 -1
  12. package/dist/components/sidebar/copyable-data-block.js +2 -1
  13. package/dist/components/sidebar/copyable-data-block.js.map +1 -1
  14. package/dist/components/sidebar/entity-detail-panel.d.ts.map +1 -1
  15. package/dist/components/sidebar/entity-detail-panel.js +12 -6
  16. package/dist/components/sidebar/entity-detail-panel.js.map +1 -1
  17. package/dist/components/sidebar/events-list.d.ts.map +1 -1
  18. package/dist/components/sidebar/events-list.js +7 -5
  19. package/dist/components/sidebar/events-list.js.map +1 -1
  20. package/dist/components/trace-viewer/components/span-segments.d.ts.map +1 -1
  21. package/dist/components/trace-viewer/components/span-segments.js +54 -1
  22. package/dist/components/trace-viewer/components/span-segments.js.map +1 -1
  23. package/dist/components/ui/error-stack-block.d.ts.map +1 -1
  24. package/dist/components/ui/error-stack-block.js +2 -1
  25. package/dist/components/ui/error-stack-block.js.map +1 -1
  26. package/dist/components/ui/timestamp-tooltip.d.ts +6 -0
  27. package/dist/components/ui/timestamp-tooltip.d.ts.map +1 -0
  28. package/dist/components/ui/timestamp-tooltip.js +200 -0
  29. package/dist/components/ui/timestamp-tooltip.js.map +1 -0
  30. package/dist/components/workflow-trace-view.d.ts.map +1 -1
  31. package/dist/components/workflow-trace-view.js +8 -1
  32. package/dist/components/workflow-trace-view.js.map +1 -1
  33. package/dist/components/workflow-traces/trace-span-construction.d.ts.map +1 -1
  34. package/dist/components/workflow-traces/trace-span-construction.js +10 -7
  35. package/dist/components/workflow-traces/trace-span-construction.js.map +1 -1
  36. package/dist/index.d.ts +2 -0
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +1 -0
  39. package/dist/index.js.map +1 -1
  40. package/dist/lib/toast.d.ts +25 -0
  41. package/dist/lib/toast.d.ts.map +1 -0
  42. package/dist/lib/toast.js +24 -0
  43. package/dist/lib/toast.js.map +1 -0
  44. package/package.json +6 -4
  45. package/src/components/event-list-view.tsx +125 -35
  46. package/src/components/hook-actions.tsx +2 -1
  47. package/src/components/sidebar/attribute-panel.tsx +60 -33
  48. package/src/components/sidebar/copyable-data-block.tsx +2 -1
  49. package/src/components/sidebar/entity-detail-panel.tsx +13 -5
  50. package/src/components/sidebar/events-list.tsx +14 -13
  51. package/src/components/trace-viewer/components/span-segments.ts +70 -1
  52. package/src/components/ui/error-stack-block.tsx +2 -1
  53. package/src/components/ui/timestamp-tooltip.tsx +326 -0
  54. package/src/components/workflow-trace-view.tsx +8 -1
  55. package/src/components/workflow-traces/trace-span-construction.ts +12 -7
  56. package/src/index.ts +2 -0
  57. package/src/lib/toast.tsx +42 -0
@@ -0,0 +1,326 @@
1
+ 'use client';
2
+
3
+ import type { ReactNode } from 'react';
4
+ import { useEffect, useRef, useState } from 'react';
5
+ import { createPortal } from 'react-dom';
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Time formatting helpers
9
+ // ---------------------------------------------------------------------------
10
+
11
+ interface TimeUnit {
12
+ unit: string;
13
+ ms: number;
14
+ }
15
+
16
+ const TIME_UNITS: TimeUnit[] = [
17
+ { unit: 'year', ms: 31536000000 },
18
+ { unit: 'month', ms: 2628000000 },
19
+ { unit: 'day', ms: 86400000 },
20
+ { unit: 'hour', ms: 3600000 },
21
+ { unit: 'minute', ms: 60000 },
22
+ { unit: 'second', ms: 1000 },
23
+ ];
24
+
25
+ function formatTimeDifference(diff: number): string {
26
+ let remaining = Math.abs(diff);
27
+ const result: string[] = [];
28
+
29
+ for (const { unit, ms } of TIME_UNITS) {
30
+ const value = Math.floor(remaining / ms);
31
+ if (value > 0 || result.length > 0) {
32
+ result.push(`${value} ${unit}${value !== 1 ? 's' : ''}`);
33
+ remaining %= ms;
34
+ }
35
+ if (result.length === 3) break;
36
+ }
37
+
38
+ return result.join(', ');
39
+ }
40
+
41
+ function useTimeAgo(date: number): string {
42
+ const [timeAgo, setTimeAgo] = useState<string>('');
43
+
44
+ useEffect(() => {
45
+ const update = (): void => {
46
+ const diff = Date.now() - date;
47
+ const formatted = formatTimeDifference(diff);
48
+ setTimeAgo(formatted ? `${formatted} ago` : 'Just now');
49
+ };
50
+ update();
51
+ const timer = setInterval(update, 1000);
52
+ return () => clearInterval(timer);
53
+ }, [date]);
54
+
55
+ return timeAgo;
56
+ }
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Timezone row
60
+ // ---------------------------------------------------------------------------
61
+
62
+ function ZoneDateTimeRow({
63
+ date,
64
+ zone,
65
+ }: {
66
+ zone: string;
67
+ date: number;
68
+ }): ReactNode {
69
+ const dateObj = new Date(date);
70
+
71
+ const formattedZone =
72
+ new Intl.DateTimeFormat('en-US', {
73
+ timeZone: zone,
74
+ timeZoneName: 'short',
75
+ })
76
+ .formatToParts(dateObj)
77
+ .find((part) => part.type === 'timeZoneName')?.value || zone;
78
+
79
+ const formattedDate = dateObj.toLocaleString('en-US', {
80
+ timeZone: zone,
81
+ year: 'numeric',
82
+ month: 'long',
83
+ day: 'numeric',
84
+ });
85
+
86
+ const formattedTime = dateObj.toLocaleTimeString('en-US', {
87
+ timeZone: zone,
88
+ hour: '2-digit',
89
+ minute: '2-digit',
90
+ second: '2-digit',
91
+ });
92
+
93
+ 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}
120
+ </div>
121
+ <span
122
+ style={{
123
+ fontSize: 13,
124
+ color: 'var(--ds-gray-1000)',
125
+ whiteSpace: 'nowrap',
126
+ }}
127
+ >
128
+ {formattedDate}
129
+ </span>
130
+ </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
+ >
140
+ {formattedTime}
141
+ </span>
142
+ </div>
143
+ );
144
+ }
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // Tooltip card content
148
+ // ---------------------------------------------------------------------------
149
+
150
+ function TimestampTooltipContent({ date }: { date: number }): ReactNode {
151
+ const localTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
152
+ const timeAgo = useTimeAgo(date);
153
+
154
+ 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 }}>
174
+ <ZoneDateTimeRow date={date} zone="UTC" />
175
+ <ZoneDateTimeRow date={date} zone={localTimezone} />
176
+ </div>
177
+ </div>
178
+ );
179
+ }
180
+
181
+ // ---------------------------------------------------------------------------
182
+ // Hover tooltip wrapper
183
+ // ---------------------------------------------------------------------------
184
+
185
+ const TOOLTIP_WIDTH = 330;
186
+ const VIEWPORT_PAD = 8;
187
+
188
+ function TooltipPortal({
189
+ triggerRect,
190
+ onMouseEnter,
191
+ onMouseLeave,
192
+ 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
+ });
205
+
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
+ }
232
+
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}
253
+ >
254
+ <TimestampTooltipContent date={date} />
255
+ </div>,
256
+ document.body
257
+ );
258
+ }
259
+
260
+ export function TimestampTooltip({
261
+ date,
262
+ children,
263
+ }: {
264
+ date: number | Date | string | null | undefined;
265
+ children: ReactNode;
266
+ }): 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
+ }, []);
277
+
278
+ const ts =
279
+ date == null
280
+ ? null
281
+ : typeof date === 'number'
282
+ ? date
283
+ : new Date(date).getTime();
284
+
285
+ if (ts == null || Number.isNaN(ts)) return <>{children}</>;
286
+
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
+ >
315
+ {children}
316
+ {open && triggerRect && (
317
+ <TooltipPortal
318
+ triggerRect={triggerRect}
319
+ onMouseEnter={cancelClose}
320
+ onMouseLeave={scheduleClose}
321
+ date={ts}
322
+ />
323
+ )}
324
+ </span>
325
+ );
326
+ }
@@ -16,7 +16,7 @@ import {
16
16
  import type { MouseEvent as ReactMouseEvent, ReactNode } from 'react';
17
17
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
18
18
  import { createPortal } from 'react-dom';
19
- import { toast } from 'sonner';
19
+ import { useToast } from '../lib/toast';
20
20
  import { ErrorBoundary } from './error-boundary';
21
21
  import {
22
22
  EntityDetailPanel,
@@ -294,6 +294,7 @@ function TraceViewerWithContextMenu({
294
294
  isLoadingMoreSpans?: boolean;
295
295
  children: ReactNode;
296
296
  }): ReactNode {
297
+ const toast = useToast();
297
298
  const { state, dispatch } = useTraceViewer();
298
299
 
299
300
  // Drive active span widths at 60fps without React re-renders
@@ -614,8 +615,13 @@ function SelectionBridge({
614
615
  const { selected } = state;
615
616
  const onSelectionChangeRef = useRef(onSelectionChange);
616
617
  onSelectionChangeRef.current = onSelectionChange;
618
+ const prevSpanIdRef = useRef<string | undefined>(undefined);
617
619
 
618
620
  useEffect(() => {
621
+ const currentSpanId = selected?.span.spanId;
622
+ if (currentSpanId === prevSpanIdRef.current) return;
623
+ prevSpanIdRef.current = currentSpanId;
624
+
619
625
  if (selected) {
620
626
  onSelectionChangeRef.current({
621
627
  data: selected.span.attributes?.data,
@@ -810,6 +816,7 @@ export const WorkflowTraceViewer = ({
810
816
  /** Whether the encryption key is currently being fetched */
811
817
  isDecrypting?: boolean;
812
818
  }) => {
819
+ const toast = useToast();
813
820
  const [selectedSpan, setSelectedSpan] = useState<SelectedSpanInfo | null>(
814
821
  null
815
822
  );
@@ -129,9 +129,14 @@ export const stepEventsToStepEntity = (
129
129
  const createdEvent = events.find(
130
130
  (event) => event.eventType === 'step_created'
131
131
  );
132
- if (!createdEvent) {
132
+
133
+ // V1 runs don't emit step_created events. Fall back to the earliest event
134
+ // in the group so we can still build a step span.
135
+ const anchorEvent = createdEvent ?? events[0];
136
+ if (!anchorEvent) {
133
137
  return null;
134
138
  }
139
+
135
140
  // Walk events in order to derive status, attempt count, and timestamps.
136
141
  // Handles both step_retrying and consecutive step_started as retry signals.
137
142
  let status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' =
@@ -168,16 +173,16 @@ export const stepEventsToStepEntity = (
168
173
 
169
174
  const lastEvent = events[events.length - 1];
170
175
  return {
171
- stepId: createdEvent.correlationId,
172
- runId: createdEvent.runId,
173
- stepName: createdEvent.eventData?.stepName ?? '',
176
+ stepId: anchorEvent.correlationId ?? '',
177
+ runId: anchorEvent.runId,
178
+ stepName: createdEvent?.eventData?.stepName ?? '',
174
179
  status,
175
180
  attempt,
176
- createdAt: createdEvent.createdAt,
177
- updatedAt: lastEvent?.createdAt ?? createdEvent.createdAt,
181
+ createdAt: anchorEvent.createdAt,
182
+ updatedAt: lastEvent?.createdAt ?? anchorEvent.createdAt,
178
183
  startedAt,
179
184
  completedAt,
180
- specVersion: createdEvent.specVersion,
185
+ specVersion: anchorEvent.specVersion,
181
186
  };
182
187
  };
183
188
 
package/src/index.ts CHANGED
@@ -49,6 +49,8 @@ export {
49
49
  STREAM_REF_TYPE,
50
50
  truncateId,
51
51
  } from './lib/hydration';
52
+ export type { ToastAdapter } from './lib/toast';
53
+ export { ToastProvider, useToast } from './lib/toast';
52
54
  export type { StreamStep } from './lib/utils';
53
55
  export {
54
56
  extractConversation,
@@ -0,0 +1,42 @@
1
+ 'use client';
2
+
3
+ import type { ReactNode } from 'react';
4
+ import { createContext, useContext } from 'react';
5
+ import { toast as sonnerToast } from 'sonner';
6
+
7
+ export interface ToastAdapter {
8
+ success: (message: string, opts?: { description?: string }) => void;
9
+ error: (message: string, opts?: { description?: string }) => void;
10
+ info: (message: string, opts?: { description?: string }) => void;
11
+ }
12
+
13
+ const defaultAdapter: ToastAdapter = {
14
+ success: (msg, opts) => sonnerToast.success(msg, opts),
15
+ error: (msg, opts) => sonnerToast.error(msg, opts),
16
+ info: (msg, opts) => sonnerToast.info(msg, opts),
17
+ };
18
+
19
+ const ToastContext = createContext<ToastAdapter>(defaultAdapter);
20
+
21
+ /**
22
+ * Provide a custom toast implementation to web-shared components.
23
+ *
24
+ * When not provided, falls back to sonner (works in packages/web).
25
+ * Host apps like vercel-site can supply their own adapter
26
+ * (e.g. Geist useToasts) so toasts render in the host's toast system.
27
+ */
28
+ export function ToastProvider({
29
+ toast,
30
+ children,
31
+ }: {
32
+ toast: ToastAdapter;
33
+ children: ReactNode;
34
+ }): ReactNode {
35
+ return (
36
+ <ToastContext.Provider value={toast}>{children}</ToastContext.Provider>
37
+ );
38
+ }
39
+
40
+ export function useToast(): ToastAdapter {
41
+ return useContext(ToastContext);
42
+ }