@workflow/web-shared 4.1.0-beta.65 → 4.1.0-beta.67
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.
- package/dist/components/event-list-view.d.ts.map +1 -1
- package/dist/components/event-list-view.js +52 -16
- package/dist/components/event-list-view.js.map +1 -1
- package/dist/components/hook-actions.d.ts.map +1 -1
- package/dist/components/hook-actions.js +2 -1
- package/dist/components/hook-actions.js.map +1 -1
- package/dist/components/sidebar/attribute-panel.d.ts +3 -1
- package/dist/components/sidebar/attribute-panel.d.ts.map +1 -1
- package/dist/components/sidebar/attribute-panel.js +77 -40
- package/dist/components/sidebar/attribute-panel.js.map +1 -1
- package/dist/components/sidebar/copyable-data-block.d.ts.map +1 -1
- package/dist/components/sidebar/copyable-data-block.js +2 -1
- package/dist/components/sidebar/copyable-data-block.js.map +1 -1
- package/dist/components/sidebar/entity-detail-panel.d.ts.map +1 -1
- package/dist/components/sidebar/entity-detail-panel.js +12 -6
- package/dist/components/sidebar/entity-detail-panel.js.map +1 -1
- package/dist/components/sidebar/events-list.d.ts.map +1 -1
- package/dist/components/sidebar/events-list.js +33 -5
- package/dist/components/sidebar/events-list.js.map +1 -1
- package/dist/components/trace-viewer/components/span-segments.d.ts.map +1 -1
- package/dist/components/trace-viewer/components/span-segments.js +54 -1
- package/dist/components/trace-viewer/components/span-segments.js.map +1 -1
- package/dist/components/trace-viewer/util/timing.d.ts.map +1 -1
- package/dist/components/trace-viewer/util/timing.js +1 -2
- package/dist/components/trace-viewer/util/timing.js.map +1 -1
- package/dist/components/ui/error-stack-block.d.ts.map +1 -1
- package/dist/components/ui/error-stack-block.js +2 -1
- package/dist/components/ui/error-stack-block.js.map +1 -1
- package/dist/components/ui/timestamp-tooltip.d.ts +6 -0
- package/dist/components/ui/timestamp-tooltip.d.ts.map +1 -0
- package/dist/components/ui/timestamp-tooltip.js +200 -0
- package/dist/components/ui/timestamp-tooltip.js.map +1 -0
- package/dist/components/workflow-trace-view.d.ts.map +1 -1
- package/dist/components/workflow-trace-view.js +8 -1
- package/dist/components/workflow-trace-view.js.map +1 -1
- package/dist/components/workflow-traces/trace-span-construction.d.ts.map +1 -1
- package/dist/components/workflow-traces/trace-span-construction.js +10 -7
- package/dist/components/workflow-traces/trace-span-construction.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/hydration.d.ts +3 -0
- package/dist/lib/hydration.d.ts.map +1 -1
- package/dist/lib/hydration.js +36 -10
- package/dist/lib/hydration.js.map +1 -1
- package/dist/lib/toast.d.ts +25 -0
- package/dist/lib/toast.d.ts.map +1 -0
- package/dist/lib/toast.js +24 -0
- package/dist/lib/toast.js.map +1 -0
- package/dist/lib/utils.d.ts.map +1 -1
- package/dist/lib/utils.js +7 -3
- package/dist/lib/utils.js.map +1 -1
- package/package.json +6 -4
- package/src/components/event-list-view.tsx +125 -35
- package/src/components/hook-actions.tsx +2 -1
- package/src/components/sidebar/attribute-panel.tsx +85 -35
- package/src/components/sidebar/copyable-data-block.tsx +2 -1
- package/src/components/sidebar/entity-detail-panel.tsx +13 -5
- package/src/components/sidebar/events-list.tsx +51 -13
- package/src/components/trace-viewer/components/span-segments.ts +70 -1
- package/src/components/trace-viewer/util/timing.ts +1 -2
- package/src/components/ui/error-stack-block.tsx +2 -1
- package/src/components/ui/timestamp-tooltip.tsx +326 -0
- package/src/components/workflow-trace-view.tsx +8 -1
- package/src/components/workflow-traces/trace-span-construction.ts +12 -7
- package/src/index.ts +2 -0
- package/src/lib/hydration.ts +43 -9
- package/src/lib/toast.tsx +42 -0
- package/src/lib/utils.ts +7 -3
|
@@ -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 {
|
|
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
|
-
|
|
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:
|
|
172
|
-
runId:
|
|
173
|
-
stepName: createdEvent
|
|
176
|
+
stepId: anchorEvent.correlationId ?? '',
|
|
177
|
+
runId: anchorEvent.runId,
|
|
178
|
+
stepName: createdEvent?.eventData?.stepName ?? '',
|
|
174
179
|
status,
|
|
175
180
|
attempt,
|
|
176
|
-
createdAt:
|
|
177
|
-
updatedAt: lastEvent?.createdAt ??
|
|
181
|
+
createdAt: anchorEvent.createdAt,
|
|
182
|
+
updatedAt: lastEvent?.createdAt ?? anchorEvent.createdAt,
|
|
178
183
|
startedAt,
|
|
179
184
|
completedAt,
|
|
180
|
-
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,
|
package/src/lib/hydration.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
extractClassName,
|
|
11
11
|
hydrateResourceIO as hydrateResourceIOGeneric,
|
|
12
12
|
isEncryptedData,
|
|
13
|
+
isExpiredStub,
|
|
13
14
|
observabilityRevivers,
|
|
14
15
|
type Revivers,
|
|
15
16
|
} from '@workflow/core/serialization-format';
|
|
@@ -149,7 +150,7 @@ export function hydrateResourceIO<T>(resource: T): T {
|
|
|
149
150
|
resource as any,
|
|
150
151
|
getRevivers()
|
|
151
152
|
) as T;
|
|
152
|
-
return
|
|
153
|
+
return replaceEncryptedAndExpiredWithMarkers(hydrated);
|
|
153
154
|
}
|
|
154
155
|
|
|
155
156
|
// ---------------------------------------------------------------------------
|
|
@@ -191,27 +192,60 @@ export function isEncryptedMarker(value: unknown): boolean {
|
|
|
191
192
|
);
|
|
192
193
|
}
|
|
193
194
|
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
// Expired data display markers
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
export const EXPIRED_DISPLAY_NAME = 'Expired Data';
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Create a display-friendly object for expired data.
|
|
203
|
+
*
|
|
204
|
+
* Uses the same named-constructor trick as the encrypted marker so that
|
|
205
|
+
* ObjectInspector renders the constructor name ("Expired Data") with no
|
|
206
|
+
* expandable children.
|
|
207
|
+
*/
|
|
208
|
+
function createExpiredMarker(): object {
|
|
209
|
+
// biome-ignore lint/complexity/useArrowFunction: arrow functions have no .prototype
|
|
210
|
+
const ctor = { [EXPIRED_DISPLAY_NAME]: function () {} }[
|
|
211
|
+
EXPIRED_DISPLAY_NAME
|
|
212
|
+
]!;
|
|
213
|
+
return Object.create(ctor.prototype);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Check if a value is an expired data display marker */
|
|
217
|
+
export function isExpiredMarker(value: unknown): boolean {
|
|
218
|
+
return (
|
|
219
|
+
value !== null &&
|
|
220
|
+
typeof value === 'object' &&
|
|
221
|
+
value.constructor?.name === EXPIRED_DISPLAY_NAME
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Replace a single field value with a display marker if it's encrypted or expired. */
|
|
226
|
+
function toDisplayMarker(value: unknown): unknown {
|
|
227
|
+
if (isEncryptedData(value)) return createEncryptedMarker(value as Uint8Array);
|
|
228
|
+
if (isExpiredStub(value)) return createExpiredMarker();
|
|
229
|
+
return value;
|
|
230
|
+
}
|
|
231
|
+
|
|
194
232
|
/**
|
|
195
233
|
* Post-process hydrated resource data: replace encrypted Uint8Array values
|
|
196
|
-
* with display-friendly marker objects in known data fields.
|
|
234
|
+
* and expired stubs with display-friendly marker objects in known data fields.
|
|
197
235
|
*/
|
|
198
|
-
function
|
|
236
|
+
function replaceEncryptedAndExpiredWithMarkers<T>(resource: T): T {
|
|
199
237
|
if (!resource || typeof resource !== 'object') return resource;
|
|
200
238
|
const r = resource as Record<string, unknown>;
|
|
201
239
|
const result = { ...r };
|
|
202
240
|
|
|
203
241
|
for (const key of ['input', 'output', 'metadata', 'error']) {
|
|
204
|
-
|
|
205
|
-
result[key] = createEncryptedMarker(result[key] as Uint8Array);
|
|
206
|
-
}
|
|
242
|
+
result[key] = toDisplayMarker(result[key]);
|
|
207
243
|
}
|
|
208
244
|
|
|
209
245
|
if (result.eventData && typeof result.eventData === 'object') {
|
|
210
246
|
const ed = { ...(result.eventData as Record<string, unknown>) };
|
|
211
247
|
for (const key of EVENT_DATA_SERIALIZED_FIELDS) {
|
|
212
|
-
|
|
213
|
-
ed[key] = createEncryptedMarker(ed[key] as Uint8Array);
|
|
214
|
-
}
|
|
248
|
+
ed[key] = toDisplayMarker(ed[key]);
|
|
215
249
|
}
|
|
216
250
|
result.eventData = ed;
|
|
217
251
|
}
|
|
@@ -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
|
+
}
|
package/src/lib/utils.ts
CHANGED
|
@@ -49,12 +49,16 @@ export function formatDuration(ms: number, compact = false): string {
|
|
|
49
49
|
return `${durationFormatter.format(ms / MS_IN_SECOND)}s`;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
// Compact format:
|
|
52
|
+
// Compact format: multi-unit without decimals (e.g. "8m 20s", "2h 30m")
|
|
53
53
|
if (compact) {
|
|
54
54
|
if (ms < MS_IN_HOUR) {
|
|
55
|
-
|
|
55
|
+
const m = Math.floor(ms / MS_IN_MINUTE);
|
|
56
|
+
const s = Math.floor((ms % MS_IN_MINUTE) / MS_IN_SECOND);
|
|
57
|
+
return s > 0 ? `${m}m ${s}s` : `${m}m`;
|
|
56
58
|
}
|
|
57
|
-
|
|
59
|
+
const h = Math.floor(ms / MS_IN_HOUR);
|
|
60
|
+
const m = Math.floor((ms % MS_IN_HOUR) / MS_IN_MINUTE);
|
|
61
|
+
return m > 0 ? `${h}h ${m}m` : `${h}h`;
|
|
58
62
|
}
|
|
59
63
|
|
|
60
64
|
// Full format: human-readable multi-part
|