forked from farhoodlabs/paperclip
Scale issue kanban board for high-volume columns (#5309)
## Thinking Path > - Paperclip is a control plane for autonomous AI-agent companies, and the board UI needs to keep operator visibility clear as company work scales. > - The involved subsystem is the Issues page board mode, specifically the Kanban rendering path for issue status columns. > - The current board keeps the classic Kanban model, but high-volume columns can become tall, slow, and hard to scan when hundreds of issues are loaded. > - We explored alternatives and chose the conservative Scaled Kanban direction: preserve status lanes and drag/drop, but bound visible cards and collapse low-signal lanes. > - This pull request adds UI-only density controls and high-volume defaults rather than introducing schema or API changes. > - The benefit is a board that remains usable with large issue inventories while keeping active workflow lanes visible. ## What Changed - Added scaled Kanban behavior with compact cards, collapsed cold-lane rails, per-column visible-card limits, and per-column "show more" reveal controls. - Added persisted board density preferences to the Issues page view state, scoped through the existing company-specific localStorage path. - Added board toolbar controls for compact cards, collapsed cold lanes, cards-per-column page size (`10`, `25`, `50`), and density reset. - Added a design spec and implementation plan under `doc/plans/`. - Added focused Vitest coverage for `KanbanBoard` and `IssuesList` high-volume board behavior. ## Verification - `pnpm exec vitest run ui/src/components/IssuesList.test.tsx ui/src/components/KanbanBoard.test.tsx` — pass, 35 tests. - `pnpm -r typecheck` — pass. - `pnpm build` — pass before the upstream merge; not rerun after docs/assets cleanup. - `curl -fsS http://127.0.0.1:3100/api/health` — pass against restarted local dev server after applying pending migration `0078_white_darwin.sql`. - `pnpm test:run` — previously failed in unrelated Cursor remote-sandbox server tests: - `server/src/__tests__/cursor-local-adapter-environment.test.ts`: expected probe status `pass`, received `fail`. - `server/src/__tests__/cursor-local-execute.test.ts`: two remote sandbox execution cases exited `127` instead of `0`. Local dev server for manual UI inspection: `http://127.0.0.1:3100`. Screenshots were captured for review and attached in the PR thread rather than committed to source. ## Risks - Low schema/API risk: this is UI-only and uses the existing issue data path. - Board users may need to notice the new density controls if they want to override high-volume defaults. - Collapsed cold lanes remain valid drop targets, so status moves can happen without expanding the destination lane. - Very large remote columns can still hit the existing 200-item per-column query cap; this PR improves rendering, not server-side board pagination. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex coding agent based on GPT-5, with repository tool use, shell execution, local test/build execution, and inline implementation planning. No subagents were used. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge
This commit is contained in:
@@ -123,7 +123,17 @@ vi.mock("./IssueRow", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./KanbanBoard", () => ({
|
||||
KanbanBoard: (props: { issues: Issue[] }) => {
|
||||
KANBAN_BOARD_HIGH_VOLUME_THRESHOLD: 100,
|
||||
KANBAN_COLD_STATUSES: ["backlog", "done", "cancelled"],
|
||||
KANBAN_COLUMN_DEFAULT_PAGE_SIZE: 10,
|
||||
KANBAN_COLUMN_PAGE_SIZE_OPTIONS: [10, 25, 50],
|
||||
KanbanBoard: (props: {
|
||||
issues: Issue[];
|
||||
compactCards?: boolean;
|
||||
collapsedStatuses?: string[];
|
||||
initialVisibleCount?: number;
|
||||
revealIncrement?: number;
|
||||
}) => {
|
||||
mockKanbanBoard(props);
|
||||
return (
|
||||
<div data-testid="kanban-board">
|
||||
@@ -1011,6 +1021,110 @@ describe("IssuesList", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses compact cards and collapsed cold lanes for high-volume boards", async () => {
|
||||
localStorage.setItem(
|
||||
"paperclip:test-issues:company-1",
|
||||
JSON.stringify({ viewMode: "board" }),
|
||||
);
|
||||
|
||||
const backlogIssues = Array.from({ length: 101 }, (_, index) =>
|
||||
createIssue({
|
||||
id: `issue-backlog-${index + 1}`,
|
||||
identifier: `PAP-${index + 1}`,
|
||||
title: `Backlog issue ${index + 1}`,
|
||||
status: "backlog",
|
||||
}),
|
||||
);
|
||||
|
||||
mockIssuesApi.list.mockImplementation((_companyId, filters) => {
|
||||
if (filters?.status === "backlog") return Promise.resolve(backlogIssues);
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
|
||||
const { root } = renderWithQueryClient(
|
||||
<IssuesList
|
||||
issues={[]}
|
||||
agents={[]}
|
||||
projects={[]}
|
||||
viewStateKey="paperclip:test-issues"
|
||||
onUpdateIssue={() => undefined}
|
||||
/>,
|
||||
container,
|
||||
);
|
||||
|
||||
await waitForAssertion(() => {
|
||||
expect(mockKanbanBoard).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
compactCards: true,
|
||||
collapsedStatuses: expect.arrayContaining(["backlog", "done", "cancelled"]),
|
||||
initialVisibleCount: 10,
|
||||
revealIncrement: 10,
|
||||
}));
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("lets board users choose the per-column page size", async () => {
|
||||
localStorage.setItem(
|
||||
"paperclip:test-issues:company-1",
|
||||
JSON.stringify({ viewMode: "board" }),
|
||||
);
|
||||
|
||||
const { root } = renderWithQueryClient(
|
||||
<IssuesList
|
||||
issues={[createIssue({ id: "issue-page-size", title: "Page size issue" })]}
|
||||
agents={[]}
|
||||
projects={[]}
|
||||
viewStateKey="paperclip:test-issues"
|
||||
onUpdateIssue={() => undefined}
|
||||
/>,
|
||||
container,
|
||||
);
|
||||
|
||||
await waitForAssertion(() => {
|
||||
expect(mockKanbanBoard).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
initialVisibleCount: 10,
|
||||
revealIncrement: 10,
|
||||
}));
|
||||
});
|
||||
|
||||
const pageSizeButton = Array.from(container.querySelectorAll("button")).find((button) =>
|
||||
button.getAttribute("title") === "Cards per column",
|
||||
);
|
||||
expect(pageSizeButton).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
pageSizeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
let option25: HTMLButtonElement | undefined;
|
||||
await waitForAssertion(() => {
|
||||
option25 = Array.from(document.body.querySelectorAll("button")).find((button) =>
|
||||
button.textContent?.includes("25 per column"),
|
||||
);
|
||||
expect(option25).toBeTruthy();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
option25?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
await waitForAssertion(() => {
|
||||
expect(mockKanbanBoard).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
initialVisibleCount: 25,
|
||||
revealIncrement: 25,
|
||||
}));
|
||||
});
|
||||
|
||||
expect(localStorage.getItem("paperclip:test-issues:company-1")).toContain("\"boardColumnPageSize\":25");
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows a refinement hint when a board column hits its server cap", async () => {
|
||||
localStorage.setItem(
|
||||
"paperclip:test-issues:company-1",
|
||||
|
||||
Reference in New Issue
Block a user