From c39b0f4ea391c1a72c2ef33b46a55b542ea5d6bd Mon Sep 17 00:00:00 2001 From: Karthik Kalyanaraman Date: Thu, 12 Mar 2026 10:29:55 -0700 Subject: [PATCH 1/8] decouple data fetching between trace viewer and events tab --- .../web/app/components/run-detail-view.tsx | 23 +++- .../lib/client/hooks/use-events-list-data.ts | 103 ++++++++++++++++++ 2 files changed, 122 insertions(+), 4 deletions(-) create mode 100644 packages/web/app/lib/client/hooks/use-events-list-data.ts diff --git a/packages/web/app/components/run-detail-view.tsx b/packages/web/app/components/run-detail-view.tsx index 67a65f474f..5dc5e50e4c 100644 --- a/packages/web/app/components/run-detail-view.tsx +++ b/packages/web/app/components/run-detail-view.tsx @@ -5,7 +5,6 @@ import { EventListView, hydrateResourceIO, hydrateResourceIOWithKey, - isEncryptedMarker, StreamViewer, stepEventsToStepEntity, WorkflowTraceViewer, @@ -55,6 +54,7 @@ import { useStreamReader } from '~/lib/hooks/use-stream-reader'; import { fetchEvent, getEncryptionKeyForRun } from '~/lib/rpc-client'; +import { useEventsListData } from '~/lib/client/hooks/use-events-list-data'; import type { EnvMap } from '~/lib/types'; import { cancelRun, @@ -328,7 +328,7 @@ export function RunDetailView({ serverConfig.backendId === 'local' || serverConfig.backendId === '@workflow/world-local'; - // Fetch run + events for the trace viewer (steps/hooks are fetched on-demand by sidebar) + // Fetch run + events for the trace viewer (always asc) const { run: runData, events: allEvents, @@ -339,6 +339,16 @@ export function RunDetailView({ hasMoreTraceData, isLoadingMoreTraceData, } = useWorkflowTraceViewerData(env, runId, { live: true }); + + // Separate event fetching for the Events tab with user-controlled sort order + const [eventsSortOrder, setEventsSortOrder] = useState<'asc' | 'desc'>('asc'); + const { + events: eventsListData, + loading: eventsListLoading, + hasMore: hasMoreEventsTab, + loadingMore: loadingMoreEventsTab, + loadMore: loadMoreEventsTab, + } = useEventsListData(env, runId, { sortOrder: eventsSortOrder }); const run = runData ?? ({} as WorkflowRun); // Encryption key persisted for the lifetime of this run page. @@ -745,11 +755,16 @@ export function RunDetailView({
diff --git a/packages/web/app/lib/client/hooks/use-events-list-data.ts b/packages/web/app/lib/client/hooks/use-events-list-data.ts new file mode 100644 index 0000000000..c339224ee0 --- /dev/null +++ b/packages/web/app/lib/client/hooks/use-events-list-data.ts @@ -0,0 +1,103 @@ +import { hydrateResourceIO } from '@workflow/web-shared'; +import type { Event } from '@workflow/world'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { unwrapServerActionResult } from '~/lib/client/workflow-errors'; +import { fetchEvents } from '~/lib/rpc-client'; +import type { EnvMap } from '~/lib/types'; + +const INITIAL_PAGE_SIZE = 100; +const LOAD_MORE_PAGE_SIZE = 100; + +/** + * Independent event fetching for the Events tab. + * Separate from the trace viewer's events so sort order changes + * don't affect the trace viewer. + */ +export function useEventsListData( + env: EnvMap, + runId: string, + options: { sortOrder?: 'asc' | 'desc' } = {} +) { + const { sortOrder = 'asc' } = options; + + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [cursor, setCursor] = useState(); + const [hasMore, setHasMore] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); + const isFetchingRef = useRef(false); + + const fetchInitial = useCallback(async () => { + if (isFetchingRef.current) return; + isFetchingRef.current = true; + setLoading(true); + setError(null); + setEvents([]); + setCursor(undefined); + setHasMore(false); + + try { + const { error: fetchError, result } = await unwrapServerActionResult( + fetchEvents(env, runId, { + sortOrder, + limit: INITIAL_PAGE_SIZE, + withData: true, + }) + ); + if (fetchError) { + setError(fetchError); + } else { + setEvents(result.data.map(hydrateResourceIO)); + setCursor(result.hasMore ? result.cursor : undefined); + setHasMore(Boolean(result.hasMore)); + } + } catch (err) { + setError(err as Error); + } finally { + setLoading(false); + isFetchingRef.current = false; + } + }, [env, runId, sortOrder]); + + useEffect(() => { + fetchInitial(); + }, [fetchInitial]); + + const loadMore = useCallback(async () => { + if (loadingMore || !cursor) return; + setLoadingMore(true); + try { + const { error: fetchError, result } = await unwrapServerActionResult( + fetchEvents(env, runId, { + cursor, + sortOrder, + limit: LOAD_MORE_PAGE_SIZE, + withData: true, + }) + ); + if (fetchError) { + setError(fetchError); + } else { + if (result.data.length > 0) { + setEvents((prev) => [...prev, ...result.data.map(hydrateResourceIO)]); + } + setCursor(result.hasMore ? result.cursor : undefined); + setHasMore(Boolean(result.hasMore)); + } + } catch (err) { + setError(err as Error); + } finally { + setLoadingMore(false); + } + }, [env, runId, sortOrder, cursor, loadingMore]); + + return { + events, + loading, + error, + hasMore, + loadingMore, + loadMore, + }; +} From 760cae06df2a8a148345486fdecff7c0e798603b Mon Sep 17 00:00:00 2001 From: Karthik Kalyanaraman Date: Thu, 12 Mar 2026 12:54:46 -0700 Subject: [PATCH 2/8] add load more button --- .../src/components/event-list-view.tsx | 44 +++++------ packages/web-shared/src/components/index.ts | 2 + .../src/components/ui/load-more-button.tsx | 41 ++++++++++ .../web-shared/src/components/ui/spinner.tsx | 74 +++++++++++++++++++ 4 files changed, 134 insertions(+), 27 deletions(-) create mode 100644 packages/web-shared/src/components/ui/load-more-button.tsx create mode 100644 packages/web-shared/src/components/ui/spinner.tsx diff --git a/packages/web-shared/src/components/event-list-view.tsx b/packages/web-shared/src/components/event-list-view.tsx index caa72924d8..d0b8d8b730 100644 --- a/packages/web-shared/src/components/event-list-view.tsx +++ b/packages/web-shared/src/components/event-list-view.tsx @@ -12,6 +12,7 @@ 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'; @@ -1408,43 +1409,32 @@ export function EventListView({ /> ); }} - components={{ - Footer: hasMoreEvents - ? () => ( -
- -
- ) - : undefined, - }} style={{ flex: 1, minHeight: 0 }} /> - {/* Fixed footer — always at the bottom of the visible area */} + {/* Fixed footer — count + load more */}
- {sortedEvents.length} event - {sortedEvents.length !== 1 ? 's' : ''} total + + {sortedEvents.length} event + {sortedEvents.length !== 1 ? 's' : ''} loaded + + {hasMoreEvents && ( +
+
+ void onLoadMoreEvents?.()} + /> +
+
+ )}
); diff --git a/packages/web-shared/src/components/index.ts b/packages/web-shared/src/components/index.ts index a6d9f9bde7..aba161a21f 100644 --- a/packages/web-shared/src/components/index.ts +++ b/packages/web-shared/src/components/index.ts @@ -22,5 +22,7 @@ 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 { 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'; diff --git a/packages/web-shared/src/components/ui/load-more-button.tsx b/packages/web-shared/src/components/ui/load-more-button.tsx new file mode 100644 index 0000000000..c5e31cd30a --- /dev/null +++ b/packages/web-shared/src/components/ui/load-more-button.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { Spinner } from './spinner'; + +interface LoadMoreButtonProps { + loading?: boolean; + onClick?: () => void; + label?: string; + loadingLabel?: string; +} + +/** + * A "Load more" button matching Geist's Button type="secondary" size="small" + * with a spinner prefix when loading. + */ +export function LoadMoreButton({ + loading = false, + onClick, + label = 'Load more', + loadingLabel = 'Loading...', +}: LoadMoreButtonProps) { + return ( + <> + + + + ); +} diff --git a/packages/web-shared/src/components/ui/spinner.tsx b/packages/web-shared/src/components/ui/spinner.tsx new file mode 100644 index 0000000000..0561d0cc38 --- /dev/null +++ b/packages/web-shared/src/components/ui/spinner.tsx @@ -0,0 +1,74 @@ +/** + * Spinner matching Geist's multi-line fade spinner. + * At size ≤12: 8 lines, ≤16: 10 lines, else: 12 lines. + */ +export function Spinner({ + size = 14, + color, +}: { + size?: number; + color?: string; +}) { + const config = + size <= 12 + ? { + count: 8, + angle: 45, + delays: [-875, -750, -625, -500, -375, -250, -125, 0], + duration: 1000, + lineW: 3, + lineH: 1.5, + } + : size <= 16 + ? { + count: 10, + angle: 36, + delays: [-900, -800, -700, -600, -500, -400, -300, -200, -100, 0], + duration: 1000, + lineW: 4, + lineH: 1.5, + } + : { + count: 12, + angle: 30, + delays: [ + -1100, -1000, -900, -800, -700, -600, -500, -400, -300, -200, + -100, 0, + ], + duration: 1200, + lineW: size * 0.24, + lineH: size * 0.08, + }; + + return ( + + + {config.delays.map((delay, i) => ( + + ))} + + ); +} From be865073486136a3b201a9a2e98472daf673901a Mon Sep 17 00:00:00 2001 From: Karthik Kalyanaraman Date: Thu, 12 Mar 2026 13:41:27 -0700 Subject: [PATCH 3/8] add encryption button to events tab --- .../src/components/event-list-view.tsx | 220 +++++++++++------- packages/web-shared/src/components/index.ts | 1 + .../sidebar/entity-detail-panel.tsx | 34 +-- .../src/components/ui/decrypt-button.tsx | 72 ++++++ .../src/components/workflow-trace-view.tsx | 25 +- .../web/app/components/run-detail-view.tsx | 54 +++-- .../lib/client/hooks/use-events-list-data.ts | 46 +++- 7 files changed, 294 insertions(+), 158 deletions(-) create mode 100644 packages/web-shared/src/components/ui/decrypt-button.tsx diff --git a/packages/web-shared/src/components/event-list-view.tsx b/packages/web-shared/src/components/event-list-view.tsx index d0b8d8b730..b88f7289d0 100644 --- a/packages/web-shared/src/components/event-list-view.tsx +++ b/packages/web-shared/src/components/event-list-view.tsx @@ -6,6 +6,8 @@ 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 { @@ -597,6 +599,30 @@ const SORT_OPTIONS = [ { value: 'asc' as const, label: 'Oldest' }, ]; +function RowsSkeleton() { + return ( +
+ {Array.from({ length: 8 }, (_, i) => ( +
+ + + + + + +
+ ))} +
+ ); +} + // ────────────────────────────────────────────────────────────────────────── // Event row // ────────────────────────────────────────────────────────────────────────── @@ -617,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({ @@ -1021,6 +1051,8 @@ export function EventListView({ isLoading = false, sortOrder: sortOrderProp, onSortOrderChange, + onDecrypt, + isDecrypting = false, }: EventsListProps) { const [internalSortOrder, setInternalSortOrder] = useState<'asc' | 'desc'>( 'asc' @@ -1047,6 +1079,20 @@ export function EventListView({ ); }, [events, effectiveSortOrder]); + // Detect encrypted fields across all loaded events + const hasEncryptedData = useMemo(() => { + if (!events) return false; + for (const event of events) { + const ed = (event as Record).eventData; + if (!ed || typeof ed !== 'object') continue; + const data = ed as Record; + 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] @@ -1202,52 +1248,43 @@ export function EventListView({ } }, [searchQuery, searchIndex]); - if (!events || events.length === 0) { - if (isLoading) { - return ( -
- {/* Skeleton search bar */} -
- -
- {/* Skeleton header */} -
- -
- -
- -
- -
- -
- {/* Skeleton rows */} -
- {Array.from({ length: 8 }, (_, i) => ( -
- - - - - - -
- ))} -
+ // 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 ( +
+ {/* Skeleton search bar */} +
+
- ); - } + {/* Skeleton header */} +
+ +
+ +
+ +
+ +
+ +
+ +
+ ); + } + + if (!isLoading && (!events || events.length === 0)) { return (
+ {(hasEncryptedData || encryptionKey) && onDecrypt && ( + + )}
{/* Header */} @@ -1370,47 +1414,51 @@ export function EventListView({
- {/* Virtualized event rows */} - { - if (!hasMoreEvents || isLoadingMoreEvents) { - return; - } - void onLoadMoreEvents?.(); - }} - itemContent={(index: number) => { - const ev = sortedEvents[index]; - return ( - - ); - }} - style={{ flex: 1, minHeight: 0 }} - /> + {/* Virtualized event rows or refetching skeleton */} + {isRefetching ? ( + + ) : ( + { + if (!hasMoreEvents || isLoadingMoreEvents) { + return; + } + void onLoadMoreEvents?.(); + }} + itemContent={(index: number) => { + const ev = sortedEvents[index]; + return ( + + ); + }} + style={{ flex: 1, minHeight: 0 }} + /> + )} {/* Fixed footer — count + load more */}
void; + /** Whether the encryption key is currently being fetched */ + isDecrypting?: boolean; /** Info about the currently selected span from the trace viewer */ selectedSpan: SelectedSpanInfo | null; }): React.JSX.Element | null { @@ -381,31 +385,11 @@ export function EntityDetailPanel({

{(hasEncryptedFields || encryptionKey) && onDecrypt && ( - + /> )}
diff --git a/packages/web-shared/src/components/ui/decrypt-button.tsx b/packages/web-shared/src/components/ui/decrypt-button.tsx new file mode 100644 index 0000000000..5f411697e6 --- /dev/null +++ b/packages/web-shared/src/components/ui/decrypt-button.tsx @@ -0,0 +1,72 @@ +'use client'; + +import { Spinner } from './spinner'; + +interface DecryptButtonProps { + /** Whether an encryption key has been obtained (decryption is active). */ + decrypted?: boolean; + /** Whether the key is currently being fetched. */ + loading?: boolean; + /** Called when the user clicks to initiate decryption. */ + onClick?: () => void; +} + +/** + * Decrypt/Decrypted button using Geist secondary style. + * Three states: idle (secondary gray), decrypting (spinner), decrypted (green success). + */ +export function DecryptButton({ + decrypted = false, + loading = false, + onClick, +}: DecryptButtonProps) { + return ( + <> + + + + ); +} diff --git a/packages/web-shared/src/components/workflow-trace-view.tsx b/packages/web-shared/src/components/workflow-trace-view.tsx index 67c416397a..bf77e471e4 100644 --- a/packages/web-shared/src/components/workflow-trace-view.tsx +++ b/packages/web-shared/src/components/workflow-trace-view.tsx @@ -31,6 +31,7 @@ import { } from './trace-viewer'; import type { Span } from './trace-viewer/types'; import { Skeleton } from './ui/skeleton'; +import { Spinner } from './ui/spinner'; import { getCustomSpanClassName, getCustomSpanEventClassName, @@ -719,25 +720,7 @@ function TraceViewerFooter({ className="flex items-center justify-center gap-2 py-3 text-xs" style={style} > - - - - + Loading more events…
); @@ -785,6 +768,7 @@ export const WorkflowTraceViewer = ({ isLoadingMoreSpans = false, encryptionKey, onDecrypt, + isDecrypting = false, }: { run: WorkflowRun; events: Event[]; @@ -823,6 +807,8 @@ export const WorkflowTraceViewer = ({ encryptionKey?: Uint8Array; /** Callback to initiate decryption of encrypted run data */ onDecrypt?: () => void; + /** Whether the encryption key is currently being fetched */ + isDecrypting?: boolean; }) => { const [selectedSpan, setSelectedSpan] = useState( null @@ -1156,6 +1142,7 @@ export const WorkflowTraceViewer = ({ onResolveHook={onResolveHook} encryptionKey={encryptionKey} onDecrypt={onDecrypt} + isDecrypting={isDecrypting} selectedSpan={selectedSpan} /> diff --git a/packages/web/app/components/run-detail-view.tsx b/packages/web/app/components/run-detail-view.tsx index 5dc5e50e4c..367677b662 100644 --- a/packages/web/app/components/run-detail-view.tsx +++ b/packages/web/app/components/run-detail-view.tsx @@ -340,6 +340,14 @@ export function RunDetailView({ isLoadingMoreTraceData, } = useWorkflowTraceViewerData(env, runId, { live: true }); + const run = runData ?? ({} as WorkflowRun); + + // Encryption key persisted for the lifetime of this run page. + // Once fetched (via Decrypt button), it's used automatically for all + // subsequent resource + event hydration, even across span selection changes. + const [encryptionKey, setEncryptionKey] = useState(null); + encryptionKeyRef.current = encryptionKey; + // Separate event fetching for the Events tab with user-controlled sort order const [eventsSortOrder, setEventsSortOrder] = useState<'asc' | 'desc'>('asc'); const { @@ -348,14 +356,10 @@ export function RunDetailView({ hasMore: hasMoreEventsTab, loadingMore: loadingMoreEventsTab, loadMore: loadMoreEventsTab, - } = useEventsListData(env, runId, { sortOrder: eventsSortOrder }); - const run = runData ?? ({} as WorkflowRun); - - // Encryption key persisted for the lifetime of this run page. - // Once fetched (via Decrypt button), it's used automatically for all - // subsequent resource + event hydration, even across span selection changes. - const [encryptionKey, setEncryptionKey] = useState(null); - encryptionKeyRef.current = encryptionKey; + } = useEventsListData(env, runId, { + sortOrder: eventsSortOrder, + encryptionKey: encryptionKey ?? undefined, + }); const [spanSelection, setSpanSelection] = useState( null @@ -380,26 +384,29 @@ export function RunDetailView({ } ); + const [isDecrypting, setIsDecrypting] = useState(false); + const handleDecrypt = useCallback(async () => { if (encryptionKey) { - // Key already available — just re-fetch to trigger decrypted hydration refreshSpanDetail(); return; } - // Fetch the key for this run - const { error: keyError, result: keyResult } = - await unwrapServerActionResult(getEncryptionKeyForRun(env, runId)); - if (keyError) { - toast.error(`Failed to fetch encryption key: ${keyError.message}`); - return; - } - if (!keyResult) { - toast.error('Encryption is not configured for this deployment.'); - return; + setIsDecrypting(true); + try { + const { error: keyError, result: keyResult } = + await unwrapServerActionResult(getEncryptionKeyForRun(env, runId)); + if (keyError) { + toast.error(`Failed to fetch encryption key: ${keyError.message}`); + return; + } + if (!keyResult) { + toast.error('Encryption is not configured for this deployment.'); + return; + } + setEncryptionKey(keyResult); + } finally { + setIsDecrypting(false); } - setEncryptionKey(keyResult); - // Refresh will happen automatically via the state change propagating - // to useWorkflowResourceData's encryptionKey option }, [encryptionKey, env, runId, refreshSpanDetail]); const handleSpanSelect = useCallback((info: SpanSelectionInfo) => { @@ -746,6 +753,7 @@ export function RunDetailView({ isLoadingMoreSpans={isLoadingMoreTraceData} encryptionKey={encryptionKey ?? undefined} onDecrypt={handleDecrypt} + isDecrypting={isDecrypting} />
@@ -765,6 +773,8 @@ export function RunDetailView({ isLoading={eventsListLoading} sortOrder={eventsSortOrder} onSortOrderChange={setEventsSortOrder} + onDecrypt={handleDecrypt} + isDecrypting={isDecrypting} />
diff --git a/packages/web/app/lib/client/hooks/use-events-list-data.ts b/packages/web/app/lib/client/hooks/use-events-list-data.ts index c339224ee0..486d697dc7 100644 --- a/packages/web/app/lib/client/hooks/use-events-list-data.ts +++ b/packages/web/app/lib/client/hooks/use-events-list-data.ts @@ -1,4 +1,7 @@ -import { hydrateResourceIO } from '@workflow/web-shared'; +import { + hydrateResourceIO, + hydrateResourceIOWithKey, +} from '@workflow/web-shared'; import type { Event } from '@workflow/world'; import { useCallback, useEffect, useRef, useState } from 'react'; import { unwrapServerActionResult } from '~/lib/client/workflow-errors'; @@ -16,9 +19,9 @@ const LOAD_MORE_PAGE_SIZE = 100; export function useEventsListData( env: EnvMap, runId: string, - options: { sortOrder?: 'asc' | 'desc' } = {} + options: { sortOrder?: 'asc' | 'desc'; encryptionKey?: Uint8Array } = {} ) { - const { sortOrder = 'asc' } = options; + const { sortOrder = 'asc', encryptionKey } = options; const [events, setEvents] = useState([]); const [loading, setLoading] = useState(true); @@ -48,7 +51,15 @@ export function useEventsListData( if (fetchError) { setError(fetchError); } else { - setEvents(result.data.map(hydrateResourceIO)); + const hydrated = result.data.map(hydrateResourceIO); + if (encryptionKey) { + const decrypted = await Promise.all( + hydrated.map((ev) => hydrateResourceIOWithKey(ev, encryptionKey)) + ); + setEvents(decrypted); + } else { + setEvents(hydrated); + } setCursor(result.hasMore ? result.cursor : undefined); setHasMore(Boolean(result.hasMore)); } @@ -58,12 +69,27 @@ export function useEventsListData( setLoading(false); isFetchingRef.current = false; } - }, [env, runId, sortOrder]); + }, [env, runId, sortOrder, encryptionKey]); useEffect(() => { fetchInitial(); }, [fetchInitial]); + // Re-hydrate loaded events with decryption when encryption key becomes available + useEffect(() => { + if (!encryptionKey || events.length === 0) return; + let cancelled = false; + Promise.all(events.map((ev) => hydrateResourceIOWithKey(ev, encryptionKey))) + .then((decrypted) => { + if (!cancelled) setEvents(decrypted); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [encryptionKey]); + const loadMore = useCallback(async () => { if (loadingMore || !cursor) return; setLoadingMore(true); @@ -80,7 +106,15 @@ export function useEventsListData( setError(fetchError); } else { if (result.data.length > 0) { - setEvents((prev) => [...prev, ...result.data.map(hydrateResourceIO)]); + const hydrated = result.data.map(hydrateResourceIO); + if (encryptionKey) { + const decrypted = await Promise.all( + hydrated.map((ev) => hydrateResourceIOWithKey(ev, encryptionKey)) + ); + setEvents((prev) => [...prev, ...decrypted]); + } else { + setEvents((prev) => [...prev, ...hydrated]); + } } setCursor(result.hasMore ? result.cursor : undefined); setHasMore(Boolean(result.hasMore)); From 6a93e7bd69b745d551fe144b8897dd62803a11ae Mon Sep 17 00:00:00 2001 From: Karthik Kalyanaraman Date: Thu, 12 Mar 2026 14:08:56 -0700 Subject: [PATCH 4/8] virtualize streams tab --- .../src/components/stream-viewer.tsx | 117 ++++++++---------- 1 file changed, 55 insertions(+), 62 deletions(-) diff --git a/packages/web-shared/src/components/stream-viewer.tsx b/packages/web-shared/src/components/stream-viewer.tsx index 3500d9d766..77f881c6b5 100644 --- a/packages/web-shared/src/components/stream-viewer.tsx +++ b/packages/web-shared/src/components/stream-viewer.tsx @@ -1,6 +1,7 @@ 'use client'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useEffect, useRef } from 'react'; +import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'; import { DataInspector } from './ui/data-inspector'; import { Skeleton } from './ui/skeleton'; @@ -46,6 +47,8 @@ interface StreamViewerProps { error?: string | null; /** True while the initial stream connection is being established */ isLoading?: boolean; + /** Called when the user scrolls near the bottom, for triggering pagination */ + onScrollEnd?: () => void; } // ────────────────────────────────────────────────────────────────────────── @@ -119,40 +122,36 @@ export function StreamViewer({ isLive, error, isLoading, + onScrollEnd, }: StreamViewerProps) { - const [hasMoreBelow, setHasMoreBelow] = useState(false); - const scrollRef = useRef(null); - - const checkScrollPosition = useCallback(() => { - if (scrollRef.current) { - const { scrollTop, scrollHeight, clientHeight } = scrollRef.current; - const isAtBottom = scrollHeight - scrollTop - clientHeight < 10; - setHasMoreBelow(!isAtBottom && scrollHeight > clientHeight); - } - }, []); + const virtuosoRef = useRef(null); + const prevChunkCountRef = useRef(0); - // biome-ignore lint/correctness/useExhaustiveDependencies: chunks.length triggers scroll on new chunks + // Auto-scroll to bottom when new chunks arrive (live streaming) useEffect(() => { - if (scrollRef.current) { - scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + if (chunks.length > prevChunkCountRef.current && chunks.length > 0) { + virtuosoRef.current?.scrollToIndex({ + index: chunks.length - 1, + align: 'end', + }); } - checkScrollPosition(); - }, [chunks.length, checkScrollPosition]); + prevChunkCountRef.current = chunks.length; + }, [chunks.length]); // Show skeleton when loading and no chunks have arrived yet if (isLoading && chunks.length === 0) { return ( -
+
); } return ( -
+
{/* Live indicator */} {isLive && ( -
+
-
- {error ? ( -
-
Error reading stream:
-
{error}
-
- ) : chunks.length === 0 ? ( -
- {isLive ? 'Waiting for stream data...' : 'Stream is empty'} -
- ) : ( - chunks.map((chunk, index) => ( - - )) - )} -
- {hasMoreBelow && ( +
+ {error ? ( +
+
Error reading stream:
+
{error}
+
+ ) : chunks.length === 0 ? (
+ {isLive ? 'Waiting for stream data...' : 'Stream is empty'} +
+ ) : ( + onScrollEnd?.()} + itemContent={(index) => ( +
+ +
+ )} + style={{ flex: 1, minHeight: 0 }} /> )}
From 69e7566e7a812bce2a38fdd5abab4595dd9040d1 Mon Sep 17 00:00:00 2001 From: Karthik Kalyanaraman Date: Thu, 12 Mar 2026 14:11:09 -0700 Subject: [PATCH 5/8] add decrypt button for stream --- .../web/app/components/run-detail-view.tsx | 42 ++++++++++++++++--- 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/packages/web/app/components/run-detail-view.tsx b/packages/web/app/components/run-detail-view.tsx index 367677b662..263d129d24 100644 --- a/packages/web/app/components/run-detail-view.tsx +++ b/packages/web/app/components/run-detail-view.tsx @@ -1,6 +1,7 @@ import { parseWorkflowName } from '@workflow/utils/parse-name'; import type { SpanSelectionInfo } from '@workflow/web-shared'; import { + DecryptButton, ErrorBoundary, EventListView, hydrateResourceIO, @@ -842,12 +843,41 @@ export function RunDetailView({ {/* Stream viewer */}
{selectedStreamId ? ( - + streamError?.includes('encrypted') && !encryptionKey ? ( +
+ +
+ This stream is encrypted. +
+ +
+ ) : ( + + ) ) : (
Date: Thu, 12 Mar 2026 14:16:37 -0700 Subject: [PATCH 6/8] add changeset --- .changeset/flat-eels-dance.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/flat-eels-dance.md diff --git a/.changeset/flat-eels-dance.md b/.changeset/flat-eels-dance.md new file mode 100644 index 0000000000..f08a4fada5 --- /dev/null +++ b/.changeset/flat-eels-dance.md @@ -0,0 +1,6 @@ +--- +"@workflow/web-shared": patch +"@workflow/web": patch +--- + +Improve workflow observability UX with decoupled pagination, stream virtualization, and decrypt actions From b59d942c4038431d1db2f1861f0a4baa8302a08a Mon Sep 17 00:00:00 2001 From: Karthik Kalyanaraman Date: Thu, 12 Mar 2026 14:34:17 -0700 Subject: [PATCH 7/8] fix agent comments --- .../src/components/event-list-view.tsx | 4 +++- .../web-shared/src/components/stream-viewer.tsx | 8 ++------ .../src/components/ui/decrypt-button.tsx | 9 +++------ .../src/components/ui/load-more-button.tsx | 9 +++------ .../src/components/ui/menu-dropdown.tsx | 9 +++------ .../web-shared/src/components/ui/spinner.tsx | 4 +++- .../app/lib/client/hooks/use-events-list-data.ts | 16 +++++----------- 7 files changed, 22 insertions(+), 37 deletions(-) diff --git a/packages/web-shared/src/components/event-list-view.tsx b/packages/web-shared/src/components/event-list-view.tsx index b88f7289d0..13c52b8504 100644 --- a/packages/web-shared/src/components/event-list-view.tsx +++ b/packages/web-shared/src/components/event-list-view.tsx @@ -1079,7 +1079,9 @@ export function EventListView({ ); }, [events, effectiveSortOrder]); - // Detect encrypted fields across all loaded events + // 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) { diff --git a/packages/web-shared/src/components/stream-viewer.tsx b/packages/web-shared/src/components/stream-viewer.tsx index 77f881c6b5..dc527c322c 100644 --- a/packages/web-shared/src/components/stream-viewer.tsx +++ b/packages/web-shared/src/components/stream-viewer.tsx @@ -117,7 +117,7 @@ function StreamSkeleton() { * of complex types (Map, Set, Date, custom classes, etc.). */ export function StreamViewer({ - streamId, + streamId: _streamId, chunks, isLive, error, @@ -213,11 +213,7 @@ export function StreamViewer({ endReached={() => onScrollEnd?.()} itemContent={(index) => (
- +
)} style={{ flex: 1, minHeight: 0 }} diff --git a/packages/web-shared/src/components/ui/decrypt-button.tsx b/packages/web-shared/src/components/ui/decrypt-button.tsx index 5f411697e6..e42ba7ab6b 100644 --- a/packages/web-shared/src/components/ui/decrypt-button.tsx +++ b/packages/web-shared/src/components/ui/decrypt-button.tsx @@ -2,6 +2,8 @@ import { Spinner } from './spinner'; +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}`; + interface DecryptButtonProps { /** Whether an encryption key has been obtained (decryption is active). */ decrypted?: boolean; @@ -22,12 +24,7 @@ export function DecryptButton({ }: DecryptButtonProps) { return ( <> - + + + +