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:
@@ -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}
|
||||
/>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user