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:
Emad Ibrahim
2026-05-15 11:53:09 -04:00
committed by GitHub
parent 7e1a27c8ec
commit afb73ba553
7 changed files with 904 additions and 32 deletions
+137 -3
View File
@@ -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}
/>
) : (