diff --git a/ui/src/components/IssuesList.test.tsx b/ui/src/components/IssuesList.test.tsx index d9c29eae4da..5abfa55d0cf 100644 --- a/ui/src/components/IssuesList.test.tsx +++ b/ui/src/components/IssuesList.test.tsx @@ -222,6 +222,20 @@ async function waitForMicrotaskAssertion(assertion: () => void, attempts = 20) { throw lastError; } +function setDocumentScrollMetrics({ + innerHeight, + scrollY, + scrollHeight, +}: { + innerHeight: number; + scrollY: number; + scrollHeight: number; +}) { + Object.defineProperty(window, "innerHeight", { configurable: true, value: innerHeight }); + Object.defineProperty(window, "scrollY", { configurable: true, value: scrollY }); + Object.defineProperty(document.documentElement, "scrollHeight", { configurable: true, value: scrollHeight }); +} + function renderWithQueryClient(node: ReactNode, container: HTMLDivElement) { const root = createRoot(container); const queryClient = new QueryClient({ @@ -269,6 +283,7 @@ describe("IssuesList", () => { mockExecutionWorkspacesApi.list.mockResolvedValue([]); mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([]); mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false }); + setDocumentScrollMetrics({ innerHeight: 600, scrollY: 0, scrollHeight: 2400 }); localStorage.clear(); }); @@ -948,6 +963,86 @@ describe("IssuesList", () => { }); }); + it("renders the next local issue batch after scrolling near the bottom", async () => { + const manyIssues = Array.from({ length: 220 }, (_, index) => + createIssue({ + id: `issue-${index + 1}`, + identifier: `PAP-${index + 1}`, + title: `Issue ${index + 1}`, + }), + ); + + const { root } = renderWithQueryClient( + undefined} + />, + container, + ); + + await waitForAssertion(() => { + expect(container.querySelectorAll('[data-testid="issue-row"]')).toHaveLength(100); + }); + + act(() => { + setDocumentScrollMetrics({ innerHeight: 600, scrollY: 1500, scrollHeight: 2000 }); + window.dispatchEvent(new Event("scroll")); + }); + + await waitForAssertion(() => { + expect(container.querySelectorAll('[data-testid="issue-row"]')).toHaveLength(220); + expect(container.textContent).not.toContain("Rendering 100 of 220 issues"); + }); + + act(() => { + root.unmount(); + }); + }); + + it("requests more server issues after scrolling past the rendered rows", async () => { + const visibleIssues = Array.from({ length: 100 }, (_, index) => + createIssue({ + id: `issue-${index + 1}`, + identifier: `PAP-${index + 1}`, + title: `Issue ${index + 1}`, + }), + ); + const onLoadMoreIssues = vi.fn(); + + const { root } = renderWithQueryClient( + undefined} + />, + container, + ); + + await waitForAssertion(() => { + expect(container.querySelectorAll('[data-testid="issue-row"]')).toHaveLength(100); + }); + + act(() => { + setDocumentScrollMetrics({ innerHeight: 600, scrollY: 1500, scrollHeight: 2000 }); + window.dispatchEvent(new Event("scroll")); + }); + + await waitForAssertion(() => { + expect(onLoadMoreIssues).toHaveBeenCalledTimes(1); + }); + + act(() => { + root.unmount(); + }); + }); + it("skips deferred row sizing for expanded parent rows with visible children", async () => { const parentIssue = createIssue({ id: "issue-parent", diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index fcf2f80578e..6f9b958cbf9 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -72,7 +72,7 @@ const ISSUE_SEARCH_RESULT_LIMIT = 200; const ISSUE_BOARD_COLUMN_RESULT_LIMIT = 200; const INITIAL_ISSUE_ROW_RENDER_LIMIT = 100; const ISSUE_ROW_RENDER_BATCH_SIZE = 150; -const ISSUE_ROW_RENDER_BATCH_DELAY_MS = 0; +const ISSUE_SCROLL_LOAD_THRESHOLD_PX = 320; const boardIssueStatuses = ISSUE_STATUSES; const issueStatusLabels: Record = { backlog: "Backlog", @@ -306,8 +306,11 @@ interface IssuesListProps { defaultSortField?: IssueSortField; showProgressSummary?: boolean; enableRoutineVisibilityFilter?: boolean; + hasMoreIssues?: boolean; + isLoadingMoreIssues?: boolean; mutedIssueIds?: Set; issueBadgeById?: Map; + onLoadMoreIssues?: () => void; onSearchChange?: (search: string) => void; onUpdateIssue: (id: string, data: Record) => void; } @@ -475,8 +478,11 @@ export function IssuesList({ defaultSortField, showProgressSummary = false, enableRoutineVisibilityFilter = false, + hasMoreIssues = false, + isLoadingMoreIssues = false, mutedIssueIds, issueBadgeById, + onLoadMoreIssues, onSearchChange, onUpdateIssue, }: IssuesListProps) { @@ -512,6 +518,7 @@ export function IssuesList({ const [issueSearch, setIssueSearch] = useState(initialSearch ?? ""); const [renderedIssueRowLimit, setRenderedIssueRowLimit] = useState(INITIAL_ISSUE_ROW_RENDER_LIMIT); const [visibleIssueColumns, setVisibleIssueColumns] = useState(() => loadIssueColumns(scopedKey)); + const renderedIssueIdsRef = useRef(""); const deferredIssueSearch = useDeferredValue(issueSearch); const normalizedIssueSearch = deferredIssueSearch.trim().toLowerCase(); @@ -966,23 +973,74 @@ export function IssuesList({ useEffect(() => { if (viewState.viewMode !== "list") return; - setRenderedIssueRowLimit(Math.min(filtered.length, INITIAL_ISSUE_ROW_RENDER_LIMIT)); + const nextIssueIds = filtered.map((issue) => issue.id).join("|"); + const previousIssueIds = renderedIssueIdsRef.current; + renderedIssueIdsRef.current = nextIssueIds; + + setRenderedIssueRowLimit((current) => { + const nextInitialLimit = Math.min(filtered.length, INITIAL_ISSUE_ROW_RENDER_LIMIT); + const listAppended = previousIssueIds.length > 0 + && nextIssueIds.startsWith(previousIssueIds) + && filtered.length >= current; + if (listAppended) return Math.min(filtered.length, Math.max(current, nextInitialLimit)); + return nextInitialLimit; + }); }, [filtered, viewState.viewMode]); - useEffect(() => { + const hasMoreRenderedRows = viewState.viewMode === "list" && renderedIssueRowLimit < filtered.length; + const remainingIssueRowCount = Math.max(filtered.length - renderedIssueRowLimit, 0); + const loadMoreIssueRows = useCallback(() => { if (viewState.viewMode !== "list") return; - if (renderedIssueRowLimit >= filtered.length) return; - - const timeoutId = window.setTimeout(() => { + if (hasMoreRenderedRows) { startTransition(() => { setRenderedIssueRowLimit((current) => Math.min(filtered.length, current + ISSUE_ROW_RENDER_BATCH_SIZE)); }); - }, ISSUE_ROW_RENDER_BATCH_DELAY_MS); + return; + } + if (hasMoreIssues && !isLoadingMoreIssues) { + onLoadMoreIssues?.(); + } + }, [ + filtered.length, + hasMoreIssues, + hasMoreRenderedRows, + isLoadingMoreIssues, + onLoadMoreIssues, + viewState.viewMode, + ]); - return () => window.clearTimeout(timeoutId); - }, [filtered.length, renderedIssueRowLimit, viewState.viewMode]); + const canLoadMoreIssues = viewState.viewMode === "list" + && !isLoading + && (hasMoreRenderedRows || (hasMoreIssues && !isLoadingMoreIssues)); - const remainingIssueRowCount = Math.max(filtered.length - renderedIssueRowLimit, 0); + useEffect(() => { + if (!canLoadMoreIssues) return; + let animationFrameId: number | null = null; + + const checkScrollPosition = () => { + if (animationFrameId !== null) return; + animationFrameId = window.requestAnimationFrame(() => { + animationFrameId = null; + const documentElement = document.documentElement; + if (documentElement.scrollHeight === 0) return; + const scrollBottom = window.scrollY + window.innerHeight; + const threshold = documentElement.scrollHeight - ISSUE_SCROLL_LOAD_THRESHOLD_PX; + if (scrollBottom >= threshold) { + loadMoreIssueRows(); + } + }); + }; + + window.addEventListener("scroll", checkScrollPosition, { passive: true }); + window.addEventListener("resize", checkScrollPosition); + checkScrollPosition(); + + return () => { + window.removeEventListener("scroll", checkScrollPosition); + window.removeEventListener("resize", checkScrollPosition); + if (animationFrameId !== null) window.cancelAnimationFrame(animationFrameId); + }; + }, [canLoadMoreIssues, loadMoreIssueRows]); const newIssueDefaults = useCallback((groupKey?: string) => { const defaults: Record = { ...(baseCreateIssueDefaults ?? {}) }; @@ -1569,10 +1627,16 @@ export function IssuesList({ ); })} - {remainingIssueRowCount > 0 && ( -

- Rendering {Math.min(renderedIssueRowLimit, filtered.length)} of {filtered.length} issues -

+ {(remainingIssueRowCount > 0 || hasMoreIssues || isLoadingMoreIssues) && ( +
+

+ {isLoadingMoreIssues + ? "Loading more issues..." + : remainingIssueRowCount > 0 + ? `Rendering ${Math.min(renderedIssueRowLimit, filtered.length)} of ${filtered.length} issues` + : "Scroll to load more issues"} +

+
)} )} diff --git a/ui/src/pages/Issues.test.tsx b/ui/src/pages/Issues.test.tsx index 1234242baec..cea0f0adb8c 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 } from "./Issues"; +import { buildIssuesSearchUrl, getNextIssuesPageLimit, hasMoreIssuesToRequest } from "./Issues"; describe("buildIssuesSearchUrl", () => { it("preserves trailing spaces in the synced search param", () => { @@ -14,3 +14,17 @@ describe("buildIssuesSearchUrl", () => { expect(buildIssuesSearchUrl("http://localhost:3100/issues?q=bug+", "bug ")).toBeNull(); }); }); + +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("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); + }); +}); diff --git a/ui/src/pages/Issues.tsx b/ui/src/pages/Issues.tsx index 63eb7704fc5..0a10c85a384 100644 --- a/ui/src/pages/Issues.tsx +++ b/ui/src/pages/Issues.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useCallback } from "react"; +import { useEffect, useMemo, useCallback, useState } from "react"; import { useLocation, useSearchParams } from "@/lib/router"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { issuesApi } from "../api/issues"; @@ -15,6 +15,17 @@ 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; + +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 buildIssuesSearchUrl(currentHref: string, search: string): string | null { const url = new URL(currentHref); @@ -36,12 +47,15 @@ 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); const participantAgentId = searchParams.get("participantAgentId") ?? undefined; const initialWorkspaces = searchParams.getAll("workspace").filter((workspaceId) => workspaceId.length > 0); const workspaceIdFilter = initialWorkspaces.length === 1 ? initialWorkspaces[0] : undefined; const handleSearchChange = useCallback((search: string) => { + setSyncedSearch(search); const nextUrl = buildIssuesSearchUrl(window.location.href, search); if (!nextUrl) return; window.history.replaceState(window.history.state, "", nextUrl); @@ -82,7 +96,17 @@ export function Issues() { setBreadcrumbs([{ label: "Issues" }]); }, [setBreadcrumbs]); - const { data: issues, isLoading, error } = useQuery({ + const effectiveIssueListLimit = workspaceIdFilter ? WORKSPACE_FILTER_ISSUE_LIMIT : issueListLimit; + + useEffect(() => { + setSyncedSearch(initialSearch); + }, [initialSearch]); + + useEffect(() => { + setIssueListLimit(ISSUES_PAGE_INITIAL_LIMIT); + }, [participantAgentId, workspaceIdFilter, selectedCompanyId, syncedSearch]); + + const { data: issues, isLoading, isFetching, error } = useQuery({ queryKey: [ ...queryKeys.issues.list(selectedCompanyId!), "participant-agent", @@ -90,16 +114,27 @@ export function Issues() { "workspace", workspaceIdFilter ?? "__all__", "with-routine-executions", + "limit", + effectiveIssueListLimit, ], queryFn: () => issuesApi.list(selectedCompanyId!, { participantAgentId, workspaceId: workspaceIdFilter, includeRoutineExecutions: true, - ...(workspaceIdFilter ? { limit: WORKSPACE_FILTER_ISSUE_LIMIT } : {}), + limit: effectiveIssueListLimit, }), enabled: !!selectedCompanyId, + placeholderData: (previousData) => previousData, }); + const hasMoreServerIssues = !workspaceIdFilter + && syncedSearch.trim().length === 0 + && hasMoreIssuesToRequest(issues?.length ?? 0, issueListLimit); + const loadMoreServerIssues = useCallback(() => { + if (workspaceIdFilter) return; + setIssueListLimit((current) => getNextIssuesPageLimit(current)); + }, [workspaceIdFilter]); + const updateIssue = useMutation({ mutationFn: ({ id, data }: { id: string; data: Record }) => issuesApi.update(id, data), @@ -116,6 +151,7 @@ export function Issues() { 0 ? initialWorkspaces : undefined} - initialSearch={initialSearch} + initialSearch={syncedSearch} onSearchChange={handleSearchChange} enableRoutineVisibilityFilter + hasMoreIssues={hasMoreServerIssues} + onLoadMoreIssues={loadMoreServerIssues} onUpdateIssue={(id, data) => updateIssue.mutate({ id, data })} searchFilters={participantAgentId || workspaceIdFilter ? { participantAgentId, workspaceId: workspaceIdFilter } : undefined} />