Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
ad8b7f1
Merge branch 'main' of github.com:vercel/workflow
karthikscale3 Jan 27, 2026
e0cb61b
Merge branch 'main' of github.com:vercel/workflow
karthikscale3 Jan 28, 2026
051cadc
Merge branch 'main' of github.com:vercel/workflow
karthikscale3 Jan 29, 2026
af8ac1f
Merge branch 'main' of github.com:vercel/workflow
karthikscale3 Jan 29, 2026
e68e7d2
Merge branch 'main' of github.com:vercel/workflow
karthikscale3 Jan 30, 2026
8305e5b
Merge branch 'main' of github.com:vercel/workflow
karthikscale3 Jan 30, 2026
f3da688
Merge branch 'main' of github.com:vercel/workflow
karthikscale3 Jan 31, 2026
f8ee413
Merge branch 'main' of github.com:vercel/workflow
karthikscale3 Feb 1, 2026
589cbd7
Merge branch 'main' of github.com:vercel/workflow
karthikscale3 Feb 2, 2026
e979fdf
Merge branch 'main' of github.com:vercel/workflow
karthikscale3 Feb 3, 2026
d4baed2
Merge branch 'main' of github.com:vercel/workflow
karthikscale3 Feb 6, 2026
53503a4
Merge branch 'main' of github.com:vercel/workflow
karthikscale3 Feb 10, 2026
d51e2e4
Merge branch 'main' of github.com:vercel/workflow
karthikscale3 Feb 13, 2026
5123088
Merge branch 'main' of github.com:vercel/workflow
karthikscale3 Feb 13, 2026
dd1a307
Merge branch 'main' of github.com:vercel/workflow
karthikscale3 Feb 13, 2026
00bcfcd
Merge branch 'main' of github.com:vercel/workflow
karthikscale3 Feb 14, 2026
f6a157b
Merge branch 'main' of github.com:vercel/workflow
karthikscale3 Feb 15, 2026
816f35b
Merge branch 'main' of github.com:vercel/workflow
karthikscale3 Feb 16, 2026
2b87dce
Merge branch 'main' of github.com:vercel/workflow
karthikscale3 Feb 17, 2026
146de28
Merge branch 'main' of github.com:vercel/workflow
karthikscale3 Feb 18, 2026
0ef6455
Merge branch 'main' of github.com:vercel/workflow
karthikscale3 Feb 18, 2026
2d8c690
Merge branch 'main' of github.com:vercel/workflow
karthikscale3 Feb 18, 2026
3513471
Merge branch 'main' of github.com:vercel/workflow
karthikscale3 Feb 20, 2026
0f5d29e
Merge branch 'main' of github.com:vercel/workflow
karthikscale3 Feb 20, 2026
c76001e
Merge branch 'main' of github.com:vercel/workflow
karthikscale3 Feb 23, 2026
f97184f
Merge branch 'main' of github.com:vercel/workflow
karthikscale3 Feb 24, 2026
f71c531
Merge branch 'main' of github.com:vercel/workflow
karthikscale3 Feb 25, 2026
530e598
Merge branch 'main' of github.com:vercel/workflow
karthikscale3 Feb 25, 2026
ea38511
Merge branch 'main' of github.com:vercel/workflow
karthikscale3 Feb 27, 2026
19b1a3f
Merge branch 'main' of github.com:vercel/workflow
karthikscale3 Mar 2, 2026
757bfdb
Merge branch 'main' of github.com:vercel/workflow
karthikscale3 Mar 2, 2026
5fb4cfb
Merge branch 'main' of github.com:vercel/workflow
karthikscale3 Mar 3, 2026
cb5adc3
Merge branch 'main' of github.com:vercel/workflow
karthikscale3 Mar 3, 2026
046fdbc
Merge branch 'main' of github.com:vercel/workflow
karthikscale3 Mar 3, 2026
2a7884f
Merge branch 'main' of github.com:vercel/workflow
karthikscale3 Mar 3, 2026
60ecae6
Merge branch 'main' of github.com:vercel/workflow
karthikscale3 Mar 5, 2026
71a6e95
Merge branch 'main' of github.com:vercel/workflow
karthikscale3 Mar 6, 2026
25fb139
Merge branch 'main' of github.com:vercel/workflow
karthikscale3 Mar 9, 2026
8fe6a36
Merge branch 'main' of github.com:vercel/workflow
karthikscale3 Mar 9, 2026
d7cb1a3
Merge branch 'main' of github.com:vercel/workflow
karthikscale3 Mar 10, 2026
99b34fe
Merge branch 'main' of github.com:vercel/workflow
karthikscale3 Mar 10, 2026
e63bfca
Merge branch 'main' of github.com:vercel/workflow
karthikscale3 Mar 11, 2026
810af84
Merge branch 'main' of github.com:vercel/workflow
karthikscale3 Mar 11, 2026
67142f9
Merge branch 'main' of github.com:vercel/workflow
karthikscale3 Mar 11, 2026
58b47e7
Merge branch 'main' of github.com:vercel/workflow
karthikscale3 Mar 12, 2026
4dfac66
Merge branch 'main' of github.com:vercel/workflow
karthikscale3 Mar 12, 2026
c39b0f4
decouple data fetching between trace viewer and events tab
karthikscale3 Mar 12, 2026
760cae0
add load more button
karthikscale3 Mar 12, 2026
93d5aa5
Merge branch 'main' of github.com:vercel/workflow into karthik/workfl…
karthikscale3 Mar 12, 2026
be86507
add encryption button to events tab
karthikscale3 Mar 12, 2026
6a93e7b
virtualize streams tab
karthikscale3 Mar 12, 2026
644b027
Merge branch 'main' of github.com:vercel/workflow into karthik/workfl…
karthikscale3 Mar 12, 2026
69e7566
add decrypt button for stream
karthikscale3 Mar 12, 2026
a9d1598
add changeset
karthikscale3 Mar 12, 2026
b59d942
fix agent comments
karthikscale3 Mar 12, 2026
69d1308
Fix events tab data fetching
karthikscale3 Mar 12, 2026
68a7ad6
Merge branch 'main' of github.com:vercel/workflow into karthik/workfl…
karthikscale3 Mar 12, 2026
3981574
Merge branch 'main' into karthik/workflow-o11y-polish-2
karthikscale3 Mar 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/flat-eels-dance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@workflow/web-shared": patch
"@workflow/web": patch
---

