paperclip/ui/src/components/KanbanBoard.tsx
Emad Ibrahim afb73ba553
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
2026-05-15 10:53:09 -05:00

378 lines
12 KiB
TypeScript

import { useEffect, useMemo, useState } from "react";
import { Link } from "@/lib/router";
import {
DndContext,
DragOverlay,
PointerSensor,
useSensor,
useSensors,
type DragStartEvent,
type DragEndEvent,
type DragOverEvent,
} from "@dnd-kit/core";
import { useDroppable } from "@dnd-kit/core";
import { CSS } from "@dnd-kit/utilities";
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { StatusIcon } from "./StatusIcon";
import { PriorityIcon } from "./PriorityIcon";
import { Identity } from "./Identity";
import type { Issue, IssueStatus } from "@paperclipai/shared";
import { AlertTriangle } from "lucide-react";
import { isSuccessfulRunHandoffRequired } from "../lib/successful-run-handoff";
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",
"in_review",
"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;
}
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;
}
/* ── Droppable Column ── */
function KanbanColumn({
status,
issues,
agents,
liveIssueIds,
compactCards = false,
collapsed = false,
visibleCount,
revealIncrement,
onShowMore,
}: {
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]"}`}>
<div className={`flex items-center gap-2 px-2 py-2 mb-1 ${isEmpty && !isOver ? "justify-center" : ""}`}>
<StatusIcon status={status} />
{(!isEmpty || isOver) && (
<>
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{statusLabel(status)}
</span>
<span className="text-xs text-muted-foreground/60 ml-auto tabular-nums">
{issues.length}
</span>
</>
)}
</div>
<div
ref={setNodeRef}
className={`flex-1 min-h-[120px] rounded-md p-1 space-y-1 transition-colors ${
isOver ? "bg-accent/40" : "bg-muted/20"
}`}
>
{/* Hidden cards are intentionally excluded from sort targets until revealed. */}
<SortableContext
items={visibleIssues.map((i) => i.id)}
strategy={verticalListSortingStrategy}
>
{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>
);
}
/* ── Draggable Card ── */
function KanbanCard({
issue,
agents,
isLive,
isOverlay,
compact = false,
}: {
issue: Issue;
agents?: Agent[];
isLive?: boolean;
isOverlay?: boolean;
compact?: boolean;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: issue.id, data: { issue } });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const agentName = (id: string | null) => {
if (!id || !agents) return null;
return agents.find((a) => a.id === id)?.name ?? null;
};
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
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"} ${
compact ? "p-2" : "p-2.5"
}`}
>
<Link
to={`/issues/${issue.identifier ?? issue.id}`}
disableIssueQuicklook
className="block no-underline text-inherit"
onClick={(e) => {
// Prevent navigation during drag
if (isDragging) e.preventDefault();
}}
>
<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>
{isSuccessfulRunHandoffRequired(issue) ? (
<span
className="inline-flex items-center gap-1 rounded-full border border-amber-400/45 bg-amber-50/60 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:border-amber-300/35 dark:bg-amber-400/10 dark:text-amber-300"
title="This issue needs a next step"
aria-label="Needs next step"
>
<AlertTriangle className="h-3 w-3" />
Next step
</span>
) : null}
{isLive && (
<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={`${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);
return name ? (
<Identity name={name} size="xs" />
) : (
<span className="text-xs text-muted-foreground font-mono">
{issue.assigneeAgentId.slice(0, 8)}
</span>
);
})()}
</div>
</Link>
</div>
);
}
/* ── Main Board ── */
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<IssueStatus, Issue[]> = {} as Record<IssueStatus, Issue[]>;
for (const status of boardStatuses) {
grouped[status] = [];
}
for (const issue of issues) {
if (grouped[issue.status]) {
grouped[issue.status].push(issue);
}
}
return grouped;
}, [issues]);
const activeIssue = useMemo(
() => (activeId ? issues.find((i) => i.id === activeId) : null),
[activeId, issues]
);
function handleDragStart(event: DragStartEvent) {
setActiveId(event.active.id as string);
}
function handleDragEnd(event: DragEndEvent) {
setActiveId(null);
const { active, over } = event;
if (!over) return;
const issueId = active.id as string;
const issue = issues.find((i) => i.id === issueId);
if (!issue) return;
// Determine target status: the "over" could be a column id (status string)
// or another card's id. Find which column the "over" belongs to.
const targetStatus = resolveKanbanTargetStatus(over.id as string, issues);
if (targetStatus && targetStatus !== issue.status) {
onUpdateIssue(issueId, { status: targetStatus });
}
}
function handleDragOver(_event: DragOverEvent) {
// Could be used for visual feedback; keeping simple for now
}
return (
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<div className="flex gap-3 overflow-x-auto pb-4 -mx-2 px-2">
{boardStatuses.map((status) => (
<KanbanColumn
key={status}
status={status}
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 compact={compactCards} />
) : null}
</DragOverlay>
</DndContext>
);
}