forked from farhoodlabs/paperclip
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:
@@ -55,4 +55,5 @@ tests/e2e/playwright-report/
|
||||
tests/release-smoke/test-results/
|
||||
tests/release-smoke/playwright-report/
|
||||
.superset/
|
||||
.superpowers/
|
||||
.claude/worktrees/
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# Scaled Kanban Board Design
|
||||
|
||||
Date: 2026-05-05
|
||||
Branch: `feat/scaled-kanban-board`
|
||||
|
||||
## Context
|
||||
|
||||
The Issues page currently supports list and board modes. List mode already has grouping, sorting, filtering, nested parent/child rows, deferred row rendering, and incremental render limits. Board mode uses classic status columns with draggable cards. It fetches per-status board data, but the current UI still presents each lane as an unbounded stack of cards, which becomes tall and heavy when a company has hundreds of issues.
|
||||
|
||||
The goal is to keep the Kanban mental model while making high-volume boards usable. This is a UI-first change. It should not introduce schema changes or new API contracts in the first pass.
|
||||
|
||||
## Problem
|
||||
|
||||
When Paperclip has many issues, board columns get too tall and slow. The operator loses the ability to scan the board quickly, and rendering or dragging through long columns becomes unpleasant. The first version should solve this by reducing the number of visible cards per column and by collapsing low-signal columns, not by replacing Kanban with a different inventory surface.
|
||||
|
||||
## Design
|
||||
|
||||
Board mode remains status-column based. Each column shows its total issue count, a bounded set of visible cards, and a local affordance to reveal more cards in that column. The board should keep active workflow lanes expanded by default and collapse cold or noisy lanes once issue volume is high.
|
||||
|
||||
Default high-volume behavior activates when the filtered board has more than 100 issues:
|
||||
|
||||
- Compact cards are used by default.
|
||||
- `backlog`, `done`, and `cancelled` auto-collapse to narrow rails.
|
||||
- `todo`, `in_progress`, `in_review`, and `blocked` remain expanded by default.
|
||||
- Each expanded column renders an initial 10 cards by default.
|
||||
- The user can choose a page size of 10, 25, or 50 cards per column.
|
||||
- The user can reveal one additional page at a time in each column without changing other columns.
|
||||
- Drag and drop continues to work for visible cards.
|
||||
|
||||
The toolbar should expose compact controls for:
|
||||
|
||||
- toggling compact cards
|
||||
- hiding or showing cold lanes
|
||||
- choosing cards per column
|
||||
- resetting board density to defaults
|
||||
|
||||
These preferences should persist through the existing issue view-state/localStorage mechanism and remain scoped by company.
|
||||
|
||||
## Component Shape
|
||||
|
||||
`IssuesList` remains the owner of issue board view state. It should store board-density preferences alongside the existing issue view state, including compact card preference, cold-lane mode, and cards-per-column page size.
|
||||
|
||||
`KanbanBoard` receives board tuning props from `IssuesList` and delegates per-lane display to `KanbanColumn`.
|
||||
|
||||
`KanbanColumn` owns only local presentation mechanics for a lane:
|
||||
|
||||
- whether the lane is rendered as an expanded column or collapsed rail
|
||||
- how many cards are currently visible in that lane
|
||||
- the local "show more" action
|
||||
|
||||
`KanbanCard` gets a compact variant. The compact card should still show the issue identifier, title, live state, priority, and assignee when available, but with tighter spacing and fewer vertical affordances.
|
||||
|
||||
## Data Flow
|
||||
|
||||
The first implementation uses the current issue data already available to board mode. No database, shared type, or route change is required.
|
||||
|
||||
Column totals are computed from the in-memory filtered board issues. If a column reaches the existing remote board query cap, the existing warning remains the truth source that more filtering may be required.
|
||||
|
||||
Future server-side column pagination can be added later if the UI-only version is not enough for very large instances.
|
||||
|
||||
## Error Handling
|
||||
|
||||
This feature should not introduce new network errors. Existing issue loading and update errors continue to surface through the Issues page.
|
||||
|
||||
For drag and drop:
|
||||
|
||||
- Moving a visible card keeps the current optimistic behavior.
|
||||
- Hidden cards remain hidden until revealed.
|
||||
- A collapsed lane rail is a valid drop target. Dropping onto it moves the issue to that status and keeps the lane collapsed.
|
||||
|
||||
## Testing
|
||||
|
||||
Focused tests should cover:
|
||||
|
||||
- board mode passes density preferences into `KanbanBoard`
|
||||
- columns render only the initial visible card count
|
||||
- "show more" reveals more cards in a single column
|
||||
- high-volume cold lanes render as collapsed rails by default
|
||||
- compact cards preserve identifier/title/live/priority/assignee signals
|
||||
- drag/drop status updates still call `onUpdateIssue`
|
||||
|
||||
Manual verification should include opening the Issues board with a large fixture or mocked issue set and confirming that columns remain usable with hundreds of issues.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Server-side per-column pagination
|
||||
- New issue schema fields
|
||||
- Replacing Kanban with a dense table or action-only board
|
||||
- Changing issue status semantics
|
||||
- Broad visual redesign of the Issues page
|
||||
@@ -0,0 +1,250 @@
|
||||
# Scaled Kanban Board Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make the Issues Kanban board usable with hundreds of issues by adding compact high-volume rendering, collapsed cold lanes, and per-column reveal controls.
|
||||
|
||||
**Architecture:** Keep the change UI-only. `IssuesList` owns persisted board density preferences in existing company-scoped view state, while `KanbanBoard` owns lane rendering, card density, collapsed rails, and per-column "show more" state.
|
||||
|
||||
**Tech Stack:** React 19, TypeScript, Vite, Vitest/jsdom, `@dnd-kit/core`, `@dnd-kit/sortable`, Tailwind utility classes.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Modify `ui/src/components/IssuesList.tsx`: extend `IssueViewState`, derive high-volume board preferences, add toolbar controls, pass props into `KanbanBoard`.
|
||||
- Modify `ui/src/components/KanbanBoard.tsx`: add compact cards, collapsed rail lanes, visible-card limits, and per-column reveal behavior.
|
||||
- Create `ui/src/components/KanbanBoard.test.tsx`: focused tests for high-volume behavior and drag/drop update callback.
|
||||
- Modify `ui/src/components/IssuesList.test.tsx`: update the mocked `KanbanBoard` expectations for new props.
|
||||
- Keep `doc/plans/2026-05-05-scaled-kanban-board-design.md` as the design source of truth.
|
||||
|
||||
## Task 1: Add Kanban Board Scaling Mechanics
|
||||
|
||||
**Files:**
|
||||
- Modify: `ui/src/components/KanbanBoard.tsx`
|
||||
- Create: `ui/src/components/KanbanBoard.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Write focused tests**
|
||||
|
||||
Create `ui/src/components/KanbanBoard.test.tsx` with tests that render 60 todo issues and assert:
|
||||
|
||||
```tsx
|
||||
renderBoard({ issues: createIssues(60, "todo"), compactCards: true, initialVisibleCount: 10, revealIncrement: 10 });
|
||||
expect(container.textContent).toContain("Showing 10 of 60");
|
||||
expect(container.textContent).toContain("Show 10 more");
|
||||
```
|
||||
|
||||
Also test collapsed rails:
|
||||
|
||||
```tsx
|
||||
renderBoard({ issues: createIssues(3, "done"), collapsedStatuses: ["done"] });
|
||||
expect(container.textContent).toContain("Done");
|
||||
expect(container.textContent).toContain("3");
|
||||
expect(container.textContent).not.toContain("Issue 1");
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify failure**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm exec vitest run ui/src/components/KanbanBoard.test.tsx
|
||||
```
|
||||
|
||||
Expected: fail because `KanbanBoard.test.tsx` is new and the props/behavior do not exist.
|
||||
|
||||
- [ ] **Step 3: Implement minimal board behavior**
|
||||
|
||||
In `KanbanBoard.tsx`, add exported constants:
|
||||
|
||||
```ts
|
||||
export const KANBAN_BOARD_HIGH_VOLUME_THRESHOLD = 100;
|
||||
export const KANBAN_COLUMN_PAGE_SIZE_OPTIONS = [10, 25, 50] as const;
|
||||
export const KANBAN_COLUMN_DEFAULT_PAGE_SIZE = 10;
|
||||
export const KANBAN_COLD_STATUSES = ["backlog", "done", "cancelled"] as const;
|
||||
```
|
||||
|
||||
Extend props:
|
||||
|
||||
```ts
|
||||
compactCards?: boolean;
|
||||
collapsedStatuses?: string[];
|
||||
initialVisibleCount?: number;
|
||||
revealIncrement?: number;
|
||||
```
|
||||
|
||||
Add per-status visible-count state keyed by status. Expanded columns render `issues.slice(0, visibleCount)` and show a button when hidden issues remain. Collapsed columns render a narrow droppable rail with status icon, label, and count, but no cards.
|
||||
|
||||
Reset per-status visible-count state when `initialVisibleCount` or `revealIncrement` changes so choosing a smaller cards-per-column preset does not leave a column expanded past the newly selected page size.
|
||||
|
||||
- [ ] **Step 4: Preserve drag/drop**
|
||||
|
||||
Keep `DndContext`, `SortableContext`, and `handleDragEnd` status detection. Because collapsed rails use `useDroppable({ id: status })`, dropping a visible card onto a rail continues to resolve `targetStatus` through the existing status-id branch.
|
||||
|
||||
- [ ] **Step 5: Run focused test**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm exec vitest run ui/src/components/KanbanBoard.test.tsx
|
||||
```
|
||||
|
||||
Expected: pass.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add ui/src/components/KanbanBoard.tsx ui/src/components/KanbanBoard.test.tsx
|
||||
git commit -m "Scale kanban board columns"
|
||||
```
|
||||
|
||||
## Task 2: Wire Board Density State Into IssuesList
|
||||
|
||||
**Files:**
|
||||
- Modify: `ui/src/components/IssuesList.tsx`
|
||||
- Modify: `ui/src/components/IssuesList.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Write/update tests**
|
||||
|
||||
In `IssuesList.test.tsx`, update the `KanbanBoard` mock to capture:
|
||||
|
||||
```ts
|
||||
compactCards?: boolean;
|
||||
collapsedStatuses?: string[];
|
||||
initialVisibleCount?: number;
|
||||
revealIncrement?: number;
|
||||
```
|
||||
|
||||
Add a test that stores board mode in localStorage, renders more than 100 issues, and expects:
|
||||
|
||||
```ts
|
||||
expect(mockKanbanBoard).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
compactCards: true,
|
||||
collapsedStatuses: expect.arrayContaining(["backlog", "done", "cancelled"]),
|
||||
initialVisibleCount: 10,
|
||||
revealIncrement: 10,
|
||||
}));
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify failure**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm exec vitest run ui/src/components/IssuesList.test.tsx
|
||||
```
|
||||
|
||||
Expected: fail because `IssuesList` does not pass the new props yet.
|
||||
|
||||
- [ ] **Step 3: Add persisted board density preferences**
|
||||
|
||||
Extend `IssueViewState`:
|
||||
|
||||
```ts
|
||||
boardCardDensity: "auto" | "compact" | "comfortable";
|
||||
boardColdLaneMode: "auto" | "collapsed" | "expanded";
|
||||
boardColumnPageSize: 10 | 25 | 50;
|
||||
```
|
||||
|
||||
Default the density modes to `"auto"` and page size to `10`. Derive:
|
||||
|
||||
```ts
|
||||
const boardHighVolume = viewState.viewMode === "board" && filtered.length > KANBAN_BOARD_HIGH_VOLUME_THRESHOLD;
|
||||
const boardCompactCards = viewState.boardCardDensity === "compact"
|
||||
|| (viewState.boardCardDensity === "auto" && boardHighVolume);
|
||||
const boardCollapsedStatuses = viewState.boardColdLaneMode === "collapsed"
|
||||
|| (viewState.boardColdLaneMode === "auto" && boardHighVolume)
|
||||
? [...KANBAN_COLD_STATUSES]
|
||||
: [];
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add toolbar controls**
|
||||
|
||||
When `viewState.viewMode === "board"`, add small outline/icon buttons near the existing view controls:
|
||||
|
||||
```tsx
|
||||
<Button ... title={boardCompactCards ? "Use comfortable cards" : "Use compact cards"}>...</Button>
|
||||
<Button ... title={boardCollapsedStatuses.length > 0 ? "Expand cold lanes" : "Collapse cold lanes"}>...</Button>
|
||||
<Button ... title="Cards per column">...</Button>
|
||||
<Button ... title="Reset board density">...</Button>
|
||||
```
|
||||
|
||||
Use lucide icons already available or import `ChevronsDownUp`, `PanelTopClose`, and `RotateCcw`.
|
||||
|
||||
- [ ] **Step 5: Pass board props**
|
||||
|
||||
Update the `KanbanBoard` call:
|
||||
|
||||
```tsx
|
||||
<KanbanBoard
|
||||
issues={filtered}
|
||||
agents={agents}
|
||||
liveIssueIds={liveIssueIds}
|
||||
compactCards={boardCompactCards}
|
||||
collapsedStatuses={boardCollapsedStatuses}
|
||||
initialVisibleCount={viewState.boardColumnPageSize}
|
||||
revealIncrement={viewState.boardColumnPageSize}
|
||||
onUpdateIssue={onUpdateIssue}
|
||||
/>
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run focused tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm exec vitest run ui/src/components/IssuesList.test.tsx ui/src/components/KanbanBoard.test.tsx
|
||||
```
|
||||
|
||||
Expected: pass.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add ui/src/components/IssuesList.tsx ui/src/components/IssuesList.test.tsx
|
||||
git commit -m "Wire issue board density controls"
|
||||
```
|
||||
|
||||
## Task 3: Verification And PR Prep
|
||||
|
||||
**Files:**
|
||||
- Verify existing changes only.
|
||||
|
||||
- [ ] **Step 1: Run targeted UI tests**
|
||||
|
||||
```bash
|
||||
pnpm exec vitest run ui/src/components/IssuesList.test.tsx ui/src/components/KanbanBoard.test.tsx
|
||||
```
|
||||
|
||||
Expected: pass.
|
||||
|
||||
- [ ] **Step 2: Run broader cheap test path**
|
||||
|
||||
```bash
|
||||
pnpm test
|
||||
```
|
||||
|
||||
Expected: pass.
|
||||
|
||||
- [ ] **Step 3: Check worktree**
|
||||
|
||||
```bash
|
||||
git status --short
|
||||
```
|
||||
|
||||
Expected: only intentional changes before committing, or clean after final commit.
|
||||
|
||||
- [ ] **Step 4: Prepare PR**
|
||||
|
||||
Read `.github/PULL_REQUEST_TEMPLATE.md` and use it for the PR body. Include:
|
||||
|
||||
- design spec path
|
||||
- scaled Kanban behavior summary
|
||||
- test commands and results
|
||||
- Model Used section with the current Codex model details available in this session
|
||||
|
||||
## Self-Review
|
||||
|
||||
- Spec coverage: The plan covers compact high-volume board cards, collapsed cold lanes, cards-per-column presets, per-column reveal controls, persisted board preferences, current API reuse, and focused tests.
|
||||
- Placeholder scan: No unresolved markers or unspecified implementation placeholders remain.
|
||||
- Type consistency: The plan consistently uses `boardCardDensity`, `boardColdLaneMode`, `boardColumnPageSize`, `compactCards`, `collapsedStatuses`, `initialVisibleCount`, and `revealIncrement`.
|
||||
@@ -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}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user