feat(ui): add infinite scroll pagination to chat completions/responses logs table

## Summary:

  This commit adds infinite scroll pagination to the
  chat completions and responses tables.


## Test Plan:
  1. Run unit tests: npm run test
  2. Manual testing: Navigate to chat
  completions/responses pages
  3. Verify infinite scroll triggers when approaching
  bottom
  4. Added playwright tests: npm run test:e2e
This commit is contained in:
Eric Huang 2025-06-17 16:26:06 -07:00
parent 15f630e5da
commit 66e217fea7
20 changed files with 1145 additions and 388 deletions

View file

@ -0,0 +1,55 @@
"use client";
import { useRef, useEffect } from "react";
interface UseInfiniteScrollOptions {
/** Whether the feature is enabled (e.g., hasMore data) */
enabled?: boolean;
/** Threshold for intersection (0-1, how much of sentinel must be visible) */
threshold?: number;
/** Margin around root to trigger earlier (e.g., "100px" to load 100px before visible) */
rootMargin?: string;
}
/**
* Custom hook for infinite scroll using Intersection Observer
*
* @param onLoadMore - Callback to load more data
* @param options - Configuration options
* @returns ref to attach to sentinel element
*/
export function useInfiniteScroll(
onLoadMore: (() => void) | undefined,
options: UseInfiniteScrollOptions = {},
) {
const { enabled = true, threshold = 0.1, rootMargin = "100px" } = options;
const sentinelRef = useRef<HTMLTableRowElement>(null);
useEffect(() => {
if (!onLoadMore || !enabled) return;
const observer = new IntersectionObserver(
(entries) => {
const [entry] = entries;
if (entry.isIntersecting) {
onLoadMore();
}
},
{
threshold,
rootMargin,
},
);
const sentinel = sentinelRef.current;
if (sentinel) {
observer.observe(sentinel);
}
return () => {
observer.disconnect();
};
}, [onLoadMore, enabled, threshold, rootMargin]);
return sentinelRef;
}

View file

@ -0,0 +1,132 @@
"use client";
import { useState, useCallback, useEffect, useRef } from "react";
import { PaginationStatus, UsePaginationOptions } from "@/lib/types";
interface PaginationState<T> {
data: T[];
status: PaginationStatus;
hasMore: boolean;
error: Error | null;
lastId: string | null;
}
interface PaginationResponse<T> {
data: T[];
has_more: boolean;
last_id: string;
first_id: string;
object: "list";
}
export interface PaginationReturn<T> {
data: T[];
status: PaginationStatus;
hasMore: boolean;
error: Error | null;
loadMore: () => void;
}
interface UsePaginationParams<T> extends UsePaginationOptions {
fetchFunction: (params: {
after?: string;
limit: number;
model?: string;
order?: string;
}) => Promise<PaginationResponse<T>>;
errorMessagePrefix: string;
}
export function usePagination<T>({
limit = 20,
model,
order = "desc",
fetchFunction,
errorMessagePrefix,
}: UsePaginationParams<T>): PaginationReturn<T> {
const [state, setState] = useState<PaginationState<T>>({
data: [],
status: "loading",
hasMore: true,
error: null,
lastId: null,
});
// Use refs to avoid stale closures
const stateRef = useRef(state);
stateRef.current = state;
/**
* Fetches data from the API with cursor-based pagination
*/
const fetchData = useCallback(
async (after?: string, targetRows?: number) => {
const isInitialLoad = !after;
const fetchLimit = targetRows || limit;
try {
setState((prev) => ({
...prev,
status: isInitialLoad ? "loading" : "loading-more",
error: null,
}));
const response = await fetchFunction({
after: after || undefined,
limit: fetchLimit,
...(model && { model }),
...(order && { order }),
});
setState((prev) => ({
...prev,
data: isInitialLoad
? response.data
: [...prev.data, ...response.data],
hasMore: response.has_more,
lastId: response.last_id || null,
status: "idle",
}));
} catch (err) {
const errorMessage = isInitialLoad
? `Failed to load ${errorMessagePrefix}. Please try refreshing the page.`
: `Failed to load more ${errorMessagePrefix}. Please try again.`;
const error =
err instanceof Error
? new Error(`${errorMessage} ${err.message}`)
: new Error(errorMessage);
setState((prev) => ({
...prev,
error,
status: "error",
}));
}
},
[limit, model, order, fetchFunction, errorMessagePrefix],
);
/**
* Loads more data for infinite scroll
*/
const loadMore = useCallback(() => {
const currentState = stateRef.current;
if (currentState.hasMore && currentState.status === "idle") {
fetchData(currentState.lastId || undefined);
}
}, [fetchData]);
// Auto-load initial data on mount
useEffect(() => {
fetchData();
}, []);
return {
data: state.data,
status: state.status,
hasMore: state.hasMore,
error: state.error,
loadMore,
};
}