diff --git a/ui/src/components/IssuesList.test.tsx b/ui/src/components/IssuesList.test.tsx
index 52f6f42f571..ab2921df8e6 100644
--- a/ui/src/components/IssuesList.test.tsx
+++ b/ui/src/components/IssuesList.test.tsx
@@ -22,6 +22,8 @@ const mockIssuesApi = vi.hoisted(() => ({
listLabels: vi.fn(),
}));
+const mockKanbanBoard = vi.hoisted(() => vi.fn());
+
const mockAuthApi = vi.hoisted(() => ({
getSession: vi.fn(),
}));
@@ -87,7 +89,16 @@ vi.mock("./IssueRow", () => ({
}));
vi.mock("./KanbanBoard", () => ({
- KanbanBoard: () => null,
+ KanbanBoard: (props: { issues: Issue[] }) => {
+ mockKanbanBoard(props);
+ return (
+
+ {props.issues.map((issue) => (
+ {issue.title}
+ ))}
+
+ );
+ },
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -189,6 +200,7 @@ describe("IssuesList", () => {
container = document.createElement("div");
document.body.appendChild(container);
dialogState.openNewIssue.mockReset();
+ mockKanbanBoard.mockReset();
mockIssuesApi.list.mockReset();
mockIssuesApi.listLabels.mockReset();
mockAuthApi.getSession.mockReset();
@@ -404,6 +416,73 @@ describe("IssuesList", () => {
});
});
+ it("loads board issues with a separate result limit for each status column", async () => {
+ localStorage.setItem(
+ "paperclip:test-issues:company-1",
+ JSON.stringify({ viewMode: "board" }),
+ );
+
+ const parentIssue = createIssue({
+ id: "issue-parent-total-limit",
+ title: "Parent total-limited issue",
+ status: "todo",
+ });
+ const backlogIssue = createIssue({
+ id: "issue-backlog",
+ title: "Backlog column issue",
+ status: "backlog",
+ });
+ const doneIssue = createIssue({
+ id: "issue-done",
+ title: "Done column issue",
+ status: "done",
+ });
+
+ mockIssuesApi.list.mockImplementation((_companyId, filters) => {
+ if (filters?.status === "backlog") return Promise.resolve([backlogIssue]);
+ if (filters?.status === "done") return Promise.resolve([doneIssue]);
+ return Promise.resolve([]);
+ });
+
+ const { root } = renderWithQueryClient(
+ undefined}
+ />,
+ container,
+ );
+
+ await waitForAssertion(() => {
+ expect(mockIssuesApi.list).toHaveBeenCalledWith("company-1", expect.objectContaining({
+ status: "backlog",
+ limit: 200,
+ includeRoutineExecutions: true,
+ }));
+ expect(mockIssuesApi.list).toHaveBeenCalledWith("company-1", expect.objectContaining({
+ status: "done",
+ limit: 200,
+ includeRoutineExecutions: true,
+ }));
+ expect(mockKanbanBoard).toHaveBeenLastCalledWith(expect.objectContaining({
+ issues: expect.arrayContaining([
+ expect.objectContaining({ id: "issue-backlog" }),
+ expect.objectContaining({ id: "issue-done" }),
+ ]),
+ }));
+ expect(container.textContent).toContain("Backlog column issue");
+ expect(container.textContent).toContain("Done column issue");
+ expect(container.textContent).not.toContain("Parent total-limited issue");
+ });
+
+ act(() => {
+ root.unmount();
+ });
+ });
+
it("caps the first paint for large issue lists", async () => {
const manyIssues = Array.from({ length: 220 }, (_, index) =>
createIssue({
diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx
index a9bc23823ab..a444a2dfd99 100644
--- a/ui/src/components/IssuesList.tsx
+++ b/ui/src/components/IssuesList.tsx
@@ -1,5 +1,5 @@
import { startTransition, useDeferredValue, useEffect, useMemo, useState, useCallback, useRef } from "react";
-import { useQuery } from "@tanstack/react-query";
+import { useQueries, useQuery } from "@tanstack/react-query";
import { accessApi } from "../api/access";
import { useDialog } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext";
@@ -60,9 +60,11 @@ import { statusBadge } from "../lib/status-colors";
import type { Issue, Project } from "@paperclipai/shared";
const ISSUE_SEARCH_DEBOUNCE_MS = 250;
const ISSUE_SEARCH_RESULT_LIMIT = 200;
+const ISSUE_BOARD_COLUMN_RESULT_LIMIT = 200;
const INITIAL_ISSUE_ROW_RENDER_LIMIT = 150;
const ISSUE_ROW_RENDER_BATCH_SIZE = 150;
const ISSUE_ROW_RENDER_BATCH_DELAY_MS = 0;
+const boardIssueStatuses = ["backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled"];
/* ── View state ── */
@@ -394,6 +396,31 @@ export function IssuesList({
enabled: !!selectedCompanyId && normalizedIssueSearch.length > 0,
placeholderData: (previousData) => previousData,
});
+ const boardIssueQueries = useQueries({
+ queries: boardIssueStatuses.map((status) => ({
+ queryKey: [
+ ...queryKeys.issues.list(selectedCompanyId ?? "__no-company__"),
+ "board-column",
+ status,
+ normalizedIssueSearch,
+ projectId ?? "__all-projects__",
+ searchFilters ?? {},
+ ISSUE_BOARD_COLUMN_RESULT_LIMIT,
+ enableRoutineVisibilityFilter ? "with-routine-executions" : "without-routine-executions",
+ ],
+ queryFn: () =>
+ issuesApi.list(selectedCompanyId!, {
+ ...searchFilters,
+ ...(normalizedIssueSearch.length > 0 ? { q: normalizedIssueSearch } : {}),
+ projectId,
+ status,
+ limit: ISSUE_BOARD_COLUMN_RESULT_LIMIT,
+ ...(enableRoutineVisibilityFilter ? { includeRoutineExecutions: true } : {}),
+ }),
+ enabled: !!selectedCompanyId && viewState.viewMode === "board",
+ placeholderData: (previousData: Issue[] | undefined) => previousData,
+ })),
+ });
const { data: executionWorkspaces = [] } = useQuery({
queryKey: selectedCompanyId
? queryKeys.executionWorkspaces.summaryList(selectedCompanyId)
@@ -570,11 +597,25 @@ export function IssuesList({
return map;
}, [issues]);
+ const boardIssues = useMemo(() => {
+ if (viewState.viewMode !== "board") return null;
+ const merged = new Map();
+ let isPending = false;
+ for (const query of boardIssueQueries) {
+ isPending ||= query.isPending;
+ for (const issue of query.data ?? []) {
+ merged.set(issue.id, issue);
+ }
+ }
+ if (merged.size > 0) return [...merged.values()];
+ return isPending ? issues : [];
+ }, [boardIssueQueries, issues, viewState.viewMode]);
+
const filtered = useMemo(() => {
- const sourceIssues = normalizedIssueSearch.length > 0 ? searchedIssues : issues;
+ const sourceIssues = boardIssues ?? (normalizedIssueSearch.length > 0 ? searchedIssues : issues);
const filteredByControls = applyIssueFilters(sourceIssues, viewState, currentUserId, enableRoutineVisibilityFilter);
return sortIssues(filteredByControls, viewState);
- }, [issues, searchedIssues, viewState, normalizedIssueSearch, currentUserId, enableRoutineVisibilityFilter]);
+ }, [boardIssues, issues, searchedIssues, viewState, normalizedIssueSearch, currentUserId, enableRoutineVisibilityFilter]);
const { data: labels } = useQuery({
queryKey: queryKeys.issues.labels(selectedCompanyId!),