Improve workflow observability UX with decoupled pagination, stream virtualization, and decrypt actions
266 changes: 153 additions & 113 deletions packages/web-shared/src/components/event-list-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,15 @@ import { Check, ChevronRight, Copy } from 'lucide-react';
import type { MouseEvent as ReactMouseEvent, ReactNode } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso';
import { isEncryptedMarker } from '../lib/hydration';
import { DecryptButton } from './ui/decrypt-button';
import { formatDuration } from '../lib/utils';
import { DataInspector } from './ui/data-inspector';
import {
ErrorStackBlock,
isStructuredErrorWithStack,
} from './ui/error-stack-block';
import { LoadMoreButton } from './ui/load-more-button';
import { MenuDropdown } from './ui/menu-dropdown';
import { Skeleton } from './ui/skeleton';

Expand Down Expand Up @@ -596,6 +599,30 @@ const SORT_OPTIONS = [
{ value: 'asc' as const, label: 'Oldest' },
];

function RowsSkeleton() {
return (
<div className="flex-1 overflow-hidden">
{Array.from({ length: 8 }, (_, i) => (
<div
key={i}
className="flex items-center gap-3 px-4"
style={{ height: 40 }}
>
<Skeleton
className="h-2 w-2 flex-shrink-0"
style={{ borderRadius: '50%' }}
/>
<Skeleton className="h-3" style={{ width: 90 }} />
<Skeleton className="h-3" style={{ width: 100 }} />
<Skeleton className="h-3" style={{ width: 80 }} />
<Skeleton className="h-3 flex-1" />
<Skeleton className="h-3 flex-1" />
</div>
))}
</div>
);
}

// ──────────────────────────────────────────────────────────────────────────
// Event row
// ──────────────────────────────────────────────────────────────────────────
Expand All @@ -616,6 +643,10 @@ interface EventsListProps {
/** Called when the user changes sort order. When provided, the sort dropdown is shown
* and the parent is expected to refetch from the API with the new order. */
onSortOrderChange?: (order: 'asc' | 'desc') => void;
/** Called when the user clicks the Decrypt button. */
onDecrypt?: () => void;
/** Whether the encryption key is currently being fetched. */
isDecrypting?: boolean;
}

