Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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/trace-viewer-load-more.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
Children,
type ReactNode,
type PointerEvent as ReactPointerEvent,
type RefObject,
useCallback,
useEffect,
useRef,
Expand All @@ -28,6 +29,7 @@ export interface SplitPaneProps {
startHeader?: ReactNode;
/** Fixed (non-scrolling) header rendered above the end pane. */
endHeader?: ReactNode;
scrollContainerRef?: RefObject<HTMLDivElement | null>;
}

export function SplitPane({
Expand All @@ -36,6 +38,7 @@ export function SplitPane({
defaultStartWidth = DEFAULT_START_PX,
startHeader,
endHeader,
scrollContainerRef,
}: SplitPaneProps) {
const parts = Children.toArray(children);
if (parts.length !== 2) {
Expand All @@ -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;
Expand Down Expand Up @@ -152,7 +165,7 @@ export function SplitPane({
<div>{endHeader}</div>
</div>
<div
ref={containerRef}
ref={setContainerRef}
className={cn(
'grid flex-1 min-h-0 overflow-x-hidden overflow-y-auto',
isDragging && 'select-none'
Expand All @@ -169,7 +182,7 @@ export function SplitPane({

return (
<div
ref={containerRef}
ref={setContainerRef}
className={cn(
'grid h-full min-h-0 content-start overflow-x-hidden overflow-y-auto',
isDragging && 'select-none',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
useRef,
useState,
} from 'react';
import { useLoadMoreOnScroll } from '../../hooks/use-load-more-on-scroll';
import { useReducedMotion } from '../../hooks/use-reduced-motion';
import { ErrorBoundary } from '../error-boundary';
import {
Expand All @@ -29,6 +30,7 @@ import { useSidebarDataOptional } from '../sidebar/sidebar-data-context';
import type { Trace } from '../trace-viewer/types';
import { formatDuration, getHighResInMs } from '../trace-viewer/util/timing';
import { IconButton } from '../ui/icon-button';
import { Spinner } from '../ui/spinner';
import { Kbd } from '../ui/kbd';
import EventList from './components/event-list';
import { SplitPane } from './components/split-pane';
Expand All @@ -50,6 +52,9 @@ import { computeRootBounds, computeTimeMarkers } from './utils';

interface NewTraceViewerProps {
trace: Trace;
onLoadMore?: () => void | Promise<void>;
hasMore?: boolean;
isLoadingMore?: boolean;
}

const MIN_VIEWPORT_MS = 0.001;
Expand Down Expand Up @@ -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 (
<TooltipProvider delayDuration={300}>
<ActiveSpanProvider spans={trace.spans}>
<NewTraceViewerContent trace={trace} />
<NewTraceViewerContent
trace={trace}
onLoadMore={onLoadMore}
hasMore={hasMore}
isLoadingMore={isLoadingMore}
/>
</ActiveSpanProvider>
</TooltipProvider>
);
}

function NewTraceViewerContent({ trace }: NewTraceViewerProps): ReactNode {
function NewTraceViewerContent({
trace,
onLoadMore,
hasMore,
isLoadingMore,
}: NewTraceViewerProps): ReactNode {
const { activeSpan, activeSpanId, setActiveSpan, clearActiveSpan } =
useActiveSpan();

Expand All @@ -186,6 +206,16 @@ function NewTraceViewerContent({ trace }: NewTraceViewerProps): ReactNode {

const root = useMemo(() => computeRootBounds(trace.spans), [trace.spans]);

const scrollContainerRef = useRef<HTMLDivElement>(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,
Expand Down Expand Up @@ -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"
>
<SplitPane
scrollContainerRef={scrollContainerRef}
startHeader={
<div className="bg-background-100 border-b border-gray-alpha-400 h-10 min-h-10 flex items-center pl-4 pr-2 gap-1.5">
<Search className="w-3.5 h-3.5 shrink-0 text-gray-800" />
Expand Down Expand Up @@ -584,6 +615,14 @@ function NewTraceViewerContent({ trace }: NewTraceViewerProps): ReactNode {
searchResult={searchResult}
onSelectSpan={handleSelectSpan}
/>
<div ref={loadMoreSentinelRef} className="flex justify-center">
{isLoadingMore ? (
<div className="flex items-center justify-center gap-2 py-3 text-sm text-gray-800">
<Spinner size={14} />
<span>Loading spans…</span>
</div>
) : null}
</div>
</div>
{/* biome-ignore lint/a11y/noStaticElementInteractions: timeline hover and wheel gestures are pointer-only annotations */}
<div
Expand Down
13 changes: 12 additions & 1 deletion packages/web-shared/src/components/trace-viewer-new.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,17 @@ const NewTraceViewer = ({
run,
events,
sidebarData,
onLoadMore,
hasMore,
isLoadingMore,
loading = false,
}: {
run: WorkflowRun;
events: Event[];
sidebarData: SidebarDataContextValue;
onLoadMore?: () => void | Promise<void>;
hasMore?: boolean;
isLoadingMore?: boolean;
loading?: boolean;
}) => {
const traceWithMeta: TraceWithMeta | undefined = useMemo(() => {
Expand All @@ -37,7 +43,12 @@ const NewTraceViewer = ({
return (
<SidebarDataProvider value={sidebarData}>
<div className="relative w-full h-full flex">
<NewTraceViewerComponent trace={trace as Trace} />
<NewTraceViewerComponent
trace={trace as Trace}
onLoadMore={onLoadMore}
hasMore={hasMore}
isLoadingMore={isLoadingMore}
/>
</div>
</SidebarDataProvider>
);
Expand Down
57 changes: 57 additions & 0 deletions packages/web-shared/src/hooks/use-intersection-observer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
'use client';

import type { RefObject } from 'react';
import { useEffect, useState } from 'react';

interface UseIntersectionObserverArgs
extends Omit<IntersectionObserverInit, 'root'> {
/**
* 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<Element | null>;
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<Element | null>,
{
threshold = 0,
rootRef,
rootMargin = '0%',
freezeOnceVisible = false,
}: UseIntersectionObserverArgs = {}
): IntersectionObserverEntry | undefined {
const [entry, setEntry] = useState<IntersectionObserverEntry>();

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,
Comment thread
mitul-s marked this conversation as resolved.
rootMargin,
});

observer.observe(node);

return () => observer.disconnect();
}, [elementRef, threshold, rootRef, rootMargin, frozen]);

return entry;
}
49 changes: 49 additions & 0 deletions packages/web-shared/src/hooks/use-load-more-on-scroll.ts
Original file line number Diff line number Diff line change
@@ -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<Element | null>;
rootMargin?: string;
}

/**
* Triggers `loadMore` when a sentinel element scrolls into view.
* Returns a ref to attach to an invisible sentinel `<div>` placed after the
* list content. Ported from vercel/front's `@vercel/hooks`
Comment thread
mitul-s marked this conversation as resolved.
* `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<HTMLDivElement>(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;
}
6 changes: 6 additions & 0 deletions packages/web/app/components/run-detail-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,9 @@ export function RunDetailView({
error,
update,
hasEncryptedData,
loadMoreTraceData,
hasMoreTraceData,
isLoadingMoreTraceData,
} = useWorkflowTraceViewerData(env, runId, { live: true });

const run = runData ?? ({} as WorkflowRun);
Expand Down Expand Up @@ -787,6 +790,9 @@ export function RunDetailView({
events={allEvents ?? []}
loading={loading}
sidebarData={sidebarData}
onLoadMore={loadMoreTraceData}
hasMore={hasMoreTraceData}
isLoadingMore={isLoadingMoreTraceData}
/>
</div>
</ErrorBoundary>
Expand Down
Loading
Loading