Add optimistic writeback flow with kanban drag-drop transitions

This commit is contained in:
zenchantlive 2026-02-11 19:59:55 -08:00
parent 2c80265258
commit cc616c1543
9 changed files with 403 additions and 45 deletions

View file

@ -19,6 +19,7 @@
{"id":"bb-92d.5","title":"Implement Windows path normalization utilities","description":"Create centralized helpers for canonical path keys, display formatting, and cross-drive normalization to avoid duplicate project identities.","acceptance_criteria":"Canonicalization is consistent for C:\\ and D:\\ style paths.","status":"closed","priority":0,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:46.0751161-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:27:27.7164974-08:00","closed_at":"2026-02-11T17:27:27.7164974-08:00","close_reason":"Implemented Windows path normalization utilities with canonicalization, keying, and display transformations.","labels":["paths","windows"],"dependencies":[{"issue_id":"bb-92d.5","depends_on_id":"bb-92d","type":"parent-child","created_at":"2026-02-11T17:11:46.0767429-08:00","created_by":"zenchantlive"}]}
{"id":"bb-92d.6","title":"Add guardrail test preventing direct writes to .beads/issues.jsonl","description":"Enforce read/write boundary by scanning source for forbidden direct file write patterns targeting Beads issue files.","acceptance_criteria":"Guardrail test fails on boundary violations and passes when write path uses bd bridge only.","status":"closed","priority":0,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:46.9013352-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:28:27.4699395-08:00","closed_at":"2026-02-11T17:28:27.4699395-08:00","close_reason":"Added guardrail scanner and automated test to block direct writes to .beads/issues.jsonl.","labels":["guardrail","safety"],"dependencies":[{"issue_id":"bb-92d.6","depends_on_id":"bb-92d","type":"parent-child","created_at":"2026-02-11T17:11:46.9029535-08:00","created_by":"zenchantlive"}]}
{"id":"bb-ag8","title":"TEMP_DELETE_ME","status":"closed","priority":4,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:10:04.5765506-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:10:10.3812634-08:00","closed_at":"2026-02-11T17:10:10.3812634-08:00","close_reason":"cleanup temp test issue"}
{"id":"bb-atl","title":"Writeback phase smoke","description":"Temp for optimistic and transition smoke","status":"closed","priority":3,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T19:58:24.0374092-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T19:58:29.147102-08:00","closed_at":"2026-02-11T19:58:29.147102-08:00","close_reason":"cleanup writeback smoke","labels":["smoke","writeback"],"comments":[{"id":3,"issue_id":"bb-atl","author":"zenchantlive","text":"transition smoke reopen","created_at":"2026-02-12T03:58:27Z"}]}
{"id":"bb-bc4","title":"Kanban Responsive Design Hardening","description":"Refine tracer-bullet Kanban into a production-grade, responsive experience across mobile/tablet/desktop using tokenized Tailwind styling and strict architecture boundaries. Scope includes layout reachability, card/column sizing integrity, improved visual language, and small-screen detail-panel behavior.","acceptance_criteria":"At 390x844, 768x1024, and 1440x900 all status columns are reachable, cards are not clipped, controls remain usable, and detail interactions work without direct JSONL write-path regressions.","status":"closed","priority":1,"issue_type":"epic","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T18:50:41.814041-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T18:59:21.5796629-08:00","closed_at":"2026-02-11T18:59:21.5796629-08:00","close_reason":"Responsive design hardening scope completed with tests and Playwright evidence.","labels":["design-system","kanban","responsive","ui"],"dependencies":[{"issue_id":"bb-bc4","depends_on_id":"bb-92d","type":"blocks","created_at":"2026-02-11T18:50:41.817863-08:00","created_by":"zenchantlive"},{"issue_id":"bb-bc4","depends_on_id":"bb-trz","type":"blocks","created_at":"2026-02-11T18:51:20.344-08:00","created_by":"zenchantlive"}]}
{"id":"bb-bc4.1","title":"Rework board responsiveness and horizontal reachability","description":"Implement intentional responsive board behavior: fluid column sizing, explicit horizontal board scrolling strategy, and viewport-safe wrappers so every status column is reachable without layout breakage. Use relative sizing constraints and avoid rigid fixed-width assumptions.","acceptance_criteria":"Board supports reliable horizontal reachability at all target breakpoints; no hidden/unreachable status columns.","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T18:50:42.8356269-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T18:59:17.3199003-08:00","closed_at":"2026-02-11T18:59:17.3199003-08:00","close_reason":"Implemented fluid horizontal board reachability with snap and overflow containment across breakpoints.","labels":["kanban","layout","responsive"],"dependencies":[{"issue_id":"bb-bc4.1","depends_on_id":"bb-bc4","type":"parent-child","created_at":"2026-02-11T18:50:42.837217-08:00","created_by":"zenchantlive"}]}
{"id":"bb-bc4.2","title":"Fix column/card sizing and overflow behavior","description":"Correct card and column sizing to prevent clipping, overflow artifacts, and unreadable metadata blocks. Ensure card internals wrap/truncate intentionally and columns maintain consistent density and scroll behavior.","acceptance_criteria":"Cards remain fully readable within columns, no clipped card content, and column internals scroll predictably.","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T18:50:43.8439541-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T18:59:18.1823946-08:00","closed_at":"2026-02-11T18:59:18.1823946-08:00","close_reason":"Fixed card/column overflow and sizing with clamp-based widths, scroll-safe columns, and improved text wrapping.","labels":["cards","kanban","overflow"],"dependencies":[{"issue_id":"bb-bc4.2","depends_on_id":"bb-bc4","type":"parent-child","created_at":"2026-02-11T18:50:43.8457677-08:00","created_by":"zenchantlive"},{"issue_id":"bb-bc4.2","depends_on_id":"bb-bc4.1","type":"blocks","created_at":"2026-02-11T18:50:43.8490043-08:00","created_by":"zenchantlive"}]}
@ -52,5 +53,5 @@
{"id":"bb-ymg.1","title":"Implement bd bridge using child_process.execFile with project-scoped cwd","description":"Wrap bd execution with command argument safety, Windows path compatibility, stdout/stderr parsing, and project-specific current working directory.","acceptance_criteria":"Bridge executes supported bd commands and returns structured result/error payloads.","notes":"Implemented src/lib/bridge.ts with execFile-based bd runner, project-scoped cwd, timeout support, structured command result payload, and failure classification (not_found, timeout, bad_args, non_zero_exit, unknown). Added RED-\u003eGREEN tests in tests/lib/bridge.test.ts.","status":"closed","priority":0,"issue_type":"task","assignee":"zenchantlive","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:01.7327732-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T19:45:16.7478549-08:00","closed_at":"2026-02-11T19:45:16.7478549-08:00","close_reason":"Bridge implemented with structured result/error classification and project-scoped execFile command execution; tests added.","labels":["bridge","execfile"],"dependencies":[{"issue_id":"bb-ymg.1","depends_on_id":"bb-ymg","type":"parent-child","created_at":"2026-02-11T17:12:01.7343468-08:00","created_by":"zenchantlive"},{"issue_id":"bb-ymg.1","depends_on_id":"bb-6aj.2","type":"blocks","created_at":"2026-02-11T17:12:32.3039711-08:00","created_by":"zenchantlive"}]}
{"id":"bb-ymg.1.1","title":"Resolve bd.exe location from PATH and configuration fallback","description":"Add detection logic for bd executable and actionable errors when not found, including setup guidance.","acceptance_criteria":"Missing bd path returns clear setup instructions and diagnostics.","notes":"Implemented src/lib/bd-path.ts executable resolution with config-first then PATH lookup (bd.exe/bd.cmd/bd.bat/bd), plus actionable setup guidance when missing. Added tests/lib/bd-path.test.ts for success/failure cases.","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:02.5593205-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T19:44:57.3720854-08:00","closed_at":"2026-02-11T19:44:57.3720854-08:00","close_reason":"Executable resolution implemented with config/PATH fallback and actionable missing-bd guidance; tests added.","labels":["bridge","setup"],"dependencies":[{"issue_id":"bb-ymg.1.1","depends_on_id":"bb-ymg.1","type":"parent-child","created_at":"2026-02-11T17:12:02.5603636-08:00","created_by":"zenchantlive"}]}
{"id":"bb-ymg.2","title":"Implement mutation API for create/update/close/reopen/comment operations","description":"Expose strict server-side mutation endpoints translating UI actions to corresponding bd commands with validated arguments.","acceptance_criteria":"All required mutation operations execute via bd and return normalized responses.","notes":"Implemented mutation validation/mapping/execution layer in src/lib/mutations.ts and App Router endpoints: /api/beads/create|update|close|reopen|comment. Added payload validation tests, route validation tests, and smoke-tested create/update/comment/close/reopen lifecycle via API.","status":"closed","priority":0,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:03.3757503-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T19:45:26.3234246-08:00","closed_at":"2026-02-11T19:45:26.3234246-08:00","close_reason":"Mutation API implemented for create/update/close/reopen/comment with payload validation, command mapping, normalized error shape, and verified smoke lifecycle via API.","labels":["api","mutation"],"dependencies":[{"issue_id":"bb-ymg.2","depends_on_id":"bb-ymg","type":"parent-child","created_at":"2026-02-11T17:12:03.377343-08:00","created_by":"zenchantlive"},{"issue_id":"bb-ymg.2","depends_on_id":"bb-ymg.1","type":"blocks","created_at":"2026-02-11T17:12:32.810993-08:00","created_by":"zenchantlive"},{"issue_id":"bb-ymg.2","depends_on_id":"bb-ymg.1.1","type":"blocks","created_at":"2026-02-11T17:12:33.313807-08:00","created_by":"zenchantlive"}]}
{"id":"bb-ymg.3","title":"Add optimistic updates with rollback and SSE reconciliation","description":"Apply immediate UI updates for responsiveness, rollback on command failure, and reconcile with watcher-triggered authoritative state updates.","acceptance_criteria":"Failed mutations restore previous UI state and emit meaningful error feedback.","status":"open","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:04.1956393-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:04.1956393-08:00","labels":["optimistic","state"],"dependencies":[{"issue_id":"bb-ymg.3","depends_on_id":"bb-ymg","type":"parent-child","created_at":"2026-02-11T17:12:04.1966728-08:00","created_by":"zenchantlive"},{"issue_id":"bb-ymg.3","depends_on_id":"bb-ymg.2","type":"blocks","created_at":"2026-02-11T17:12:33.8246167-08:00","created_by":"zenchantlive"}]}
{"id":"bb-ymg.4","title":"Implement drag-and-drop status transitions mapped to bd commands","description":"Map card moves to valid status transitions and use close/reopen semantics where applicable instead of direct file manipulation.","acceptance_criteria":"DnD transitions call proper bd commands and reject invalid transitions safely.","status":"open","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:05.0129676-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:05.0129676-08:00","labels":["dnd","kanban"],"dependencies":[{"issue_id":"bb-ymg.4","depends_on_id":"bb-ymg","type":"parent-child","created_at":"2026-02-11T17:12:05.014527-08:00","created_by":"zenchantlive"},{"issue_id":"bb-ymg.4","depends_on_id":"bb-ymg.2","type":"blocks","created_at":"2026-02-11T17:12:34.329788-08:00","created_by":"zenchantlive"},{"issue_id":"bb-ymg.4","depends_on_id":"bb-trz.1","type":"blocks","created_at":"2026-02-11T17:12:34.8422542-08:00","created_by":"zenchantlive"}]}
{"id":"bb-ymg.3","title":"Add optimistic updates with rollback and SSE reconciliation","description":"Apply immediate UI updates for responsiveness, rollback on command failure, and reconcile with watcher-triggered authoritative state updates.","acceptance_criteria":"Failed mutations restore previous UI state and emit meaningful error feedback.","notes":"Implemented optimistic status updates with rollback in Kanban page, per-issue pending state, and authoritative reconciliation via new GET /api/beads/read endpoint after successful mutations.","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:04.1956393-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T19:59:02.289739-08:00","closed_at":"2026-02-11T19:59:02.289739-08:00","close_reason":"Optimistic board updates with rollback and authoritative post-mutation reconciliation via read route implemented and validated.","labels":["optimistic","state"],"dependencies":[{"issue_id":"bb-ymg.3","depends_on_id":"bb-ymg","type":"parent-child","created_at":"2026-02-11T17:12:04.1966728-08:00","created_by":"zenchantlive"},{"issue_id":"bb-ymg.3","depends_on_id":"bb-ymg.2","type":"blocks","created_at":"2026-02-11T17:12:33.8246167-08:00","created_by":"zenchantlive"}]}
{"id":"bb-ymg.4","title":"Implement drag-and-drop status transitions mapped to bd commands","description":"Map card moves to valid status transitions and use close/reopen semantics where applicable instead of direct file manipulation.","acceptance_criteria":"DnD transitions call proper bd commands and reject invalid transitions safely.","notes":"Implemented lane drag-and-drop interactions in Kanban board, status transition planning (including closed -\u003e reopen+update), and mapped transitions to bd mutation API routes with pending-state safeguards.","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:05.0129676-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T19:59:21.7655834-08:00","closed_at":"2026-02-11T19:59:21.7655834-08:00","close_reason":"Kanban lane drag-and-drop transitions now map to bd-backed close/reopen/update mutations with transition planner tests and runtime smoke validation.","labels":["dnd","kanban"],"dependencies":[{"issue_id":"bb-ymg.4","depends_on_id":"bb-ymg","type":"parent-child","created_at":"2026-02-11T17:12:05.014527-08:00","created_by":"zenchantlive"},{"issue_id":"bb-ymg.4","depends_on_id":"bb-ymg.2","type":"blocks","created_at":"2026-02-11T17:12:34.329788-08:00","created_by":"zenchantlive"},{"issue_id":"bb-ymg.4","depends_on_id":"bb-trz.1","type":"blocks","created_at":"2026-02-11T17:12:34.8422542-08:00","created_by":"zenchantlive"}]}

View file

@ -9,7 +9,7 @@
"start": "next start",
"lint": "next lint",
"typecheck": "tsc --noEmit",
"test": "node --test tests/bootstrap.test.mjs && node --import tsx --test tests/lib/parser.test.ts && node --import tsx --test tests/lib/pathing.test.ts && node --import tsx --test tests/lib/kanban.test.ts && node --import tsx --test tests/lib/read-issues.test.ts && node --import tsx --test tests/lib/bd-path.test.ts && node --import tsx --test tests/lib/bridge.test.ts && node --import tsx --test tests/lib/mutations.test.ts && node --import tsx --test tests/api/mutations-routes.test.ts && node --test tests/guards/no-direct-jsonl-write.test.mjs && node --test tests/guards/no-inline-style-in-kanban.test.mjs && node --test tests/guards/kanban-responsive-contract.test.mjs"
"test": "node --test tests/bootstrap.test.mjs && node --import tsx --test tests/lib/parser.test.ts && node --import tsx --test tests/lib/pathing.test.ts && node --import tsx --test tests/lib/kanban.test.ts && node --import tsx --test tests/lib/read-issues.test.ts && node --import tsx --test tests/lib/bd-path.test.ts && node --import tsx --test tests/lib/bridge.test.ts && node --import tsx --test tests/lib/mutations.test.ts && node --import tsx --test tests/lib/writeback.test.ts && node --import tsx --test tests/api/mutations-routes.test.ts && node --test tests/guards/no-direct-jsonl-write.test.mjs && node --test tests/guards/no-inline-style-in-kanban.test.mjs && node --test tests/guards/kanban-responsive-contract.test.mjs"
},
"dependencies": {
"framer-motion": "^11.18.2",

View file

@ -0,0 +1,24 @@
import { NextResponse } from 'next/server';
import { readIssuesFromDisk } from '../../../../lib/read-issues';
export async function GET(request: Request): Promise<Response> {
const url = new URL(request.url);
const projectRoot = url.searchParams.get('projectRoot') ?? process.cwd();
try {
const issues = await readIssuesFromDisk({ projectRoot });
return NextResponse.json({ ok: true, issues });
} catch (error) {
return NextResponse.json(
{
ok: false,
error: {
classification: 'unknown',
message: error instanceof Error ? error.message : 'Failed to read issues.',
},
},
{ status: 500 },
);
}
}

View file

@ -3,5 +3,5 @@ import { readIssuesFromDisk } from '../lib/read-issues';
export default async function Page() {
const issues = await readIssuesFromDisk();
return <KanbanPage issues={issues} />;
return <KanbanPage issues={issues} projectRoot={process.cwd()} />;
}

View file

@ -1,8 +1,9 @@
'use client';
import { AnimatePresence } from 'framer-motion';
import type { DragEvent } from 'react';
import { KANBAN_STATUSES } from '../../lib/kanban';
import { KANBAN_STATUSES, type KanbanStatus } from '../../lib/kanban';
import type { BeadIssue } from '../../lib/types';
import { KanbanCard } from './kanban-card';
@ -10,47 +11,153 @@ import { KanbanCard } from './kanban-card';
interface KanbanBoardProps {
columns: Record<(typeof KANBAN_STATUSES)[number], BeadIssue[]>;
selectedIssueId: string | null;
pendingIssueIds: Set<string>;
activeStatus: KanbanStatus | null;
onActivateStatus: (status: KanbanStatus | null) => void;
onMoveIssue: (issue: BeadIssue, targetStatus: KanbanStatus) => void;
onSelect: (issue: BeadIssue) => void;
}
const STATUS_META: Record<(typeof KANBAN_STATUSES)[number], { label: string; dot: string }> = {
open: { label: 'Open', dot: 'bg-sky-300' },
open: { label: 'Open', dot: 'bg-zinc-300' },
in_progress: { label: 'In Progress', dot: 'bg-amber-300' },
blocked: { label: 'Blocked', dot: 'bg-rose-300' },
deferred: { label: 'Deferred', dot: 'bg-slate-300' },
deferred: { label: 'Deferred', dot: 'bg-stone-400' },
closed: { label: 'Done', dot: 'bg-emerald-300' },
};
const STATUS_COLUMN_CLASS: Record<(typeof KANBAN_STATUSES)[number], string> = {
open: 'bg-sky-500/10',
open: 'bg-zinc-500/10',
in_progress: 'bg-amber-500/10',
blocked: 'bg-rose-500/10',
deferred: 'bg-slate-500/10',
deferred: 'bg-stone-500/10',
closed: 'bg-emerald-500/10',
};
export function KanbanBoard({ columns, selectedIssueId, onSelect }: KanbanBoardProps) {
export function KanbanBoard({ columns, selectedIssueId, pendingIssueIds, activeStatus, onActivateStatus, onMoveIssue, onSelect }: KanbanBoardProps) {
const allIssues = KANBAN_STATUSES.flatMap((status) => columns[status]);
const issueLookup = new Map(allIssues.map((issue) => [issue.id, issue]));
const handleExpandAndSelect = (status: KanbanStatus, issue: BeadIssue) => {
onActivateStatus(status);
onSelect(issue);
};
const onDragStart = (issue: BeadIssue, event: DragEvent<HTMLButtonElement>) => {
event.dataTransfer.setData('application/x-bead-id', issue.id);
event.dataTransfer.setData('application/x-bead-status', issue.status);
event.dataTransfer.effectAllowed = 'move';
};
const onDropLane = (targetStatus: KanbanStatus, event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
const issueId = event.dataTransfer.getData('application/x-bead-id');
const sourceStatus = event.dataTransfer.getData('application/x-bead-status') as KanbanStatus;
if (!issueId || !sourceStatus || sourceStatus === targetStatus) {
return;
}
const issue = issueLookup.get(issueId);
if (!issue) {
return;
}
onMoveIssue(issue, targetStatus);
};
return (
<section className="flex min-w-fit snap-x snap-mandatory gap-3 overflow-x-auto overscroll-x-contain pb-2">
<section className="grid min-h-[58vh] gap-2.5">
{KANBAN_STATUSES.map((status) => (
<div
key={status}
className={`w-[clamp(17rem,24vw,22rem)] shrink-0 snap-start rounded-2xl border border-border-soft ${STATUS_COLUMN_CLASS[status]} p-2.5`}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => onDropLane(status, event)}
className={`rounded-2xl border border-border-soft ${STATUS_COLUMN_CLASS[status]} p-2.5 transition ${
activeStatus === status ? 'shadow-card' : 'opacity-90'
}`}
>
<div className="mb-2 flex items-center justify-between">
<strong className="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.14em] text-text-body">
<span className={`h-2 w-2 rounded-full ${STATUS_META[status].dot}`} />
{STATUS_META[status].label}
</strong>
<span className="font-mono text-xs text-text-muted">{columns[status].length}</span>
<div className="flex items-center gap-2">
<button
type="button"
aria-expanded={activeStatus === status}
onClick={() => {
onActivateStatus(status);
const firstIssue = columns[status][0];
if (firstIssue) {
onSelect(firstIssue);
}
}}
className="flex w-full items-center justify-between rounded-lg px-1 py-0.5 text-left"
>
<strong className="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.14em] text-text-body">
<span className={`h-2 w-2 rounded-full ${STATUS_META[status].dot}`} />
{STATUS_META[status].label}
</strong>
<span className="font-mono text-xs text-text-muted">{columns[status].length}</span>
</button>
{activeStatus === status ? (
<button
type="button"
aria-label={`Minimize ${STATUS_META[status].label} lane`}
onClick={() => onActivateStatus(null)}
className="inline-flex h-6 w-6 items-center justify-center rounded-md border border-border-soft bg-surface-muted/60 text-sm text-text-muted hover:border-border-strong hover:text-text-body"
>
-
</button>
) : null}
</div>
<div className="grid h-[clamp(24rem,60vh,48rem)] content-start gap-2 overflow-y-auto pr-1">
<AnimatePresence initial={false}>
{columns[status].map((issue) => (
<KanbanCard key={issue.id} issue={issue} selected={selectedIssueId === issue.id} onSelect={onSelect} />
{activeStatus === status ? (
<div className="mt-2 grid max-h-[50vh] gap-2 overflow-y-auto pr-1 sm:grid-cols-2 2xl:grid-cols-3">
<AnimatePresence initial={false}>
{columns[status].map((issue) => (
<KanbanCard
key={issue.id}
issue={issue}
pending={pendingIssueIds.has(issue.id)}
selected={selectedIssueId === issue.id}
draggable={!pendingIssueIds.has(issue.id)}
onNativeDragStart={onDragStart}
onSelect={onSelect}
/>
))}
</AnimatePresence>
{columns[status].length === 0 ? (
<div className="flex h-24 w-full items-center justify-center rounded-xl border border-dashed border-border-soft/80 bg-surface/35 text-xs text-text-muted">
No beads
</div>
) : null}
</div>
) : (
<div className="mt-2 flex flex-wrap gap-1.5">
{columns[status].slice(0, 6).map((issue) => (
<button
key={issue.id}
type="button"
onClick={() => handleExpandAndSelect(status, issue)}
className="max-w-full rounded-lg border border-border-soft bg-surface-muted/60 px-2 py-1 text-left hover:border-border-strong hover:bg-surface-raised/70"
title={issue.title}
>
<div className="font-mono text-[10px] text-text-muted">{issue.id}</div>
<div className="line-clamp-1 text-xs font-medium text-text-body">{issue.title}</div>
</button>
))}
</AnimatePresence>
</div>
{columns[status].length > 6 ? (
<button
type="button"
onClick={() => onActivateStatus(status)}
className="rounded-lg border border-border-soft bg-surface/50 px-2 py-1 text-xs text-text-muted hover:bg-surface-muted/70"
>
+{columns[status].length - 6} more
</button>
) : null}
{columns[status].length === 0 ? (
<span className="rounded-lg border border-dashed border-border-soft/80 bg-surface/30 px-2 py-1 text-xs text-text-muted">
No beads
</span>
) : null}
</div>
)}
</div>
))}
</section>

View file

@ -1,6 +1,7 @@
'use client';
import { motion } from 'framer-motion';
import type { DragEvent } from 'react';
import type { BeadIssue } from '../../lib/types';
@ -9,6 +10,9 @@ import { Chip } from '../shared/chip';
interface KanbanCardProps {
issue: BeadIssue;
selected: boolean;
pending?: boolean;
draggable?: boolean;
onNativeDragStart?: (issue: BeadIssue, event: DragEvent<HTMLButtonElement>) => void;
onSelect: (issue: BeadIssue) => void;
}
@ -19,7 +23,7 @@ function priorityClass(priority: number): string {
case 1:
return 'border-amber-300/40 bg-amber-500/20 text-amber-50';
case 2:
return 'border-sky-300/40 bg-sky-500/20 text-sky-50';
return 'border-teal-300/40 bg-teal-500/20 text-teal-50';
case 3:
return 'border-slate-300/35 bg-slate-500/22 text-slate-50';
default:
@ -27,9 +31,9 @@ function priorityClass(priority: number): string {
}
}
export function KanbanCard({ issue, selected, onSelect }: KanbanCardProps) {
export function KanbanCard({ issue, selected, pending = false, draggable = false, onNativeDragStart, onSelect }: KanbanCardProps) {
const selectedClass = selected
? 'border-cyan-300/80 bg-surface-raised shadow-card ring-1 ring-cyan-300/35'
? 'border-amber-200/60 bg-surface-raised shadow-card ring-1 ring-amber-200/20'
: 'border-border-soft bg-surface/95 shadow-[0_6px_18px_rgba(4,8,17,0.5)] hover:border-border-strong hover:bg-surface-raised/95';
return (
@ -37,8 +41,12 @@ export function KanbanCard({ issue, selected, onSelect }: KanbanCardProps) {
layout
transition={{ duration: 0.18, ease: 'easeOut' }}
type="button"
draggable={draggable}
onDragStartCapture={(event) => onNativeDragStart?.(issue, event)}
onClick={() => onSelect(issue)}
className={`w-full cursor-pointer rounded-2xl border px-3 py-2.5 text-left transition ${selectedClass}`}
className={`w-full cursor-pointer rounded-2xl border px-3 py-2.5 text-left transition ${selectedClass} ${
pending ? 'opacity-70' : ''
}`}
>
<div className="font-mono text-[11px] text-text-muted break-all">{issue.id}</div>
<div className="mt-1 text-sm font-semibold leading-5 text-text-strong break-words">{issue.title}</div>
@ -51,7 +59,7 @@ export function KanbanCard({ issue, selected, onSelect }: KanbanCardProps) {
<Chip>{issue.issue_type}</Chip>
<Chip tone="status">deps {issue.dependencies.length}</Chip>
</div>
<div className="mt-2 break-words font-mono text-xs text-cyan-100/90">
<div className="mt-2 break-words font-mono text-xs text-amber-100/90">
{issue.assignee ? `@${issue.assignee}` : 'unassigned'}
</div>
{issue.labels.length > 0 ? (
@ -61,6 +69,7 @@ export function KanbanCard({ issue, selected, onSelect }: KanbanCardProps) {
))}
</div>
) : null}
{pending ? <div className="mt-2 text-[11px] font-medium text-amber-200">Saving</div> : null}
</motion.button>
);
}

View file

@ -1,11 +1,12 @@
'use client';
import { motion } from 'framer-motion';
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import type { KanbanFilterOptions } from '../../lib/kanban';
import type { KanbanFilterOptions, KanbanStatus } from '../../lib/kanban';
import { buildKanbanColumns, buildKanbanStats, filterKanbanIssues } from '../../lib/kanban';
import type { BeadIssue } from '../../lib/types';
import { applyOptimisticStatus, planStatusTransition } from '../../lib/writeback';
import { KanbanBoard } from './kanban-board';
import { KanbanControls } from './kanban-controls';
@ -13,49 +14,154 @@ import { KanbanDetail } from './kanban-detail';
interface KanbanPageProps {
issues: BeadIssue[];
projectRoot: string;
}
export function KanbanPage({ issues }: KanbanPageProps) {
type MutationOperation = 'create' | 'update' | 'close' | 'reopen' | 'comment';
interface MutationErrorResponse {
error?: { message?: string };
}
async function postMutation(operation: MutationOperation, body: Record<string, unknown>) {
const response = await fetch(`/api/beads/${operation}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
const payload = (await response.json()) as { ok: boolean; error?: { message?: string } };
if (!response.ok || !payload.ok) {
throw new Error(payload.error?.message ?? `${operation} failed`);
}
}
async function fetchIssues(projectRoot: string): Promise<BeadIssue[]> {
const response = await fetch(`/api/beads/read?projectRoot=${encodeURIComponent(projectRoot)}`, {
cache: 'no-store',
});
const payload = (await response.json()) as { ok: boolean; issues?: BeadIssue[] } & MutationErrorResponse;
if (!response.ok || !payload.ok || !payload.issues) {
throw new Error(payload.error?.message ?? 'Failed to refresh issues');
}
return payload.issues;
}
export function KanbanPage({ issues, projectRoot }: KanbanPageProps) {
const [localIssues, setLocalIssues] = useState<BeadIssue[]>(issues);
const [filters, setFilters] = useState<KanbanFilterOptions>({
query: '',
type: '',
priority: '',
showClosed: false,
showClosed: true,
});
const [selectedIssueId, setSelectedIssueId] = useState<string | null>(issues[0]?.id ?? null);
const [selectedIssueId, setSelectedIssueId] = useState<string | null>(null);
const [mobileDetailOpen, setMobileDetailOpen] = useState(false);
const [activeStatus, setActiveStatus] = useState<KanbanStatus | null>('open');
const [desktopDetailMinimized, setDesktopDetailMinimized] = useState(false);
const [pendingIssueIds, setPendingIssueIds] = useState<Set<string>>(new Set());
const [mutationError, setMutationError] = useState<string | null>(null);
const filteredIssues = useMemo(() => filterKanbanIssues(issues, filters), [issues, filters]);
useEffect(() => {
setLocalIssues(issues);
}, [issues]);
const filteredIssues = useMemo(() => filterKanbanIssues(localIssues, filters), [localIssues, filters]);
const columns = useMemo(() => buildKanbanColumns(filteredIssues), [filteredIssues]);
const stats = useMemo(() => buildKanbanStats(filteredIssues), [filteredIssues]);
const selectedIssue = useMemo(
() => filteredIssues.find((issue) => issue.id === selectedIssueId) ?? filteredIssues[0] ?? null,
[filteredIssues, selectedIssueId],
);
const selectedIssue = useMemo(() => filteredIssues.find((issue) => issue.id === selectedIssueId) ?? null, [filteredIssues, selectedIssueId]);
const showDesktopDetail = Boolean(selectedIssue) && !desktopDetailMinimized;
const mutateStatus = async (issue: BeadIssue, targetStatus: KanbanStatus) => {
const steps = planStatusTransition(issue, targetStatus);
if (steps.length === 0) {
return;
}
setMutationError(null);
const previous = localIssues;
setPendingIssueIds((value) => new Set(value).add(issue.id));
setLocalIssues((current) => applyOptimisticStatus(current, issue.id, targetStatus));
try {
for (const step of steps) {
await postMutation(step.operation, {
projectRoot,
...step.payload,
});
}
const reconciled = await fetchIssues(projectRoot);
setLocalIssues(reconciled);
} catch (error) {
setLocalIssues(previous);
setMutationError(error instanceof Error ? error.message : 'Mutation failed');
} finally {
setPendingIssueIds((value) => {
const next = new Set(value);
next.delete(issue.id);
return next;
});
}
};
return (
<main className="mx-auto min-h-screen max-w-[1800px] px-4 py-4 sm:px-6 sm:py-6">
<header className="mb-4 rounded-2xl border border-border-soft bg-surface/90 px-4 py-4 shadow-card backdrop-blur md:px-5">
<p className="font-mono text-xs uppercase tracking-[0.14em] text-cyan-100/80">BeadBoard</p>
<p className="font-mono text-xs uppercase tracking-[0.14em] text-text-muted">BeadBoard</p>
<h1 className="mt-1 text-2xl font-semibold text-text-strong sm:text-3xl">Kanban Dashboard</h1>
<p className="mt-2 text-sm text-text-muted">Tracer Bullet 1 from live `.beads/issues.jsonl` on Windows-native paths.</p>
</header>
<KanbanControls filters={filters} stats={stats} onFiltersChange={setFilters} />
<section className="mt-3 grid grid-cols-1 gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(20rem,24rem)] xl:grid-cols-[minmax(0,1fr)_minmax(22rem,26rem)]">
<motion.div layout className="overflow-x-auto rounded-2xl border border-border-soft bg-surface/80 p-2.5 shadow-card">
{mutationError ? (
<div className="mt-3 rounded-xl border border-rose-300/40 bg-rose-950/40 px-3 py-2 text-sm text-rose-100">{mutationError}</div>
) : null}
<section
className={`mt-3 overflow-hidden rounded-2xl border border-border-soft bg-surface/82 shadow-card ${
showDesktopDetail ? 'lg:grid lg:grid-cols-[minmax(0,1fr)_minmax(22rem,26rem)]' : ''
}`}
>
<motion.div layout className="p-2.5 sm:p-3">
<KanbanBoard
columns={columns}
selectedIssueId={selectedIssue?.id ?? null}
pendingIssueIds={pendingIssueIds}
activeStatus={activeStatus}
onActivateStatus={setActiveStatus}
onMoveIssue={mutateStatus}
onSelect={(issue) => {
setSelectedIssueId(issue.id);
setDesktopDetailMinimized(false);
setMobileDetailOpen(true);
}}
/>
</motion.div>
<div className="hidden lg:sticky lg:top-4 lg:block lg:self-start">
<KanbanDetail issue={selectedIssue} />
</div>
{showDesktopDetail ? (
<div className="hidden border-t border-border-soft bg-surface/72 p-3 lg:block lg:border-l lg:border-t-0">
<aside className="rounded-xl border border-border-soft bg-surface/78 p-3">
<div className="mb-2 flex items-center justify-end gap-2 border-b border-border-soft pb-2">
<button
type="button"
onClick={() => setDesktopDetailMinimized(true)}
className="rounded-md border border-border-soft bg-surface-muted/70 px-2 py-1 text-xs text-text-body"
>
Minimize
</button>
<button
type="button"
onClick={() => setSelectedIssueId(null)}
className="rounded-md border border-border-soft bg-surface-muted/70 px-2 py-1 text-xs text-text-muted"
>
Clear
</button>
</div>
<div className="max-h-[calc(100vh-16rem)] overflow-y-auto pr-1">
<KanbanDetail issue={selectedIssue} framed={false} />
</div>
</aside>
</div>
) : null}
</section>
{mobileDetailOpen && selectedIssue ? (

56
src/lib/writeback.ts Normal file
View file

@ -0,0 +1,56 @@
import type { BeadIssue, BeadStatus } from './types';
export type MutationStep =
| { operation: 'close'; payload: { id: string; reason?: string } }
| { operation: 'reopen'; payload: { id: string; reason?: string } }
| { operation: 'update'; payload: { id: string; status: 'open' | 'in_progress' | 'blocked' | 'deferred' } };
function isBoardStatus(status: BeadStatus): status is 'open' | 'in_progress' | 'blocked' | 'deferred' | 'closed' {
return ['open', 'in_progress', 'blocked', 'deferred', 'closed'].includes(status);
}
export function planStatusTransition(
issue: Pick<BeadIssue, 'id' | 'status'>,
targetStatus: 'open' | 'in_progress' | 'blocked' | 'deferred' | 'closed',
): MutationStep[] {
if (!isBoardStatus(issue.status) || issue.status === targetStatus) {
return [];
}
if (targetStatus === 'closed') {
return [{ operation: 'close', payload: { id: issue.id, reason: 'Moved to closed via board drag-and-drop' } }];
}
if (issue.status === 'closed') {
if (targetStatus === 'open') {
return [{ operation: 'reopen', payload: { id: issue.id, reason: 'Moved from closed via board drag-and-drop' } }];
}
return [
{ operation: 'reopen', payload: { id: issue.id, reason: 'Moved from closed via board drag-and-drop' } },
{ operation: 'update', payload: { id: issue.id, status: targetStatus } },
];
}
return [{ operation: 'update', payload: { id: issue.id, status: targetStatus } }];
}
export function applyOptimisticStatus(
issues: BeadIssue[],
issueId: string,
targetStatus: 'open' | 'in_progress' | 'blocked' | 'deferred' | 'closed',
atIso: string = new Date().toISOString(),
): BeadIssue[] {
return issues.map((issue) => {
if (issue.id !== issueId) {
return issue;
}
return {
...issue,
status: targetStatus,
updated_at: atIso,
closed_at: targetStatus === 'closed' ? atIso : null,
};
});
}

View file

@ -0,0 +1,55 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyOptimisticStatus, planStatusTransition } from '../../src/lib/writeback';
import type { BeadIssue } from '../../src/lib/types';
test('planStatusTransition maps open -> closed to close command', () => {
const steps = planStatusTransition({ id: 'bb-1', status: 'open' }, 'closed');
assert.deepEqual(steps, [{ operation: 'close', payload: { id: 'bb-1', reason: 'Moved to closed via board drag-and-drop' } }]);
});
test('planStatusTransition maps closed -> in_progress to reopen + update', () => {
const steps = planStatusTransition({ id: 'bb-2', status: 'closed' }, 'in_progress');
assert.deepEqual(steps, [
{ operation: 'reopen', payload: { id: 'bb-2', reason: 'Moved from closed via board drag-and-drop' } },
{ operation: 'update', payload: { id: 'bb-2', status: 'in_progress' } },
]);
});
test('planStatusTransition maps non-closed transitions to update', () => {
const steps = planStatusTransition({ id: 'bb-3', status: 'blocked' }, 'open');
assert.deepEqual(steps, [{ operation: 'update', payload: { id: 'bb-3', status: 'open' } }]);
});
test('applyOptimisticStatus updates selected issue status and timestamps', () => {
const issues: BeadIssue[] = [
{
id: 'bb-1',
title: 'One',
description: null,
status: 'open',
priority: 2,
issue_type: 'task',
assignee: null,
owner: null,
labels: [],
dependencies: [],
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
closed_at: null,
close_reason: null,
closed_by_session: null,
created_by: null,
due_at: null,
estimated_minutes: null,
external_ref: null,
metadata: {},
},
];
const updated = applyOptimisticStatus(issues, 'bb-1', 'closed', '2026-02-12T00:00:00Z');
assert.equal(updated[0].status, 'closed');
assert.equal(updated[0].closed_at, '2026-02-12T00:00:00Z');
assert.equal(updated[0].updated_at, '2026-02-12T00:00:00Z');
});