function EventRow({
Expand Down Expand Up @@ -1020,6 +1051,8 @@ export function EventListView({
isLoading = false,
sortOrder: sortOrderProp,
onSortOrderChange,
onDecrypt,
isDecrypting = false,
}: EventsListProps) {
const [internalSortOrder, setInternalSortOrder] = useState<'asc' | 'desc'>(
'asc'
Expand All @@ -1046,6 +1079,22 @@ export function EventListView({
);
}, [events, effectiveSortOrder]);

// Detect encrypted fields across all loaded events.
// Only checks top-level eventData values (input, output, result, etc.) —
// the current data model guarantees encrypted markers appear at this level.
const hasEncryptedData = useMemo(() => {
if (!events) return false;
for (const event of events) {
const ed = (event as Record<string, unknown>).eventData;
if (!ed || typeof ed !== 'object') continue;
const data = ed as Record<string, unknown>;
for (const val of Object.values(data)) {
if (isEncryptedMarker(val)) return true;
}
}
return false;
}, [events]);

const { correlationNameMap, workflowName } = useMemo(
() => buildNameMaps(events ?? null, run ?? null),
[events, run]
Expand Down Expand Up @@ -1201,52 +1250,43 @@ export function EventListView({
}
}, [searchQuery, searchIndex]);

if (!events || events.length === 0) {
if (isLoading) {
return (
<div className="h-full flex flex-col overflow-hidden">
{/* Skeleton search bar */}
<div style={{ padding: 6 }}>
<Skeleton style={{ height: 40, borderRadius: 6 }} />
</div>
{/* Skeleton header */}
<div
className="flex items-center gap-0 h-10 border-b flex-shrink-0 px-4"
style={{ borderColor: 'var(--ds-gray-alpha-200)' }}
>
<Skeleton className="h-3" style={{ width: 60 }} />
<div style={{ flex: 1 }} />
<Skeleton className="h-3" style={{ width: 80 }} />
<div style={{ flex: 1 }} />
<Skeleton className="h-3" style={{ width: 50 }} />
<div style={{ flex: 1 }} />
<Skeleton className="h-3" style={{ width: 90 }} />
<div style={{ flex: 1 }} />
<Skeleton className="h-3" style={{ width: 70 }} />
</div>
{/* Skeleton rows */}
<div className="flex-1 overflow-hidden">
{Array.from({ length: 8 }, (_, i) => (
<div
key={i}
className="flex items-center gap-3 px-4"
style={{ height: 40 }}
>
<Skeleton
className="h-2 w-2 flex-shrink-0"
style={{ borderRadius: '50%' }}
/>
<Skeleton className="h-3" style={{ width: 90 }} />
<Skeleton className="h-3" style={{ width: 100 }} />
<Skeleton className="h-3" style={{ width: 80 }} />
<Skeleton className="h-3 flex-1" />
<Skeleton className="h-3 flex-1" />
</div>
))}
</div>
// Track whether we've ever had events to distinguish initial load from refetch
const hasHadEventsRef = useRef(false);
if (sortedEvents.length > 0) {
hasHadEventsRef.current = true;
}
const isInitialLoad = isLoading && !hasHadEventsRef.current;
const isRefetching =
isLoading && hasHadEventsRef.current && sortedEvents.length === 0;

if (isInitialLoad) {
return (
<div className="h-full flex flex-col overflow-hidden">
{/* Skeleton search bar */}
<div style={{ padding: 6 }}>
<Skeleton style={{ height: 40, borderRadius: 6 }} />
</div>
);
}
{/* Skeleton header */}
<div
className="flex items-center gap-0 h-10 border-b flex-shrink-0 px-4"
style={{ borderColor: 'var(--ds-gray-alpha-200)' }}
>
<Skeleton className="h-3" style={{ width: 60 }} />
<div style={{ flex: 1 }} />
<Skeleton className="h-3" style={{ width: 80 }} />
<div style={{ flex: 1 }} />
<Skeleton className="h-3" style={{ width: 50 }} />
<div style={{ flex: 1 }} />
<Skeleton className="h-3" style={{ width: 90 }} />
<div style={{ flex: 1 }} />
<Skeleton className="h-3" style={{ width: 70 }} />
</div>
<RowsSkeleton />
</div>
);
}

if (!isLoading && (!events || events.length === 0)) {
return (
<div
className="flex items-center justify-center h-full text-sm"
Expand Down Expand Up @@ -1339,6 +1379,13 @@ export function EventListView({
value={effectiveSortOrder}
onChange={handleSortOrderChange}
/>
{(hasEncryptedData || encryptionKey) && onDecrypt && (
<DecryptButton
decrypted={!!encryptionKey}
loading={isDecrypting}
onClick={onDecrypt}
/>
)}
</div>

{/* Header */}
Expand Down Expand Up @@ -1369,82 +1416,75 @@ export function EventListView({
</div>
</div>

{/* Virtualized event rows */}
<Virtuoso
ref={virtuosoRef}
totalCount={sortedEvents.length}
overscan={20}
defaultItemHeight={40}
endReached={() => {
if (!hasMoreEvents || isLoadingMoreEvents) {
return;
}
void onLoadMoreEvents?.();
}}
itemContent={(index: number) => {
const ev = sortedEvents[index];
return (
<EventRow
event={ev}
index={index}
isFirst={index === 0}
isLast={index === sortedEvents.length - 1}
isExpanded={expandedEventIds.has(ev.eventId)}
onToggleExpand={toggleEventExpanded}
activeGroupKey={activeGroupKey}
selectedGroupKey={selectedGroupKey}
selectedGroupRange={selectedGroupRange}
correlationNameMap={correlationNameMap}
workflowName={workflowName}
durationMap={durationMap}
onSelectGroup={onSelectGroup}
onHoverGroup={onHoverGroup}
onLoadEventData={onLoadEventData}
cachedEventData={
eventDataCacheRef.current.get(ev.eventId) ?? null
}
onCacheEventData={cacheEventData}
encryptionKey={encryptionKey}
/>
);
}}
components={{
Footer: hasMoreEvents
? () => (
<div className="px-3 pt-3 flex justify-center">
<button
type="button"
onClick={() => void onLoadMoreEvents?.()}
disabled={isLoadingMoreEvents}
className="h-8 px-3 text-xs rounded-md border transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
style={{
borderColor: 'var(--ds-gray-alpha-400)',
color: 'var(--ds-gray-900)',
backgroundColor: 'var(--ds-background-100)',
}}
>
{isLoadingMoreEvents
? 'Loading more events...'
: 'Load more'}
</button>
</div>
)
: undefined,
}}
style={{ flex: 1, minHeight: 0 }}
/>
{/* Virtualized event rows or refetching skeleton */}
{isRefetching ? (
<RowsSkeleton />
) : (
<Virtuoso
ref={virtuosoRef}
totalCount={sortedEvents.length}
overscan={20}
defaultItemHeight={40}
endReached={() => {
if (!hasMoreEvents || isLoadingMoreEvents) {
return;
}
void onLoadMoreEvents?.();
}}
itemContent={(index: number) => {
const ev = sortedEvents[index];
return (
<EventRow
event={ev}
index={index}
isFirst={index === 0}
isLast={index === sortedEvents.length - 1}
isExpanded={expandedEventIds.has(ev.eventId)}
onToggleExpand={toggleEventExpanded}
activeGroupKey={activeGroupKey}
selectedGroupKey={selectedGroupKey}
selectedGroupRange={selectedGroupRange}
correlationNameMap={correlationNameMap}
workflowName={workflowName}
durationMap={durationMap}
onSelectGroup={onSelectGroup}
onHoverGroup={onHoverGroup}
onLoadEventData={onLoadEventData}
cachedEventData={
eventDataCacheRef.current.get(ev.eventId) ?? null
}
onCacheEventData={cacheEventData}
encryptionKey={encryptionKey}
/>
);
}}
style={{ flex: 1, minHeight: 0 }}
/>
)}

{/* Fixed footer — always at the bottom of the visible area */}
{/* Fixed footer — count + load more */}
<div
className="flex-shrink-0 border-t text-xs px-3 py-2"
className="relative flex-shrink-0 flex items-center h-10 border-t px-4 text-xs"
style={{
borderColor: 'var(--ds-gray-alpha-200)',
color: 'var(--ds-gray-900)',
backgroundColor: 'var(--ds-background-100)',
}}
>
{sortedEvents.length} event
{sortedEvents.length !== 1 ? 's' : ''} total
<span>
{sortedEvents.length} event
{sortedEvents.length !== 1 ? 's' : ''} loaded
</span>
{hasMoreEvents && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="pointer-events-auto">
<LoadMoreButton
loading={isLoadingMoreEvents}
onClick={() => void onLoadMoreEvents?.()}
/>
</div>
</div>
)}
</div>
</div>
);
Expand Down
3 changes: 3 additions & 0 deletions packages/web-shared/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,8 @@ export type {
export { type StreamChunk, StreamViewer } from './stream-viewer';
export type { Span, SpanEvent } from './trace-viewer/types';
export { DataInspector, type DataInspectorProps } from './ui/data-inspector';
export { DecryptButton } from './ui/decrypt-button';
export { LoadMoreButton } from './ui/load-more-button';
export { MenuDropdown, type MenuDropdownOption } from './ui/menu-dropdown';
export { Spinner } from './ui/spinner';
export { WorkflowTraceViewer } from './workflow-trace-view';
Loading
Loading