diff --git a/.changeset/trace-viewer-load-more.md b/.changeset/trace-viewer-load-more.md new file mode 100644 index 0000000000..3c5da0dd51 --- /dev/null +++ b/.changeset/trace-viewer-load-more.md @@ -0,0 +1,6 @@ +--- +"@workflow/web-shared": patch +"@workflow/web": patch +--- + +Fix new trace viewer getting stuck on the first page: re-wire pagination so it auto-loads pages up to an event cap and scroll-loads the rest for very large runs. diff --git a/packages/web-shared/src/components/new-trace-viewer/components/split-pane.tsx b/packages/web-shared/src/components/new-trace-viewer/components/split-pane.tsx index 9a2677c736..977c0ff497 100644 --- a/packages/web-shared/src/components/new-trace-viewer/components/split-pane.tsx +++ b/packages/web-shared/src/components/new-trace-viewer/components/split-pane.tsx @@ -4,6 +4,7 @@ import { Children, type ReactNode, type PointerEvent as ReactPointerEvent, + type RefObject, useCallback, useEffect, useRef, @@ -28,6 +29,7 @@ export interface SplitPaneProps { startHeader?: ReactNode; /** Fixed (non-scrolling) header rendered above the end pane. */ endHeader?: ReactNode; + scrollContainerRef?: RefObject; } export function SplitPane({ @@ -36,6 +38,7 @@ export function SplitPane({ defaultStartWidth = DEFAULT_START_PX, startHeader, endHeader, + scrollContainerRef, }: SplitPaneProps) { const parts = Children.toArray(children); if (parts.length !== 2) { @@ -57,6 +60,16 @@ export function SplitPane({ }; }, []); + const setContainerRef = useCallback( + (node: HTMLDivElement | null) => { + containerRef.current = node; + if (scrollContainerRef) { + scrollContainerRef.current = node; + } + }, + [scrollContainerRef] + ); + const clampPx = useCallback((px: number) => { const el = containerRef.current; if (!el) return px; @@ -152,7 +165,7 @@ export function SplitPane({
{endHeader}
void | Promise; + hasMore?: boolean; + isLoadingMore?: boolean; } const MIN_VIEWPORT_MS = 0.001; @@ -159,17 +164,32 @@ function useSelectedSpanInfo(): SelectedSpanInfo | null { // Root component // --------------------------------------------------------------------------- -export function NewTraceViewer({ trace }: NewTraceViewerProps): ReactNode { +export function NewTraceViewer({ + trace, + onLoadMore, + hasMore, + isLoadingMore, +}: NewTraceViewerProps): ReactNode { return ( - + ); } -function NewTraceViewerContent({ trace }: NewTraceViewerProps): ReactNode { +function NewTraceViewerContent({ + trace, + onLoadMore, + hasMore, + isLoadingMore, +}: NewTraceViewerProps): ReactNode { const { activeSpan, activeSpanId, setActiveSpan, clearActiveSpan } = useActiveSpan(); @@ -186,6 +206,16 @@ function NewTraceViewerContent({ trace }: NewTraceViewerProps): ReactNode { const root = useMemo(() => computeRootBounds(trace.spans), [trace.spans]); + const scrollContainerRef = useRef(null); + const loadMore = useCallback(() => { + void onLoadMore?.(); + }, [onLoadMore]); + const loadMoreSentinelRef = useLoadMoreOnScroll(loadMore, { + hasMore: Boolean(onLoadMore && hasMore), + isLoadingMore: Boolean(isLoadingMore), + rootRef: scrollContainerRef, + }); + const { viewport, setViewport, animateTo } = useAnimatedViewport({ start: root.startTime, end: root.startTime + root.duration, @@ -539,6 +569,7 @@ function NewTraceViewerContent({ trace }: NewTraceViewerProps): ReactNode { className="grid grid-rows-[1fr] h-full min-h-0 overflow-hidden relative bg-background-100" > @@ -584,6 +615,14 @@ function NewTraceViewerContent({ trace }: NewTraceViewerProps): ReactNode { searchResult={searchResult} onSelectSpan={handleSelectSpan} /> +
+ {isLoadingMore ? ( +
+ + Loading spans… +
+ ) : null} +
{/* biome-ignore lint/a11y/noStaticElementInteractions: timeline hover and wheel gestures are pointer-only annotations */}
void | Promise; + hasMore?: boolean; + isLoadingMore?: boolean; loading?: boolean; }) => { const traceWithMeta: TraceWithMeta | undefined = useMemo(() => { @@ -37,7 +43,12 @@ const NewTraceViewer = ({ return (
- +
); diff --git a/packages/web-shared/src/hooks/use-intersection-observer.ts b/packages/web-shared/src/hooks/use-intersection-observer.ts new file mode 100644 index 0000000000..590236fccb --- /dev/null +++ b/packages/web-shared/src/hooks/use-intersection-observer.ts @@ -0,0 +1,57 @@ +'use client'; + +import type { RefObject } from 'react'; +import { useEffect, useState } from 'react'; + +interface UseIntersectionObserverArgs + extends Omit { + /** + * Scroll container the target is observed within. Defaults to the viewport. + * Passed as a ref so it resolves after commit (when the node exists). + */ + rootRef?: RefObject; + freezeOnceVisible?: boolean; +} + +/** + * Returns the latest IntersectionObserverEntry for `elementRef`. + * Ported from vercel/front's `@vercel/hooks` `useIntersectionObserver`, with + * `root` threaded as a ref so we can observe inside a custom scroll container. + */ +export function useIntersectionObserver( + elementRef: RefObject, + { + threshold = 0, + rootRef, + rootMargin = '0%', + freezeOnceVisible = false, + }: UseIntersectionObserverArgs = {} +): IntersectionObserverEntry | undefined { + const [entry, setEntry] = useState(); + + const frozen = entry?.isIntersecting && freezeOnceVisible; + + useEffect(() => { + const node = elementRef?.current; + + const hasIOSupport = !!window.IntersectionObserver; + + if (!hasIOSupport || frozen || !node) return; + + const updateEntry = ([entry]: IntersectionObserverEntry[]): void => { + setEntry(entry); + }; + + const observer = new IntersectionObserver(updateEntry, { + threshold, + root: rootRef?.current ?? null, + rootMargin, + }); + + observer.observe(node); + + return () => observer.disconnect(); + }, [elementRef, threshold, rootRef, rootMargin, frozen]); + + return entry; +} diff --git a/packages/web-shared/src/hooks/use-load-more-on-scroll.ts b/packages/web-shared/src/hooks/use-load-more-on-scroll.ts new file mode 100644 index 0000000000..91681e2f16 --- /dev/null +++ b/packages/web-shared/src/hooks/use-load-more-on-scroll.ts @@ -0,0 +1,49 @@ +'use client'; + +import type { RefObject } from 'react'; +import { useEffect, useRef } from 'react'; +import { useIntersectionObserver } from './use-intersection-observer'; + +interface UseLoadMoreOnScrollOptions { + hasMore: boolean; + isLoadingMore: boolean; + /** Scroll container the sentinel lives in. Defaults to the viewport. */ + rootRef?: RefObject; + rootMargin?: string; +} + +/** + * Triggers `loadMore` when a sentinel element scrolls into view. + * Returns a ref to attach to an invisible sentinel `
` placed after the + * list content. Ported from vercel/front's `@vercel/hooks` + * `useLoadMoreOnScroll`. + * + * Because it keys off `isIntersecting`/`isLoadingMore`, it self-continues: + * if the sentinel is still in view once a page finishes loading, the next + * page is requested automatically. + */ +export function useLoadMoreOnScroll( + loadMore: () => void, + { + hasMore, + isLoadingMore, + rootRef, + rootMargin = '400px', + }: UseLoadMoreOnScrollOptions +) { + const sentinelRef = useRef(null); + const entry = useIntersectionObserver(sentinelRef, { rootRef, rootMargin }); + + const loadMoreRef = useRef(loadMore); + useEffect(() => { + loadMoreRef.current = loadMore; + }, [loadMore]); + + useEffect(() => { + if (entry?.isIntersecting && hasMore && !isLoadingMore) { + loadMoreRef.current(); + } + }, [entry?.isIntersecting, hasMore, isLoadingMore]); + + return sentinelRef; +} diff --git a/packages/web/app/components/run-detail-view.tsx b/packages/web/app/components/run-detail-view.tsx index 111ea78967..5011c3a736 100644 --- a/packages/web/app/components/run-detail-view.tsx +++ b/packages/web/app/components/run-detail-view.tsx @@ -345,6 +345,9 @@ export function RunDetailView({ error, update, hasEncryptedData, + loadMoreTraceData, + hasMoreTraceData, + isLoadingMoreTraceData, } = useWorkflowTraceViewerData(env, runId, { live: true }); const run = runData ?? ({} as WorkflowRun); @@ -787,6 +790,9 @@ export function RunDetailView({ events={allEvents ?? []} loading={loading} sidebarData={sidebarData} + onLoadMore={loadMoreTraceData} + hasMore={hasMoreTraceData} + isLoadingMore={isLoadingMoreTraceData} />
diff --git a/packages/web/app/lib/client/hooks/use-trace-viewer.test.ts b/packages/web/app/lib/client/hooks/use-trace-viewer.test.ts index a3e9ba9114..6a6c76f713 100644 --- a/packages/web/app/lib/client/hooks/use-trace-viewer.test.ts +++ b/packages/web/app/lib/client/hooks/use-trace-viewer.test.ts @@ -1,4 +1,4 @@ -import { renderHook, waitFor } from '@testing-library/react'; +import { act, renderHook, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { useWorkflowTraceViewerData } from './use-trace-viewer'; @@ -40,6 +40,44 @@ function emptyPage() { }); } +// Mirrors AUTO_LOAD_MAX_EVENTS in use-trace-viewer.ts. Keep in sync. +const AUTO_LOAD_MAX_EVENTS = 500; + +function makeEvents(start: number, count: number) { + return Array.from({ length: count }, (_, i) => ({ + eventId: `evt-${start + i}`, + runId: 'run-1', + eventType: 'step_created', + correlationId: `step-${start + i}`, + createdAt: new Date(), + eventData: {}, + })) as any; +} + +function eventsPage( + data: unknown[], + { cursor, hasMore }: { cursor?: string; hasMore: boolean } +) { + return { success: true as const, data: { data, cursor, hasMore } }; +} + +// `withData: true` is the one-shot encryption probe in fetchAllData — never a +// pagination fetch. Tests count only the real (withData: false) page fetches. +function isProbe(call: unknown[]): boolean { + return Boolean((call[2] as { withData?: boolean } | undefined)?.withData); +} +function pageFetchCount(): number { + return vi.mocked(fetchEvents).mock.calls.filter((c) => !isProbe(c)).length; +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + describe('useWorkflowTraceViewerData', () => { beforeEach(() => { vi.clearAllMocks(); @@ -173,4 +211,129 @@ describe('useWorkflowTraceViewerData', () => { expect(result.current.hasMoreTraceData).toBe(true); }); + + it('auto-backfills pages only until events reach AUTO_LOAD_MAX_EVENTS', async () => { + vi.mocked(fetchRun).mockResolvedValue({ + success: true, + data: WORKFLOW_RUN, + }); + + // Every page reports more is available, so the only thing that can stop the + // auto-backfill is the event cap itself. + let next = 0; + vi.mocked(fetchEvents).mockImplementation((_env, _runId, opts: any) => { + if (opts?.withData) { + return Promise.resolve(eventsPage([], { hasMore: false })); + } + const page = makeEvents(next, 100); + next += 100; + return Promise.resolve( + eventsPage(page, { cursor: `c-${next}`, hasMore: true }) + ); + }); + + const { result } = renderHook(() => + useWorkflowTraceViewerData(env, 'run-1') + ); + + await waitFor(() => { + expect(result.current.events).toHaveLength(AUTO_LOAD_MAX_EVENTS); + }); + + // Stopped exactly at the cap: initial page + 4 backfill pages (100 each), + // even though hasMore is still true. + expect(pageFetchCount()).toBe(5); + expect(result.current.events).toHaveLength(AUTO_LOAD_MAX_EVENTS); + expect(result.current.hasMoreTraceData).toBe(true); + }); + + it('does not load more once there are no more pages', async () => { + vi.mocked(fetchRun).mockResolvedValue({ + success: true, + data: WORKFLOW_RUN, + }); + vi.mocked(fetchEvents).mockImplementation((_env, _runId, opts: any) => { + if (opts?.withData) { + return Promise.resolve(eventsPage([], { hasMore: false })); + } + return Promise.resolve(eventsPage(makeEvents(0, 1), { hasMore: false })); + }); + + const { result } = renderHook(() => + useWorkflowTraceViewerData(env, 'run-1') + ); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + expect(result.current.hasMoreTraceData).toBe(false); + + const before = pageFetchCount(); + await act(async () => { + await result.current.loadMoreTraceData(); + }); + + // !eventsHasMore short-circuits loadMoreTraceData — no extra fetch. + expect(pageFetchCount()).toBe(before); + }); + + it('does not load more while a load is already in flight', async () => { + vi.mocked(fetchRun).mockResolvedValue({ + success: true, + data: WORKFLOW_RUN, + }); + + const pending = deferred>(); + let initialDone = false; + vi.mocked(fetchEvents).mockImplementation((_env, _runId, opts: any) => { + if (opts?.withData) { + return Promise.resolve(eventsPage([], { hasMore: false })); + } + if (!initialDone) { + initialDone = true; + // Initial page already at the cap so auto-backfill never fires; the + // only load-more calls are the explicit ones below. + return Promise.resolve( + eventsPage(makeEvents(0, AUTO_LOAD_MAX_EVENTS), { + cursor: 'c1', + hasMore: true, + }) + ); + } + // The load-more fetch hangs so we can observe the in-flight guard. + return pending.promise; + }); + + const { result } = renderHook(() => + useWorkflowTraceViewerData(env, 'run-1') + ); + + await waitFor(() => { + expect(result.current.events).toHaveLength(AUTO_LOAD_MAX_EVENTS); + }); + + // Kick off a load-more that stays in flight. + act(() => { + void result.current.loadMoreTraceData(); + }); + await waitFor(() => { + expect(result.current.isLoadingMoreTraceData).toBe(true); + }); + expect(pageFetchCount()).toBe(2); // initial + first load-more + + // A second call while the first is in flight is a no-op. + await act(async () => { + await result.current.loadMoreTraceData(); + }); + expect(pageFetchCount()).toBe(2); + + // Let the in-flight load settle so there are no dangling state updates. + await act(async () => { + pending.resolve(eventsPage([], { hasMore: false })); + await pending.promise; + }); + await waitFor(() => { + expect(result.current.isLoadingMoreTraceData).toBe(false); + }); + }); }); diff --git a/packages/web/app/lib/client/hooks/use-trace-viewer.ts b/packages/web/app/lib/client/hooks/use-trace-viewer.ts index 071b8301a1..ef60db8925 100644 --- a/packages/web/app/lib/client/hooks/use-trace-viewer.ts +++ b/packages/web/app/lib/client/hooks/use-trace-viewer.ts @@ -10,6 +10,7 @@ const LIVE_POLL_LIMIT = 100; const INITIAL_PAGE_SIZE = 500; const LOAD_MORE_PAGE_SIZE = 100; const LIVE_UPDATE_INTERVAL_MS = 5000; +const AUTO_LOAD_MAX_EVENTS = 500; /** * Returns (and keeps up-to-date) the Run and Events for a workflow run. @@ -171,6 +172,13 @@ export function useWorkflowTraceViewerData( eventsCursor, ]); + useEffect(() => { + if (events.length >= AUTO_LOAD_MAX_EVENTS) { + return; + } + loadMoreTraceData(); + }, [events.length, loadMoreTraceData]); + const pollRun = useCallback(async (): Promise => { if (run?.completedAt) { return false;