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 diff --git a/packages/web-shared/src/components/event-list-view.tsx b/packages/web-shared/src/components/event-list-view.tsx index caa72924d8..13c52b8504 100644 --- a/packages/web-shared/src/components/event-list-view.tsx +++ b/packages/web-shared/src/components/event-list-view.tsx @@ -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'; @@ -596,6 +599,30 @@ const SORT_OPTIONS = [ { value: 'asc' as const, label: 'Oldest' }, ]; +function RowsSkeleton() { + return ( +
+ {Array.from({ length: 8 }, (_, i) => ( +
+ + + + + + +
+ ))} +
+ ); +} + // ────────────────────────────────────────────────────────────────────────── // Event row // ────────────────────────────────────────────────────────────────────────── @@ -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({ @@ -1020,6 +1051,8 @@ export function EventListView({ isLoading = false, sortOrder: sortOrderProp, onSortOrderChange, + onDecrypt, + isDecrypting = false, }: EventsListProps) { const [internalSortOrder, setInternalSortOrder] = useState<'asc' | 'desc'>( 'asc' @@ -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).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] @@ -1201,52 +1250,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 */} @@ -1369,82 +1416,75 @@ export function EventListView({
- {/* Virtualized event rows */} - { - if (!hasMoreEvents || isLoadingMoreEvents) { - return; - } - void onLoadMoreEvents?.(); - }} - itemContent={(index: number) => { - const ev = sortedEvents[index]; - return ( - - ); - }} - components={{ - Footer: hasMoreEvents - ? () => ( -
- -
- ) - : undefined, - }} - 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 — 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..f62e0a5c0b 100644 --- a/packages/web-shared/src/components/index.ts +++ b/packages/web-shared/src/components/index.ts @@ -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'; diff --git a/packages/web-shared/src/components/sidebar/entity-detail-panel.tsx b/packages/web-shared/src/components/sidebar/entity-detail-panel.tsx index 0d9e8fe778..b2da9d7d17 100644 --- a/packages/web-shared/src/components/sidebar/entity-detail-panel.tsx +++ b/packages/web-shared/src/components/sidebar/entity-detail-panel.tsx @@ -2,10 +2,11 @@ import type { Event, Hook, Step, WorkflowRun } from '@workflow/world'; import clsx from 'clsx'; -import { Lock, Send, Unlock, Zap } from 'lucide-react'; +import { Send, Zap } from 'lucide-react'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { toast } from 'sonner'; import { isEncryptedMarker } from '../../lib/hydration'; +import { DecryptButton } from '../ui/decrypt-button'; import { AttributePanel } from './attribute-panel'; import { EventsList } from './events-list'; import { ResolveHookModal } from './resolve-hook-modal'; @@ -64,6 +65,7 @@ export function EntityDetailPanel({ onResolveHook, encryptionKey, onDecrypt, + isDecrypting = false, selectedSpan, }: { run: WorkflowRun; @@ -97,6 +99,8 @@ export function EntityDetailPanel({ encryptionKey?: Uint8Array; /** Callback to initiate decryption of encrypted run data */ onDecrypt?: () => 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/stream-viewer.tsx b/packages/web-shared/src/components/stream-viewer.tsx index 3500d9d766..dc527c322c 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; } // ────────────────────────────────────────────────────────────────────────── @@ -114,45 +117,41 @@ function StreamSkeleton() { * of complex types (Map, Set, Date, custom classes, etc.). */ export function StreamViewer({ - streamId, + streamId: _streamId, chunks, 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 }} /> )}
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..e42ba7ab6b --- /dev/null +++ b/packages/web-shared/src/components/ui/decrypt-button.tsx @@ -0,0 +1,69 @@ +'use client'; + +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; + /** 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 ( + <> + +