From cc616c1543042183f85dfdb2ebc80a710fd2ac37 Mon Sep 17 00:00:00 2001 From: zenchantlive Date: Wed, 11 Feb 2026 19:59:55 -0800 Subject: [PATCH] Add optimistic writeback flow with kanban drag-drop transitions --- .beads/issues.jsonl | 5 +- package.json | 2 +- src/app/api/beads/read/route.ts | 24 ++++ src/app/page.tsx | 2 +- src/components/kanban/kanban-board.tsx | 147 +++++++++++++++++++++---- src/components/kanban/kanban-card.tsx | 19 +++- src/components/kanban/kanban-page.tsx | 138 ++++++++++++++++++++--- src/lib/writeback.ts | 56 ++++++++++ tests/lib/writeback.test.ts | 55 +++++++++ 9 files changed, 403 insertions(+), 45 deletions(-) create mode 100644 src/app/api/beads/read/route.ts create mode 100644 src/lib/writeback.ts create mode 100644 tests/lib/writeback.test.ts diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 46a9cb9..932462e 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -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"}]} diff --git a/package.json b/package.json index adefd10..564f5fa 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/api/beads/read/route.ts b/src/app/api/beads/read/route.ts new file mode 100644 index 0000000..a3510bc --- /dev/null +++ b/src/app/api/beads/read/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from 'next/server'; + +import { readIssuesFromDisk } from '../../../../lib/read-issues'; + +export async function GET(request: Request): Promise { + 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 }, + ); + } +} diff --git a/src/app/page.tsx b/src/app/page.tsx index a3d12e7..694d171 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -3,5 +3,5 @@ import { readIssuesFromDisk } from '../lib/read-issues'; export default async function Page() { const issues = await readIssuesFromDisk(); - return ; + return ; } diff --git a/src/components/kanban/kanban-board.tsx b/src/components/kanban/kanban-board.tsx index ef98b85..39f0151 100644 --- a/src/components/kanban/kanban-board.tsx +++ b/src/components/kanban/kanban-board.tsx @@ -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; + 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) => { + 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) => { + 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 ( -
+
{KANBAN_STATUSES.map((status) => (
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' + }`} > -
- - - {STATUS_META[status].label} - - {columns[status].length} +
+ + {activeStatus === status ? ( + + ) : null}
-
- - {columns[status].map((issue) => ( - + {activeStatus === status ? ( +
+ + {columns[status].map((issue) => ( + + ))} + + {columns[status].length === 0 ? ( +
+ No beads +
+ ) : null} +
+ ) : ( +
+ {columns[status].slice(0, 6).map((issue) => ( + ))} - -
+ {columns[status].length > 6 ? ( + + ) : null} + {columns[status].length === 0 ? ( + + No beads + + ) : null} +
+ )}
))}
diff --git a/src/components/kanban/kanban-card.tsx b/src/components/kanban/kanban-card.tsx index f2642a1..c105666 100644 --- a/src/components/kanban/kanban-card.tsx +++ b/src/components/kanban/kanban-card.tsx @@ -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) => 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' : '' + }`} >
{issue.id}
{issue.title}
@@ -51,7 +59,7 @@ export function KanbanCard({ issue, selected, onSelect }: KanbanCardProps) { {issue.issue_type} deps {issue.dependencies.length} -
+
{issue.assignee ? `@${issue.assignee}` : 'unassigned'}
{issue.labels.length > 0 ? ( @@ -61,6 +69,7 @@ export function KanbanCard({ issue, selected, onSelect }: KanbanCardProps) { ))}
) : null} + {pending ?
Saving…
: null} ); } diff --git a/src/components/kanban/kanban-page.tsx b/src/components/kanban/kanban-page.tsx index 5bcb194..1eb04af 100644 --- a/src/components/kanban/kanban-page.tsx +++ b/src/components/kanban/kanban-page.tsx @@ -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) { + 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 { + 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(issues); const [filters, setFilters] = useState({ query: '', type: '', priority: '', - showClosed: false, + showClosed: true, }); - const [selectedIssueId, setSelectedIssueId] = useState(issues[0]?.id ?? null); + const [selectedIssueId, setSelectedIssueId] = useState(null); const [mobileDetailOpen, setMobileDetailOpen] = useState(false); + const [activeStatus, setActiveStatus] = useState('open'); + const [desktopDetailMinimized, setDesktopDetailMinimized] = useState(false); + const [pendingIssueIds, setPendingIssueIds] = useState>(new Set()); + const [mutationError, setMutationError] = useState(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 (
-

BeadBoard

+

BeadBoard

Kanban Dashboard

Tracer Bullet 1 from live `.beads/issues.jsonl` on Windows-native paths.

-
- + {mutationError ? ( +
{mutationError}
+ ) : null} +
+ { setSelectedIssueId(issue.id); + setDesktopDetailMinimized(false); setMobileDetailOpen(true); }} /> -
- -
+ {showDesktopDetail ? ( +
+ +
+ ) : null}
{mobileDetailOpen && selectedIssue ? ( diff --git a/src/lib/writeback.ts b/src/lib/writeback.ts new file mode 100644 index 0000000..ca61e85 --- /dev/null +++ b/src/lib/writeback.ts @@ -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, + 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, + }; + }); +} diff --git a/tests/lib/writeback.test.ts b/tests/lib/writeback.test.ts new file mode 100644 index 0000000..d0ab061 --- /dev/null +++ b/tests/lib/writeback.test.ts @@ -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'); +});