@workflow/web-shared 4.1.0-beta.64 → 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.
- package/dist/components/event-list-view.d.ts +5 -1
- package/dist/components/event-list-view.d.ts.map +1 -1
- package/dist/components/event-list-view.js +79 -22
- 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/index.d.ts +3 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +3 -0
- package/dist/components/index.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 +55 -38
- 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 +3 -1
- package/dist/components/sidebar/entity-detail-panel.d.ts.map +1 -1
- package/dist/components/sidebar/entity-detail-panel.js +16 -20
- 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 +7 -5
- package/dist/components/sidebar/events-list.js.map +1 -1
- package/dist/components/stream-viewer.d.ts +3 -1
- package/dist/components/stream-viewer.d.ts.map +1 -1
- package/dist/components/stream-viewer.js +23 -28
- package/dist/components/stream-viewer.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/ui/decrypt-button.d.ts +15 -0
- package/dist/components/ui/decrypt-button.d.ts.map +1 -0
- package/dist/components/ui/decrypt-button.js +12 -0
- package/dist/components/ui/decrypt-button.js.map +1 -0
- 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/load-more-button.d.ts +13 -0
- package/dist/components/ui/load-more-button.d.ts.map +1 -0
- package/dist/components/ui/load-more-button.js +12 -0
- package/dist/components/ui/load-more-button.js.map +1 -0
- package/dist/components/ui/menu-dropdown.d.ts.map +1 -1
- package/dist/components/ui/menu-dropdown.js +2 -6
- package/dist/components/ui/menu-dropdown.js.map +1 -1
- package/dist/components/ui/spinner.d.ts +9 -0
- package/dist/components/ui/spinner.d.ts.map +1 -0
- package/dist/components/ui/spinner.js +57 -0
- package/dist/components/ui/spinner.js.map +1 -0
- 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 +3 -1
- package/dist/components/workflow-trace-view.d.ts.map +1 -1
- package/dist/components/workflow-trace-view.js +12 -4
- 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/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/package.json +6 -4
- package/src/components/event-list-view.tsx +241 -111
- package/src/components/hook-actions.tsx +2 -1
- package/src/components/index.ts +3 -0
- package/src/components/sidebar/attribute-panel.tsx +60 -33
- package/src/components/sidebar/copyable-data-block.tsx +2 -1
- package/src/components/sidebar/entity-detail-panel.tsx +22 -30
- package/src/components/sidebar/events-list.tsx +14 -13
- package/src/components/stream-viewer.tsx +52 -63
- package/src/components/trace-viewer/components/span-segments.ts +70 -1
- package/src/components/ui/decrypt-button.tsx +69 -0
- package/src/components/ui/error-stack-block.tsx +2 -1
- package/src/components/ui/load-more-button.tsx +38 -0
- package/src/components/ui/menu-dropdown.tsx +3 -6
- package/src/components/ui/spinner.tsx +76 -0
- package/src/components/ui/timestamp-tooltip.tsx +326 -0
- package/src/components/workflow-trace-view.tsx +14 -20
- package/src/components/workflow-traces/trace-span-construction.ts +12 -7
- package/src/index.ts +2 -0
- package/src/lib/toast.tsx +42 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { AlertCircle, Copy } from 'lucide-react';
|
|
4
|
-
import {
|
|
4
|
+
import { useToast } from '../../lib/toast';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Check whether `value` looks like a structured error object with a `stack`
|
|
@@ -28,6 +28,7 @@ export function ErrorStackBlock({
|
|
|
28
28
|
}: {
|
|
29
29
|
value: Record<string, unknown> & { stack: string };
|
|
30
30
|
}) {
|
|
31
|
+
const toast = useToast();
|
|
31
32
|
const stack = value.stack;
|
|
32
33
|
const message = typeof value.message === 'string' ? value.message : undefined;
|
|
33
34
|
const copyText = message ? `${message}\n\n${stack}` : stack;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Spinner } from './spinner';
|
|
4
|
+
|
|
5
|
+
const STYLES = `.wf-load-more{appearance:none;-webkit-appearance:none;border:none;display:inline-flex;align-items:center;justify-content:center;height:32px;padding:0 12px;border-radius:6px;font-size:13px;font-weight:500;line-height:20px;color:var(--ds-gray-1000);background:var(--ds-background-100);box-shadow:0 0 0 1px var(--ds-gray-400);cursor:pointer;white-space:nowrap;gap:6px;transition:background 150ms}.wf-load-more:hover{background:var(--ds-gray-alpha-200)}.wf-load-more:disabled{opacity:.6;cursor:default}.wf-load-more:disabled:hover{background:var(--ds-background-100)}`;
|
|
6
|
+
|
|
7
|
+
interface LoadMoreButtonProps {
|
|
8
|
+
loading?: boolean;
|
|
9
|
+
onClick?: () => void;
|
|
10
|
+
label?: string;
|
|
11
|
+
loadingLabel?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A "Load more" button matching Geist's Button type="secondary" size="small"
|
|
16
|
+
* with a spinner prefix when loading.
|
|
17
|
+
*/
|
|
18
|
+
export function LoadMoreButton({
|
|
19
|
+
loading = false,
|
|
20
|
+
onClick,
|
|
21
|
+
label = 'Load more',
|
|
22
|
+
loadingLabel = 'Loading...',
|
|
23
|
+
}: LoadMoreButtonProps) {
|
|
24
|
+
return (
|
|
25
|
+
<>
|
|
26
|
+
<style dangerouslySetInnerHTML={{ __html: STYLES }} />
|
|
27
|
+
<button
|
|
28
|
+
type="button"
|
|
29
|
+
onClick={onClick}
|
|
30
|
+
disabled={loading}
|
|
31
|
+
className="wf-load-more"
|
|
32
|
+
>
|
|
33
|
+
{loading && <Spinner size={14} />}
|
|
34
|
+
{loading ? loadingLabel : label}
|
|
35
|
+
</button>
|
|
36
|
+
</>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect, useRef, useState } from 'react';
|
|
4
4
|
|
|
5
|
+
const STYLES = `.wf-menu-btn{appearance:none;-webkit-appearance:none;border:none;display:inline-flex;align-items:center;justify-content:center;height:40px;padding:0 12px;border-radius:6px;font-size:14px;font-weight:500;line-height:20px;color:var(--ds-gray-1000);background:var(--ds-background-100);box-shadow:0 0 0 1px var(--ds-gray-400);cursor:pointer;white-space:nowrap;transition:background 150ms}.wf-menu-btn:hover{background:var(--ds-gray-alpha-200)}.wf-menu-item{appearance:none;-webkit-appearance:none;border:none;display:flex;align-items:center;width:100%;height:40px;padding:0 8px;border-radius:6px;font-size:14px;color:var(--ds-gray-1000);background:transparent;cursor:pointer;transition:background 150ms}.wf-menu-item:hover{background:var(--ds-gray-alpha-100)}`;
|
|
6
|
+
|
|
5
7
|
export interface MenuDropdownOption<T extends string = string> {
|
|
6
8
|
value: T;
|
|
7
9
|
label: string;
|
|
@@ -40,12 +42,7 @@ export function MenuDropdown<T extends string = string>({
|
|
|
40
42
|
|
|
41
43
|
return (
|
|
42
44
|
<div ref={ref} style={{ position: 'relative', flexShrink: 0 }}>
|
|
43
|
-
<style
|
|
44
|
-
.wf-menu-btn{appearance:none;-webkit-appearance:none;border:none;display:inline-flex;align-items:center;justify-content:center;height:40px;padding:0 12px;border-radius:6px;font-size:14px;font-weight:500;line-height:20px;color:var(--ds-gray-1000);background:var(--ds-background-100);box-shadow:0 0 0 1px var(--ds-gray-400);cursor:pointer;white-space:nowrap;transition:background 150ms}
|
|
45
|
-
.wf-menu-btn:hover{background:var(--ds-gray-alpha-200)}
|
|
46
|
-
.wf-menu-item{appearance:none;-webkit-appearance:none;border:none;display:flex;align-items:center;width:100%;height:40px;padding:0 8px;border-radius:6px;font-size:14px;color:var(--ds-gray-1000);background:transparent;cursor:pointer;transition:background 150ms}
|
|
47
|
-
.wf-menu-item:hover{background:var(--ds-gray-alpha-100)}
|
|
48
|
-
`}</style>
|
|
45
|
+
<style dangerouslySetInnerHTML={{ __html: STYLES }} />
|
|
49
46
|
|
|
50
47
|
<button
|
|
51
48
|
type="button"
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
const KEYFRAMES = `@keyframes wf-spinner-fade{0%{opacity:1}100%{opacity:.15}}`;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Spinner matching Geist's multi-line fade spinner.
|
|
5
|
+
* At size ≤12: 8 lines, ≤16: 10 lines, else: 12 lines.
|
|
6
|
+
*/
|
|
7
|
+
export function Spinner({
|
|
8
|
+
size = 14,
|
|
9
|
+
color,
|
|
10
|
+
}: {
|
|
11
|
+
size?: number;
|
|
12
|
+
color?: string;
|
|
13
|
+
}) {
|
|
14
|
+
const config =
|
|
15
|
+
size <= 12
|
|
16
|
+
? {
|
|
17
|
+
count: 8,
|
|
18
|
+
angle: 45,
|
|
19
|
+
delays: [-875, -750, -625, -500, -375, -250, -125, 0],
|
|
20
|
+
duration: 1000,
|
|
21
|
+
lineW: 3,
|
|
22
|
+
lineH: 1.5,
|
|
23
|
+
}
|
|
24
|
+
: size <= 16
|
|
25
|
+
? {
|
|
26
|
+
count: 10,
|
|
27
|
+
angle: 36,
|
|
28
|
+
delays: [-900, -800, -700, -600, -500, -400, -300, -200, -100, 0],
|
|
29
|
+
duration: 1000,
|
|
30
|
+
lineW: 4,
|
|
31
|
+
lineH: 1.5,
|
|
32
|
+
}
|
|
33
|
+
: {
|
|
34
|
+
count: 12,
|
|
35
|
+
angle: 30,
|
|
36
|
+
delays: [
|
|
37
|
+
-1100, -1000, -900, -800, -700, -600, -500, -400, -300, -200,
|
|
38
|
+
-100, 0,
|
|
39
|
+
],
|
|
40
|
+
duration: 1200,
|
|
41
|
+
lineW: size * 0.24,
|
|
42
|
+
lineH: size * 0.08,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<span
|
|
47
|
+
style={{
|
|
48
|
+
display: 'inline-flex',
|
|
49
|
+
position: 'relative',
|
|
50
|
+
width: size,
|
|
51
|
+
height: size,
|
|
52
|
+
}}
|
|
53
|
+
>
|
|
54
|
+
<style dangerouslySetInnerHTML={{ __html: KEYFRAMES }} />
|
|
55
|
+
{config.delays.map((delay, i) => (
|
|
56
|
+
<span
|
|
57
|
+
key={delay}
|
|
58
|
+
style={{
|
|
59
|
+
position: 'absolute',
|
|
60
|
+
left: '50%',
|
|
61
|
+
top: '50%',
|
|
62
|
+
width: config.lineW,
|
|
63
|
+
height: config.lineH,
|
|
64
|
+
marginLeft: -config.lineW / 2,
|
|
65
|
+
marginTop: -config.lineH / 2,
|
|
66
|
+
borderRadius: 1,
|
|
67
|
+
backgroundColor: color ?? 'var(--ds-gray-700)',
|
|
68
|
+
transform: `rotate(${i * config.angle}deg) translate(${size * 0.36}px)`,
|
|
69
|
+
animation: `wf-spinner-fade ${config.duration}ms linear infinite`,
|
|
70
|
+
animationDelay: `${delay}ms`,
|
|
71
|
+
}}
|
|
72
|
+
/>
|
|
73
|
+
))}
|
|
74
|
+
</span>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -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,
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
} from './trace-viewer';
|
|
32
32
|
import type { Span } from './trace-viewer/types';
|
|
33
33
|
import { Skeleton } from './ui/skeleton';
|
|
34
|
+
import { Spinner } from './ui/spinner';
|
|
34
35
|
import {
|
|
35
36
|
getCustomSpanClassName,
|
|
36
37
|
getCustomSpanEventClassName,
|
|
@@ -293,6 +294,7 @@ function TraceViewerWithContextMenu({
|
|
|
293
294
|
isLoadingMoreSpans?: boolean;
|
|
294
295
|
children: ReactNode;
|
|
295
296
|
}): ReactNode {
|
|
297
|
+
const toast = useToast();
|
|
296
298
|
const { state, dispatch } = useTraceViewer();
|
|
297
299
|
|
|
298
300
|
// Drive active span widths at 60fps without React re-renders
|
|
@@ -613,8 +615,13 @@ function SelectionBridge({
|
|
|
613
615
|
const { selected } = state;
|
|
614
616
|
const onSelectionChangeRef = useRef(onSelectionChange);
|
|
615
617
|
onSelectionChangeRef.current = onSelectionChange;
|
|
618
|
+
const prevSpanIdRef = useRef<string | undefined>(undefined);
|
|
616
619
|
|
|
617
620
|
useEffect(() => {
|
|
621
|
+
const currentSpanId = selected?.span.spanId;
|
|
622
|
+
if (currentSpanId === prevSpanIdRef.current) return;
|
|
623
|
+
prevSpanIdRef.current = currentSpanId;
|
|
624
|
+
|
|
618
625
|
if (selected) {
|
|
619
626
|
onSelectionChangeRef.current({
|
|
620
627
|
data: selected.span.attributes?.data,
|
|
@@ -719,25 +726,7 @@ function TraceViewerFooter({
|
|
|
719
726
|
className="flex items-center justify-center gap-2 py-3 text-xs"
|
|
720
727
|
style={style}
|
|
721
728
|
>
|
|
722
|
-
<
|
|
723
|
-
className="h-3.5 w-3.5 animate-spin"
|
|
724
|
-
viewBox="0 0 24 24"
|
|
725
|
-
fill="none"
|
|
726
|
-
>
|
|
727
|
-
<circle
|
|
728
|
-
className="opacity-25"
|
|
729
|
-
cx="12"
|
|
730
|
-
cy="12"
|
|
731
|
-
r="10"
|
|
732
|
-
stroke="currentColor"
|
|
733
|
-
strokeWidth="4"
|
|
734
|
-
/>
|
|
735
|
-
<path
|
|
736
|
-
className="opacity-75"
|
|
737
|
-
fill="currentColor"
|
|
738
|
-
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
|
739
|
-
/>
|
|
740
|
-
</svg>
|
|
729
|
+
<Spinner size={14} />
|
|
741
730
|
Loading more events…
|
|
742
731
|
</div>
|
|
743
732
|
);
|
|
@@ -785,6 +774,7 @@ export const WorkflowTraceViewer = ({
|
|
|
785
774
|
isLoadingMoreSpans = false,
|
|
786
775
|
encryptionKey,
|
|
787
776
|
onDecrypt,
|
|
777
|
+
isDecrypting = false,
|
|
788
778
|
}: {
|
|
789
779
|
run: WorkflowRun;
|
|
790
780
|
events: Event[];
|
|
@@ -823,7 +813,10 @@ export const WorkflowTraceViewer = ({
|
|
|
823
813
|
encryptionKey?: Uint8Array;
|
|
824
814
|
/** Callback to initiate decryption of encrypted run data */
|
|
825
815
|
onDecrypt?: () => void;
|
|
816
|
+
/** Whether the encryption key is currently being fetched */
|
|
817
|
+
isDecrypting?: boolean;
|
|
826
818
|
}) => {
|
|
819
|
+
const toast = useToast();
|
|
827
820
|
const [selectedSpan, setSelectedSpan] = useState<SelectedSpanInfo | null>(
|
|
828
821
|
null
|
|
829
822
|
);
|
|
@@ -1156,6 +1149,7 @@ export const WorkflowTraceViewer = ({
|
|
|
1156
1149
|
onResolveHook={onResolveHook}
|
|
1157
1150
|
encryptionKey={encryptionKey}
|
|
1158
1151
|
onDecrypt={onDecrypt}
|
|
1152
|
+
isDecrypting={isDecrypting}
|
|
1159
1153
|
selectedSpan={selectedSpan}
|
|
1160
1154
|
/>
|
|
1161
1155
|
</ErrorBoundary>
|
|
@@ -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,
|
|
@@ -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
|
+
}
|