Make issues page load more on scroll

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-28 11:48:02 -05:00
1 parent 39ba2797fb
commit 09923b01b2
4 files changed
+230 -19

No files matched your search

+95
View File
@@ -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(
<IssuesList
issues={manyIssues}
agents={[]}
projects={[]}
viewStateKey="paperclip:test-issues"
onUpdateIssue={() => 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(
<IssuesList
issues={visibleIssues}
agents={[]}
projects={[]}
viewStateKey="paperclip:test-issues"
hasMoreIssues
onLoadMoreIssues={onLoadMoreIssues}
onUpdateIssue={() => 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",
+78 -14
View File
@@ -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<IssueStatus, string> = {
backlog: "Backlog",
@@ -306,8 +306,11 @@ interface IssuesListProps {
defaultSortField?: IssueSortField;
showProgressSummary?: boolean;
enableRoutineVisibilityFilter?: boolean;
hasMoreIssues?: boolean;
isLoadingMoreIssues?: boolean;
mutedIssueIds?: Set<string>;
issueBadgeById?: Map<string, string>;
onLoadMoreIssues?: () => void;
onSearchChange?: (search: string) => void;
onUpdateIssue: (id: string, data: Record<string, unknown>) => 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<InboxIssueColumn[]>(() => 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<string, unknown> = { ...(baseCreateIssueDefaults ?? {}) };
@@ -1569,10 +1627,16 @@ export function IssuesList({
</Collapsible>
);
})}
{remainingIssueRowCount > 0 && (
<p className="text-xs text-muted-foreground">
Rendering {Math.min(renderedIssueRowLimit, filtered.length)} of {filtered.length} issues
</p>
{(remainingIssueRowCount > 0 || hasMoreIssues || isLoadingMoreIssues) && (
<div className="py-2" data-testid="issues-load-more-sentinel">
<p className="text-xs text-muted-foreground">
{isLoadingMoreIssues
? "Loading more issues..."
: remainingIssueRowCount > 0
? `Rendering ${Math.min(renderedIssueRowLimit, filtered.length)} of ${filtered.length} issues`
: "Scroll to load more issues"}
</p>
</div>
)}
</>
)}
+15 -1
View File
@@ -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);
});
});
+42 -4
View File
@@ -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<string, unknown> }) =>
issuesApi.update(id, data),
@@ -116,6 +151,7 @@ export function Issues() {
<IssuesList
issues={issues ?? []}
isLoading={isLoading}
isLoadingMoreIssues={isFetching && !isLoading}
error={error as Error | null}
agents={agents}
projects={projects}
@@ -124,9 +160,11 @@ export function Issues() {
issueLinkState={issueLinkState}
initialAssignees={searchParams.get("assignee") ? [searchParams.get("assignee")!] : undefined}
initialWorkspaces={initialWorkspaces.length > 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}
/>