diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 09c5c24c5cd..5b9bb6410f8 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -924,6 +924,11 @@ export function issueRoutes( ? Number.parseInt(rawLimit, 10) : null; const limit = parsedLimit === null ? ISSUE_LIST_DEFAULT_LIMIT : clampIssueListLimit(parsedLimit); + const rawOffset = req.query.offset as string | undefined; + const parsedOffset = rawOffset !== undefined && /^\d+$/.test(rawOffset) + ? Number.parseInt(rawOffset, 10) + : null; + const offset = parsedOffset ?? 0; if (assigneeUserFilterRaw === "me" && (!assigneeUserId || req.actor.type !== "board")) { res.status(403).json({ error: "assigneeUserId=me requires board authentication" }); @@ -945,6 +950,10 @@ export function issueRoutes( res.status(400).json({ error: `limit must be a positive integer up to ${ISSUE_LIST_MAX_LIMIT}` }); return; } + if (rawOffset !== undefined && (parsedOffset === null || !Number.isInteger(parsedOffset) || parsedOffset < 0)) { + res.status(400).json({ error: "offset must be a non-negative integer" }); + return; + } const result = await svc.list(companyId, { status: req.query.status as string | undefined, @@ -969,6 +978,7 @@ export function issueRoutes( includeBlockedBy: req.query.includeBlockedBy === "true" || req.query.includeBlockedBy === "1", q: req.query.q as string | undefined, limit, + offset, }); res.json(result); }); diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index f93633f4156..741d8ce302c 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -112,6 +112,7 @@ export interface IssueFilters { includeBlockedBy?: boolean; q?: string; limit?: number; + offset?: number; } type IssueRow = typeof issues.$inferSelect; @@ -2083,6 +2084,9 @@ export function issueService(db: Db) { const limit = typeof filters?.limit === "number" && Number.isFinite(filters.limit) ? Math.max(1, Math.floor(filters.limit)) : undefined; + const offset = typeof filters?.offset === "number" && Number.isFinite(filters.offset) + ? Math.max(0, Math.floor(filters.offset)) + : 0; const touchedByUserId = filters?.touchedByUserId?.trim() || undefined; const inboxArchivedByUserId = filters?.inboxArchivedByUserId?.trim() || undefined; const unreadForUserId = filters?.unreadForUserId?.trim() || undefined; @@ -2205,8 +2209,12 @@ export function issueService(db: Db) { asc(priorityOrder), desc(canonicalLastActivityAt), desc(issues.updatedAt), + desc(issues.id), ); - const rows = (limit === undefined ? await baseQuery : await baseQuery.limit(limit)).map((row) => ({ + const pageQuery = offset > 0 + ? (limit === undefined ? baseQuery.offset(offset) : baseQuery.limit(limit).offset(offset)) + : (limit === undefined ? baseQuery : baseQuery.limit(limit)); + const rows = (await pageQuery).map((row) => ({ ...row, description: decodeDatabaseTextPreview(row.description, ISSUE_LIST_DESCRIPTION_MAX_CHARS), })); 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 5abfa55d0cf..399cbdf3c28 100644 --- a/ui/src/components/IssuesList.test.tsx +++ b/ui/src/components/IssuesList.test.tsx @@ -963,8 +963,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}`, @@ -993,8 +993,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 6f9b958cbf9..d282aded27a 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() {