@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
|
@@ -6,10 +6,11 @@ import type { ModelMessage } from 'ai';
|
|
|
6
6
|
import { Lock } from 'lucide-react';
|
|
7
7
|
import type { KeyboardEvent, ReactNode } from 'react';
|
|
8
8
|
import { useCallback, useMemo, useState } from 'react';
|
|
9
|
-
import {
|
|
9
|
+
import { useToast } from '../../lib/toast';
|
|
10
10
|
import { isEncryptedMarker } from '../../lib/hydration';
|
|
11
11
|
import { extractConversation, isDoStreamStep } from '../../lib/utils';
|
|
12
12
|
import { StreamClickContext } from '../ui/data-inspector';
|
|
13
|
+
import { TimestampTooltip } from '../ui/timestamp-tooltip';
|
|
13
14
|
import { ErrorCard } from '../ui/error-card';
|
|
14
15
|
import {
|
|
15
16
|
ErrorStackBlock,
|
|
@@ -336,6 +337,16 @@ const localMillisecondTimeOrNull = (value: unknown): string | null => {
|
|
|
336
337
|
return formatLocalMillisecondTime(date);
|
|
337
338
|
};
|
|
338
339
|
|
|
340
|
+
const timestampWithTooltipOrNull = (value: unknown): ReactNode | null => {
|
|
341
|
+
const date = parseDateValue(value);
|
|
342
|
+
if (!date) return null;
|
|
343
|
+
return (
|
|
344
|
+
<TimestampTooltip date={date}>
|
|
345
|
+
<span>{formatLocalMillisecondTime(date)}</span>
|
|
346
|
+
</TimestampTooltip>
|
|
347
|
+
);
|
|
348
|
+
};
|
|
349
|
+
|
|
339
350
|
interface DisplayContext {
|
|
340
351
|
stepName?: string;
|
|
341
352
|
}
|
|
@@ -375,15 +386,14 @@ const attributeToDisplayFn: Record<
|
|
|
375
386
|
projectId: (_value: unknown) => null,
|
|
376
387
|
environment: (_value: unknown) => null,
|
|
377
388
|
executionContext: (_value: unknown) => null,
|
|
378
|
-
// Dates
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
resumeAt: localMillisecondTimeOrNull,
|
|
389
|
+
// Dates — wrapped with TimestampTooltip showing UTC/local + relative time
|
|
390
|
+
createdAt: timestampWithTooltipOrNull,
|
|
391
|
+
startedAt: timestampWithTooltipOrNull,
|
|
392
|
+
updatedAt: timestampWithTooltipOrNull,
|
|
393
|
+
completedAt: timestampWithTooltipOrNull,
|
|
394
|
+
expiredAt: timestampWithTooltipOrNull,
|
|
395
|
+
retryAfter: timestampWithTooltipOrNull,
|
|
396
|
+
resumeAt: timestampWithTooltipOrNull,
|
|
387
397
|
// Resolved attributes, won't actually use this function
|
|
388
398
|
metadata: (value: unknown) => {
|
|
389
399
|
if (!hasDisplayContent(value)) return null;
|
|
@@ -580,12 +590,6 @@ export const AttributeBlock = ({
|
|
|
580
590
|
attribute === 'output' ||
|
|
581
591
|
attribute === 'eventData';
|
|
582
592
|
if (isLoading && isExpandableLoadingTarget) {
|
|
583
|
-
const label =
|
|
584
|
-
attribute === 'eventData'
|
|
585
|
-
? 'Event Data'
|
|
586
|
-
: attribute === 'output'
|
|
587
|
-
? 'Output'
|
|
588
|
-
: 'Input';
|
|
589
593
|
return (
|
|
590
594
|
<div
|
|
591
595
|
className={`my-2 flex flex-col ${attribute === 'input' || attribute === 'output' ? 'gap-2 my-3.5' : 'gap-0'}`}
|
|
@@ -596,19 +600,7 @@ export const AttributeBlock = ({
|
|
|
596
600
|
>
|
|
597
601
|
{attribute}
|
|
598
602
|
</span>
|
|
599
|
-
<
|
|
600
|
-
summary={label}
|
|
601
|
-
summaryClassName="text-base py-2"
|
|
602
|
-
disabled
|
|
603
|
-
/>
|
|
604
|
-
<div
|
|
605
|
-
className="overflow-x-auto rounded-md border p-3"
|
|
606
|
-
style={{ borderColor: 'var(--ds-gray-300)' }}
|
|
607
|
-
>
|
|
608
|
-
<Skeleton className="h-4 w-[38%]" />
|
|
609
|
-
<Skeleton className="mt-2 h-4 w-[88%]" />
|
|
610
|
-
<Skeleton className="mt-2 h-4 w-[72%]" />
|
|
611
|
-
</div>
|
|
603
|
+
<Skeleton className="h-9 w-full rounded-md" />
|
|
612
604
|
</div>
|
|
613
605
|
);
|
|
614
606
|
}
|
|
@@ -674,6 +666,7 @@ export const AttributePanel = ({
|
|
|
674
666
|
error,
|
|
675
667
|
expiredAt,
|
|
676
668
|
onStreamClick,
|
|
669
|
+
resource,
|
|
677
670
|
}: {
|
|
678
671
|
data: Record<string, unknown>;
|
|
679
672
|
moduleSpecifier?: string;
|
|
@@ -682,7 +675,10 @@ export const AttributePanel = ({
|
|
|
682
675
|
expiredAt?: string | Date;
|
|
683
676
|
/** Callback when a stream reference is clicked */
|
|
684
677
|
onStreamClick?: (streamId: string) => void;
|
|
678
|
+
/** Resource type of the selected span — used to show targeted loading skeletons. */
|
|
679
|
+
resource?: string;
|
|
685
680
|
}) => {
|
|
681
|
+
const toast = useToast();
|
|
686
682
|
// Extract workflowCoreVersion from executionContext for display
|
|
687
683
|
const displayData = useMemo(() => {
|
|
688
684
|
const result = { ...data };
|
|
@@ -705,9 +701,23 @@ export const AttributePanel = ({
|
|
|
705
701
|
const basicAttributes = Object.keys(displayData)
|
|
706
702
|
.filter((key) => !resolvableAttributes.includes(key))
|
|
707
703
|
.sort(sortByAttributeOrder);
|
|
708
|
-
const resolvedAttributes =
|
|
709
|
-
|
|
710
|
-
|
|
704
|
+
const resolvedAttributes = useMemo(() => {
|
|
705
|
+
const present = Object.keys(displayData)
|
|
706
|
+
.filter((key) => resolvableAttributes.includes(key))
|
|
707
|
+
.sort(sortByAttributeOrder);
|
|
708
|
+
|
|
709
|
+
if (!isLoading) return present;
|
|
710
|
+
|
|
711
|
+
// During loading, ensure input/output appear so their skeletons render
|
|
712
|
+
// in the correct position (above the events section).
|
|
713
|
+
const loadingDefaults = ['input', 'output'];
|
|
714
|
+
for (const key of loadingDefaults) {
|
|
715
|
+
if (!present.includes(key)) {
|
|
716
|
+
present.push(key);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
return present.sort(sortByAttributeOrder);
|
|
720
|
+
}, [displayData, isLoading]);
|
|
711
721
|
|
|
712
722
|
// Filter out attributes that return null
|
|
713
723
|
const visibleBasicAttributes = basicAttributes.filter((attribute) => {
|
|
@@ -784,7 +794,11 @@ export const AttributePanel = ({
|
|
|
784
794
|
? displayValue
|
|
785
795
|
: String(displayValue ?? displayData.moduleSpecifier ?? '');
|
|
786
796
|
const shouldCapitalizeLabel = attribute !== 'workflowCoreVersion';
|
|
787
|
-
const
|
|
797
|
+
const showResumeAtSkeleton =
|
|
798
|
+
isLoading && resource === 'sleep' && !displayData.resumeAt;
|
|
799
|
+
const showDivider =
|
|
800
|
+
index < orderedBasicAttributes.length - 1 ||
|
|
801
|
+
showResumeAtSkeleton;
|
|
788
802
|
|
|
789
803
|
return (
|
|
790
804
|
<div key={attribute} className="py-1">
|
|
@@ -834,6 +848,19 @@ export const AttributePanel = ({
|
|
|
834
848
|
</div>
|
|
835
849
|
);
|
|
836
850
|
})}
|
|
851
|
+
{isLoading && resource === 'sleep' && !displayData.resumeAt && (
|
|
852
|
+
<div className="py-1">
|
|
853
|
+
<div className="flex min-h-[32px] items-center justify-between gap-4 rounded-sm px-2.5 py-1">
|
|
854
|
+
<span
|
|
855
|
+
className="text-[14px] first-letter:uppercase"
|
|
856
|
+
style={{ color: 'var(--ds-gray-700)' }}
|
|
857
|
+
>
|
|
858
|
+
resumeAt
|
|
859
|
+
</span>
|
|
860
|
+
<Skeleton className="h-4 w-[55%]" />
|
|
861
|
+
</div>
|
|
862
|
+
</div>
|
|
863
|
+
)}
|
|
837
864
|
</div>
|
|
838
865
|
)}
|
|
839
866
|
{error ? (
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { Copy } from 'lucide-react';
|
|
4
|
-
import {
|
|
4
|
+
import { useToast } from '../../lib/toast';
|
|
5
5
|
import { DataInspector } from '../ui/data-inspector';
|
|
6
6
|
|
|
7
7
|
const serializeForClipboard = (value: unknown): string => {
|
|
@@ -21,6 +21,7 @@ const serializeForClipboard = (value: unknown): string => {
|
|
|
21
21
|
};
|
|
22
22
|
|
|
23
23
|
export function CopyableDataBlock({ data }: { data: unknown }) {
|
|
24
|
+
const toast = useToast();
|
|
24
25
|
return (
|
|
25
26
|
<div
|
|
26
27
|
className="relative overflow-x-auto rounded-md border p-3 pt-9"
|
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
import type { Event, Hook, Step, WorkflowRun } from '@workflow/world';
|
|
4
4
|
import clsx from 'clsx';
|
|
5
|
-
import {
|
|
6
|
-
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
7
|
-
import {
|
|
5
|
+
import { Send, Zap } from 'lucide-react';
|
|
6
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
7
|
+
import { useToast } from '../../lib/toast';
|
|
8
8
|
import { isEncryptedMarker } from '../../lib/hydration';
|
|
9
|
+
import { DecryptButton } from '../ui/decrypt-button';
|
|
9
10
|
import { AttributePanel } from './attribute-panel';
|
|
10
11
|
import { EventsList } from './events-list';
|
|
11
12
|
import { ResolveHookModal } from './resolve-hook-modal';
|
|
@@ -64,6 +65,7 @@ export function EntityDetailPanel({
|
|
|
64
65
|
onResolveHook,
|
|
65
66
|
encryptionKey,
|
|
66
67
|
onDecrypt,
|
|
68
|
+
isDecrypting = false,
|
|
67
69
|
selectedSpan,
|
|
68
70
|
}: {
|
|
69
71
|
run: WorkflowRun;
|
|
@@ -97,9 +99,12 @@ export function EntityDetailPanel({
|
|
|
97
99
|
encryptionKey?: Uint8Array;
|
|
98
100
|
/** Callback to initiate decryption of encrypted run data */
|
|
99
101
|
onDecrypt?: () => void;
|
|
102
|
+
/** Whether the encryption key is currently being fetched */
|
|
103
|
+
isDecrypting?: boolean;
|
|
100
104
|
/** Info about the currently selected span from the trace viewer */
|
|
101
105
|
selectedSpan: SelectedSpanInfo | null;
|
|
102
106
|
}): React.JSX.Element | null {
|
|
107
|
+
const toast = useToast();
|
|
103
108
|
const [stoppingSleep, setStoppingSleep] = useState(false);
|
|
104
109
|
const [showResolveHookModal, setShowResolveHookModal] = useState(false);
|
|
105
110
|
const [resolvingHook, setResolvingHook] = useState(false);
|
|
@@ -140,20 +145,26 @@ export function EntityDetailPanel({
|
|
|
140
145
|
return { resource: undefined, resourceId: undefined, runId: undefined };
|
|
141
146
|
}, [selectedSpan, data]);
|
|
142
147
|
|
|
143
|
-
// Notify parent when span selection changes
|
|
148
|
+
// Notify parent when span selection changes.
|
|
149
|
+
// Use a ref for the callback so the effect only fires when the actual
|
|
150
|
+
// selection values change, not when the callback identity changes due to
|
|
151
|
+
// parent re-renders from polling.
|
|
152
|
+
const onSpanSelectRef = useRef(onSpanSelect);
|
|
153
|
+
onSpanSelectRef.current = onSpanSelect;
|
|
154
|
+
|
|
144
155
|
useEffect(() => {
|
|
145
156
|
if (
|
|
146
157
|
resource &&
|
|
147
158
|
resourceId &&
|
|
148
159
|
['run', 'step', 'hook', 'sleep'].includes(resource)
|
|
149
160
|
) {
|
|
150
|
-
|
|
161
|
+
onSpanSelectRef.current({
|
|
151
162
|
resource: resource as 'run' | 'step' | 'hook' | 'sleep',
|
|
152
163
|
resourceId,
|
|
153
164
|
runId,
|
|
154
165
|
});
|
|
155
166
|
}
|
|
156
|
-
}, [resource, resourceId, runId
|
|
167
|
+
}, [resource, resourceId, runId]);
|
|
157
168
|
|
|
158
169
|
// Check if this sleep is still pending and can be woken up
|
|
159
170
|
const canWakeUp = useMemo(() => {
|
|
@@ -381,31 +392,11 @@ export function EntityDetailPanel({
|
|
|
381
392
|
</p>
|
|
382
393
|
</div>
|
|
383
394
|
{(hasEncryptedFields || encryptionKey) && onDecrypt && (
|
|
384
|
-
<
|
|
385
|
-
|
|
395
|
+
<DecryptButton
|
|
396
|
+
decrypted={!!encryptionKey}
|
|
397
|
+
loading={isDecrypting}
|
|
386
398
|
onClick={onDecrypt}
|
|
387
|
-
|
|
388
|
-
className="inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs font-medium transition-colors flex-shrink-0"
|
|
389
|
-
style={{
|
|
390
|
-
borderColor: encryptionKey
|
|
391
|
-
? 'var(--ds-green-400)'
|
|
392
|
-
: 'var(--ds-gray-300)',
|
|
393
|
-
color: encryptionKey
|
|
394
|
-
? 'var(--ds-green-900)'
|
|
395
|
-
: 'var(--ds-gray-900)',
|
|
396
|
-
backgroundColor: encryptionKey
|
|
397
|
-
? 'var(--ds-green-100)'
|
|
398
|
-
: 'var(--ds-background-100)',
|
|
399
|
-
cursor: encryptionKey ? 'default' : 'pointer',
|
|
400
|
-
}}
|
|
401
|
-
>
|
|
402
|
-
{encryptionKey ? (
|
|
403
|
-
<Unlock className="h-3 w-3" />
|
|
404
|
-
) : (
|
|
405
|
-
<Lock className="h-3 w-3" />
|
|
406
|
-
)}
|
|
407
|
-
{encryptionKey ? 'Decrypted' : 'Decrypt'}
|
|
408
|
-
</button>
|
|
399
|
+
/>
|
|
409
400
|
)}
|
|
410
401
|
</div>
|
|
411
402
|
</div>
|
|
@@ -490,6 +481,7 @@ export function EntityDetailPanel({
|
|
|
490
481
|
isLoading={loading}
|
|
491
482
|
error={error ?? undefined}
|
|
492
483
|
onStreamClick={onStreamClick}
|
|
484
|
+
resource={resource}
|
|
493
485
|
/>
|
|
494
486
|
</section>
|
|
495
487
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import type { Event } from '@workflow/world';
|
|
4
4
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
5
|
+
import { ErrorCard } from '../ui/error-card';
|
|
5
6
|
import {
|
|
6
7
|
ErrorStackBlock,
|
|
7
8
|
isStructuredErrorWithStack,
|
|
@@ -23,10 +24,14 @@ const DATA_EVENT_TYPES = new Set([
|
|
|
23
24
|
'step_created',
|
|
24
25
|
'step_completed',
|
|
25
26
|
'step_failed',
|
|
27
|
+
'step_retrying',
|
|
26
28
|
'hook_created',
|
|
27
29
|
'hook_received',
|
|
28
30
|
'run_created',
|
|
29
31
|
'run_completed',
|
|
32
|
+
'run_failed',
|
|
33
|
+
'wait_created',
|
|
34
|
+
'wait_completed',
|
|
30
35
|
]);
|
|
31
36
|
|
|
32
37
|
/**
|
|
@@ -175,15 +180,11 @@ function EventItem({
|
|
|
175
180
|
|
|
176
181
|
{/* Error state */}
|
|
177
182
|
{loadError && (
|
|
178
|
-
<
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
}}
|
|
184
|
-
>
|
|
185
|
-
{loadError}
|
|
186
|
-
</div>
|
|
183
|
+
<ErrorCard
|
|
184
|
+
title="Failed to load event data"
|
|
185
|
+
details={loadError}
|
|
186
|
+
className="mt-2"
|
|
187
|
+
/>
|
|
187
188
|
)}
|
|
188
189
|
|
|
189
190
|
{/* Event data */}
|
|
@@ -269,10 +270,10 @@ export function EventsList({
|
|
|
269
270
|
Events {!isLoading && `(${sortedEvents.length})`}
|
|
270
271
|
</h3>
|
|
271
272
|
{isLoading ? (
|
|
272
|
-
<div className="flex flex-col gap-
|
|
273
|
-
<Skeleton className="h-
|
|
274
|
-
<Skeleton className="h-
|
|
275
|
-
<Skeleton className="h-
|
|
273
|
+
<div className="flex flex-col gap-4">
|
|
274
|
+
<Skeleton className="h-9 w-full rounded-md" />
|
|
275
|
+
<Skeleton className="h-9 w-full rounded-md" />
|
|
276
|
+
<Skeleton className="h-9 w-full rounded-md" />
|
|
276
277
|
</div>
|
|
277
278
|
) : null}
|
|
278
279
|
{!isLoading && !error && sortedEvents.length === 0 && (
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import React, {
|
|
3
|
+
import React, { useEffect, useRef } from 'react';
|
|
4
|
+
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso';
|
|
4
5
|
import { DataInspector } from './ui/data-inspector';
|
|
5
6
|
import { Skeleton } from './ui/skeleton';
|
|
6
7
|
|
|
@@ -46,6 +47,8 @@ interface StreamViewerProps {
|
|
|
46
47
|
error?: string | null;
|
|
47
48
|
/** True while the initial stream connection is being established */
|
|
48
49
|
isLoading?: boolean;
|
|
50
|
+
/** Called when the user scrolls near the bottom, for triggering pagination */
|
|
51
|
+
onScrollEnd?: () => void;
|
|
49
52
|
}
|
|
50
53
|
|
|
51
54
|
// ──────────────────────────────────────────────────────────────────────────
|
|
@@ -114,45 +117,41 @@ function StreamSkeleton() {
|
|
|
114
117
|
* of complex types (Map, Set, Date, custom classes, etc.).
|
|
115
118
|
*/
|
|
116
119
|
export function StreamViewer({
|
|
117
|
-
streamId,
|
|
120
|
+
streamId: _streamId,
|
|
118
121
|
chunks,
|
|
119
122
|
isLive,
|
|
120
123
|
error,
|
|
121
124
|
isLoading,
|
|
125
|
+
onScrollEnd,
|
|
122
126
|
}: StreamViewerProps) {
|
|
123
|
-
const
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
const checkScrollPosition = useCallback(() => {
|
|
127
|
-
if (scrollRef.current) {
|
|
128
|
-
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
|
|
129
|
-
const isAtBottom = scrollHeight - scrollTop - clientHeight < 10;
|
|
130
|
-
setHasMoreBelow(!isAtBottom && scrollHeight > clientHeight);
|
|
131
|
-
}
|
|
132
|
-
}, []);
|
|
127
|
+
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
|
128
|
+
const prevChunkCountRef = useRef(0);
|
|
133
129
|
|
|
134
|
-
//
|
|
130
|
+
// Auto-scroll to bottom when new chunks arrive (live streaming)
|
|
135
131
|
useEffect(() => {
|
|
136
|
-
if (
|
|
137
|
-
|
|
132
|
+
if (chunks.length > prevChunkCountRef.current && chunks.length > 0) {
|
|
133
|
+
virtuosoRef.current?.scrollToIndex({
|
|
134
|
+
index: chunks.length - 1,
|
|
135
|
+
align: 'end',
|
|
136
|
+
});
|
|
138
137
|
}
|
|
139
|
-
|
|
140
|
-
}, [chunks.length
|
|
138
|
+
prevChunkCountRef.current = chunks.length;
|
|
139
|
+
}, [chunks.length]);
|
|
141
140
|
|
|
142
141
|
// Show skeleton when loading and no chunks have arrived yet
|
|
143
142
|
if (isLoading && chunks.length === 0) {
|
|
144
143
|
return (
|
|
145
|
-
<div className="flex flex-col h-full
|
|
144
|
+
<div className="flex flex-col h-full">
|
|
146
145
|
<StreamSkeleton />
|
|
147
146
|
</div>
|
|
148
147
|
);
|
|
149
148
|
}
|
|
150
149
|
|
|
151
150
|
return (
|
|
152
|
-
<div className="flex flex-col h-full
|
|
151
|
+
<div className="flex flex-col h-full">
|
|
153
152
|
{/* Live indicator */}
|
|
154
153
|
{isLive && (
|
|
155
|
-
<div className="flex items-center gap-1.5 mb-
|
|
154
|
+
<div className="flex items-center gap-1.5 mb-2 px-1">
|
|
156
155
|
<span
|
|
157
156
|
className="inline-block w-2 h-2 rounded-full"
|
|
158
157
|
style={{ backgroundColor: 'var(--ds-green-600)' }}
|
|
@@ -182,52 +181,42 @@ export function StreamViewer({
|
|
|
182
181
|
)}
|
|
183
182
|
|
|
184
183
|
{/* Content */}
|
|
185
|
-
<div className="
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
>
|
|
200
|
-
<div>Error reading stream:</div>
|
|
201
|
-
<div>{error}</div>
|
|
202
|
-
</div>
|
|
203
|
-
) : chunks.length === 0 ? (
|
|
204
|
-
<div
|
|
205
|
-
className="text-[11px] rounded-md border p-3"
|
|
206
|
-
style={{
|
|
207
|
-
borderColor: 'var(--ds-gray-300)',
|
|
208
|
-
backgroundColor: 'var(--ds-gray-100)',
|
|
209
|
-
color: 'var(--ds-gray-600)',
|
|
210
|
-
}}
|
|
211
|
-
>
|
|
212
|
-
{isLive ? 'Waiting for stream data...' : 'Stream is empty'}
|
|
213
|
-
</div>
|
|
214
|
-
) : (
|
|
215
|
-
chunks.map((chunk, index) => (
|
|
216
|
-
<ChunkRow
|
|
217
|
-
key={`${streamId}-chunk-${chunk.id}`}
|
|
218
|
-
chunk={chunk}
|
|
219
|
-
index={index}
|
|
220
|
-
/>
|
|
221
|
-
))
|
|
222
|
-
)}
|
|
223
|
-
</div>
|
|
224
|
-
{hasMoreBelow && (
|
|
184
|
+
<div className="flex-1 min-h-0">
|
|
185
|
+
{error ? (
|
|
186
|
+
<div
|
|
187
|
+
className="text-[11px] rounded-md border p-3"
|
|
188
|
+
style={{
|
|
189
|
+
borderColor: 'var(--ds-red-300)',
|
|
190
|
+
backgroundColor: 'var(--ds-red-100)',
|
|
191
|
+
color: 'var(--ds-red-700)',
|
|
192
|
+
}}
|
|
193
|
+
>
|
|
194
|
+
<div>Error reading stream:</div>
|
|
195
|
+
<div>{error}</div>
|
|
196
|
+
</div>
|
|
197
|
+
) : chunks.length === 0 ? (
|
|
225
198
|
<div
|
|
226
|
-
className="
|
|
199
|
+
className="text-[11px] rounded-md border p-3"
|
|
227
200
|
style={{
|
|
228
|
-
|
|
229
|
-
|
|
201
|
+
borderColor: 'var(--ds-gray-300)',
|
|
202
|
+
backgroundColor: 'var(--ds-gray-100)',
|
|
203
|
+
color: 'var(--ds-gray-600)',
|
|
230
204
|
}}
|
|
205
|
+
>
|
|
206
|
+
{isLive ? 'Waiting for stream data...' : 'Stream is empty'}
|
|
207
|
+
</div>
|
|
208
|
+
) : (
|
|
209
|
+
<Virtuoso
|
|
210
|
+
ref={virtuosoRef}
|
|
211
|
+
totalCount={chunks.length}
|
|
212
|
+
overscan={10}
|
|
213
|
+
endReached={() => onScrollEnd?.()}
|
|
214
|
+
itemContent={(index) => (
|
|
215
|
+
<div style={{ paddingBottom: 8 }}>
|
|
216
|
+
<ChunkRow chunk={chunks[index]} index={index} />
|
|
217
|
+
</div>
|
|
218
|
+
)}
|
|
219
|
+
style={{ flex: 1, minHeight: 0 }}
|
|
231
220
|
/>
|
|
232
221
|
)}
|
|
233
222
|
</div>
|
|
@@ -341,6 +341,26 @@ function computeRunSegments(node: SpanNode): Segment[] {
|
|
|
341
341
|
|
|
342
342
|
if (duration <= 0) return segments;
|
|
343
343
|
|
|
344
|
+
// V1 runs (specVersion 1) don't emit run lifecycle events (run_created,
|
|
345
|
+
// run_started, run_completed). Detect this by checking for the absence of
|
|
346
|
+
// run_created — it's always the first event for v2 runs and is always
|
|
347
|
+
// loaded in the first page, so its absence reliably signals a v1 run.
|
|
348
|
+
// For v1, fall back to the run entity's status from span.attributes.data.
|
|
349
|
+
const hasRunCreated = events.some((e) => e.event.name === 'run_created');
|
|
350
|
+
|
|
351
|
+
if (!hasRunCreated) {
|
|
352
|
+
const runData = node.span.attributes?.data as
|
|
353
|
+
| Record<string, unknown>
|
|
354
|
+
| undefined;
|
|
355
|
+
const runStatus = runData?.status as string | undefined;
|
|
356
|
+
return computeV1RunSegments(
|
|
357
|
+
startTime,
|
|
358
|
+
duration,
|
|
359
|
+
activeStartTime,
|
|
360
|
+
runStatus
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
|
|
344
364
|
const failedEvent = events.find((e) => e.event.name === 'run_failed');
|
|
345
365
|
const completedEvent = events.find((e) => e.event.name === 'run_completed');
|
|
346
366
|
|
|
@@ -396,7 +416,56 @@ function computeRunSegments(node: SpanNode): Segment[] {
|
|
|
396
416
|
status: 'succeeded',
|
|
397
417
|
});
|
|
398
418
|
} else {
|
|
399
|
-
// Running to completion
|
|
419
|
+
// Running to completion (or terminal events haven't loaded yet)
|
|
420
|
+
segments.push({
|
|
421
|
+
startFraction: cursor,
|
|
422
|
+
endFraction: 1,
|
|
423
|
+
status: 'running',
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
return segments;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Compute segments for a v1 run using only the run entity's status and
|
|
432
|
+
* activeStartTime (derived from run.startedAt). V1 runs have no run
|
|
433
|
+
* lifecycle events, so we infer the visual segments from entity fields.
|
|
434
|
+
*/
|
|
435
|
+
function computeV1RunSegments(
|
|
436
|
+
startTime: number,
|
|
437
|
+
duration: number,
|
|
438
|
+
activeStartTime: number | undefined,
|
|
439
|
+
runStatus: string | undefined
|
|
440
|
+
): Segment[] {
|
|
441
|
+
const segments: Segment[] = [];
|
|
442
|
+
|
|
443
|
+
let cursor = 0;
|
|
444
|
+
if (activeStartTime && activeStartTime > startTime) {
|
|
445
|
+
const queuedFraction = timeToFraction(activeStartTime, startTime, duration);
|
|
446
|
+
if (queuedFraction > 0.001) {
|
|
447
|
+
segments.push({
|
|
448
|
+
startFraction: 0,
|
|
449
|
+
endFraction: queuedFraction,
|
|
450
|
+
status: 'queued',
|
|
451
|
+
});
|
|
452
|
+
cursor = queuedFraction;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (runStatus === 'failed') {
|
|
457
|
+
segments.push({
|
|
458
|
+
startFraction: cursor,
|
|
459
|
+
endFraction: 1,
|
|
460
|
+
status: 'failed',
|
|
461
|
+
});
|
|
462
|
+
} else if (runStatus === 'completed' || runStatus === 'cancelled') {
|
|
463
|
+
segments.push({
|
|
464
|
+
startFraction: cursor,
|
|
465
|
+
endFraction: 1,
|
|
466
|
+
status: 'succeeded',
|
|
467
|
+
});
|
|
468
|
+
} else {
|
|
400
469
|
segments.push({
|
|
401
470
|
startFraction: cursor,
|
|
402
471
|
endFraction: 1,
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Spinner } from './spinner';
|
|
4
|
+
|
|
5
|
+
const STYLES = `.wf-decrypt-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;cursor:pointer;white-space:nowrap;gap:6px;transition:background 150ms}.wf-decrypt-idle{color:var(--ds-gray-1000);background:var(--ds-background-100);box-shadow:0 0 0 1px var(--ds-gray-400)}.wf-decrypt-idle:hover{background:var(--ds-gray-alpha-200)}.wf-decrypt-done{color:var(--ds-green-900);background:var(--ds-green-100);box-shadow:0 0 0 1px var(--ds-green-400);cursor:default}`;
|
|
6
|
+
|
|
7
|
+
interface DecryptButtonProps {
|
|
8
|
+
/** Whether an encryption key has been obtained (decryption is active). */
|
|
9
|
+
decrypted?: boolean;
|
|
10
|
+
/** Whether the key is currently being fetched. */
|
|
11
|
+
loading?: boolean;
|
|
12
|
+
/** Called when the user clicks to initiate decryption. */
|
|
13
|
+
onClick?: () => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Decrypt/Decrypted button using Geist secondary style.
|
|
18
|
+
* Three states: idle (secondary gray), decrypting (spinner), decrypted (green success).
|
|
19
|
+
*/
|
|
20
|
+
export function DecryptButton({
|
|
21
|
+
decrypted = false,
|
|
22
|
+
loading = false,
|
|
23
|
+
onClick,
|
|
24
|
+
}: DecryptButtonProps) {
|
|
25
|
+
return (
|
|
26
|
+
<>
|
|
27
|
+
<style dangerouslySetInnerHTML={{ __html: STYLES }} />
|
|
28
|
+
<button
|
|
29
|
+
type="button"
|
|
30
|
+
onClick={decrypted ? undefined : onClick}
|
|
31
|
+
disabled={decrypted || loading}
|
|
32
|
+
className={`wf-decrypt-btn ${decrypted ? 'wf-decrypt-done' : 'wf-decrypt-idle'}`}
|
|
33
|
+
>
|
|
34
|
+
{loading ? (
|
|
35
|
+
<Spinner size={14} />
|
|
36
|
+
) : decrypted ? (
|
|
37
|
+
<svg
|
|
38
|
+
width={14}
|
|
39
|
+
height={14}
|
|
40
|
+
viewBox="0 0 24 24"
|
|
41
|
+
fill="none"
|
|
42
|
+
stroke="currentColor"
|
|
43
|
+
strokeWidth={2}
|
|
44
|
+
strokeLinecap="round"
|
|
45
|
+
strokeLinejoin="round"
|
|
46
|
+
>
|
|
47
|
+
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
|
48
|
+
<path d="M7 11V7a5 5 0 0 1 9.9-1" />
|
|
49
|
+
</svg>
|
|
50
|
+
) : (
|
|
51
|
+
<svg
|
|
52
|
+
width={14}
|
|
53
|
+
height={14}
|
|
54
|
+
viewBox="0 0 24 24"
|
|
55
|
+
fill="none"
|
|
56
|
+
stroke="currentColor"
|
|
57
|
+
strokeWidth={2}
|
|
58
|
+
strokeLinecap="round"
|
|
59
|
+
strokeLinejoin="round"
|
|
60
|
+
>
|
|
61
|
+
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
|
62
|
+
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
|
63
|
+
</svg>
|
|
64
|
+
)}
|
|
65
|
+
{loading ? 'Decrypting…' : decrypted ? 'Decrypted' : 'Decrypt'}
|
|
66
|
+
</button>
|
|
67
|
+
</>
|
|
68
|
+
);
|
|
69
|
+
}
|