mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
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:
parent
7e1a27c8ec
commit
afb73ba553
7 changed files with 904 additions and 32 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -60,8 +60,15 @@ import { Button } from "@/components/ui/button";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
|
||||
import { Collapsible, CollapsibleContent } from "@/components/ui/collapsible";
|
||||
import { CircleDot, Plus, ArrowUpDown, Layers, Check, ChevronRight, List, ListTree, Columns3, User, Search, CircleSlash2 } from "lucide-react";
|
||||
import { KanbanBoard } from "./KanbanBoard";
|
||||
import { CircleDot, Plus, ArrowUpDown, Layers, Check, ChevronRight, List, ListTree, Columns3, User, Search, CircleSlash2, ChevronsDownUp, PanelTopClose, RotateCcw, ListCollapse } from "lucide-react";
|
||||
import {
|
||||
KanbanBoard,
|
||||
KANBAN_BOARD_HIGH_VOLUME_THRESHOLD,
|
||||
KANBAN_COLD_STATUSES,
|
||||
KANBAN_COLUMN_DEFAULT_PAGE_SIZE,
|
||||
KANBAN_COLUMN_PAGE_SIZE_OPTIONS,
|
||||
type KanbanColumnPageSize,
|
||||
} from "./KanbanBoard";
|
||||
import { buildIssueTree, countDescendants } from "../lib/issue-tree";
|
||||
import { buildSubIssueDefaultsForViewer } from "../lib/subIssueDefaults";
|
||||
import { statusBadge } from "../lib/status-colors";
|
||||
|
|
@ -110,6 +117,9 @@ const progressSegmentClasses: Record<IssueStatus, string> = {
|
|||
/* ── View state ── */
|
||||
|
||||
export type IssueSortField = "status" | "priority" | "title" | "created" | "updated" | "workflow";
|
||||
export type BoardCardDensity = "auto" | "compact" | "comfortable";
|
||||
export type BoardColdLaneMode = "auto" | "collapsed" | "expanded";
|
||||
export type BoardColumnPageSize = KanbanColumnPageSize;
|
||||
|
||||
export type IssueViewState = IssueFilterState & {
|
||||
sortField: IssueSortField;
|
||||
|
|
@ -119,6 +129,9 @@ export type IssueViewState = IssueFilterState & {
|
|||
nestingEnabled: boolean;
|
||||
collapsedGroups: string[];
|
||||
collapsedParents: string[];
|
||||
boardCardDensity: BoardCardDensity;
|
||||
boardColdLaneMode: BoardColdLaneMode;
|
||||
boardColumnPageSize: BoardColumnPageSize;
|
||||
};
|
||||
|
||||
const defaultViewState: IssueViewState = {
|
||||
|
|
@ -130,14 +143,38 @@ const defaultViewState: IssueViewState = {
|
|||
nestingEnabled: true,
|
||||
collapsedGroups: [],
|
||||
collapsedParents: [],
|
||||
boardCardDensity: "auto",
|
||||
boardColdLaneMode: "auto",
|
||||
boardColumnPageSize: KANBAN_COLUMN_DEFAULT_PAGE_SIZE,
|
||||
};
|
||||
|
||||
function normalizeBoardCardDensity(value: unknown): BoardCardDensity {
|
||||
return value === "compact" || value === "comfortable" || value === "auto" ? value : "auto";
|
||||
}
|
||||
|
||||
function normalizeBoardColdLaneMode(value: unknown): BoardColdLaneMode {
|
||||
return value === "collapsed" || value === "expanded" || value === "auto" ? value : "auto";
|
||||
}
|
||||
|
||||
function normalizeBoardColumnPageSize(value: unknown): BoardColumnPageSize {
|
||||
return KANBAN_COLUMN_PAGE_SIZE_OPTIONS.includes(value as BoardColumnPageSize)
|
||||
? value as BoardColumnPageSize
|
||||
: KANBAN_COLUMN_DEFAULT_PAGE_SIZE;
|
||||
}
|
||||
|
||||
function getViewState(key: string): IssueViewState {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
return { ...defaultViewState, ...parsed, ...normalizeIssueFilterState(parsed) };
|
||||
return {
|
||||
...defaultViewState,
|
||||
...parsed,
|
||||
...normalizeIssueFilterState(parsed),
|
||||
boardCardDensity: normalizeBoardCardDensity(parsed.boardCardDensity),
|
||||
boardColdLaneMode: normalizeBoardColdLaneMode(parsed.boardColdLaneMode),
|
||||
boardColumnPageSize: normalizeBoardColumnPageSize(parsed.boardColumnPageSize),
|
||||
};
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return { ...defaultViewState };
|
||||
|
|
@ -1007,6 +1044,22 @@ export function IssuesList({
|
|||
});
|
||||
|
||||
const activeFilterCount = countActiveIssueFilters(viewState, enableRoutineVisibilityFilter);
|
||||
const boardHighVolume = viewState.viewMode === "board" && filtered.length > KANBAN_BOARD_HIGH_VOLUME_THRESHOLD;
|
||||
const boardCompactCards =
|
||||
viewState.boardCardDensity === "compact"
|
||||
|| (viewState.boardCardDensity === "auto" && boardHighVolume);
|
||||
const boardCollapsedStatuses = useMemo(
|
||||
() =>
|
||||
viewState.boardColdLaneMode === "collapsed"
|
||||
|| (viewState.boardColdLaneMode === "auto" && boardHighVolume)
|
||||
? [...KANBAN_COLD_STATUSES]
|
||||
: [],
|
||||
[boardHighVolume, viewState.boardColdLaneMode],
|
||||
);
|
||||
const boardDensityCustomized =
|
||||
viewState.boardCardDensity !== "auto"
|
||||
|| viewState.boardColdLaneMode !== "auto"
|
||||
|| viewState.boardColumnPageSize !== KANBAN_COLUMN_DEFAULT_PAGE_SIZE;
|
||||
|
||||
const groupedContent = useMemo(() => {
|
||||
if (viewState.groupBy === "none") {
|
||||
|
|
@ -1324,6 +1377,83 @@ export function IssuesList({
|
|||
</Button>
|
||||
)}
|
||||
|
||||
{viewState.viewMode === "board" && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className={cn("h-8 w-8 shrink-0", boardCompactCards && "bg-accent")}
|
||||
onClick={() => updateView({ boardCardDensity: boardCompactCards ? "comfortable" : "compact" })}
|
||||
title={boardCompactCards ? "Use comfortable cards" : "Use compact cards"}
|
||||
>
|
||||
<ChevronsDownUp className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className={cn("h-8 w-8 shrink-0", boardCollapsedStatuses.length > 0 && "bg-accent")}
|
||||
onClick={() => updateView({ boardColdLaneMode: boardCollapsedStatuses.length > 0 ? "expanded" : "collapsed" })}
|
||||
title={boardCollapsedStatuses.length > 0 ? "Expand cold lanes" : "Collapse cold lanes"}
|
||||
>
|
||||
<PanelTopClose className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-8 shrink-0 gap-1.5 px-2",
|
||||
viewState.boardColumnPageSize !== KANBAN_COLUMN_DEFAULT_PAGE_SIZE && "bg-accent",
|
||||
)}
|
||||
title="Cards per column"
|
||||
>
|
||||
<ListCollapse className="h-3.5 w-3.5" />
|
||||
<span className="min-w-4 text-xs tabular-nums">{viewState.boardColumnPageSize}</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-40 p-0">
|
||||
<div className="p-2 space-y-0.5">
|
||||
{KANBAN_COLUMN_PAGE_SIZE_OPTIONS.map((pageSize) => (
|
||||
<button
|
||||
key={pageSize}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm",
|
||||
viewState.boardColumnPageSize === pageSize
|
||||
? "bg-accent/50 text-foreground"
|
||||
: "text-muted-foreground hover:bg-accent/50",
|
||||
)}
|
||||
onClick={() => updateView({ boardColumnPageSize: pageSize })}
|
||||
>
|
||||
<span>{pageSize} per column</span>
|
||||
{viewState.boardColumnPageSize === pageSize && <Check className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => updateView({
|
||||
boardCardDensity: "auto",
|
||||
boardColdLaneMode: "auto",
|
||||
boardColumnPageSize: KANBAN_COLUMN_DEFAULT_PAGE_SIZE,
|
||||
})}
|
||||
disabled={!boardDensityCustomized}
|
||||
title="Reset board density"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<IssueColumnPicker
|
||||
availableColumns={availableIssueColumns}
|
||||
visibleColumnSet={visibleIssueColumnSet}
|
||||
|
|
@ -1454,6 +1584,10 @@ export function IssuesList({
|
|||
issues={filtered}
|
||||
agents={agents}
|
||||
liveIssueIds={liveIssueIds}
|
||||
compactCards={boardCompactCards}
|
||||
collapsedStatuses={boardCollapsedStatuses}
|
||||
initialVisibleCount={viewState.boardColumnPageSize}
|
||||
revealIncrement={viewState.boardColumnPageSize}
|
||||
onUpdateIssue={onUpdateIssue}
|
||||
/>
|
||||
) : (
|
||||
|
|
|
|||
198
ui/src/components/KanbanBoard.test.tsx
Normal file
198
ui/src/components/KanbanBoard.test.tsx
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
// @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();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link } from "@/lib/router";
|
||||
import {
|
||||
DndContext,
|
||||
|
|
@ -20,11 +20,19 @@ import {
|
|||
import { StatusIcon } from "./StatusIcon";
|
||||
import { PriorityIcon } from "./PriorityIcon";
|
||||
import { Identity } from "./Identity";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import type { Issue, IssueStatus } from "@paperclipai/shared";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { isSuccessfulRunHandoffRequired } from "../lib/successful-run-handoff";
|
||||
|
||||
const boardStatuses = [
|
||||
export const KANBAN_BOARD_HIGH_VOLUME_THRESHOLD = 100;
|
||||
export const KANBAN_COLUMN_PAGE_SIZE_OPTIONS = [10, 25, 50] as const;
|
||||
export type KanbanColumnPageSize = (typeof KANBAN_COLUMN_PAGE_SIZE_OPTIONS)[number];
|
||||
export const KANBAN_COLUMN_DEFAULT_PAGE_SIZE: KanbanColumnPageSize = 10;
|
||||
export const KANBAN_COLUMN_INITIAL_VISIBLE_LIMIT = KANBAN_COLUMN_DEFAULT_PAGE_SIZE;
|
||||
export const KANBAN_COLUMN_REVEAL_INCREMENT = KANBAN_COLUMN_DEFAULT_PAGE_SIZE;
|
||||
export const KANBAN_COLD_STATUSES = ["backlog", "done", "cancelled"] as const;
|
||||
|
||||
export const boardStatuses = [
|
||||
"backlog",
|
||||
"todo",
|
||||
"in_progress",
|
||||
|
|
@ -32,12 +40,19 @@ const boardStatuses = [
|
|||
"blocked",
|
||||
"done",
|
||||
"cancelled",
|
||||
];
|
||||
] as const satisfies readonly IssueStatus[];
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
export function resolveKanbanTargetStatus(overId: string, issues: Issue[]): IssueStatus | null {
|
||||
if ((boardStatuses as readonly string[]).includes(overId)) {
|
||||
return overId as IssueStatus;
|
||||
}
|
||||
return issues.find((issue) => issue.id === overId)?.status ?? null;
|
||||
}
|
||||
|
||||
interface Agent {
|
||||
id: string;
|
||||
name: string;
|
||||
|
|
@ -47,6 +62,10 @@ interface KanbanBoardProps {
|
|||
issues: Issue[];
|
||||
agents?: Agent[];
|
||||
liveIssueIds?: Set<string>;
|
||||
compactCards?: boolean;
|
||||
collapsedStatuses?: string[];
|
||||
initialVisibleCount?: number;
|
||||
revealIncrement?: number;
|
||||
onUpdateIssue: (id: string, data: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
|
|
@ -57,15 +76,48 @@ function KanbanColumn({
|
|||
issues,
|
||||
agents,
|
||||
liveIssueIds,
|
||||
compactCards = false,
|
||||
collapsed = false,
|
||||
visibleCount,
|
||||
revealIncrement,
|
||||
onShowMore,
|
||||
}: {
|
||||
status: string;
|
||||
status: IssueStatus;
|
||||
issues: Issue[];
|
||||
agents?: Agent[];
|
||||
liveIssueIds?: Set<string>;
|
||||
compactCards?: boolean;
|
||||
collapsed?: boolean;
|
||||
visibleCount: number;
|
||||
revealIncrement: number;
|
||||
onShowMore: () => void;
|
||||
}) {
|
||||
const { setNodeRef, isOver } = useDroppable({ id: status });
|
||||
|
||||
const isEmpty = issues.length === 0;
|
||||
const visibleIssues = collapsed ? [] : issues.slice(0, visibleCount);
|
||||
const hiddenCount = Math.max(issues.length - visibleIssues.length, 0);
|
||||
const nextRevealCount = Math.min(revealIncrement, hiddenCount);
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={`flex min-h-[220px] w-[52px] shrink-0 flex-col items-center rounded-md border border-border bg-muted/20 px-1.5 py-2 transition-colors ${
|
||||
isOver ? "bg-accent/50 ring-1 ring-primary/20" : ""
|
||||
}`}
|
||||
title={`${statusLabel(status)}: ${issues.length}`}
|
||||
>
|
||||
<StatusIcon status={status} />
|
||||
<span className="mt-2 [writing-mode:vertical-rl] rotate-180 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{statusLabel(status)}
|
||||
</span>
|
||||
<span className="mt-auto rounded-full bg-background px-1.5 py-0.5 text-[10px] font-medium tabular-nums text-muted-foreground">
|
||||
{issues.length}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col shrink-0 transition-[width,min-width] ${isEmpty && !isOver ? "min-w-[48px] w-[48px]" : "min-w-[260px] w-[260px]"}`}>
|
||||
|
|
@ -88,19 +140,35 @@ function KanbanColumn({
|
|||
isOver ? "bg-accent/40" : "bg-muted/20"
|
||||
}`}
|
||||
>
|
||||
{/* Hidden cards are intentionally excluded from sort targets until revealed. */}
|
||||
<SortableContext
|
||||
items={issues.map((i) => i.id)}
|
||||
items={visibleIssues.map((i) => i.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{issues.map((issue) => (
|
||||
{visibleIssues.map((issue) => (
|
||||
<KanbanCard
|
||||
key={issue.id}
|
||||
issue={issue}
|
||||
agents={agents}
|
||||
isLive={liveIssueIds?.has(issue.id)}
|
||||
compact={compactCards}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
{hiddenCount > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="mt-1 flex w-full items-center justify-center rounded-md border border-dashed border-border bg-background/70 px-2 py-2 text-xs font-medium text-muted-foreground transition-colors hover:border-foreground/30 hover:text-foreground"
|
||||
onClick={onShowMore}
|
||||
>
|
||||
Show {nextRevealCount} more
|
||||
</button>
|
||||
) : null}
|
||||
{issues.length > 0 && (hiddenCount > 0 || issues.length >= visibleCount) ? (
|
||||
<p className="px-1 pt-1 text-[11px] text-muted-foreground">
|
||||
Showing {visibleIssues.length} of {issues.length}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -113,11 +181,13 @@ function KanbanCard({
|
|||
agents,
|
||||
isLive,
|
||||
isOverlay,
|
||||
compact = false,
|
||||
}: {
|
||||
issue: Issue;
|
||||
agents?: Agent[];
|
||||
isLive?: boolean;
|
||||
isOverlay?: boolean;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
|
|
@ -144,9 +214,11 @@ function KanbanCard({
|
|||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className={`rounded-md border bg-card p-2.5 cursor-grab active:cursor-grabbing transition-shadow ${
|
||||
className={`rounded-md border bg-card cursor-grab active:cursor-grabbing transition-shadow ${
|
||||
isDragging && !isOverlay ? "opacity-30" : ""
|
||||
} ${isOverlay ? "shadow-lg ring-1 ring-primary/20" : "hover:shadow-sm"}`}
|
||||
} ${isOverlay ? "shadow-lg ring-1 ring-primary/20" : "hover:shadow-sm"} ${
|
||||
compact ? "p-2" : "p-2.5"
|
||||
}`}
|
||||
>
|
||||
<Link
|
||||
to={`/issues/${issue.identifier ?? issue.id}`}
|
||||
|
|
@ -157,7 +229,7 @@ function KanbanCard({
|
|||
if (isDragging) e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-1.5 mb-1.5">
|
||||
<div className={`flex items-start gap-1.5 ${compact ? "mb-1" : "mb-1.5"}`}>
|
||||
<span className="text-xs text-muted-foreground font-mono shrink-0">
|
||||
{issue.identifier ?? issue.id.slice(0, 8)}
|
||||
</span>
|
||||
|
|
@ -172,14 +244,17 @@ function KanbanCard({
|
|||
</span>
|
||||
) : null}
|
||||
{isLive && (
|
||||
<span className="relative flex h-2 w-2 shrink-0 mt-0.5">
|
||||
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
|
||||
<span className="inline-flex shrink-0 items-center gap-1 text-[10px] font-medium text-blue-600 dark:text-blue-400">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
|
||||
</span>
|
||||
{compact ? "Live" : null}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm leading-snug line-clamp-2 mb-2">{issue.title}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className={`${compact ? "mb-1.5 text-xs" : "mb-2 text-sm"} leading-snug line-clamp-2`}>{issue.title}</p>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<PriorityIcon priority={issue.priority} />
|
||||
{issue.assigneeAgentId && (() => {
|
||||
const name = agentName(issue.assigneeAgentId);
|
||||
|
|
@ -203,16 +278,26 @@ export function KanbanBoard({
|
|||
issues,
|
||||
agents,
|
||||
liveIssueIds,
|
||||
compactCards = false,
|
||||
collapsedStatuses = [],
|
||||
initialVisibleCount = KANBAN_COLUMN_INITIAL_VISIBLE_LIMIT,
|
||||
revealIncrement = KANBAN_COLUMN_REVEAL_INCREMENT,
|
||||
onUpdateIssue,
|
||||
}: KanbanBoardProps) {
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [visibleCountByStatus, setVisibleCountByStatus] = useState<Record<string, number>>({});
|
||||
const collapsedStatusSet = useMemo(() => new Set(collapsedStatuses), [collapsedStatuses]);
|
||||
|
||||
useEffect(() => {
|
||||
setVisibleCountByStatus({});
|
||||
}, [initialVisibleCount, revealIncrement]);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
|
||||
);
|
||||
|
||||
const columnIssues = useMemo(() => {
|
||||
const grouped: Record<string, Issue[]> = {};
|
||||
const grouped: Record<IssueStatus, Issue[]> = {} as Record<IssueStatus, Issue[]>;
|
||||
for (const status of boardStatuses) {
|
||||
grouped[status] = [];
|
||||
}
|
||||
|
|
@ -244,17 +329,7 @@ export function KanbanBoard({
|
|||
|
||||
// Determine target status: the "over" could be a column id (status string)
|
||||
// or another card's id. Find which column the "over" belongs to.
|
||||
let targetStatus: string | null = null;
|
||||
|
||||
if (boardStatuses.includes(over.id as string)) {
|
||||
targetStatus = over.id as string;
|
||||
} else {
|
||||
// It's a card - find which column it's in
|
||||
const targetIssue = issues.find((i) => i.id === over.id);
|
||||
if (targetIssue) {
|
||||
targetStatus = targetIssue.status;
|
||||
}
|
||||
}
|
||||
const targetStatus = resolveKanbanTargetStatus(over.id as string, issues);
|
||||
|
||||
if (targetStatus && targetStatus !== issue.status) {
|
||||
onUpdateIssue(issueId, { status: targetStatus });
|
||||
|
|
@ -280,12 +355,22 @@ export function KanbanBoard({
|
|||
issues={columnIssues[status] ?? []}
|
||||
agents={agents}
|
||||
liveIssueIds={liveIssueIds}
|
||||
compactCards={compactCards}
|
||||
collapsed={collapsedStatusSet.has(status)}
|
||||
visibleCount={visibleCountByStatus[status] ?? initialVisibleCount}
|
||||
revealIncrement={revealIncrement}
|
||||
onShowMore={() => {
|
||||
setVisibleCountByStatus((current) => ({
|
||||
...current,
|
||||
[status]: (current[status] ?? initialVisibleCount) + revealIncrement,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<DragOverlay>
|
||||
{activeIssue ? (
|
||||
<KanbanCard issue={activeIssue} agents={agents} isOverlay />
|
||||
<KanbanCard issue={activeIssue} agents={agents} isOverlay compact={compactCards} />
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue