From 57f197dfc143f2fc0d5a3291a2ba8ea83fb267aa Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 28 Apr 2026 12:52:09 -0500 Subject: [PATCH] Page issues list with scroll offsets Co-Authored-By: Paperclip --- ui/src/api/issues.test.ts | 8 ++++ ui/src/api/issues.ts | 2 + ui/src/components/IssuesList.test.tsx | 8 ++-- ui/src/components/IssuesList.tsx | 2 +- ui/src/pages/Issues.test.tsx | 17 ++++---- ui/src/pages/Issues.tsx | 60 ++++++++++++++------------- 6 files changed, 55 insertions(+), 42 deletions(-) diff --git a/ui/src/api/issues.test.ts b/ui/src/api/issues.test.ts index 6dba7027ec4..107f12b1a36 100644 --- a/ui/src/api/issues.test.ts +++ b/ui/src/api/issues.test.ts @@ -39,4 +39,12 @@ describe("issuesApi.list", () => { "/companies/company-1/issues?workspaceId=workspace-1&limit=1000", ); }); + + it("passes pagination offsets through to the company issues endpoint", async () => { + await issuesApi.list("company-1", { limit: 500, offset: 1500 }); + + expect(mockApi.get).toHaveBeenCalledWith( + "/companies/company-1/issues?limit=500&offset=1500", + ); + }); }); diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 53fb9b92f9f..61abcba691a 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -48,6 +48,7 @@ export const issuesApi = { includeBlockedBy?: boolean; q?: string; limit?: number; + offset?: number; }, ) => { const params = new URLSearchParams(); @@ -70,6 +71,7 @@ export const issuesApi = { if (filters?.includeBlockedBy) params.set("includeBlockedBy", "true"); if (filters?.q) params.set("q", filters.q); if (filters?.limit) params.set("limit", String(filters.limit)); + if (filters?.offset !== undefined) params.set("offset", String(filters.offset)); const qs = params.toString(); return api.get(`/companies/${companyId}/issues${qs ? `?${qs}` : ""}`); }, diff --git a/ui/src/components/IssuesList.test.tsx b/ui/src/components/IssuesList.test.tsx index 09fae279096..c49abeea63c 100644 --- a/ui/src/components/IssuesList.test.tsx +++ b/ui/src/components/IssuesList.test.tsx @@ -869,8 +869,8 @@ describe("IssuesList", () => { }); }); - it("renders the next local issue batch after scrolling near the bottom", async () => { - const manyIssues = Array.from({ length: 220 }, (_, index) => + it("keeps rendering local issue batches while the user stays near the bottom", async () => { + const manyIssues = Array.from({ length: 420 }, (_, index) => createIssue({ id: `issue-${index + 1}`, identifier: `PAP-${index + 1}`, @@ -899,8 +899,8 @@ describe("IssuesList", () => { }); await waitForAssertion(() => { - expect(container.querySelectorAll('[data-testid="issue-row"]')).toHaveLength(220); - expect(container.textContent).not.toContain("Rendering 100 of 220 issues"); + expect(container.querySelectorAll('[data-testid="issue-row"]')).toHaveLength(420); + expect(container.textContent).not.toContain("Rendering 250 of 420 issues"); }); act(() => { diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 159b516a334..48df8ae992b 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -1040,7 +1040,7 @@ export function IssuesList({ window.removeEventListener("resize", checkScrollPosition); if (animationFrameId !== null) window.cancelAnimationFrame(animationFrameId); }; - }, [canLoadMoreIssues, loadMoreIssueRows]); + }, [canLoadMoreIssues, loadMoreIssueRows, renderedIssueRowLimit]); const newIssueDefaults = useCallback((groupKey?: string) => { const defaults: Record = { ...(baseCreateIssueDefaults ?? {}) }; diff --git a/ui/src/pages/Issues.test.tsx b/ui/src/pages/Issues.test.tsx index cea0f0adb8c..25580c6ba90 100644 --- a/ui/src/pages/Issues.test.tsx +++ b/ui/src/pages/Issues.test.tsx @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { buildIssuesSearchUrl, getNextIssuesPageLimit, hasMoreIssuesToRequest } from "./Issues"; +import { buildIssuesSearchUrl, getNextIssuesPageOffset } from "./Issues"; describe("buildIssuesSearchUrl", () => { it("preserves trailing spaces in the synced search param", () => { @@ -16,15 +16,14 @@ describe("buildIssuesSearchUrl", () => { }); describe("issues page pagination helpers", () => { - it("increments issue list limits up to the server cap", () => { - expect(getNextIssuesPageLimit(500)).toBe(750); - expect(getNextIssuesPageLimit(750)).toBe(1000); - expect(getNextIssuesPageLimit(1000)).toBe(1000); + it("advances to the next offset when the current page is full", () => { + expect(getNextIssuesPageOffset(500, 0)).toBe(500); + expect(getNextIssuesPageOffset(500, 500)).toBe(1000); + expect(getNextIssuesPageOffset(1000, 2000, 1000)).toBe(3000); }); - it("requests another issue page only when the current server limit is full", () => { - expect(hasMoreIssuesToRequest(499, 500)).toBe(false); - expect(hasMoreIssuesToRequest(500, 500)).toBe(true); - expect(hasMoreIssuesToRequest(1000, 1000)).toBe(false); + it("stops requesting issue pages when the current page is partial", () => { + expect(getNextIssuesPageOffset(499, 0)).toBeUndefined(); + expect(getNextIssuesPageOffset(999, 2000, 1000)).toBeUndefined(); }); }); diff --git a/ui/src/pages/Issues.tsx b/ui/src/pages/Issues.tsx index 0a10c85a384..bb7db67d047 100644 --- a/ui/src/pages/Issues.tsx +++ b/ui/src/pages/Issues.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useCallback, useState } from "react"; import { useLocation, useSearchParams } from "@/lib/router"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useInfiniteQuery, useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { issuesApi } from "../api/issues"; import { agentsApi } from "../api/agents"; import { projectsApi } from "../api/projects"; @@ -15,16 +15,14 @@ import { IssuesList } from "../components/IssuesList"; import { CircleDot } from "lucide-react"; const WORKSPACE_FILTER_ISSUE_LIMIT = 1000; -const ISSUES_PAGE_INITIAL_LIMIT = 500; -const ISSUES_PAGE_LIMIT_INCREMENT = 250; -const ISSUES_PAGE_MAX_LIMIT = 1000; +const ISSUES_PAGE_SIZE = 500; -export function getNextIssuesPageLimit(currentLimit: number): number { - return Math.min(ISSUES_PAGE_MAX_LIMIT, currentLimit + ISSUES_PAGE_LIMIT_INCREMENT); -} - -export function hasMoreIssuesToRequest(loadedIssueCount: number, currentLimit: number): boolean { - return loadedIssueCount >= currentLimit && currentLimit < ISSUES_PAGE_MAX_LIMIT; +export function getNextIssuesPageOffset( + loadedPageSize: number, + currentOffset: number, + pageSize: number = ISSUES_PAGE_SIZE, +): number | undefined { + return loadedPageSize >= pageSize ? currentOffset + pageSize : undefined; } export function buildIssuesSearchUrl(currentHref: string, search: string): string | null { @@ -47,7 +45,6 @@ export function Issues() { const location = useLocation(); const [searchParams] = useSearchParams(); const queryClient = useQueryClient(); - const [issueListLimit, setIssueListLimit] = useState(ISSUES_PAGE_INITIAL_LIMIT); const initialSearch = searchParams.get("q") ?? ""; const [syncedSearch, setSyncedSearch] = useState(initialSearch); @@ -96,17 +93,20 @@ export function Issues() { setBreadcrumbs([{ label: "Issues" }]); }, [setBreadcrumbs]); - const effectiveIssueListLimit = workspaceIdFilter ? WORKSPACE_FILTER_ISSUE_LIMIT : issueListLimit; + const issuePageSize = workspaceIdFilter ? WORKSPACE_FILTER_ISSUE_LIMIT : ISSUES_PAGE_SIZE; useEffect(() => { setSyncedSearch(initialSearch); }, [initialSearch]); - useEffect(() => { - setIssueListLimit(ISSUES_PAGE_INITIAL_LIMIT); - }, [participantAgentId, workspaceIdFilter, selectedCompanyId, syncedSearch]); - - const { data: issues, isLoading, isFetching, error } = useQuery({ + const { + data: issuePages, + isLoading, + isFetchingNextPage, + error, + hasNextPage, + fetchNextPage, + } = useInfiniteQuery({ queryKey: [ ...queryKeys.issues.list(selectedCompanyId!), "participant-agent", @@ -114,26 +114,30 @@ export function Issues() { "workspace", workspaceIdFilter ?? "__all__", "with-routine-executions", - "limit", - effectiveIssueListLimit, + "infinite", + issuePageSize, ], - queryFn: () => issuesApi.list(selectedCompanyId!, { + queryFn: ({ pageParam }) => issuesApi.list(selectedCompanyId!, { participantAgentId, workspaceId: workspaceIdFilter, includeRoutineExecutions: true, - limit: effectiveIssueListLimit, + limit: issuePageSize, + offset: pageParam, }), + initialPageParam: 0, + getNextPageParam: (lastPage, _allPages, lastPageParam) => + getNextIssuesPageOffset(lastPage.length, lastPageParam, issuePageSize), enabled: !!selectedCompanyId, placeholderData: (previousData) => previousData, }); - const hasMoreServerIssues = !workspaceIdFilter - && syncedSearch.trim().length === 0 - && hasMoreIssuesToRequest(issues?.length ?? 0, issueListLimit); + const issues = useMemo(() => issuePages?.pages.flat() ?? [], [issuePages]); + const hasMoreServerIssues = syncedSearch.trim().length === 0 + && hasNextPage === true; const loadMoreServerIssues = useCallback(() => { - if (workspaceIdFilter) return; - setIssueListLimit((current) => getNextIssuesPageLimit(current)); - }, [workspaceIdFilter]); + if (!hasNextPage || isFetchingNextPage) return; + void fetchNextPage(); + }, [fetchNextPage, hasNextPage, isFetchingNextPage]); const updateIssue = useMutation({ mutationFn: ({ id, data }: { id: string; data: Record }) => @@ -151,7 +155,7 @@ export function Issues() {