forked from farhoodlabs/paperclip
afb73ba553
## 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
199 lines
5.8 KiB
TypeScript
199 lines
5.8 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { act } from "react";
|
|
import { createRoot } from "react-dom/client";
|
|
import type { Issue, IssueStatus } from "@paperclipai/shared";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { KanbanBoard, resolveKanbanTargetStatus } from "./KanbanBoard";
|
|
|
|
vi.mock("@/lib/router", () => ({
|
|
Link: ({
|
|
children,
|
|
to,
|
|
disableIssueQuicklook: _disableIssueQuicklook,
|
|
...props
|
|
}: React.AnchorHTMLAttributes<HTMLAnchorElement> & {
|
|
to: string;
|
|
disableIssueQuicklook?: boolean;
|
|
}) => (
|
|
<a href={to} {...props}>{children}</a>
|
|
),
|
|
}));
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
function createIssue(index: number, status: IssueStatus): Issue {
|
|
return {
|
|
id: `issue-${status}-${index}`,
|
|
identifier: `PAP-${index}`,
|
|
companyId: "company-1",
|
|
projectId: null,
|
|
projectWorkspaceId: null,
|
|
goalId: null,
|
|
parentId: null,
|
|
title: `Issue ${index}`,
|
|
description: null,
|
|
status,
|
|
workMode: "standard",
|
|
priority: "medium",
|
|
assigneeAgentId: index === 1 ? "agent-1" : null,
|
|
assigneeUserId: null,
|
|
createdByAgentId: null,
|
|
createdByUserId: null,
|
|
issueNumber: index,
|
|
requestDepth: 0,
|
|
billingCode: null,
|
|
assigneeAdapterOverrides: null,
|
|
executionWorkspaceId: null,
|
|
executionWorkspacePreference: null,
|
|
executionWorkspaceSettings: null,
|
|
checkoutRunId: null,
|
|
executionRunId: null,
|
|
executionAgentNameKey: null,
|
|
executionLockedAt: null,
|
|
startedAt: null,
|
|
completedAt: null,
|
|
cancelledAt: null,
|
|
hiddenAt: null,
|
|
createdAt: new Date("2026-05-05T00:00:00.000Z"),
|
|
updatedAt: new Date("2026-05-05T00:00:00.000Z"),
|
|
labels: [],
|
|
labelIds: [],
|
|
myLastTouchAt: null,
|
|
lastExternalCommentAt: null,
|
|
lastActivityAt: null,
|
|
isUnreadForMe: false,
|
|
};
|
|
}
|
|
|
|
function createIssues(count: number, status: IssueStatus): Issue[] {
|
|
return Array.from({ length: count }, (_, index) => createIssue(index + 1, status));
|
|
}
|
|
|
|
function renderBoard(
|
|
props: Partial<React.ComponentProps<typeof KanbanBoard>> & { issues: Issue[] },
|
|
) {
|
|
const container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
const root = createRoot(container);
|
|
|
|
const render = (nextProps: Partial<React.ComponentProps<typeof KanbanBoard>> & { issues: Issue[] }) => {
|
|
act(() => {
|
|
root.render(
|
|
<KanbanBoard
|
|
agents={[{ id: "agent-1", name: "Codex" }]}
|
|
liveIssueIds={new Set(["issue-todo-1"])}
|
|
onUpdateIssue={vi.fn()}
|
|
{...nextProps}
|
|
/>,
|
|
);
|
|
});
|
|
};
|
|
|
|
render(props);
|
|
|
|
return { container, root, render };
|
|
}
|
|
|
|
describe("KanbanBoard", () => {
|
|
beforeEach(() => {
|
|
document.body.innerHTML = "";
|
|
});
|
|
|
|
afterEach(() => {
|
|
document.body.innerHTML = "";
|
|
});
|
|
|
|
it("limits visible cards and reveals more cards per column", () => {
|
|
const { container } = renderBoard({
|
|
issues: createIssues(60, "todo"),
|
|
compactCards: true,
|
|
initialVisibleCount: 50,
|
|
revealIncrement: 50,
|
|
});
|
|
|
|
expect(container.textContent).toContain("Showing 50 of 60");
|
|
expect(container.textContent).toContain("Show 10 more");
|
|
expect(container.textContent).toContain("Issue 50");
|
|
expect(container.textContent).not.toContain("Issue 51");
|
|
|
|
const showMoreButton = Array.from(container.querySelectorAll("button")).find((button) =>
|
|
button.textContent?.includes("Show 10 more"),
|
|
);
|
|
expect(showMoreButton).toBeTruthy();
|
|
|
|
act(() => {
|
|
showMoreButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
|
|
expect(container.textContent).toContain("Issue 60");
|
|
expect(container.textContent).not.toContain("Show 10 more");
|
|
});
|
|
|
|
it("resets visible counts when the column page size changes", () => {
|
|
const issues = createIssues(60, "todo");
|
|
const { container, render } = renderBoard({
|
|
issues,
|
|
initialVisibleCount: 50,
|
|
revealIncrement: 50,
|
|
});
|
|
|
|
const showMoreButton = Array.from(container.querySelectorAll("button")).find((button) =>
|
|
button.textContent?.includes("Show 10 more"),
|
|
);
|
|
expect(showMoreButton).toBeTruthy();
|
|
|
|
act(() => {
|
|
showMoreButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
|
|
expect(container.textContent).toContain("Issue 60");
|
|
|
|
render({
|
|
issues,
|
|
initialVisibleCount: 10,
|
|
revealIncrement: 10,
|
|
});
|
|
|
|
expect(container.textContent).toContain("Showing 10 of 60");
|
|
expect(container.textContent).toContain("Show 10 more");
|
|
expect(container.textContent).toContain("Issue 10");
|
|
expect(container.textContent).not.toContain("Issue 11");
|
|
});
|
|
|
|
it("renders collapsed statuses as rails without cards", () => {
|
|
const { container } = renderBoard({
|
|
issues: createIssues(3, "done"),
|
|
collapsedStatuses: ["done"],
|
|
});
|
|
|
|
expect(container.textContent).toContain("Done");
|
|
expect(container.textContent).toContain("3");
|
|
expect(container.textContent).not.toContain("Issue 1");
|
|
});
|
|
|
|
it("keeps core issue signals in compact cards", () => {
|
|
const { container } = renderBoard({
|
|
issues: createIssues(1, "todo"),
|
|
compactCards: true,
|
|
});
|
|
|
|
expect(container.textContent).toContain("PAP-1");
|
|
expect(container.textContent).toContain("Issue 1");
|
|
expect(container.textContent).toContain("Codex");
|
|
expect(container.textContent).toContain("Live");
|
|
});
|
|
|
|
it("resolves drop targets from status rails and cards", () => {
|
|
const issues = [
|
|
createIssue(1, "todo"),
|
|
createIssue(2, "blocked"),
|
|
];
|
|
|
|
expect(resolveKanbanTargetStatus("done", issues)).toBe("done");
|
|
expect(resolveKanbanTargetStatus("issue-blocked-2", issues)).toBe("blocked");
|
|
expect(resolveKanbanTargetStatus("missing", issues)).toBeNull();
|
|
});
|
|
});
|