feat(ui): add useInfiniteStream hook

Wraps tanstack useInfiniteQuery with cursor flattening, top-gated
polling, and a refresh() invalidator. Used by log and agent-event
streaming views.
This commit is contained in:
hsiegeln
2026-04-17 12:39:59 +02:00
parent bfb5a7a895
commit a7f53c8993

View File

@@ -0,0 +1,65 @@
import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo } from 'react';
export interface StreamPage<T> {
data: T[];
nextCursor: string | null;
hasMore: boolean;
}
export interface UseInfiniteStreamArgs<T> {
queryKey: readonly unknown[];
fetchPage: (cursor: string | undefined) => Promise<StreamPage<T>>;
enabled?: boolean;
/** When true, the query auto-refetches every refetchMs ms. When false, polling pauses. */
isAtTop: boolean;
refetchMs?: number;
staleTime?: number;
}
export interface UseInfiniteStreamResult<T> {
items: T[];
fetchNextPage: () => void;
hasNextPage: boolean;
isFetchingNextPage: boolean;
isLoading: boolean;
refresh: () => void;
}
/**
* Thin wrapper over tanstack useInfiniteQuery that:
* - flattens pages into a single items[] array (newest first)
* - gates auto-refetch on isAtTop (so a user scrolled down does not lose their viewport)
* - exposes refresh() that invalidates the query (reset to page 1 on next render)
*/
export function useInfiniteStream<T>(args: UseInfiniteStreamArgs<T>): UseInfiniteStreamResult<T> {
const { queryKey, fetchPage, enabled = true, isAtTop, refetchMs = 15_000, staleTime = 300 } = args;
const queryClient = useQueryClient();
const query = useInfiniteQuery<StreamPage<T>, Error>({
queryKey: [...queryKey],
initialPageParam: undefined as string | undefined,
queryFn: ({ pageParam }) => fetchPage(pageParam as string | undefined),
getNextPageParam: (last) => (last.hasMore && last.nextCursor ? last.nextCursor : undefined),
enabled,
refetchInterval: isAtTop ? refetchMs : false,
staleTime,
placeholderData: (prev) => prev,
});
const items = useMemo<T[]>(
() => (query.data?.pages ?? []).flatMap((p) => p.data),
[query.data],
);
return {
items,
fetchNextPage: () => {
if (query.hasNextPage && !query.isFetchingNextPage) query.fetchNextPage();
},
hasNextPage: !!query.hasNextPage,
isFetchingNextPage: query.isFetchingNextPage,
isLoading: query.isLoading,
refresh: () => queryClient.invalidateQueries({ queryKey: [...queryKey] }),
};
}