Merge pull request #2 from zenchantlive/master
Core infrastructure: New modules for project scanning, registry management, filesystem watching, and real-time event bus with SSE support Graph visualization: Complete dependency graph analysis system with layout algorithms, cycle detection, and blocker chain analysis Issue mutations: Full mutation operation support (create, update, close, reopen, comment) with validation and error classification Kanban enhancements: Refactored status model with execution checklist, blocked-by tree building, and actionable task identification Issue editing: New draft editing module with field-level validation and change tracking BD CLI bridge: Command execution wrapper with timeout support and structured error handling Multi-project support: Project scope resolution for single vs. aggregate modes with registry-based project management API endpoints: Complete REST API for projects, mutations, scanning, and real-time events UI components: New dependency graph page, task card grid, enhanced kanban detail panel, and project scope controls Styling: Comprehensive design system overhaul with Tailwind CSS v4, Google Fonts integration, and graph visualization styles Parser improvements: Enhanced dependency parsing with schema flexibility (depends_on_id support, parent-child normalization) Resilience: File read retry utility for handling transient filesystem errors Comprehensive test coverage: 30+ new test suites covering all major modules and API routes
This commit is contained in:
commit
8a8257d08f
83 changed files with 14749 additions and 241 deletions
|
|
@ -1,14 +1,41 @@
|
|||
{"id":"bb-29x","title":"Quality Gates, Testing, and Performance Validation","description":"Establish verification confidence through unit/integration tests, boundary tests, and performance baselines for parser and realtime workflows.","acceptance_criteria":"Core functionality is covered by automated checks and target baselines are recorded.","status":"open","priority":1,"issue_type":"epic","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:15.8368971-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:15.8368971-08:00","labels":["perf","quality","testing"],"dependencies":[{"issue_id":"bb-29x","depends_on_id":"bb-ymg","type":"blocks","created_at":"2026-02-11T17:12:23.6722466-08:00","created_by":"zenchantlive"},{"issue_id":"bb-29x","depends_on_id":"bb-xhm","type":"blocks","created_at":"2026-02-11T17:12:24.1823625-08:00","created_by":"zenchantlive"},{"issue_id":"bb-29x","depends_on_id":"bb-bvn","type":"blocks","created_at":"2026-02-11T17:12:24.6873031-08:00","created_by":"zenchantlive"},{"issue_id":"bb-29x","depends_on_id":"bb-u6f","type":"blocks","created_at":"2026-02-11T17:12:25.193566-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-29x.1","title":"Implement unit tests for parser, pathing, scanner, and bd bridge","description":"Add focused fast tests for foundational modules and error handling paths.","acceptance_criteria":"Unit tests cover nominal and edge-case logic for each foundational module.","status":"open","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:16.6578316-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:16.6578316-08:00","labels":["tests","unit"],"dependencies":[{"issue_id":"bb-29x.1","depends_on_id":"bb-29x","type":"parent-child","created_at":"2026-02-11T17:12:16.6594181-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-29x.2","title":"Implement API integration tests for read, mutate, and SSE routes","description":"Validate route contracts and interaction boundaries across read/write/realtime layers.","acceptance_criteria":"Integration suite verifies route behavior and error semantics.","status":"open","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:17.4912736-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:17.4912736-08:00","labels":["integration","tests"],"dependencies":[{"issue_id":"bb-29x.2","depends_on_id":"bb-29x","type":"parent-child","created_at":"2026-02-11T17:12:17.4923012-08:00","created_by":"zenchantlive"},{"issue_id":"bb-29x.2","depends_on_id":"bb-29x.1","type":"blocks","created_at":"2026-02-11T17:12:38.9423299-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-29x.3","title":"Record parser and realtime performance baseline against PRD targets","description":"Measure parse latency and update propagation using realistic sample sizes and document outcomes.","acceptance_criteria":"Performance report exists with methodology and observed timings.","status":"open","priority":2,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:18.3210495-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:18.3210495-08:00","labels":["benchmark","perf"],"dependencies":[{"issue_id":"bb-29x.3","depends_on_id":"bb-29x","type":"parent-child","created_at":"2026-02-11T17:12:18.3220949-08:00","created_by":"zenchantlive"},{"issue_id":"bb-29x.3","depends_on_id":"bb-29x.2","type":"blocks","created_at":"2026-02-11T17:12:39.4534943-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-29x.4","title":"Document operational runbook and boundary rationale","description":"Write architecture docs covering scanner policy, bd bridge behavior, and consistency guardrails for future maintainers.","acceptance_criteria":"Runbook documents startup, troubleshooting, and boundary rules.","status":"open","priority":2,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:19.1385778-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:19.1385778-08:00","labels":["docs","runbook"],"dependencies":[{"issue_id":"bb-29x.4","depends_on_id":"bb-29x","type":"parent-child","created_at":"2026-02-11T17:12:19.1402086-08:00","created_by":"zenchantlive"},{"issue_id":"bb-29x.4","depends_on_id":"bb-29x.2","type":"blocks","created_at":"2026-02-11T17:12:39.9591458-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-6aj","title":"Project Registry and Multi-Project Scanner","description":"Support multiple Windows project roots using profile-scoped registry storage and safe discovery scanning tuned for developer machines.","acceptance_criteria":"Projects can be added/removed/listed and discovered via scanner with deterministic normalization.","status":"open","priority":0,"issue_type":"epic","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:47.7205517-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:11:47.7205517-08:00","labels":["multi-project","scanner"],"dependencies":[{"issue_id":"bb-6aj","depends_on_id":"bb-92d","type":"blocks","created_at":"2026-02-11T17:12:19.6374139-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-18e","title":"Graph Clarity Follow-up (Post-Kanban)","description":"Objective:\nTrack graph-focused clarity enhancements that are explicitly out of current Kanban execution scope.\n\nScope:\n- Dependency graph comprehension aids\n- anomaly communication for cycle/deadlock scenarios\n- future AI explanation hooks for dependency understanding\n\nOut of scope for immediate execution:\n- Kanban UI polish currently underway in bb-1es\n\nPlanning note:\nThis epic stays pending until current Kanban actionability epic reaches acceptance.","acceptance_criteria":"- Graph clarity backlog is explicitly captured and linked to future implementation beads.\n- No accidental scope leakage into current Kanban pass.","notes":"Backlog epic for graph-specific clarity work discussed after bb-1es.\nPost-Kanban gate enforced: bb-18e depends on bb-1es completion. Child bb-18e.1 is also explicitly blocked by bb-1es to prevent scope bleed before Kanban pass completes.\nExecution sequence added: (1) bb-18e.2 edge labels/contrast + bb-18e.3 direction hints + bb-18e.1 cycle card, (2) bb-18e.4 edge toggles + bb-18e.6 centered focus, (3) bb-18e.5 external blockers + bb-18e.7 progressive details + bb-18e.10 risk tinting, (4) bb-18e.8 keyboard nav + bb-18e.9 URL state, (5) bb-18e.11 AI explanation scaffold (deferred).","status":"open","priority":2,"issue_type":"epic","owner":"jordanlive121@gmail.com","created_at":"2026-02-12T19:45:40.5451814-08:00","created_by":"zenchantlive","updated_at":"2026-02-12T20:21:45.1768727-08:00","labels":["backlog","graph","ux"],"dependencies":[{"issue_id":"bb-18e","depends_on_id":"bb-1es","type":"blocks","created_at":"2026-02-12T19:53:11.4089925-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-18e.1","title":"Add cycle warning card with focus actions in graph view","description":"Goal:\nWhen dependency cycles exist, show an explicit cycle warning card so users understand why work may be deadlocked.\n\nProblem:\nCycle states are hard to infer from dense node/edge layouts, leading to confusion (“why can’t this move?”).\n\nBehavior contract:\n- Show warning card only when cycle analysis is non-empty.\n- Card includes:\n - cycle count\n - affected bead ids (compact list)\n - click-to-focus action for each cycle group\n - plain language explanation of impact (“tasks in this loop cannot fully unblock each other without breaking the cycle”).\n- Visual style: warning but not alarmist (amber/red subtle).\n\nImplementation tasks:\n1) Build compact cycle summary model from existing detection output.\n2) Add warning card component above graph viewport.\n3) Wire click handlers to focus selected cycle nodes.\n4) Add tests for no-cycle and multi-cycle rendering behavior.\n\nOut of scope:\n- Automatic cycle resolution suggestions.\n- Mutation/write automation.","acceptance_criteria":"- Cycle warning card appears only when cycles are present.\n- Card provides actionable cycle navigation.\n- Language is plain and explains user impact.\n- Tests cover empty and non-empty cycle states.\n- Typecheck and graph guards pass.","notes":"This is the #19 idea captured as an explicit implementation bead.\nDepends on existing cycle analysis primitives already implemented.","status":"open","priority":2,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-12T19:46:01.2478576-08:00","created_by":"zenchantlive","updated_at":"2026-02-12T19:46:01.2478576-08:00","labels":["anomaly","graph","ux"],"dependencies":[{"issue_id":"bb-18e.1","depends_on_id":"bb-18e","type":"parent-child","created_at":"2026-02-12T19:46:01.2494327-08:00","created_by":"zenchantlive"},{"issue_id":"bb-18e.1","depends_on_id":"bb-1es","type":"blocks","created_at":"2026-02-12T19:53:11.9910819-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-18e.10","title":"Add downstream-impact risk tinting","description":"Add subtle risk tinting based on downstream impact count to highlight high-blast-radius tasks.","acceptance_criteria":"- Higher downstream impact gets stronger but subtle visual signal.\n- Does not overpower status/selection color language.\n- Works with existing legend semantics.","notes":"Use restrained styling; no heavy borders.","status":"open","priority":2,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-12T20:21:21.812041-08:00","created_by":"zenchantlive","updated_at":"2026-02-12T20:21:21.812041-08:00","labels":["graph","signal","ux"],"dependencies":[{"issue_id":"bb-18e.10","depends_on_id":"bb-18e","type":"parent-child","created_at":"2026-02-12T20:21:21.8153577-08:00","created_by":"zenchantlive"},{"issue_id":"bb-18e.10","depends_on_id":"bb-18e.4","type":"blocks","created_at":"2026-02-12T20:21:43.4643033-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-18e.11","title":"AI dependency explanation scaffold (deferred)","description":"Prepare integration scaffold for later AI explanation in dependency view (why blocked / next steps), without shipping model calls yet.","acceptance_criteria":"- Data contract for AI explanation input is defined.\n- UI placeholder state exists but feature-flagged/off by default.\n- No network/model dependency in this bead.","notes":"Deferred feature: implement only scaffolding and interfaces.","status":"closed","priority":3,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-12T20:21:22.4738465-08:00","created_by":"zenchantlive","updated_at":"2026-02-12T23:35:59.2331675-08:00","closed_at":"2026-02-12T23:35:59.2331675-08:00","labels":["ai","backlog","graph"],"dependencies":[{"issue_id":"bb-18e.11","depends_on_id":"bb-18e","type":"parent-child","created_at":"2026-02-12T20:21:22.4799753-08:00","created_by":"zenchantlive"},{"issue_id":"bb-18e.11","depends_on_id":"bb-18e.9","type":"blocks","created_at":"2026-02-12T20:21:44.0206618-08:00","created_by":"zenchantlive"},{"issue_id":"bb-18e.11","depends_on_id":"bb-18e.1","type":"blocks","created_at":"2026-02-12T20:21:44.590355-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-18e.2","title":"Add plain-English edge labels + contrast upgrade","description":"Improve edge readability by labeling relationships in plain language (blocks/parent/related) and increasing contrast for fast scan.\nScope: graph viewport only; no mutation behavior changes.","acceptance_criteria":"- Edge labels are visible and readable at default zoom.\n- Labels map correctly to relation type.\n- Contrast remains accessible on dark background.","notes":"Verification: typecheck + graph responsive guard + visual screenshot at 390/768/1440.","status":"open","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-12T20:21:16.9461643-08:00","created_by":"zenchantlive","updated_at":"2026-02-12T20:21:16.9461643-08:00","labels":["graph","readability","ux"],"dependencies":[{"issue_id":"bb-18e.2","depends_on_id":"bb-18e","type":"parent-child","created_at":"2026-02-12T20:21:16.9493723-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-18e.3","title":"Add directional context hints for dependency reading","description":"Add concise orientation hints explaining graph reading order (left prerequisites, right downstream impact).\nPlace hints near graph legend and keep copy plain.","acceptance_criteria":"- Direction hint appears in graph UI.\n- Hint remains visible and non-intrusive on mobile and desktop.","notes":"Added plain-language directional hint in Graph legend: 'Read left to right: Left = blockers, middle = selected task, Right = work this task unblocks.' Also added guard contract assertions in tests/guards/graph-responsive-contract.test.mjs to prevent regressions.","status":"closed","priority":2,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-12T20:21:17.525886-08:00","created_by":"zenchantlive","updated_at":"2026-02-12T23:47:50.0333842-08:00","closed_at":"2026-02-12T23:47:50.0333842-08:00","close_reason":"Directional context hint shipped with guard coverage; visibility and wording verified on responsive graph layout.","labels":["graph","orientation","ux"],"dependencies":[{"issue_id":"bb-18e.3","depends_on_id":"bb-18e","type":"parent-child","created_at":"2026-02-12T20:21:17.5284994-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-18e.4","title":"Add edge-type toggles to reduce graph noise","description":"Add controls to show/hide edge categories (blocks,parent,related) so users can simplify complex views.","acceptance_criteria":"- Users can toggle edge categories independently.\n- Default preserves current behavior.\n- Toggle state updates graph without runtime errors.","notes":"Include test coverage for toggle behavior.","status":"open","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-12T20:21:18.1326942-08:00","created_by":"zenchantlive","updated_at":"2026-02-12T20:21:18.1326942-08:00","labels":["controls","graph","ux"],"dependencies":[{"issue_id":"bb-18e.4","depends_on_id":"bb-18e","type":"parent-child","created_at":"2026-02-12T20:21:18.1347832-08:00","created_by":"zenchantlive"},{"issue_id":"bb-18e.4","depends_on_id":"bb-18e.2","type":"blocks","created_at":"2026-02-12T20:21:39.3944582-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-18e.5","title":"Add external-blockers-only filter","description":"Add a filter that shows only blockers outside selected epic/task context to focus on cross-epic constraints.","acceptance_criteria":"- Filter clearly isolates external blockers.\n- Selected/focus node remains visible.\n- UX works on mobile and desktop.","notes":"Must degrade gracefully if no external blockers exist.","status":"open","priority":2,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-12T20:21:18.7705681-08:00","created_by":"zenchantlive","updated_at":"2026-02-12T20:21:18.7705681-08:00","labels":["dependencies","filters","graph"],"dependencies":[{"issue_id":"bb-18e.5","depends_on_id":"bb-18e","type":"parent-child","created_at":"2026-02-12T20:21:18.7726627-08:00","created_by":"zenchantlive"},{"issue_id":"bb-18e.5","depends_on_id":"bb-18e.4","type":"blocks","created_at":"2026-02-12T20:21:41.1088626-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-18e.6","title":"Keep selected node centered during focus navigation","description":"Refine viewport behavior so selected node remains centered/predictable when user selects tasks or changes depth.","acceptance_criteria":"- Selection keeps focus node in stable viewport position.\n- No clipping/bleed regressions.\n- Fit behavior remains bounded.","notes":"Add tests for focus/viewport contract where feasible.","status":"open","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-12T20:21:19.3791473-08:00","created_by":"zenchantlive","updated_at":"2026-02-12T20:21:19.3791473-08:00","labels":["graph","interaction","ux"],"dependencies":[{"issue_id":"bb-18e.6","depends_on_id":"bb-18e","type":"parent-child","created_at":"2026-02-12T20:21:19.3807243-08:00","created_by":"zenchantlive"},{"issue_id":"bb-18e.6","depends_on_id":"bb-18e.2","type":"blocks","created_at":"2026-02-12T20:21:39.9439739-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-18e.7","title":"Progressive disclosure in graph task details panel","description":"Refactor graph details panel to show summary first and collapse secondary metadata under explicit expand control.","acceptance_criteria":"- Primary summary is immediately readable.\n- Secondary fields are accessible via expand action.\n- Mobile detail experience stays compact.","notes":"Do not remove any existing information; only restructure hierarchy.","status":"in_progress","priority":2,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-12T20:21:20.0136797-08:00","created_by":"zenchantlive","updated_at":"2026-02-12T22:02:52.9759525-08:00","labels":["details","graph","ux"],"dependencies":[{"issue_id":"bb-18e.7","depends_on_id":"bb-18e","type":"parent-child","created_at":"2026-02-12T20:21:20.0164851-08:00","created_by":"zenchantlive"},{"issue_id":"bb-18e.7","depends_on_id":"bb-18e.3","type":"blocks","created_at":"2026-02-12T20:21:40.5255149-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-18e.8","title":"Add graph keyboard navigation shortcuts","description":"Add keyboard navigation for graph workflow (e.g., next/prev task, open flow/overview, focus search).","acceptance_criteria":"- Shortcuts work without interfering with text inputs.\n- Shortcut list documented in UI/help hint.\n- Accessibility remains intact.","notes":"Treat as later-phase productivity enhancement after core clarity features.","status":"open","priority":3,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-12T20:21:20.617034-08:00","created_by":"zenchantlive","updated_at":"2026-02-12T20:21:20.617034-08:00","labels":["accessibility","graph","productivity"],"dependencies":[{"issue_id":"bb-18e.8","depends_on_id":"bb-18e","type":"parent-child","created_at":"2026-02-12T20:21:20.6196393-08:00","created_by":"zenchantlive"},{"issue_id":"bb-18e.8","depends_on_id":"bb-18e.6","type":"blocks","created_at":"2026-02-12T20:21:41.7395727-08:00","created_by":"zenchantlive"},{"issue_id":"bb-18e.8","depends_on_id":"bb-18e.7","type":"blocks","created_at":"2026-02-12T20:21:42.3306409-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-18e.9","title":"Persist graph state in URL params","description":"Persist selected epic/task/tab/depth/filter state in URL so refresh/share restores context.","acceptance_criteria":"- Reload restores graph context from URL.\n- Shared URL opens same state deterministically.\n- Invalid params fail safely to defaults.","notes":"Add route/param parsing tests.","status":"in_progress","priority":2,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-12T20:21:21.2077039-08:00","created_by":"zenchantlive","updated_at":"2026-02-12T21:58:56.9471903-08:00","labels":["graph","routing","ux"],"dependencies":[{"issue_id":"bb-18e.9","depends_on_id":"bb-18e","type":"parent-child","created_at":"2026-02-12T20:21:21.2103973-08:00","created_by":"zenchantlive"},{"issue_id":"bb-18e.9","depends_on_id":"bb-18e.6","type":"blocks","created_at":"2026-02-12T20:21:42.8897325-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-1es","title":"Kanban Actionability \u0026 Execution Clarity","description":"Objective:\nCreate a focused Kanban polish epic that improves execution clarity and triage speed without expanding scope into graph rendering changes.\n\nScope boundaries:\n- In scope: Kanban page only (`/`), especially lane workflow, task card signal density, and detail panel actionability.\n- Out of scope: Dependency graph edge visuals/layout (tracked separately), AI-generated summaries (future bead), keyboard system-wide shortcuts (future bead).\n\nUser outcomes this epic must deliver:\n1) Users can immediately identify what to pick next.\n2) Users can quickly understand impact (what this task unblocks).\n3) Users can evaluate readiness from one details panel without context switching.\n4) Users can triage by recency and urgency with minimal cognitive load.\n\nExecution plan:\n- Phase A: Add Next Actionable workflow entrypoint.\n- Phase B: Improve card signal density (recency + unblocks count).\n- Phase C: Add execution checklist to details panel.\n- Phase D: Verify responsive behavior and no regressions on write/mutation flow.\n\nNon-negotiables:\n- Maintain strict read/write boundary (no direct JSONL writes).\n- Preserve existing mutation semantics via bd bridge.\n- Keep mobile layout readable and avoid extra vertical clutter.\n- Evidence-first completion: tests + visual proof.","acceptance_criteria":"- Kanban has a deterministic “next actionable” affordance and it selects a valid ready task.\n- Task cards expose recency and unblock-impact signals without overwhelming visual noise.\n- Details panel includes concise execution checklist with clear pass/fail indicators.\n- All additions are responsive and do not regress existing lane filtering or detail drawer behavior.\n- Typecheck and tests pass.","notes":"Planning contract:\n- This epic is implementation-focused and should be executed through child beads.\n- Child beads must include explicit UX contract, test updates, and verification commands.\n- AI summary concept is deferred to a later phase after core UX/actionability stabilizes.\nExecution sequencing updated: bb-1es.1 is foundational and now blocks bb-1es.3 + bb-1es.4. Recommended implementation order: bb-1es.1 -\u003e bb-1es.3 -\u003e bb-1es.4 -\u003e bb-1es.2.","status":"closed","priority":1,"issue_type":"epic","owner":"jordanlive121@gmail.com","created_at":"2026-02-12T19:44:06.0783399-08:00","created_by":"zenchantlive","updated_at":"2026-02-12T20:16:55.5910638-08:00","closed_at":"2026-02-12T20:16:55.5910638-08:00","close_reason":"All child beads complete and verified (typecheck + kanban tests + kanban guard contract).","labels":["kanban","ux","workflow"]}
|
||||
{"id":"bb-1es.1","title":"Add Next Actionable task picker to Kanban","description":"Goal:\nAdd a high-signal “Next Actionable” control in Kanban that jumps users directly to the best next task to work on.\n\nProblem being solved:\nUsers currently scan multiple lanes/cards manually to find what is unblocked and high-priority. This is slow and inconsistent.\n\nBehavior contract:\n- Action is visible in Kanban controls area.\n- On click, algorithm selects one candidate task from Ready lane.\n- Candidate ranking:\n 1) lowest priority number first (P0 \u003e P1 \u003e ...)\n 2) tasks with higher unblock impact first (if tie)\n 3) most recently updated first (if tie)\n 4) stable deterministic fallback by bead id\n- Resulting behavior:\n - Ready lane becomes active.\n - Selected task is focused (details open if currently closed/minimized).\n - If no actionable task exists, show lightweight empty-state feedback.\n\nImplementation tasks:\n1) Add selector helper in lib layer (pure function + tests).\n2) Wire control button in Kanban controls.\n3) Connect selection plumbing in Kanban page state.\n4) Add empty-path UX when no candidate found.\n5) Ensure no side effects on mutation/write paths.\n\nOut of scope:\n- AI ranking\n- dependency graph page behavior","acceptance_criteria":"- A “Next Actionable” control exists and is keyboard accessible.\n- It always picks a deterministic candidate from Ready lane or shows no-candidate feedback.\n- It activates Ready lane + selects the target task.\n- Unit tests cover ranking and no-candidate case.\n- Guard and type checks pass.","notes":"Verification required:\n- npm run typecheck\n- node --import tsx --test tests/lib/kanban.test.ts\n- node --test tests/guards/kanban-responsive-contract.test.mjs\n- Visual spot check on desktop + mobile screenshot","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-12T19:44:24.021787-08:00","created_by":"zenchantlive","updated_at":"2026-02-12T20:16:26.9615478-08:00","closed_at":"2026-02-12T20:16:26.9615478-08:00","close_reason":"Implemented deterministic Next Actionable picker + UI control wiring; verified with typecheck and kanban tests/guards.","labels":["kanban","triage","workflow"],"dependencies":[{"issue_id":"bb-1es.1","depends_on_id":"bb-1es","type":"parent-child","created_at":"2026-02-12T19:44:24.0238625-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-1es.2","title":"Add recency signal (last updated) to Kanban cards","description":"Goal:\nIncrease Kanban card decision signal with a subtle “updated recently” indicator that helps triage stale vs active work.\n\nProblem:\nCards currently lack strong temporal signal, making it hard to prioritize fresh blockers and newly changed work.\n\nBehavior contract:\n- Each visible card shows concise recency text (e.g., “updated 2h ago”, “updated 3d ago”).\n- Use neutral/subtle styling so it does not overpower title/status.\n- Handle missing/invalid timestamps gracefully (“updated unknown”).\n- Time formatting should be deterministic and testable.\n\nImplementation tasks:\n1) Add timestamp formatter utility (pure + tested).\n2) Add recency metadata row to card footer/header with subtle hierarchy.\n3) Ensure recency doesn’t break compact/mobile card layouts.\n4) Add tests for formatting buckets (minutes/hours/days).\n\nOut of scope:\n- Relative live ticking every second.\n- server-side locale negotiation.","acceptance_criteria":"- Cards show readable recency text derived from updated_at when available.\n- Missing timestamp case is handled without UI breakage.\n- Visual hierarchy remains subtle and non-noisy.\n- Unit tests cover formatter behavior.\n- Typecheck + guard tests pass.","notes":"Design guidance:\n- Keep recency in secondary typography tier.\n- Avoid adding hard borders/heavy pills.\n- Use existing token palette.\n\nVerification required:\n- npm run typecheck\n- node --import tsx --test tests/lib/kanban.test.ts\n- node --test tests/guards/kanban-responsive-contract.test.mjs","status":"closed","priority":2,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-12T19:44:41.8782564-08:00","created_by":"zenchantlive","updated_at":"2026-02-12T20:16:41.4329721-08:00","closed_at":"2026-02-12T20:16:41.4329721-08:00","close_reason":"Implemented recency signal on Kanban cards with safe fallback; verified with typecheck and kanban tests/guards.","labels":["kanban","triage","ux"],"dependencies":[{"issue_id":"bb-1es.2","depends_on_id":"bb-1es","type":"parent-child","created_at":"2026-02-12T19:44:41.8803405-08:00","created_by":"zenchantlive"},{"issue_id":"bb-1es.2","depends_on_id":"bb-1es.3","type":"blocks","created_at":"2026-02-12T20:14:31.3947619-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-1es.3","title":"Show downstream impact chip (Unblocks N) on Kanban cards","description":"Goal:\nAdd a compact “Unblocks N” impact chip on Kanban cards so users can quickly see downstream value of completing a task.\n\nProblem:\nUsers can’t quickly assess impact from card scan alone; downstream unblock effect is hidden.\n\nBehavior contract:\n- Cards display `Unblocks N` when N \u003e 0.\n- Value is derived from dependency graph model / adjacency semantics already in app.\n- Clicking card still selects task normally; chip itself is not a separate interaction target.\n- Styling should be subtle and consistent with existing status metadata.\n\nImplementation tasks:\n1) Define computation source for downstream count in kanban data helpers.\n2) Add chip to card metadata row with low visual weight.\n3) Validate counts on sample fixtures including zero and multi-dependency cases.\n4) Ensure no overlap/clipping in narrow mobile cards.\n\nOut of scope:\n- Deep dependency chain impact scoring.\n- graph-page edge/line enhancements.","acceptance_criteria":"- Cards show `Unblocks N` for tasks with downstream dependents.\n- Zero-impact tasks do not show noisy empty chip.\n- Counts are consistent with current dependency model.\n- Tests cover representative dependency cases.\n- Typecheck and responsive guard pass.","notes":"Verification required:\n- npm run typecheck\n- node --import tsx --test tests/lib/kanban.test.ts\n- node --import tsx --test tests/lib/graph.test.ts\n- node --test tests/guards/kanban-responsive-contract.test.mjs","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-12T19:44:58.9549903-08:00","created_by":"zenchantlive","updated_at":"2026-02-12T20:16:28.8736644-08:00","closed_at":"2026-02-12T20:16:28.8736644-08:00","close_reason":"Implemented Unblocks N impact chip on cards with dependency-based counts; verified with typecheck and kanban tests/guards.","labels":["dependencies","kanban","signal"],"dependencies":[{"issue_id":"bb-1es.3","depends_on_id":"bb-1es","type":"parent-child","created_at":"2026-02-12T19:44:58.9576417-08:00","created_by":"zenchantlive"},{"issue_id":"bb-1es.3","depends_on_id":"bb-1es.1","type":"blocks","created_at":"2026-02-12T19:53:13.1942487-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-1es.4","title":"Add execution-readiness checklist to Kanban details","description":"Goal:\nAdd an execution checklist block in Kanban detail panel to translate issue state into actionable readiness checks.\n\nProblem:\nDetails currently show metadata, but users still need to mentally compute if task is executable now.\n\nBehavior contract:\n- Detail panel includes a compact checklist with pass/fail states.\n- Initial checklist items:\n 1) Owner assigned\n 2) Not blocked by open blockers\n 3) Has acceptance/description signal (basic quality gate)\n 4) Status compatible with execution (ready/in_progress)\n- Checklist should read as guidance, not hard enforcement.\n- Works on desktop detail and mobile drawer detail.\n\nImplementation tasks:\n1) Add pure checklist derivation helper + tests.\n2) Render checklist component in detail panel below summary metadata.\n3) Ensure blocked-tree links still work unchanged.\n4) Keep footprint compact (no excessive vertical expansion).\n\nOut of scope:\n- AI-generated checklist reasoning.\n- Workflow mutation side effects.","acceptance_criteria":"- Detail panel displays checklist with deterministic computed states.\n- Checklist visible on desktop and mobile detail experiences.\n- No regressions in blocked-by tree deep links.\n- Unit tests cover checklist derivation scenarios.\n- Typecheck + tests pass.","notes":"Future hook:\n- This bead should structure checklist data so future AI explanations can enrich each failed item.\n\nVerification required:\n- npm run typecheck\n- node --import tsx --test tests/lib/kanban.test.ts\n- node --test tests/guards/kanban-responsive-contract.test.mjs\n- Manual mobile + desktop detail check","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-12T19:45:15.7992627-08:00","created_by":"zenchantlive","updated_at":"2026-02-12T20:16:27.6045434-08:00","closed_at":"2026-02-12T20:16:27.6045434-08:00","close_reason":"Implemented execution-readiness checklist in Kanban detail (desktop/mobile paths); verified with typecheck and kanban tests/guards.","labels":["details","kanban","workflow"],"dependencies":[{"issue_id":"bb-1es.4","depends_on_id":"bb-1es","type":"parent-child","created_at":"2026-02-12T19:45:15.8018512-08:00","created_by":"zenchantlive"},{"issue_id":"bb-1es.4","depends_on_id":"bb-1es.1","type":"blocks","created_at":"2026-02-12T19:53:12.6068453-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-29x","title":"Quality Gates, Testing, and Performance Validation","description":"Establish verification confidence through unit/integration tests, boundary tests, and performance baselines for parser and realtime workflows.","acceptance_criteria":"Feature lanes are only closed after passing tests, capturing visual evidence, and documenting smoke-check results.","notes":"Definition of done locked (2026-02-12): every completed feature lane requires automated tests + visual screenshots + runtime smoke checks before close.","status":"open","priority":1,"issue_type":"epic","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:15.8368971-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T20:54:11.1739286-08:00","labels":["perf","quality","testing"],"dependencies":[{"issue_id":"bb-29x","depends_on_id":"bb-ymg","type":"blocks","created_at":"2026-02-11T17:12:23.6722466-08:00","created_by":"zenchantlive"},{"issue_id":"bb-29x","depends_on_id":"bb-xhm","type":"blocks","created_at":"2026-02-11T17:12:24.1823625-08:00","created_by":"zenchantlive"},{"issue_id":"bb-29x","depends_on_id":"bb-bvn","type":"blocks","created_at":"2026-02-11T17:12:24.6873031-08:00","created_by":"zenchantlive"},{"issue_id":"bb-29x","depends_on_id":"bb-u6f","type":"blocks","created_at":"2026-02-11T17:12:25.193566-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-29x.1","title":"Implement unit tests for parser, pathing, scanner, and bd bridge","description":"Add focused fast tests for foundational modules and error handling paths.","acceptance_criteria":"Unit tests cover nominal and edge-case logic for each foundational module.","status":"open","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:16.6578316-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:16.6578316-08:00","labels":["tests","unit"],"dependencies":[{"issue_id":"bb-29x.1","depends_on_id":"bb-29x","type":"parent-child","created_at":"2026-02-11T17:12:16.6594181-08:00","created_by":"zenchantlive"},{"issue_id":"bb-29x.1","depends_on_id":"bb-29x.5","type":"blocks","created_at":"2026-02-11T20:10:11.5066258-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-29x.2","title":"Implement API integration tests for read, mutate, and SSE routes","description":"Validate route contracts and interaction boundaries across read/write/realtime layers.","acceptance_criteria":"Integration suite verifies route behavior and error semantics.","status":"open","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:17.4912736-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:17.4912736-08:00","labels":["integration","tests"],"dependencies":[{"issue_id":"bb-29x.2","depends_on_id":"bb-29x","type":"parent-child","created_at":"2026-02-11T17:12:17.4923012-08:00","created_by":"zenchantlive"},{"issue_id":"bb-29x.2","depends_on_id":"bb-29x.1","type":"blocks","created_at":"2026-02-11T17:12:38.9423299-08:00","created_by":"zenchantlive"},{"issue_id":"bb-29x.2","depends_on_id":"bb-29x.5","type":"blocks","created_at":"2026-02-11T20:10:10.6325422-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-29x.3","title":"Record parser and realtime performance baseline against PRD targets","description":"Measure parse latency and update propagation using realistic sample sizes and document outcomes.","acceptance_criteria":"Performance report exists with methodology and observed timings.","status":"open","priority":2,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:18.3210495-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:18.3210495-08:00","labels":["benchmark","perf"],"dependencies":[{"issue_id":"bb-29x.3","depends_on_id":"bb-29x","type":"parent-child","created_at":"2026-02-11T17:12:18.3220949-08:00","created_by":"zenchantlive"},{"issue_id":"bb-29x.3","depends_on_id":"bb-29x.2","type":"blocks","created_at":"2026-02-11T17:12:39.4534943-08:00","created_by":"zenchantlive"},{"issue_id":"bb-29x.3","depends_on_id":"bb-29x.5","type":"blocks","created_at":"2026-02-11T20:10:13.1864837-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-29x.4","title":"Document operational runbook and boundary rationale","description":"Write architecture docs covering scanner policy, bd bridge behavior, and consistency guardrails for future maintainers.","acceptance_criteria":"Runbook documents startup, troubleshooting, and boundary rules.","status":"open","priority":2,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:19.1385778-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:19.1385778-08:00","labels":["docs","runbook"],"dependencies":[{"issue_id":"bb-29x.4","depends_on_id":"bb-29x","type":"parent-child","created_at":"2026-02-11T17:12:19.1402086-08:00","created_by":"zenchantlive"},{"issue_id":"bb-29x.4","depends_on_id":"bb-29x.2","type":"blocks","created_at":"2026-02-11T17:12:39.9591458-08:00","created_by":"zenchantlive"},{"issue_id":"bb-29x.4","depends_on_id":"bb-29x.5","type":"blocks","created_at":"2026-02-11T20:10:12.3474801-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-29x.5","title":"Epic Design Gate: scope, decisions, and acceptance contract","description":"Design/discovery gate for bb-29x before further implementation.\n\nMust capture:\n- Product intent and user outcomes for this epic\n- Explicit architecture decisions and tradeoffs\n- API/data contracts and edge cases\n- Windows-specific constraints and path/process assumptions\n- Test strategy and verification commands\n- Non-goals and out-of-scope boundaries\n\nCompletion rule:\nDo not start new implementation tasks in this epic until this gate is closed with agreed decisions.","acceptance_criteria":"A written execution-grade plan exists for this epic and all child task descriptions are updated with concrete implementation details, dependencies, and testable acceptance criteria.","status":"open","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T20:09:42.1507616-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T20:09:42.1507616-08:00","dependencies":[{"issue_id":"bb-29x.5","depends_on_id":"bb-29x","type":"parent-child","created_at":"2026-02-11T20:09:42.1525436-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-3pr","title":"Smoke test mutation lifecycle 2","description":"Temporary issue for API mutation smoke test","status":"closed","priority":1,"issue_type":"task","assignee":"zenchantlive","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T19:44:10.9737485-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T19:44:16.4912473-08:00","closed_at":"2026-02-11T19:44:16.4912473-08:00","close_reason":"Cleanup after API smoke test","labels":["api","smoke"],"comments":[{"id":1,"issue_id":"bb-3pr","author":"zenchantlive","text":"Smoke test comment via API route","created_at":"2026-02-12T03:44:13Z"},{"id":2,"issue_id":"bb-3pr","author":"zenchantlive","text":"Smoke test reopen","created_at":"2026-02-12T03:44:15Z"}]}
|
||||
{"id":"bb-6aj","title":"Project Registry and Multi-Project Scanner","description":"Deliver a Windows-first multi-project registry and discovery pipeline: persist project roots in the user profile, expose add/remove/list APIs, and scan safe roots to find .beads directories. Normalize all paths to stable identity keys and support aggregate views without full-drive traversal by default.","acceptance_criteria":"Projects can be added/removed/listed and discovered via scanner with deterministic normalization.","notes":"UI productization backlog added (2026-02-12): bb-6aj.6 design gate -\u003e bb-6aj.7 shared scope state -\u003e bb-6aj.8 project manager panel + bb-6aj.9 scanner UX + bb-6aj.10 scoped reads -\u003e bb-6aj.11 aggregate mode -\u003e bb-6aj.12 verification evidence. This sequence turns existing backend scanner/registry foundations into end-user multi-project workflows.\n2026-02-13 epic completion: UI productization chain complete (bb-6aj.6 -\u003e .7 -\u003e .8/.9/.10 -\u003e .11 -\u003e .12). Multi-project scope selection, registry manager, scanner discover/import, mode-aware reads, aggregate mode with project badges, and full verification evidence are now in place.","status":"closed","priority":0,"issue_type":"epic","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:47.7205517-08:00","created_by":"zenchantlive","updated_at":"2026-02-12T22:35:21.1595002-08:00","closed_at":"2026-02-12T22:35:21.1595002-08:00","close_reason":"multi-project-scanner-epic-complete","labels":["multi-project","scanner"],"dependencies":[{"issue_id":"bb-6aj","depends_on_id":"bb-92d","type":"blocks","created_at":"2026-02-11T17:12:19.6374139-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-6aj.1","title":"Persist project registry in %USERPROFILE%\\\\.beadboard\\\\projects.json","description":"Implement read/write management for registry file in user profile path, isolated from repository files and safe for local machine usage.","acceptance_criteria":"Registry file is created lazily and survives app restarts.","status":"closed","priority":0,"issue_type":"task","assignee":"agent-a","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:48.5403111-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:53:17.2085722-08:00","closed_at":"2026-02-11T17:53:17.2085722-08:00","close_reason":"Implemented %USERPROFILE%/.beadboard/projects.json registry persistence with Windows-safe normalization and dedupe.","labels":["config","registry"],"dependencies":[{"issue_id":"bb-6aj.1","depends_on_id":"bb-6aj","type":"parent-child","created_at":"2026-02-11T17:11:48.5419102-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-6aj.10","title":"Wire project-scoped reads into Kanban and Graph","description":"Connect selected project scope to data-loading paths for Kanban and Graph pages.\\n\\nScope:\\n- pass selected project root to read APIs\\n- ensure page refresh keeps selected scope\\n- keep existing single-project behavior as fallback\\n- preserve strict read/write boundary contracts","acceptance_criteria":"Kanban and Graph render data for the selected project scope and remain stable when switching projects.","notes":"2026-02-13 completed: rewired / and /graph server pages to resolve project scope from URL and load issues with selected root; implemented readIssuesForScope utility for mode-aware reads; preserved strict read-only boundaries (no direct JSONL writes).","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-12T21:41:42.9381588-08:00","created_by":"zenchantlive","updated_at":"2026-02-12T22:33:58.8681434-08:00","closed_at":"2026-02-12T22:33:58.8681434-08:00","close_reason":"project-scoped-reads-wired","labels":["graph","kanban","multi-project"],"dependencies":[{"issue_id":"bb-6aj.10","depends_on_id":"bb-6aj","type":"parent-child","created_at":"2026-02-12T21:41:42.9408199-08:00","created_by":"zenchantlive"},{"issue_id":"bb-6aj.10","depends_on_id":"bb-6aj.7","type":"blocks","created_at":"2026-02-12T21:41:42.9477322-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-6aj.11","title":"Aggregate mode (cross-project view) with clear project badges","description":"Add aggregate mode that combines issues across selected/registered projects for high-level supervision.\\n\\nScope:\\n- aggregate toggle in shared project controls\\n- stable project badge/context on cards and details\\n- deterministic ordering and project identity display\\n- avoid ambiguity between local and aggregated issue IDs","acceptance_criteria":"Users can switch between single-project and aggregate mode and always see which project each issue belongs to.","notes":"2026-02-13 completed: aggregate mode implemented via URL mode=aggregate and shared controls. Aggregate read path scopes IDs per project key to avoid collisions and preserves project context metadata. Added clear project badges in Kanban cards/details and Graph task cards/details; aggregate mode set read-only for edits/mutations.","status":"closed","priority":2,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-12T21:41:55.9490928-08:00","created_by":"zenchantlive","updated_at":"2026-02-12T22:34:25.0717745-08:00","closed_at":"2026-02-12T22:34:25.0717745-08:00","close_reason":"aggregate-mode-with-project-badges-shipped","labels":["aggregate","multi-project","ux"],"dependencies":[{"issue_id":"bb-6aj.11","depends_on_id":"bb-6aj","type":"parent-child","created_at":"2026-02-12T21:41:55.9506643-08:00","created_by":"zenchantlive"},{"issue_id":"bb-6aj.11","depends_on_id":"bb-6aj.10","type":"blocks","created_at":"2026-02-12T21:41:55.9554276-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-6aj.12","title":"Verification pass: multi-project UX, guards, and screenshots","description":"End-to-end verification for multi-project productization.\\n\\nMust include:\\n- typecheck + full test suite\\n- guard checks for no direct JSONL writes\\n- project switching smoke checks (kanban + graph)\\n- Playwright screenshots for desktop/tablet/mobile in single + aggregate modes\\n- bead notes with observed issues and fixes","acceptance_criteria":"Evidence bundle exists showing multi-project registry/scanner/project-scope behavior works across supported breakpoints without boundary regressions.","notes":"2026-02-13 verification evidence: npm run typecheck PASS; npm run test PASS (includes no-direct-jsonl-write and responsive guard suites). Playwright captures: artifacts/kanban-mobile-after.png, artifacts/kanban-tablet-after.png, artifacts/kanban-desktop-after.png, artifacts/kanban-mobile-aggregate.png, artifacts/kanban-tablet-aggregate.png, artifacts/kanban-desktop-aggregate.png, artifacts/graph-next-1440-single.png, artifacts/graph-next-768-single.png, artifacts/graph-next-390-overview-single.png, artifacts/graph-next-390-flow-single.png, artifacts/graph-next-1440-aggregate.png, artifacts/graph-next-768-aggregate.png, artifacts/graph-next-390-overview-aggregate.png, artifacts/graph-next-390-flow-aggregate.png.","status":"closed","priority":2,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-12T21:42:09.5711968-08:00","created_by":"zenchantlive","updated_at":"2026-02-12T22:34:52.5493875-08:00","closed_at":"2026-02-12T22:34:52.5493875-08:00","close_reason":"verification-evidence-bundle-complete","labels":["multi-project","qa","verification"],"dependencies":[{"issue_id":"bb-6aj.12","depends_on_id":"bb-6aj","type":"parent-child","created_at":"2026-02-12T21:42:09.5732507-08:00","created_by":"zenchantlive"},{"issue_id":"bb-6aj.12","depends_on_id":"bb-6aj.8","type":"blocks","created_at":"2026-02-12T21:42:09.5784936-08:00","created_by":"zenchantlive"},{"issue_id":"bb-6aj.12","depends_on_id":"bb-6aj.9","type":"blocks","created_at":"2026-02-12T21:42:09.5816386-08:00","created_by":"zenchantlive"},{"issue_id":"bb-6aj.12","depends_on_id":"bb-6aj.10","type":"blocks","created_at":"2026-02-12T21:42:09.5847707-08:00","created_by":"zenchantlive"},{"issue_id":"bb-6aj.12","depends_on_id":"bb-6aj.11","type":"blocks","created_at":"2026-02-12T21:42:09.5884105-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-6aj.2","title":"Implement registry API for add/remove/list operations","description":"Expose robust API endpoints with path validation and normalized identity checks to prevent duplicates.","acceptance_criteria":"API supports add, remove, list and returns clear validation errors.","status":"closed","priority":0,"issue_type":"task","assignee":"agent-a","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:49.3542564-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:53:23.9298353-08:00","closed_at":"2026-02-11T17:53:23.9298353-08:00","close_reason":"Implemented /api/projects GET/POST/DELETE with validation, normalization, and registry integration.","labels":["api","registry"],"dependencies":[{"issue_id":"bb-6aj.2","depends_on_id":"bb-6aj","type":"parent-child","created_at":"2026-02-11T17:11:49.3558158-08:00","created_by":"zenchantlive"},{"issue_id":"bb-6aj.2","depends_on_id":"bb-6aj.1","type":"blocks","created_at":"2026-02-11T17:12:26.7117348-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-6aj.3","title":"Build scanner with profile-root default and depth/ignore controls","description":"Scan %USERPROFILE% and user-defined roots for .beads directories with bounded recursion and ignore patterns to protect performance.","acceptance_criteria":"Scanner discovers projects without traversing entire drives by default.","status":"open","priority":0,"issue_type":"task","assignee":"agent-c","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:50.1925005-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:43:32.4095636-08:00","labels":["performance","scanner"],"dependencies":[{"issue_id":"bb-6aj.3","depends_on_id":"bb-6aj","type":"parent-child","created_at":"2026-02-11T17:11:50.1940841-08:00","created_by":"zenchantlive"},{"issue_id":"bb-6aj.3","depends_on_id":"bb-6aj.1","type":"blocks","created_at":"2026-02-11T17:12:27.2225981-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-6aj.3.1","title":"Add explicit full-drive scan mode for C:/D: by user action","description":"Provide an opt-in scan mode for full drive enumeration while retaining safe defaults and progress reporting expectations.","acceptance_criteria":"Full-drive scan is only activated explicitly, never by default startup logic.","status":"open","priority":2,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:51.0244174-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:11:51.0244174-08:00","labels":["optional","scanner"],"dependencies":[{"issue_id":"bb-6aj.3.1","depends_on_id":"bb-6aj.3","type":"parent-child","created_at":"2026-02-11T17:11:51.0259617-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-6aj.4","title":"Implement aggregate project issue context model","description":"Define normalized project identity payload attached to every issue for cross-project Kanban, timeline, and session views.","acceptance_criteria":"Aggregated read output always includes stable project metadata.","status":"in_progress","priority":1,"issue_type":"task","assignee":"zenchantlive","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:51.8518922-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T19:31:12.4614879-08:00","labels":["aggregation","data-model"],"dependencies":[{"issue_id":"bb-6aj.4","depends_on_id":"bb-6aj","type":"parent-child","created_at":"2026-02-11T17:11:51.8534893-08:00","created_by":"zenchantlive"},{"issue_id":"bb-6aj.4","depends_on_id":"bb-6aj.2","type":"blocks","created_at":"2026-02-11T17:12:27.7270195-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-6aj.3","title":"Build scanner with profile-root default and depth/ignore controls","description":"Implement a scanner that searches for .beads directories under %USERPROFILE% and any user-added roots. Enforce bounded recursion depth, ignore patterns (e.g., node_modules, .git, .next, dist, build), and de-duplicate results by normalized path. Return discovered project roots with source metadata and summary counts while avoiding drive-wide enumeration.","acceptance_criteria":"Scanner discovers projects without traversing entire drives by default.","status":"closed","priority":0,"issue_type":"task","assignee":"zenchantlive","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:50.1925005-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T20:47:56.2978358-08:00","closed_at":"2026-02-11T20:47:56.2978358-08:00","close_reason":"Implemented scanner + /api/scan with safe defaults and full-drive mode.","labels":["performance","scanner"],"dependencies":[{"issue_id":"bb-6aj.3","depends_on_id":"bb-6aj","type":"parent-child","created_at":"2026-02-11T17:11:50.1940841-08:00","created_by":"zenchantlive"},{"issue_id":"bb-6aj.3","depends_on_id":"bb-6aj.1","type":"blocks","created_at":"2026-02-11T17:12:27.2225981-08:00","created_by":"zenchantlive"},{"issue_id":"bb-6aj.3","depends_on_id":"bb-6aj.5","type":"blocks","created_at":"2026-02-11T20:10:09.155154-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-6aj.3.1","title":"Add explicit full-drive scan mode for C:/D: by user action","description":"Add an explicit opt-in scan mode that enumerates entire drives (C:\\ and D:\\) only when the user requests it. Provide progress feedback and guardrails so this mode never runs on startup or default scan paths, and clearly label it as potentially slow.","acceptance_criteria":"Full-drive scan is only activated explicitly, never by default startup logic.","status":"closed","priority":2,"issue_type":"task","assignee":"zenchantlive","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:51.0244174-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T20:42:04.4870337-08:00","closed_at":"2026-02-11T20:42:04.4870337-08:00","close_reason":"Added explicit full-drive scan mode gated by mode=full-drive.","labels":["optional","scanner"],"dependencies":[{"issue_id":"bb-6aj.3.1","depends_on_id":"bb-6aj.3","type":"parent-child","created_at":"2026-02-11T17:11:51.0259617-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-6aj.4","title":"Implement aggregate project issue context model","description":"Define normalized project identity payload attached to every issue for cross-project Kanban, timeline, and session views.","acceptance_criteria":"Aggregated read output always includes stable project metadata.","status":"closed","priority":1,"issue_type":"task","assignee":"zenchantlive","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:51.8518922-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T19:45:21.5826669-08:00","closed_at":"2026-02-11T19:45:21.5826669-08:00","close_reason":"Added project context model and attached to read issues.","labels":["aggregation","data-model"],"dependencies":[{"issue_id":"bb-6aj.4","depends_on_id":"bb-6aj","type":"parent-child","created_at":"2026-02-11T17:11:51.8534893-08:00","created_by":"zenchantlive"},{"issue_id":"bb-6aj.4","depends_on_id":"bb-6aj.2","type":"blocks","created_at":"2026-02-11T17:12:27.7270195-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-6aj.5","title":"Epic Design Gate: scope, decisions, and acceptance contract","description":"Design/discovery gate for bb-6aj before further implementation.\n\nMust capture:\n- Product intent and user outcomes for this epic\n- Explicit architecture decisions and tradeoffs\n- API/data contracts and edge cases\n- Windows-specific constraints and path/process assumptions\n- Test strategy and verification commands\n- Non-goals and out-of-scope boundaries\n\nCompletion rule:\nDo not start new implementation tasks in this epic until this gate is closed with agreed decisions.","design":"Intent: Provide Windows-native multi-project discovery using registry + scanner with safe defaults; never scan full drives unless explicitly requested.\n\nDecisions:\n- Scan roots: %USERPROFILE% + registry entries; optional full-drive mode adds C:\\ and D:\\ only when mode=full-drive.\n- Bounded recursion (default maxDepth=6) and ignore list to protect performance.\n- Normalize paths with canonicalizeWindowsPath/windowsPathKey; dedupe by key.\n- API contract: GET /api/scan?mode=default|full-drive\u0026depth=\u003cint\u003e returns { mode, roots, projects, stats }.\n\nEdge cases:\n- Missing/unreadable directories are skipped (ENOENT/ENOTDIR/EACCES/EPERM) without aborting scan.\n- Invalid mode/depth returns 400.\n\nWindows constraints:\n- Use drive-letter paths only; no Unix assumptions.\n\nTesting:\n- scanner.test.ts covers default roots, full-drive roots, ignore list, and depth limits.\n- npm test to verify.\n\nNon-goals:\n- No background watcher or SSE here.\n- No default full-drive scan.","acceptance_criteria":"A written execution-grade plan exists for this epic and all child task descriptions are updated with concrete implementation details, dependencies, and testable acceptance criteria.","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T20:09:37.50785-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T20:47:55.9830645-08:00","closed_at":"2026-02-11T20:47:55.9830645-08:00","close_reason":"Captured scanner design/contract and verification plan.","dependencies":[{"issue_id":"bb-6aj.5","depends_on_id":"bb-6aj","type":"parent-child","created_at":"2026-02-11T20:09:37.509509-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-6aj.6","title":"UI multi-project design gate and execution contract","description":"Define the concrete UI productization plan for the existing registry/scanner backend. Lock interaction model, data flow boundaries, and sequencing before implementation churn.\\n\\nMust define:\\n- primary workflow (select project, manage registry, scan/import)\\n- screen ownership (kanban, graph, shared controls)\\n- URL/query state for project scope\\n- aggregate-mode behavior and constraints\\n- error states and empty states\\n- verification matrix (typecheck/tests/Playwright)","acceptance_criteria":"Written implementation-ready contract exists with explicit child task sequencing and no ambiguity on project-scoping behavior.","notes":"2026-02-13 contract completed: docs/plans/2026-02-13-multi-project-ui-contract.md. Locked workflow, screen ownership, URL key semantics (project query), fallback/error states, aggregate-mode constraints, and phased execution/verification matrix.","status":"closed","priority":1,"issue_type":"task","assignee":"zenchantlive","owner":"jordanlive121@gmail.com","created_at":"2026-02-12T21:40:46.3161508-08:00","created_by":"zenchantlive","updated_at":"2026-02-12T22:00:06.0043328-08:00","closed_at":"2026-02-12T22:00:06.0043328-08:00","close_reason":"design-contract-complete","labels":["multi-project","planning","ui"],"dependencies":[{"issue_id":"bb-6aj.6","depends_on_id":"bb-6aj","type":"parent-child","created_at":"2026-02-12T21:40:46.3177321-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-6aj.7","title":"Shared project scope store + URL persistence","description":"Implement shared client-side project scope state consumed by Kanban and Graph.\\n\\nScope:\\n- selectedProjectKey, mode(single|aggregate), and source metadata\\n- URL persistence (e.g. ?project=\u003ckey\u003e\u0026mode=single|aggregate)\\n- hydration from URL on load and safe fallback when missing/invalid\\n- no JSONL writes; read boundaries preserved","acceptance_criteria":"Project scope can be selected, persisted in URL, restored on refresh, and consumed consistently by both pages.","notes":"2026-02-13 partial implementation complete: added shared scope resolver (src/lib/project-scope.ts) with deterministic local/registry key resolution + fallback; added tests (tests/lib/project-scope.test.ts); wired / and /graph server pages to hydrate from ?project= and read issues from resolved project root; preserved scope in Kanban\u003c-\u003eGraph links and rendered active scope badge in both headers. Remaining for full AC: explicit interactive scope selector/store and mode(single|aggregate) URL state.\n2026-02-13 completed: added mode-aware scope resolver (single|aggregate) with URL hydration/fallback in src/lib/project-scope.ts; added tests in tests/lib/project-scope.test.ts; implemented shared ProjectScopeControls UI used on Kanban + Graph for selecting project key and mode with URL persistence; preserved scoped cross-page links.","status":"closed","priority":1,"issue_type":"task","assignee":"zenchantlive","owner":"jordanlive121@gmail.com","created_at":"2026-02-12T21:41:00.7974464-08:00","created_by":"zenchantlive","updated_at":"2026-02-12T22:32:27.6431192-08:00","closed_at":"2026-02-12T22:32:27.6431192-08:00","close_reason":"scope-state-url-persistence-complete","labels":["multi-project","state","ui"],"dependencies":[{"issue_id":"bb-6aj.7","depends_on_id":"bb-6aj","type":"parent-child","created_at":"2026-02-12T21:41:00.7992088-08:00","created_by":"zenchantlive"},{"issue_id":"bb-6aj.7","depends_on_id":"bb-6aj.6","type":"blocks","created_at":"2026-02-12T21:41:00.804019-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-6aj.8","title":"Project manager panel (list/add/remove registry roots)","description":"Build a user-facing project manager panel backed by /api/projects.\\n\\nFeatures:\\n- list registered projects with normalized display path\\n- add project path with validation feedback\\n- remove project with confirm affordance\\n- clearly indicate current selected project\\n- mobile-safe layout and keyboard accessibility","acceptance_criteria":"Users can manage registry projects entirely from UI and immediately use newly added project roots in scope selection.","notes":"2026-02-13 completed: implemented registry manager panel in shared ProjectScopeControls component with list/add/remove flows backed by /api/projects; includes validation/error messaging, active-scope indication, and mobile-safe controls. Integrated into src/components/kanban/kanban-page.tsx and src/components/graph/dependency-graph-page.tsx.","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-12T21:41:13.2668167-08:00","created_by":"zenchantlive","updated_at":"2026-02-12T22:33:03.9808623-08:00","closed_at":"2026-02-12T22:33:03.9808623-08:00","close_reason":"project-manager-panel-shipped","labels":["multi-project","registry","ui"],"dependencies":[{"issue_id":"bb-6aj.8","depends_on_id":"bb-6aj","type":"parent-child","created_at":"2026-02-12T21:41:13.2688831-08:00","created_by":"zenchantlive"},{"issue_id":"bb-6aj.8","depends_on_id":"bb-6aj.7","type":"blocks","created_at":"2026-02-12T21:41:13.2730616-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-6aj.9","title":"Scanner UX (discover/import projects from safe roots)","description":"Expose scanner workflow in UI using /api/scan.\\n\\nFeatures:\\n- run default scan (profile + registry roots)\\n- optional full-drive scan behind explicit advanced control\\n- show discovered roots with source metadata and deduped list\\n- one-click import selected discoveries to registry\\n- loading/timeout/error states with plain-language messaging","acceptance_criteria":"Users can discover projects via scanner and import them into registry without leaving the app.","notes":"2026-02-13 completed: scanner UX added to ProjectScopeControls using /api/scan with mode controls (safe roots/full-drive), scan stats, deduped discovery list, and one-click import to registry via /api/projects POST. Loading/error states surfaced in-panel.\n2026-02-13 post-close hardening: scanner now requires .beads/issues.jsonl or .beads/issues.jsonl.new and adds deny rules for tool/cache/worktree noise (directory names: .agents/.kimi/.gemini/.zenflow/worktrees/appdata + name prefixes beadboard-read-, beadboard-watch-, skills- + go/pkg/mod fragment). Added regression coverage in tests/lib/scanner.test.ts.","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-12T21:41:29.9411271-08:00","created_by":"zenchantlive","updated_at":"2026-02-12T23:17:32.5251756-08:00","closed_at":"2026-02-12T22:33:32.8319572-08:00","close_reason":"scanner-discover-import-ux-shipped","labels":["multi-project","scanner","ui"],"dependencies":[{"issue_id":"bb-6aj.9","depends_on_id":"bb-6aj","type":"parent-child","created_at":"2026-02-12T21:41:29.9432224-08:00","created_by":"zenchantlive"},{"issue_id":"bb-6aj.9","depends_on_id":"bb-6aj.7","type":"blocks","created_at":"2026-02-12T21:41:29.9479575-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-92d","title":"Foundation and Read/Write Boundary","description":"Establish the Windows-native Next.js foundation, canonical Beads schema handling, and strict data boundaries: read from JSONL, write only via bd.exe. This epic defines the non-negotiable invariants that all later work must preserve.","acceptance_criteria":"App boots on Windows, schema/parser contracts exist, and no direct issues.jsonl write path exists in code.","status":"closed","priority":0,"issue_type":"epic","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:41.0756295-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:28:27.8108066-08:00","closed_at":"2026-02-11T17:28:27.8108066-08:00","close_reason":"Completed foundation milestone: bootstrap, licensing/docs, schema contracts, parser, windows path normalization, and write-boundary guardrails.","labels":["beadboard","foundation","windows"]}
|
||||
{"id":"bb-92d.1","title":"Bootstrap Next.js 15 + React 19 + TypeScript strict","description":"Initialize project scaffold with strict TypeScript, App Router baseline, and repeatable scripts for lint/typecheck/test in PowerShell.","acceptance_criteria":"npm install and dev startup work on Windows; strict type checking enabled.","status":"closed","priority":0,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:41.9363647-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:23:14.0089901-08:00","closed_at":"2026-02-11T17:23:14.0089901-08:00","close_reason":"Bootstrapped Next.js 15 + React 19 + strict TypeScript; install/typecheck/dev startup verified on Windows.","labels":["foundation","nextjs"],"dependencies":[{"issue_id":"bb-92d.1","depends_on_id":"bb-92d","type":"parent-child","created_at":"2026-02-11T17:11:41.9379355-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-92d.2","title":"Add MIT license and baseline repository docs","description":"Add LICENSE and baseline docs that state Windows-native support, read/write boundaries, and required runtime dependencies.","acceptance_criteria":"MIT license present and docs describe core architecture constraints.","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:42.7699961-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:23:50.7519159-08:00","closed_at":"2026-02-11T17:23:50.7519159-08:00","close_reason":"Added MIT license and baseline repository documentation with architecture boundary rules.","labels":["docs","license"],"dependencies":[{"issue_id":"bb-92d.2","depends_on_id":"bb-92d","type":"parent-child","created_at":"2026-02-11T17:11:42.7715653-08:00","created_by":"zenchantlive"}]}
|
||||
|
|
@ -18,37 +45,52 @@
|
|||
{"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"}]}
|
||||
{"id":"bb-bc4.3","title":"Redesign tokenized theme and visual hierarchy","description":"Upgrade visual system quality using semantic tokens for surface/text/status/priority states, stronger typography hierarchy, and improved contrast. Move away from flat/basic palette while preserving clarity and performance.","acceptance_criteria":"UI theme shows clear hierarchy and contrast, aligns with premium demo quality expectations, and remains consistent across components.","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T18:50:44.8548956-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T18:59:19.0348391-08:00","closed_at":"2026-02-11T18:59:19.0348391-08:00","close_reason":"Redesigned semantic tokens/theme contrast and hierarchy to improve production visual quality.","labels":["design-system","theme","tokens"],"dependencies":[{"issue_id":"bb-bc4.3","depends_on_id":"bb-bc4","type":"parent-child","created_at":"2026-02-11T18:50:44.8564376-08:00","created_by":"zenchantlive"},{"issue_id":"bb-bc4.3","depends_on_id":"bb-bc4.1","type":"blocks","created_at":"2026-02-11T18:50:44.8606805-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-bc4.4","title":"Implement mobile/tablet detail panel interaction model","description":"Adapt detail panel behavior for small screens (overlay or drawer model) with safe viewport sizing, accessible dismissal, and non-destructive navigation. Desktop retains efficient side-panel behavior.","acceptance_criteria":"Detail view is usable on mobile/tablet and does not trap or obscure board interaction irrecoverably.","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T18:50:45.8342573-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T18:59:19.8911935-08:00","closed_at":"2026-02-11T18:59:19.8911935-08:00","close_reason":"Implemented mobile detail overlay flow while preserving desktop sticky side-detail behavior.","labels":["detail-panel","mobile","ux"],"dependencies":[{"issue_id":"bb-bc4.4","depends_on_id":"bb-bc4","type":"parent-child","created_at":"2026-02-11T18:50:45.8360334-08:00","created_by":"zenchantlive"},{"issue_id":"bb-bc4.4","depends_on_id":"bb-bc4.2","type":"blocks","created_at":"2026-02-11T18:51:10.0929812-08:00","created_by":"zenchantlive"},{"issue_id":"bb-bc4.4","depends_on_id":"bb-bc4.3","type":"blocks","created_at":"2026-02-11T18:51:10.9352149-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-bc4.5","title":"Playwright multi-breakpoint visual verification","description":"Capture and review before/after screenshots at 390x844, 768x1024, and 1440x900 to validate reachability, clipping, control usability, and detail-panel behavior. Store artifacts under artifacts/ with explicit naming conventions.","acceptance_criteria":"Required six screenshots exist (before/after x 3 breakpoints) and observations confirm responsive/visual acceptance criteria.","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T18:50:47.0018379-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T18:59:20.7427588-08:00","closed_at":"2026-02-11T18:59:20.7427588-08:00","close_reason":"Captured required Playwright before/after screenshots at mobile/tablet/desktop and validated layout usability.","labels":["playwright","verification","visual"],"dependencies":[{"issue_id":"bb-bc4.5","depends_on_id":"bb-bc4","type":"parent-child","created_at":"2026-02-11T18:50:47.0034039-08:00","created_by":"zenchantlive"},{"issue_id":"bb-bc4.5","depends_on_id":"bb-bc4.4","type":"blocks","created_at":"2026-02-11T18:51:11.7817934-08:00","created_by":"zenchantlive"},{"issue_id":"bb-bc4.5","depends_on_id":"bb-bc4.3","type":"blocks","created_at":"2026-02-11T18:51:12.6236762-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-bvn","title":"Dependency Graph (React Flow)","description":"Visualize issue relationships and blocked chains through an interactive graph backed by parsed dependency edges.","acceptance_criteria":"Graph renders dependencies correctly and supports navigation to issue details.","status":"open","priority":2,"issue_type":"epic","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:09.2057278-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:09.2057278-08:00","labels":["graph","react-flow"],"dependencies":[{"issue_id":"bb-bvn","depends_on_id":"bb-trz","type":"blocks","created_at":"2026-02-11T17:12:22.6642419-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-bvn.1","title":"Parse dependency edges and build adjacency structures","description":"Extract edges for blocks, parent, relates_to, duplicates, and supersedes to support graph rendering and analysis.","acceptance_criteria":"Adjacency output is complete and consistent for all supported edge types.","status":"open","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:10.0434044-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:10.0434044-08:00","labels":["graph","parser"],"dependencies":[{"issue_id":"bb-bvn.1","depends_on_id":"bb-bvn","type":"parent-child","created_at":"2026-02-11T17:12:10.0449367-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-bvn.2","title":"Implement React Flow graph view with pan/zoom/select interactions","description":"Render nodes and edges with interactive navigation and issue selection integration.","acceptance_criteria":"Users can pan, zoom, and select nodes to inspect linked issue context.","status":"open","priority":2,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:10.8683725-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:10.8683725-08:00","labels":["graph","ui"],"dependencies":[{"issue_id":"bb-bvn.2","depends_on_id":"bb-bvn","type":"parent-child","created_at":"2026-02-11T17:12:10.8694189-08:00","created_by":"zenchantlive"},{"issue_id":"bb-bvn.2","depends_on_id":"bb-bvn.1","type":"blocks","created_at":"2026-02-11T17:12:36.8736785-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-bvn.3","title":"Add blocked-chain highlighting and cycle anomaly signaling","description":"Improve graph decision support by emphasizing blocked paths and flagging unexpected cycle conditions.","acceptance_criteria":"Blocked paths and cycle warnings are visible and actionable.","status":"open","priority":2,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:11.687878-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:11.687878-08:00","labels":["analysis","graph"],"dependencies":[{"issue_id":"bb-bvn.3","depends_on_id":"bb-bvn","type":"parent-child","created_at":"2026-02-11T17:12:11.6890831-08:00","created_by":"zenchantlive"},{"issue_id":"bb-bvn.3","depends_on_id":"bb-bvn.2","type":"blocks","created_at":"2026-02-11T17:12:37.378326-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-tpc","title":"Live File Watching and SSE Transport","description":"Deliver real-time dashboard updates by watching Beads issue files and streaming one-way change notifications via SSE.","acceptance_criteria":"File changes trigger UI refresh without manual reload and reconnect behavior is stable.","status":"open","priority":0,"issue_type":"epic","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:52.6737283-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:11:52.6737283-08:00","labels":["realtime","sse","watcher"],"dependencies":[{"issue_id":"bb-tpc","depends_on_id":"bb-6aj","type":"blocks","created_at":"2026-02-11T17:12:20.1444149-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-tpc.1","title":"Implement chokidar watch manager for registered projects","description":"Start/stop watchers per active project and ensure watcher lifecycle tracks registry changes without leaking handles.","acceptance_criteria":"Watcher list updates correctly when projects are added or removed.","status":"open","priority":0,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:53.5050717-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:11:53.5050717-08:00","labels":["chokidar","watcher"],"dependencies":[{"issue_id":"bb-tpc.1","depends_on_id":"bb-tpc","type":"parent-child","created_at":"2026-02-11T17:11:53.5071586-08:00","created_by":"zenchantlive"},{"issue_id":"bb-tpc.1","depends_on_id":"bb-6aj.2","type":"blocks","created_at":"2026-02-11T17:12:28.2304516-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-tpc.2","title":"Add debounce/coalescing and transient lock handling for file change bursts","description":"Coalesce rapid updates from agent activity and handle temporary read lock contention without surfacing noisy errors.","acceptance_criteria":"Burst writes produce stable event cadence and no hard failures from temporary locks.","status":"open","priority":0,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:54.315119-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:11:54.315119-08:00","labels":["stability","watcher"],"dependencies":[{"issue_id":"bb-tpc.2","depends_on_id":"bb-tpc","type":"parent-child","created_at":"2026-02-11T17:11:54.3172104-08:00","created_by":"zenchantlive"},{"issue_id":"bb-tpc.2","depends_on_id":"bb-tpc.1","type":"blocks","created_at":"2026-02-11T17:12:28.7308524-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-tpc.3","title":"Implement SSE events API endpoint with heartbeat and event IDs","description":"Create SSE route supporting keepalive heartbeats and resumable event consumption patterns for browser clients.","acceptance_criteria":"SSE stream remains alive and clients can reconnect automatically.","status":"open","priority":0,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:55.1518352-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:11:55.1518352-08:00","labels":["api","sse"],"dependencies":[{"issue_id":"bb-tpc.3","depends_on_id":"bb-tpc","type":"parent-child","created_at":"2026-02-11T17:11:55.1533991-08:00","created_by":"zenchantlive"},{"issue_id":"bb-tpc.3","depends_on_id":"bb-tpc.2","type":"blocks","created_at":"2026-02-11T17:12:29.2599782-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-tpc.4","title":"Build frontend SSE client with scoped React Query invalidation","description":"Consume server events and invalidate only affected query keys, limiting unnecessary re-fetches in multi-project mode.","acceptance_criteria":"Changed project views refresh while unrelated views remain stable.","status":"open","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:56.0008015-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:11:56.0008015-08:00","labels":["frontend","react-query"],"dependencies":[{"issue_id":"bb-tpc.4","depends_on_id":"bb-tpc","type":"parent-child","created_at":"2026-02-11T17:11:56.0024218-08:00","created_by":"zenchantlive"},{"issue_id":"bb-tpc.4","depends_on_id":"bb-tpc.3","type":"blocks","created_at":"2026-02-11T17:12:29.768818-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-trz","title":"Kanban Experience (Baseline Dashboard)","description":"Ship a production-ready Kanban baseline inspired by prototype behavior but backed by real Beads project data and strict typing.","acceptance_criteria":"Users can inspect and filter live Beads issues through stable Kanban workflows.","status":"closed","priority":1,"issue_type":"epic","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:56.8115491-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:56:51.4226568-08:00","closed_at":"2026-02-11T17:56:51.4226568-08:00","close_reason":"Kanban epic complete for tracer bullet 1","labels":["kanban","ui"],"dependencies":[{"issue_id":"bb-trz","depends_on_id":"bb-92d","type":"blocks","created_at":"2026-02-11T17:12:20.6480287-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-bq6","title":"Smoke test mutation lifecycle","description":"Temporary issue for API mutation smoke test","status":"open","priority":3,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T19:43:52.1686473-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T20:40:02.1018374-08:00","labels":["api","smoke"],"comments":[{"id":4,"issue_id":"bb-bq6","author":"zenchantlive","text":"UI visibility test complete: reopening","created_at":"2026-02-12T04:40:02Z"}]}
|
||||
{"id":"bb-bvn","title":"Dependency Graph (React Flow)","description":"Deliver an epic-first dependency workspace that is readable at a glance: 1) pick epic, 2) pick task, 3) understand blockers and downstream impact, 4) inspect details. Prioritize visual hierarchy, dependency clarity, bounded graph behavior, and mobile-first usability over graph complexity.","acceptance_criteria":"Workflow is linear and obvious on desktop/mobile; dependency meaning is explicit in both flow list and graph; graph remains bounded with no bleed/overlap; flow/details sections never clip and are independently scrollable; screenshots and full verification remain green.","notes":"Product baseline locked (2026-02-12): Graph UX will use React Flow with deterministic DAG layout (no chaotic freeform). Default depth is 2 hops from selected issue with controls for 1 hop / 2 hops / full. Mobile uses simplified dependency focus view (selected + immediate blockers/dependents); desktop/tablet uses full graph workspace.\nExecution order set 2026-02-12: bb-bvn is the active next epic and should be finished to UX acceptance before timeline/session epics.","status":"closed","priority":0,"issue_type":"epic","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:09.2057278-08:00","created_by":"zenchantlive","updated_at":"2026-02-12T18:57:49.5827802-08:00","closed_at":"2026-02-12T18:57:49.5827802-08:00","close_reason":"All child tasks complete (bb-bvn.1/.2/.3/.4), dependency graph workflow implemented and verified across tests and visual artifacts.","labels":["graph","react-flow"],"dependencies":[{"issue_id":"bb-bvn","depends_on_id":"bb-trz","type":"blocks","created_at":"2026-02-11T17:12:22.6642419-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-bvn.1","title":"Parse dependency edges and build adjacency structures","description":"Build graph data preparation pipeline for dependency workspace.\n\nScope:\n- Input: parsed Bead issues from read layer only (`readIssuesFromDisk`).\n- Build normalized node map keyed by issue id.\n- Build typed edge list from `dependencies[]` supporting: blocks, parent, relates_to, duplicates, supersedes.\n- Include reverse index (incoming/outgoing) to support focus queries.\n- Preserve issue metadata needed by UI nodes: id, title, status, priority, issue_type, assignee, updated_at.\n\nRules:\n- Ignore dependency edges that point to missing issue IDs but record count for diagnostics.\n- Deduplicate duplicate edges (same source, target, type).\n- Treat path/project context as explicit API argument for future multi-project support.\n- Do not mutate source issues.\n\nOutput contracts:\n- `GraphModel = { nodes, edges, adjacency, diagnostics }`\n- `adjacency` includes incoming/outgoing arrays per node.\n- `diagnostics` includes counts for missing targets and dropped duplicates.\n\nTest plan:\n- Unit tests for edge extraction across all supported types.\n- Unit tests for dedupe and missing-target behavior.\n- Unit tests for adjacency correctness and deterministic ordering.\r\n","acceptance_criteria":"- Graph model contains all valid nodes and typed edges from issue dependencies.\n- Duplicate edges are removed deterministically.\n- Missing-target edges do not crash model generation and are surfaced in diagnostics.\n- Adjacency maps are correct for incoming/outgoing lookups.\n- Unit tests cover all supported dependency types and edge cases.\r\n","notes":"Implemented src/lib/graph.ts GraphModel builder with deterministic node/edge ordering, supported edge-type filtering (blocks/parent/relates_to/duplicates/supersedes), duplicate-edge suppression, missing-target diagnostics, and adjacency incoming/outgoing indexes. Added tests/lib/graph.test.ts covering extraction, dedupe, unsupported/missing handling, and adjacency correctness. Updated package.json test chain to include graph tests.","status":"closed","priority":1,"issue_type":"task","assignee":"zenchantlive","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:10.0434044-08:00","created_by":"zenchantlive","updated_at":"2026-02-12T09:10:52.6262123-08:00","closed_at":"2026-02-12T09:10:52.6262123-08:00","close_reason":"Completed graph model preparation pipeline with deterministic contracts and full unit coverage; ready for React Flow rendering task bb-bvn.2.","labels":["graph","parser"],"dependencies":[{"issue_id":"bb-bvn.1","depends_on_id":"bb-bvn","type":"parent-child","created_at":"2026-02-11T17:12:10.0449367-08:00","created_by":"zenchantlive"},{"issue_id":"bb-bvn.1","depends_on_id":"bb-bvn.4","type":"blocks","created_at":"2026-02-11T20:10:02.7644711-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-bvn.2","title":"Implement React Flow graph view with pan/zoom/select interactions","description":"Implement deterministic React Flow graph UI (non-chaotic workspace mode).\n\nScope:\n- New graph page/view with React Flow canvas.\n- Deterministic auto-layout (DAG style) for stable mental model:\n - selected node centered in focus mode\n - upstream blockers left, downstream dependents right\n- Use card-like nodes (not bubbles) with minimal status accent.\n- Edge styling by dependency type:\n - blocks: solid\n - parent: thicker muted\n - relates_to: dashed\n - duplicates/supersedes: distinct but subtle styles\n\nInteraction:\n- Click node opens shared detail panel.\n- Controls: hop depth switch (1/2/full), collapse closed, fit-to-selection.\n- Disable freeform drag by default to avoid n8n-like chaos (optional manual toggle can be deferred).\n\nResponsive behavior:\n- Desktop/tablet: full canvas + detail panel split.\n- Mobile: simplified dependency focus mode (selected + immediate blockers/dependents list) instead of dense full canvas.\n\nIntegration:\n- Read-only against graph model from bb-bvn.1.\n- No writeback from graph lane.\n\nTest/verification:\n- Component tests for control toggles and selected-node behavior.\n- Guard test for responsive fallback contract.\n- Playwright screenshots: mobile/tablet/desktop graph view.\r\n","acceptance_criteria":"- Graph renders with deterministic layout and typed edges.\n- Default depth is 2 hops with controls for 1/2/full.\n- Node selection opens detail panel and fit-to-selection works.\n- Mobile shows simplified focus view (no unusable dense canvas).\n- Visual verification screenshots captured for mobile/tablet/desktop.\r\n","notes":"Full visual buff and relationship clarity pass complete. 1) Implemented modern aurora surface theme with refined typography and rhythm. 2) Fixed invisible relationship lines by increasing edge contrast, width, and adding animations for 'blocks' paths. 3) Refined layout to ensure 'Dependency Flow' is fully scrollable and correctly prioritized. 4) Improved mobile UX with a simplified overview and toggleable graph view. 5) Implemented groundwork for bb-bvn.3 (analyzeBlockedChain, detectDependencyCycles) to satisfy tests. Verified via npm run test, typecheck, and captured screenshots in artifacts/.","status":"closed","priority":1,"issue_type":"task","assignee":"zenchantlive","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:10.8683725-08:00","created_by":"zenchantlive","updated_at":"2026-02-12T18:57:24.4716865-08:00","closed_at":"2026-02-12T18:57:24.4716865-08:00","close_reason":"Implemented React Flow graph workspace with deterministic layout, interaction controls, responsive fallback, and visual verification artifacts; tests/typecheck are green.","labels":["graph","ui"],"dependencies":[{"issue_id":"bb-bvn.2","depends_on_id":"bb-bvn","type":"parent-child","created_at":"2026-02-11T17:12:10.8694189-08:00","created_by":"zenchantlive"},{"issue_id":"bb-bvn.2","depends_on_id":"bb-bvn.1","type":"blocks","created_at":"2026-02-11T17:12:36.8736785-08:00","created_by":"zenchantlive"},{"issue_id":"bb-bvn.2","depends_on_id":"bb-bvn.4","type":"blocks","created_at":"2026-02-11T20:10:04.4783802-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-bvn.3","title":"Add blocked-chain highlighting and cycle anomaly signaling","description":"Add analysis overlays for blocker triage and anomaly visibility.\n\nScope:\n- Compute and highlight blocked chains from selected node.\n- Show concise blocker summary:\n - open blocker count\n - in-progress blocker count\n - first actionable blocker\n- Cycle/anomaly signaling:\n - detect cycles in dependency graph\n - mark involved nodes/edges with warning style and explanation text\n\nUI behavior:\n- \"Show blocking path only\" toggle to reduce noise.\n- Hovering a node/edge highlights direct dependency chain.\n- Keep styling subtle and readable; avoid visual overload.\n\nRules:\n- Analysis is read-only and derived from current graph model.\n- Must not fail hard on malformed dependency data; degrade with warnings.\n\nTest plan:\n- Unit tests for blocked-chain derivation and cycle detection logic.\n- UI tests for toggle behavior and warning visibility.\n- Screenshot verification for normal and anomaly cases.\r\n","acceptance_criteria":"- Selected issue can display clear blocked-chain context.\n- Cycle/anomaly conditions are detected and visibly flagged.\n- Blocking-path-only mode materially reduces graph noise.\n- Analysis features remain performant and do not break base graph rendering.\n- Tests and screenshots verify normal + anomaly paths.\r\n","notes":"Addressed review P1 in detectDependencyCycles: removed early-return DFS behavior that leaked recStack/path state; traversal now always unwinds and collects cycles without contaminating predecessor nodes. Added regression test in tests/lib/graph-view.test.ts: detectDependencyCycles does not mark non-cycle predecessor as cyclic. Verification: node --import tsx --test tests/lib/graph-view.test.ts (pass), npm run typecheck (pass), npm run test (pass).","status":"closed","priority":2,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:11.687878-08:00","created_by":"zenchantlive","updated_at":"2026-02-12T18:57:24.8694169-08:00","closed_at":"2026-02-12T18:57:24.8694169-08:00","close_reason":"Implemented blocked-chain analysis, blocking-path emphasis, and cycle anomaly signaling with regression coverage; tests/typecheck are green.","labels":["analysis","graph"],"dependencies":[{"issue_id":"bb-bvn.3","depends_on_id":"bb-bvn","type":"parent-child","created_at":"2026-02-11T17:12:11.6890831-08:00","created_by":"zenchantlive"},{"issue_id":"bb-bvn.3","depends_on_id":"bb-bvn.2","type":"blocks","created_at":"2026-02-11T17:12:37.378326-08:00","created_by":"zenchantlive"},{"issue_id":"bb-bvn.3","depends_on_id":"bb-bvn.4","type":"blocks","created_at":"2026-02-11T20:10:03.6326727-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-bvn.4","title":"Epic Design Gate: scope, decisions, and acceptance contract","description":"Design/discovery gate for bb-bvn before further implementation.\n\nMust capture:\n- Product intent and user outcomes for this epic\n- Explicit architecture decisions and tradeoffs\n- API/data contracts and edge cases\n- Windows-specific constraints and path/process assumptions\n- Test strategy and verification commands\n- Non-goals and out-of-scope boundaries\n\nCompletion rule:\nDo not start new implementation tasks in this epic until this gate is closed with agreed decisions.","acceptance_criteria":"A written execution-grade plan exists for this epic and all child task descriptions are updated with concrete implementation details, dependencies, and testable acceptance criteria.","notes":"Graph design gate completed: agreed React Flow deterministic UX, default 2-hop depth controls, mobile simplified fallback, typed edge semantics, and verification contract (tests + screenshots + smoke). Child tasks bb-bvn.1/.2/.3 updated with execution-grade details.","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T20:09:40.290642-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T20:59:12.4823711-08:00","closed_at":"2026-02-11T20:59:12.4823711-08:00","close_reason":"Design gate complete: bb-bvn child tasks now contain concrete scope, contracts, dependencies, and testable acceptance criteria.","dependencies":[{"issue_id":"bb-bvn.4","depends_on_id":"bb-bvn","type":"parent-child","created_at":"2026-02-11T20:09:40.2922349-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-dcv","title":"Agent Communication \u0026 Coordination Patterns","description":"Agents need a standardized way to coordinate (handoffs, help requests, blockers) without breaking flow. We are opting for a **Issue-Centric** communication model (using Comments) rather than an Inbox-Centric model. This epic defines the protocols, patterns, and tool support needed to make that robust.\n\nGoals:\n- Define 'Protocol' for agent-to-agent comments (prefixes, structure).\n- Establish Identity standards (how agents refer to themselves).\n- Ensure CLI support for all protocol actions (commenting, signaling).\n\nDeliverables:\n- RFC-001: Agent Coordination Protocol.\n- Skill: beadboard-driver (teaching the protocol).\n\nThis epic blocks bb-u6f (Agent Sessions) because session attribution relies on the Identity standards defined here.","status":"open","priority":1,"issue_type":"epic","owner":"jordanlive121@gmail.com","created_at":"2026-02-12T21:35:07.1826787-08:00","created_by":"zenchantlive","updated_at":"2026-02-12T21:36:56.4424829-08:00"}
|
||||
{"id":"bb-dcv.1","title":"Research \u0026 RFC: Agent Skills and Handoff Protocols","description":"Conduct research and draft a 'Request for Comments' (RFC) document that defines the standard operating procedures for agent-to-agent interaction within Beadboard.\n\nKey Questions to Answer:\n1. Identity: How do we consistently identify an agent? (e.g. assignee formats).\n2. Handoff Protocol: Structure of a handoff comment (e.g. [HANDOFF]).\n3. Blocker Signaling: How to raise a flag (e.g. [BLOCKED]).\n4. Parsing: Can/should we have bd parse-comments?\n\nDeliverables:\n- docs/RFC-001-Agent-Coordination.md: Finalized spec.\n- skills/beadboard-driver/SKILL.md (Draft): Prototype skill.\n- Gap Analysis: Missing CLI commands.\n\nAcceptance Criteria:\n- RFC document created and committed.\n- Protocol covers: Identity, Handoff, Blocker, Assignment.\n- Gap analysis lists required code changes.","status":"open","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-12T21:37:32.9086915-08:00","created_by":"zenchantlive","updated_at":"2026-02-12T21:37:32.9086915-08:00","dependencies":[{"issue_id":"bb-dcv.1","depends_on_id":"bb-dcv","type":"parent-child","created_at":"2026-02-12T21:37:32.9107758-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-n7p","title":"Swimlane status model: ready + dependency-derived blocked","notes":"Implemented new swimlane model: removed deferred lane from board usage; added ready lane and dependency-derived blocked lane. Lane rules: closed-\u003eDone; blocked-\u003eBlocked if explicit status blocked OR has active incoming blocker edge; in_progress/review-\u003eIn Progress; otherwise Ready. Added laneToMutationStatus to map board lane writes to bead statuses (ready-\u003eopen). Updated board labels/colors, drag-drop lane source tracking, and controls stat label Open-\u003eReady. TDD: updated tests/lib/kanban.test.ts for ready/blocked semantics. Verification: node --import tsx --test tests/lib/kanban.test.ts (pass), npm run typecheck (pass), npm run test (pass).","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-12T17:55:04.1851993-08:00","created_by":"zenchantlive","updated_at":"2026-02-12T18:40:08.0620089-08:00","closed_at":"2026-02-12T18:40:08.0620089-08:00","close_reason":"Implemented ready/blocked swimlane model, blocked-tree deep links to lane focus, and verification passed (kanban tests, typecheck, full test suite).","labels":["kanban","status","swimlane"]}
|
||||
{"id":"bb-q1s","title":"UI Bead Editing Across Kanban + Graph","description":"Objective:\nAdd true UI editing for bead fields across both detail panels (Kanban + Graph) using one shared edit core so behavior stays consistent.\n\nWhy:\nWrite-back infrastructure exists, but users currently cannot edit bead content from UI detail panels.\n\nScope:\n- Shared edit validation + mutation adapter.\n- Reusable editor UI block for issue fields.\n- Integration into both Kanban and Graph detail panels.\n- Verification for responsive behavior and mutation safety.\n\nOut of scope:\n- Dependency relation editing.\n- AI content generation.\n- Bulk editing.","acceptance_criteria":"- Users can edit core bead fields from both Kanban and Graph detail panels.\n- Both surfaces use the same validation and update path.\n- Save/cancel/error states are consistent across both surfaces.\n- Typecheck/tests/guards pass and no direct JSONL writes are introduced.","notes":"Execution order enforced through child dependencies.\nExecution order: bb-q1s.1 shared core -\u003e bb-q1s.2 kanban + bb-q1s.3 graph (parallel) -\u003e bb-q1s.4 verification/polish.","status":"closed","priority":1,"issue_type":"epic","owner":"jordanlive121@gmail.com","created_at":"2026-02-12T20:50:12.3431904-08:00","created_by":"zenchantlive","updated_at":"2026-02-12T21:11:43.1747329-08:00","closed_at":"2026-02-12T21:11:43.1747329-08:00","close_reason":"Shared UI bead editing shipped across Kanban and Graph with verification evidence.","labels":["editing","mutation","ui"]}
|
||||
{"id":"bb-q1s.1","title":"Shared edit core: schema + update adapter + state machine","description":"Build shared edit core used by both detail panels.\n\nIncludes:\n- editable field schema\n- validation rules\n- payload adapter for /api/beads/update\n- form state model: pristine/dirty/saving/error","acceptance_criteria":"- Shared edit core is framework-agnostic and reused by both UIs.\n- Validation covers title/priority/labels/assignee/owner/description.\n- Adapter emits stable update payload.","notes":"Implemented shared edit core in src/lib/issue-editor.ts with draft schema, validation, diff-to-update adapter, label parsing, and edit-state classifier. Added tests in tests/lib/issue-editor.test.ts and expanded mutation adapter to support issueType updates.","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-12T20:50:31.668852-08:00","created_by":"zenchantlive","updated_at":"2026-02-12T21:10:59.9315015-08:00","closed_at":"2026-02-12T21:10:59.9315015-08:00","close_reason":"Shared edit core delivered and validated via unit tests + typecheck.","labels":["editing","lib","shared"],"dependencies":[{"issue_id":"bb-q1s.1","depends_on_id":"bb-q1s","type":"parent-child","created_at":"2026-02-12T20:50:31.6709483-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-q1s.2","title":"Kanban detail integration of shared editor","description":"Integrate shared editor into Kanban detail panel (desktop + mobile drawer).\n\nIncludes:\n- Edit button and mode switch\n- Save/Cancel\n- optimistic update + rollback via existing mutation path\n- inline error handling","acceptance_criteria":"- Kanban detail can edit and save core fields.\n- Cancel restores non-saved edits.\n- Save errors show clear inline message.","notes":"Integrated shared editor into Kanban detail panel (desktop and mobile drawer) with edit mode, save/cancel, inline validation and save errors, and post-save refresh callback.","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-12T20:50:32.2815939-08:00","created_by":"zenchantlive","updated_at":"2026-02-12T21:11:00.5718636-08:00","closed_at":"2026-02-12T21:11:00.5718636-08:00","close_reason":"Kanban detail integration complete with shared edit behavior and verification.","labels":["editing","kanban","ui"],"dependencies":[{"issue_id":"bb-q1s.2","depends_on_id":"bb-q1s","type":"parent-child","created_at":"2026-02-12T20:50:32.2836956-08:00","created_by":"zenchantlive"},{"issue_id":"bb-q1s.2","depends_on_id":"bb-q1s.1","type":"blocks","created_at":"2026-02-12T20:50:47.1937109-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-q1s.3","title":"Graph detail integration of shared editor","description":"Integrate same shared editor into graph detail panel container.\n\nIncludes:\n- identical field behavior/validation\n- identical save/cancel semantics\n- deep-link context preserved after save","acceptance_criteria":"- Graph detail can edit and save same fields as Kanban.\n- Behavior matches Kanban editing semantics.","notes":"Integrated same shared editor path into Graph task details drawer by reusing KanbanDetail and passing projectRoot/onIssueUpdated hooks; refresh wired via router.refresh().","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-12T20:50:32.9165031-08:00","created_by":"zenchantlive","updated_at":"2026-02-12T21:11:01.2178741-08:00","closed_at":"2026-02-12T21:11:01.2178741-08:00","close_reason":"Graph detail integration complete with shared edit semantics.","labels":["editing","graph","ui"],"dependencies":[{"issue_id":"bb-q1s.3","depends_on_id":"bb-q1s","type":"parent-child","created_at":"2026-02-12T20:50:32.9234917-08:00","created_by":"zenchantlive"},{"issue_id":"bb-q1s.3","depends_on_id":"bb-q1s.1","type":"blocks","created_at":"2026-02-12T20:50:47.795674-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-q1s.4","title":"Cross-surface verification + UX polish for edit flows","description":"Finalize edit experience and verify both surfaces end-to-end.\n\nIncludes:\n- responsive polish\n- keyboard/focus behavior\n- guard/unit test updates\n- mutation smoke checks","acceptance_criteria":"- Typecheck and tests pass.\n- Guards confirm edit controls render on both surfaces.\n- No write boundary regressions.","notes":"Verification complete: npm run typecheck, npm run test, guard tests, and screenshots (artifacts/kanban-mobile-after.png, artifacts/kanban-tablet-after.png, artifacts/kanban-desktop-after.png, artifacts/graph-next-1440.png, artifacts/graph-next-768.png, artifacts/graph-next-390-overview.png, artifacts/graph-next-390-flow.png). Also adjusted screenshot script to use domcontentloaded due SSE/networkidle hang.","status":"closed","priority":2,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-12T20:50:33.598391-08:00","created_by":"zenchantlive","updated_at":"2026-02-12T21:11:29.5913307-08:00","closed_at":"2026-02-12T21:11:29.5913307-08:00","close_reason":"Cross-surface verification and polish completed with fresh evidence.","labels":["editing","ux","verification"],"dependencies":[{"issue_id":"bb-q1s.4","depends_on_id":"bb-q1s","type":"parent-child","created_at":"2026-02-12T20:50:33.601069-08:00","created_by":"zenchantlive"},{"issue_id":"bb-q1s.4","depends_on_id":"bb-q1s.2","type":"blocks","created_at":"2026-02-12T20:50:48.3822381-08:00","created_by":"zenchantlive"},{"issue_id":"bb-q1s.4","depends_on_id":"bb-q1s.3","type":"blocks","created_at":"2026-02-12T20:50:48.9933212-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-sse-smoke","title":"SSE smoke 1770870992420","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-12T04:36:32.42Z","updated_at":"2026-02-12T04:36:32.422Z"}
|
||||
{"id":"bb-tpc","title":"Live File Watching and SSE Transport","description":"Realtime transport epic to deliver deterministic file-change propagation from .beads/issues.jsonl(.new) into the Kanban UI.\n\nScope boundaries:\n- Read source remains disk JSONL via read-issues; no bd CLI reads.\n- Mutation/write path remains bd.exe only (already implemented in bb-ymg).\n- This epic adds one-way change detection + push invalidation, not business-rule mutation logic.\n\nImplementation contract:\n1) Watch manager (`src/lib/watcher.ts`)\n- Uses chokidar to monitor `\u003cprojectRoot\u003e/.beads/issues.jsonl` and `.beads/issues.jsonl.new`.\n- Normalizes project roots with existing Windows path helpers.\n- Supports start/stop per project and global cleanup for tests/dev reloads.\n- Emits typed change events with monotonic event ids and timestamps.\n\n2) Burst and lock stability (`bb-tpc.2`)\n- Debounce/coalesce rapid write bursts into one logical event per project window.\n- Treat transient lock/read contention as retryable (EBUSY/EPERM) in read path.\n\n3) SSE server (`src/app/api/events/route.ts`)\n- `text/event-stream` endpoint with heartbeat and `id:` fields.\n- Optional `projectRoot` filter for scoped subscribers.\n- Cleans up subscriptions and timers on disconnect.\n\n4) Frontend subscriber (`bb-tpc.4`)\n- EventSource client reconnect behavior handled by browser defaults.\n- On event, re-fetch affected project issues and reconcile local state.\n- No direct JSONL polling loops after SSE is active.\n\nNon-goals in this epic:\n- WebSocket transport.\n- Cross-process durable event bus.\n- React Query migration (deferred; current lane keeps existing local fetch/reconcile pattern).\r\n","acceptance_criteria":"- Editing `.beads/issues.jsonl` externally triggers UI refresh in \u003c1s without manual reload.\n- SSE stream remains connected with periodic heartbeat; reconnect path resumes safely.\n- Event stream and watcher code use Windows-safe path normalization.\n- No direct JSONL writes introduced (guard still passes).\n- `npm run typecheck`, `npm run test`, `npm run dev` pass.\r\n","notes":"Decoupled bb-tpc baseline from scanner epic: current implementation is project-scoped via query projectRoot and does not require registry integration. Multi-project watcher orchestration remains under scanner follow-up tasks.","status":"closed","priority":0,"issue_type":"epic","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:52.6737283-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T20:37:49.787539-08:00","closed_at":"2026-02-11T20:37:49.787539-08:00","close_reason":"Completed watcher/SSE tracer end-to-end for project-scoped realtime updates with tests and smoke checks.","labels":["realtime","sse","watcher"]}
|
||||
{"id":"bb-tpc.1","title":"Implement chokidar watch manager for registered projects","description":"Implement `src/lib/watcher.ts` watch manager for project-scoped issue files.\n\nScope:\n- Watch both `\u003cprojectRoot\u003e/.beads/issues.jsonl` and `\u003cprojectRoot\u003e/.beads/issues.jsonl.new`.\n- Support startWatch(projectRoot), stopWatch(projectRoot), stopAll().\n- Ensure idempotent start behavior (no duplicate watchers for same canonical root).\n- Emit typed events into in-process realtime bus with:\n - id (monotonic)\n - projectRoot (canonical path)\n - kind (changed|renamed)\n - path\n - at (ISO timestamp)\n\nImplementation notes:\n- chokidar with `ignoreInitial: true` and Windows-safe normalized paths.\n- Maintain internal map keyed by windowsPathKey(projectRoot).\n- Route event -\u003e coalescer (bb-tpc.2), not direct SSE writes.\n\nTest plan:\n- Unit tests verify idempotent lifecycle and key normalization behavior.\n- Unit tests verify events from both jsonl candidates are accepted.\r\n","acceptance_criteria":"- Starting watch for same project twice creates one active watcher.\n- Stopping watch removes watcher and prevents further events.\n- Events include canonical project root and unique monotonic event id.\n- Watch target includes both `.beads/issues.jsonl` and `.beads/issues.jsonl.new`.\r\n","notes":"Implemented src/lib/watcher.ts with chokidar manager, idempotent start/stop lifecycle, windowsPathKey normalization, and dual-file watch targets (.jsonl + .jsonl.new). Added tests/lib/watcher.test.ts.","status":"closed","priority":0,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:53.5050717-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T20:36:50.2745024-08:00","closed_at":"2026-02-11T20:36:50.2745024-08:00","close_reason":"Watcher lifecycle manager implemented with canonical project scoping and tested watch behavior.","labels":["chokidar","watcher"],"dependencies":[{"issue_id":"bb-tpc.1","depends_on_id":"bb-tpc","type":"parent-child","created_at":"2026-02-11T17:11:53.5071586-08:00","created_by":"zenchantlive"},{"issue_id":"bb-tpc.1","depends_on_id":"bb-6aj.2","type":"blocks","created_at":"2026-02-11T17:12:28.2304516-08:00","created_by":"zenchantlive"},{"issue_id":"bb-tpc.1","depends_on_id":"bb-tpc.5","type":"blocks","created_at":"2026-02-11T20:10:00.4246352-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-tpc.2","title":"Add debounce/coalescing and transient lock handling for file change bursts","description":"Implement debounce/coalescing + transient lock resilience for realtime updates.\n\nScope:\n- Coalesce rapid filesystem bursts into a single logical change event per project in a short window (e.g. 100-250ms).\n- Suppress duplicate events for same project/path within the same window.\n- Handle transient file lock contention in read layer with bounded retry for EBUSY/EPERM before surfacing failure.\n\nIntegration points:\n- Coalescer sits between watcher and SSE broadcaster.\n- Read retry applied in `readIssuesFromDisk` path used by UI reconciliation.\n\nTest plan:\n- Unit tests for coalescer burst behavior (N events =\u003e 1 broadcast).\n- Unit tests for lock retry logic and eventual success/failure behavior.\r\n","acceptance_criteria":"- Burst writes within debounce window produce one emitted project event.\n- Distinct project events are not incorrectly merged.\n- Transient EBUSY/EPERM reads are retried with bounded backoff.\n- Permanent read errors still surface as explicit failures.\r\n","notes":"Implemented src/lib/coalescer.ts for burst event coalescing and integrated in watcher manager. Added src/lib/read-text-retry.ts and wired readIssuesFromDisk to retry transient lock errors (EBUSY/EPERM). Added tests/lib/coalescer.test.ts and tests/lib/read-text-retry.test.ts.","status":"closed","priority":0,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:54.315119-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T20:36:50.8832053-08:00","closed_at":"2026-02-11T20:36:50.8832053-08:00","close_reason":"Burst coalescing and transient lock retry behavior implemented and covered by tests.","labels":["stability","watcher"],"dependencies":[{"issue_id":"bb-tpc.2","depends_on_id":"bb-tpc","type":"parent-child","created_at":"2026-02-11T17:11:54.3172104-08:00","created_by":"zenchantlive"},{"issue_id":"bb-tpc.2","depends_on_id":"bb-tpc.1","type":"blocks","created_at":"2026-02-11T17:12:28.7308524-08:00","created_by":"zenchantlive"},{"issue_id":"bb-tpc.2","depends_on_id":"bb-tpc.5","type":"blocks","created_at":"2026-02-11T20:09:59.5779123-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-tpc.3","title":"Implement SSE events API endpoint with heartbeat and event IDs","description":"Implement SSE endpoint at `src/app/api/events/route.ts` backed by in-process event bus.\n\nScope:\n- Response headers: `Content-Type: text/event-stream`, `Cache-Control: no-cache, no-transform`, `Connection: keep-alive`.\n- Emit named events (`event: issues`) with `id:` and JSON payload.\n- Heartbeat comments at fixed cadence to keep intermediaries alive.\n- Support optional `projectRoot` query filter so client receives only scoped updates.\n- Cleanup subscriber + heartbeat resources on request abort.\n\nEvent payload contract:\n{\n id: number,\n projectRoot: string,\n changedPath?: string,\n at: string\n}\n\nTest plan:\n- Unit tests for SSE formatting helper and filter matching.\n- Route-level test ensures proper content-type and streaming status.\r\n","acceptance_criteria":"- SSE endpoint responds with valid event-stream headers.\n- Each issue update includes event id + timestamp payload.\n- Subscriber cleanup occurs on disconnect without leaks.\n- Project filter limits event delivery to matching subscribers.\r\n","notes":"Implemented SSE route at src/app/api/events/route.ts with event-stream headers, connected frame, heartbeats, issue event frames, projectRoot filtering via bus subscription, and cleanup on abort/cancel. Added tests/api/events-route.test.ts + tests/lib/realtime.test.ts.","status":"closed","priority":0,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:55.1518352-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T20:36:51.5000671-08:00","closed_at":"2026-02-11T20:36:51.5000671-08:00","close_reason":"SSE transport endpoint implemented with heartbeat/id frames and lifecycle cleanup.","labels":["api","sse"],"dependencies":[{"issue_id":"bb-tpc.3","depends_on_id":"bb-tpc","type":"parent-child","created_at":"2026-02-11T17:11:55.1533991-08:00","created_by":"zenchantlive"},{"issue_id":"bb-tpc.3","depends_on_id":"bb-tpc.2","type":"blocks","created_at":"2026-02-11T17:12:29.2599782-08:00","created_by":"zenchantlive"},{"issue_id":"bb-tpc.3","depends_on_id":"bb-tpc.5","type":"blocks","created_at":"2026-02-11T20:09:58.6992189-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-tpc.4","title":"Build frontend SSE client with scoped React Query invalidation","description":"Implement frontend realtime subscriber in Kanban page.\n\nScope:\n- Create EventSource subscription to `/api/events?projectRoot=...`.\n- Listen for `issues` events and trigger authoritative refresh (`/api/beads/read`).\n- Guard against duplicate subscriptions and ensure cleanup on unmount/project change.\n- Preserve current optimistic mutation flow and reconcile after both mutation success and realtime events.\n\nFailure handling:\n- Do not hard-fail UI on temporary SSE disconnect.\n- Keep page usable while EventSource auto-reconnects.\n\nTest plan:\n- Unit test(s) for event payload parsing and refresh trigger behavior.\n- Guard checks confirm no direct JSONL writes and existing UI contracts remain intact.\r\n","acceptance_criteria":"- Kanban refreshes automatically after external issue file changes.\n- EventSource subscription lifecycle is clean across mount/unmount.\n- Mutation UX remains functional with realtime enabled.\n- No regression to existing guard/test suite.\r\n","notes":"Integrated EventSource subscription in src/components/kanban/kanban-page.tsx. On issues events, Kanban performs authoritative refresh from /api/beads/read while preserving optimistic mutation flow.","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:56.0008015-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T20:36:52.0959253-08:00","closed_at":"2026-02-11T20:36:52.0959253-08:00","close_reason":"Frontend SSE subscriber implemented with auto-refresh reconciliation and clean subscription lifecycle.","labels":["frontend","react-query"],"dependencies":[{"issue_id":"bb-tpc.4","depends_on_id":"bb-tpc","type":"parent-child","created_at":"2026-02-11T17:11:56.0024218-08:00","created_by":"zenchantlive"},{"issue_id":"bb-tpc.4","depends_on_id":"bb-tpc.3","type":"blocks","created_at":"2026-02-11T17:12:29.768818-08:00","created_by":"zenchantlive"},{"issue_id":"bb-tpc.4","depends_on_id":"bb-tpc.5","type":"blocks","created_at":"2026-02-11T20:10:01.2739557-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-tpc.5","title":"Epic Design Gate: scope, decisions, and acceptance contract","description":"Design/discovery gate for bb-tpc before further implementation.\n\nMust capture:\n- Product intent and user outcomes for this epic\n- Explicit architecture decisions and tradeoffs\n- API/data contracts and edge cases\n- Windows-specific constraints and path/process assumptions\n- Test strategy and verification commands\n- Non-goals and out-of-scope boundaries\n\nCompletion rule:\nDo not start new implementation tasks in this epic until this gate is closed with agreed decisions.","acceptance_criteria":"A written execution-grade plan exists for this epic and all child task descriptions are updated with concrete implementation details, dependencies, and testable acceptance criteria.","notes":"Readiness pass complete: child tasks now include scope boundaries, contracts, failure handling, and test plans. Execution order: .1 watcher lifecycle -\u003e .2 coalescing/retry -\u003e .3 SSE endpoint -\u003e .4 frontend subscriber.","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T20:09:38.4238327-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T20:27:50.5456542-08:00","closed_at":"2026-02-11T20:27:50.5456542-08:00","close_reason":"Design gate satisfied with execution-grade contracts and explicit verification strategy for watcher/SSE lane.","dependencies":[{"issue_id":"bb-tpc.5","depends_on_id":"bb-tpc","type":"parent-child","created_at":"2026-02-11T20:09:38.4249429-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-trz","title":"Kanban Experience (Baseline Dashboard)","description":"Ship a production-ready Kanban baseline inspired by prototype behavior but backed by real Beads project data and strict typing.","acceptance_criteria":"Users can inspect and filter live Beads issues through stable Kanban workflows.","notes":"Product baseline locked (2026-02-12): Default landing view is Kanban for fast triage. Primary user is solo dev supervising multi-agent work. Project scope defaults to one project with explicit aggregate toggle.","status":"closed","priority":1,"issue_type":"epic","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:56.8115491-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T20:54:10.0562801-08:00","closed_at":"2026-02-11T17:56:51.4226568-08:00","close_reason":"Kanban epic complete for tracer bullet 1","labels":["kanban","ui"],"dependencies":[{"issue_id":"bb-trz","depends_on_id":"bb-92d","type":"blocks","created_at":"2026-02-11T17:12:20.6480287-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-trz.1","title":"Implement Kanban column layout for Beads statuses","description":"Render columns for open, in_progress, blocked, deferred, and closed with responsive behavior and clear status counts.","acceptance_criteria":"All statuses map correctly and render with stable ordering.","status":"closed","priority":1,"issue_type":"task","assignee":"agent-b","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:57.6278082-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:56:50.8105288-08:00","closed_at":"2026-02-11T17:56:50.8105288-08:00","close_reason":"Tracer bullet 1 Kanban baseline implemented and verified","labels":["columns","kanban"],"dependencies":[{"issue_id":"bb-trz.1","depends_on_id":"bb-trz","type":"parent-child","created_at":"2026-02-11T17:11:57.6288535-08:00","created_by":"zenchantlive"},{"issue_id":"bb-trz.1","depends_on_id":"bb-92d.4","type":"blocks","created_at":"2026-02-11T17:12:30.2796473-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-trz.2","title":"Build bead cards with priority/type/labels/assignee/dependency metadata","description":"Design compact cards exposing the most actionable issue metadata while preserving readability at high board density.","acceptance_criteria":"Cards show id, priority, type, labels, assignee, and dependency indicators.","status":"closed","priority":1,"issue_type":"task","assignee":"agent-b","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:58.4435327-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:56:50.8141656-08:00","closed_at":"2026-02-11T17:56:50.8141656-08:00","close_reason":"Tracer bullet 1 Kanban baseline implemented and verified","labels":["cards","kanban"],"dependencies":[{"issue_id":"bb-trz.2","depends_on_id":"bb-trz","type":"parent-child","created_at":"2026-02-11T17:11:58.4450798-08:00","created_by":"zenchantlive"},{"issue_id":"bb-trz.2","depends_on_id":"bb-trz.1","type":"blocks","created_at":"2026-02-11T17:12:30.7837277-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-trz.3","title":"Implement detail slide-out panel with full issue metadata","description":"Add focused issue detail panel showing description, timestamps, dependencies, and lifecycle fields used by power users.","acceptance_criteria":"Selecting a card opens detail panel with complete issue context.","status":"closed","priority":1,"issue_type":"task","assignee":"agent-b","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:11:59.2746013-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:56:50.8161639-08:00","closed_at":"2026-02-11T17:56:50.8161639-08:00","close_reason":"Tracer bullet 1 Kanban baseline implemented and verified","labels":["details","kanban"],"dependencies":[{"issue_id":"bb-trz.3","depends_on_id":"bb-trz","type":"parent-child","created_at":"2026-02-11T17:11:59.2756402-08:00","created_by":"zenchantlive"},{"issue_id":"bb-trz.3","depends_on_id":"bb-trz.2","type":"blocks","created_at":"2026-02-11T17:12:31.2944-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-trz.4","title":"Add search/filter/stats controls for status/type/priority/labels","description":"Provide fast filtering and at-a-glance counts, including critical issue indicators, for daily planning and triage workflows.","acceptance_criteria":"Search and filters apply consistently across board and counts.","status":"closed","priority":1,"issue_type":"task","assignee":"agent-b","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:00.0927161-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:56:50.8186688-08:00","closed_at":"2026-02-11T17:56:50.8186688-08:00","close_reason":"Tracer bullet 1 Kanban baseline implemented and verified","labels":["filters","stats"],"dependencies":[{"issue_id":"bb-trz.4","depends_on_id":"bb-trz","type":"parent-child","created_at":"2026-02-11T17:12:00.0942721-08:00","created_by":"zenchantlive"},{"issue_id":"bb-trz.4","depends_on_id":"bb-trz.2","type":"blocks","created_at":"2026-02-11T17:12:31.798413-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-u6f","title":"Agent Session Views and Metrics","description":"Group work by agent session and actor fields to provide auditability and practical productivity insights for asynchronous coding workflows.","acceptance_criteria":"Session-based summaries and detail views are available per project and aggregate contexts.","status":"open","priority":2,"issue_type":"epic","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:12.5083912-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:12.5083912-08:00","labels":["agents","sessions"],"dependencies":[{"issue_id":"bb-u6f","depends_on_id":"bb-tpc","type":"blocks","created_at":"2026-02-11T17:12:23.1727361-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-u6f.1","title":"Extract and normalize session identity fields from issue data","description":"Derive session grouping from closed_by_session, assignee, and created_by with robust fallback semantics.","acceptance_criteria":"Issues are consistently assigned to session buckets when data exists.","status":"open","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:13.3239834-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:13.3239834-08:00","labels":["agents","data"],"dependencies":[{"issue_id":"bb-u6f.1","depends_on_id":"bb-u6f","type":"parent-child","created_at":"2026-02-11T17:12:13.3255058-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-u6f.2","title":"Build session list and detail views for claimed/completed/open outcomes","description":"Present session-level issue outcomes and navigation for operational review and accountability.","acceptance_criteria":"Users can inspect session summaries and drill into individual session issue sets.","status":"open","priority":2,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:14.1559358-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:14.1559358-08:00","labels":["agents","ui"],"dependencies":[{"issue_id":"bb-u6f.2","depends_on_id":"bb-u6f","type":"parent-child","created_at":"2026-02-11T17:12:14.157502-08:00","created_by":"zenchantlive"},{"issue_id":"bb-u6f.2","depends_on_id":"bb-u6f.1","type":"blocks","created_at":"2026-02-11T17:12:37.9045555-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-u6f.3","title":"Add baseline productivity metrics (completion rate, throughput, active span)","description":"Compute lightweight operational metrics from session issue events and timestamps.","acceptance_criteria":"Metrics are available with documented definitions and caveats.","status":"open","priority":2,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:15.0144056-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:15.0144056-08:00","labels":["agents","metrics"],"dependencies":[{"issue_id":"bb-u6f.3","depends_on_id":"bb-u6f","type":"parent-child","created_at":"2026-02-11T17:12:15.0155323-08:00","created_by":"zenchantlive"},{"issue_id":"bb-u6f.3","depends_on_id":"bb-u6f.2","type":"blocks","created_at":"2026-02-11T17:12:38.4424336-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-xhm","title":"Timeline and Activity Feed","description":"Provide a chronological activity view derived from issue snapshots and updates, enabling users to review agent/system activity over time.","acceptance_criteria":"Users can inspect chronological issue lifecycle events with useful filtering.","status":"open","priority":2,"issue_type":"epic","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:05.8525088-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:05.8525088-08:00","labels":["activity","timeline"],"dependencies":[{"issue_id":"bb-xhm","depends_on_id":"bb-tpc","type":"blocks","created_at":"2026-02-11T17:12:22.1602338-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-xhm.1","title":"Define activity event model for created/updated/closed/reopened actions","description":"Create stable event schema to represent issue lifecycle transitions and their project/session attribution.","acceptance_criteria":"Event model supports all required timeline activity types.","status":"open","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:06.6781387-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:06.6781387-08:00","labels":["model","timeline"],"dependencies":[{"issue_id":"bb-xhm.1","depends_on_id":"bb-xhm","type":"parent-child","created_at":"2026-02-11T17:12:06.6791721-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-xhm.2","title":"Implement snapshot diffing for derived timeline events","description":"Compare periodic snapshots and watcher updates to infer meaningful change events without requiring write interception.","acceptance_criteria":"Diff engine emits deterministic event records for relevant field changes.","status":"open","priority":2,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:07.5007059-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:07.5007059-08:00","labels":["diff","timeline"],"dependencies":[{"issue_id":"bb-xhm.2","depends_on_id":"bb-xhm","type":"parent-child","created_at":"2026-02-11T17:12:07.501756-08:00","created_by":"zenchantlive"},{"issue_id":"bb-xhm.2","depends_on_id":"bb-xhm.1","type":"blocks","created_at":"2026-02-11T17:12:35.3430513-08:00","created_by":"zenchantlive"},{"issue_id":"bb-xhm.2","depends_on_id":"bb-tpc.2","type":"blocks","created_at":"2026-02-11T17:12:35.8495336-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-xhm.3","title":"Build timeline UI with date grouping and project/assignee/event filters","description":"Render reverse-chronological feed suitable for morning review workflows with practical filter controls.","acceptance_criteria":"Timeline view supports grouping and filter combinations with acceptable performance.","status":"open","priority":2,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:08.3834905-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:08.3834905-08:00","labels":["timeline","ui"],"dependencies":[{"issue_id":"bb-xhm.3","depends_on_id":"bb-xhm","type":"parent-child","created_at":"2026-02-11T17:12:08.3851144-08:00","created_by":"zenchantlive"},{"issue_id":"bb-xhm.3","depends_on_id":"bb-xhm.2","type":"blocks","created_at":"2026-02-11T17:12:36.3627477-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-ymg","title":"CLI Write-Back via bd.exe","description":"Enable safe issue mutations from UI by routing all write operations through bd.exe and reflecting results through realtime reconciliation.","acceptance_criteria":"No direct JSONL writes exist; all mutations use bd commands and recover cleanly from failures.","status":"open","priority":1,"issue_type":"epic","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:00.9164956-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:00.9164956-08:00","labels":["bd-cli","mutation"],"dependencies":[{"issue_id":"bb-ymg","depends_on_id":"bb-trz","type":"blocks","created_at":"2026-02-11T17:12:21.1512868-08:00","created_by":"zenchantlive"},{"issue_id":"bb-ymg","depends_on_id":"bb-tpc","type":"blocks","created_at":"2026-02-11T17:12:21.6536312-08:00","created_by":"zenchantlive"}]}
|
||||
{"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.","status":"in_progress","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:35:15.8003769-08:00","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.","status":"open","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-11T17:12:02.5593205-08:00","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.","status":"open","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-11T17:12:03.3757503-08:00","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-u6f","title":"Agent Session Views and Metrics","description":"Provide agent-session accountability views for solo supervision: session list, per-session outcomes, and practical throughput metrics. Focus on operational decisions, not vanity dashboards.","acceptance_criteria":"Session identity is normalized and stable; per-session open/in-progress/closed outcomes are visible; baseline metrics (throughput, completion rate, active span) are correct and explainable; UI uses the same interaction and visual hierarchy conventions established in bb-bvn.","notes":"Product baseline locked (2026-02-12): Agent-session features should optimize for solo supervisor workflows (who changed what, when, and why) with clear per-agent accountability and low-noise summaries.\nExecution order set 2026-02-12: start after bb-xhm baseline event stream patterns are stable; depends on bb-xhm for shared timeline/session event semantics.","status":"open","priority":1,"issue_type":"epic","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:12.5083912-08:00","created_by":"zenchantlive","updated_at":"2026-02-12T12:45:51.8058469-08:00","labels":["agents","sessions"],"dependencies":[{"issue_id":"bb-u6f","depends_on_id":"bb-tpc","type":"blocks","created_at":"2026-02-11T17:12:23.1727361-08:00","created_by":"zenchantlive"},{"issue_id":"bb-u6f","depends_on_id":"bb-xhm","type":"blocks","created_at":"2026-02-12T12:45:51.3676788-08:00","created_by":"zenchantlive"},{"issue_id":"bb-u6f","depends_on_id":"bb-dcv","type":"blocks","created_at":"2026-02-12T21:40:13.985575-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-u6f.1","title":"Extract and normalize session identity fields from issue data","description":"Define the AgentSession model and implement logic to aggregate individual events and closed tasks into coherent sessions.\n\nFiles:\n- [NEW] src/lib/agent-sessions.ts -- Model types + aggregation logic\n\nData Contract:\ninterface AgentSession {\n id: string; // from closed_by_session or inferred\n startedAt: string; // ISO timestamp\n endedAt: string; // ISO timestamp\n closedIssueIds: string[];\n touchedIssueIds: string[];\n status: 'active' | 'completed';\n}\n\nAggregation Logic:\n1. Group by closed_by_session (hard link). All issues with same session ID belong together.\n2. Infer from ActivityLogs: For non-closed items, checking actor/time matches.\n3. MVP: Focus primarily on closed_by_session grouping.\n\nAcceptance Criteria:\n- AgentSession interface defined and exported.\n- aggregateSessions(issues, events) returns AgentSession[].\n- Correctly groups issues by closed_by_session.\n- Correctly computes start/end from closed_at timestamps.\n- Unit tests for aggregation logic.","acceptance_criteria":"Issues are consistently assigned to session buckets when data exists.","status":"open","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:13.3239834-08:00","created_by":"zenchantlive","updated_at":"2026-02-12T21:37:59.3265433-08:00","labels":["agents","data"],"dependencies":[{"issue_id":"bb-u6f.1","depends_on_id":"bb-u6f","type":"parent-child","created_at":"2026-02-11T17:12:13.3255058-08:00","created_by":"zenchantlive"},{"issue_id":"bb-u6f.1","depends_on_id":"bb-u6f.4","type":"blocks","created_at":"2026-02-11T20:09:55.5193741-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-u6f.2","title":"Build session list and detail views for claimed/completed/open outcomes","description":"Present session-level issue outcomes and navigation for operational review and accountability.","acceptance_criteria":"Users can inspect session summaries and drill into individual session issue sets.","status":"open","priority":2,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:14.1559358-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:14.1559358-08:00","labels":["agents","ui"],"dependencies":[{"issue_id":"bb-u6f.2","depends_on_id":"bb-u6f","type":"parent-child","created_at":"2026-02-11T17:12:14.157502-08:00","created_by":"zenchantlive"},{"issue_id":"bb-u6f.2","depends_on_id":"bb-u6f.1","type":"blocks","created_at":"2026-02-11T17:12:37.9045555-08:00","created_by":"zenchantlive"},{"issue_id":"bb-u6f.2","depends_on_id":"bb-u6f.4","type":"blocks","created_at":"2026-02-11T20:09:57.2147927-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-u6f.3","title":"Add baseline productivity metrics (completion rate, throughput, active span)","description":"Compute lightweight operational metrics from session issue events and timestamps.","acceptance_criteria":"Metrics are available with documented definitions and caveats.","status":"open","priority":2,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:15.0144056-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T17:12:15.0144056-08:00","labels":["agents","metrics"],"dependencies":[{"issue_id":"bb-u6f.3","depends_on_id":"bb-u6f","type":"parent-child","created_at":"2026-02-11T17:12:15.0155323-08:00","created_by":"zenchantlive"},{"issue_id":"bb-u6f.3","depends_on_id":"bb-u6f.2","type":"blocks","created_at":"2026-02-11T17:12:38.4424336-08:00","created_by":"zenchantlive"},{"issue_id":"bb-u6f.3","depends_on_id":"bb-u6f.4","type":"blocks","created_at":"2026-02-11T20:09:56.3707709-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-u6f.4","title":"Epic Design Gate: scope, decisions, and acceptance contract","description":"Design/discovery gate for bb-u6f before further implementation.\n\nMust capture:\n- Product intent and user outcomes for this epic\n- Explicit architecture decisions and tradeoffs\n- API/data contracts and edge cases\n- Windows-specific constraints and path/process assumptions\n- Test strategy and verification commands\n- Non-goals and out-of-scope boundaries\n\nCompletion rule:\nDo not start new implementation tasks in this epic until this gate is closed with agreed decisions.","acceptance_criteria":"A written execution-grade plan exists for this epic and all child task descriptions are updated with concrete implementation details, dependencies, and testable acceptance criteria.","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T20:09:41.2150441-08:00","created_by":"zenchantlive","updated_at":"2026-02-12T23:29:55.9362248-08:00","closed_at":"2026-02-12T23:29:55.9362248-08:00","dependencies":[{"issue_id":"bb-u6f.4","depends_on_id":"bb-u6f","type":"parent-child","created_at":"2026-02-11T20:09:41.216603-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-xhm","title":"Timeline and Activity Feed","description":"Build a high-signal timeline view that explains what changed, by whom, and when, without noise.\n\nProduct baseline (2026-02-12): Timeline is secondary to Kanban (not default landing). It should support solo-dev live supervision and focus on actionable event stream clarity rather than exhaustive noise.\n\nArchitecture decisions:\n1. Snapshot-based diffing (not write interception) - keeps read/write boundary clean\n2. Derived events, not stored - ActivityEvent[] computed from JSONL diffs, not persisted separately\n3. In-memory snapshot store - watcher fires on changes, we diff previous vs current in memory\n4. Solo-dev focus - no multi-user attribution beyond created_by/assignee\n\nExecution order: bb-xhm.4 (design gate, DONE) -\u003e bb-xhm.1 (event model) -\u003e bb-xhm.2 (diffing) -\u003e bb-xhm.3 (UI)\n\nDependencies:\n- Depends on bb-bvn for shared UX and component patterns (DONE)\n- Depends on bb-tpc for live file watching and SSE transport (DONE)\n\nNon-goals:\n- No direct JSONL writes; read/write boundary preserved\n- No multi-user collaboration features\n- No persisted event store or event database\n\nAcceptance criteria:\n- Event model (bb-xhm.1) defines ActivityEventKind union with 16 transition types and ActivityEvent interface\n- Snapshot diffing (bb-xhm.2) produces deterministic ActivityEvent[] from JSONL changes with all field comparison rules\n- Timeline UI (bb-xhm.3) at /timeline renders events with date grouping, kind/assignee/project filters, and one-click navigation to issue context\n- All pages share the existing visual design system (dark theme, text-text-* tokens, rounded cards)\n- Tests exist for event model, diffing engine, and UI rendering\n- Typecheck and lint pass across all new files (bun run typecheck \u0026\u0026 bun run lint)","acceptance_criteria":"Timeline events are deterministic from snapshots/diffs; filters (project/actor/event/date) are fast and useful; users can move from event row to issue context in one action; UI follows the same visual system and hierarchy patterns established in bb-bvn.","notes":"Product baseline locked (2026-02-12): Timeline is secondary to Kanban (not default landing). It should support solo-dev live supervision and focus on actionable event stream clarity rather than exhaustive noise.\nExecution order set 2026-02-12: start after bb-bvn visual/interaction contracts are finalized; depends on bb-bvn for shared UX and component patterns.","status":"open","priority":1,"issue_type":"epic","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:05.8525088-08:00","created_by":"zenchantlive","updated_at":"2026-02-12T21:08:37.3922977-08:00","labels":["activity","timeline"],"dependencies":[{"issue_id":"bb-xhm","depends_on_id":"bb-tpc","type":"blocks","created_at":"2026-02-11T17:12:22.1602338-08:00","created_by":"zenchantlive"},{"issue_id":"bb-xhm","depends_on_id":"bb-bvn","type":"blocks","created_at":"2026-02-12T12:45:52.624726-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-xhm.1","title":"Define activity event model for created/updated/closed/reopened actions","description":"Create the ActivityEvent TypeScript interface and supporting types that represent issue lifecycle transitions. This is the core data contract consumed by both the snapshot diffing engine (bb-xhm.2) and the timeline UI (bb-xhm.3).\n\nFiles:\n- [NEW] src/lib/activity-events.ts -- Type definitions + factory helpers\n\nData contract:\n- ActivityEventKind union type covering 16 event kinds: created, closed, reopened, status_changed, title_changed, priority_changed, assigned, unassigned, reassigned, labels_changed, dependency_added, dependency_removed, deleted\n- ActivityEvent interface with fields: id (monotonic counter), issueId, issueTitle, kind, at (ISO timestamp), projectRoot, actor (best-effort attribution from assignee/created_by), previousValue, newValue\n- createActivityEvent() factory function enforcing required fields at construction\n\nGrounding in existing types:\n- Builds on BeadIssue from src/lib/types.ts (uses status, assignee, created_by, dependencies, labels fields)\n- ProjectRoot string for multi-project support (matches IssuesChangedEvent convention from realtime.ts)\n\nAcceptance criteria:\n- ActivityEventKind union covers all 16 transition types listed above\n- ActivityEvent interface is exported and importable by bb-xhm.2 and bb-xhm.3\n- Factory function createActivityEvent() enforces required fields at construction time\n- Unit tests cover: creating each event kind, validating required fields, round-trip JSON serialization\n- Typecheck passes (bun run typecheck)","acceptance_criteria":"Event model supports all required timeline activity types.","status":"open","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:06.6781387-08:00","created_by":"zenchantlive","updated_at":"2026-02-12T21:00:55.2316751-08:00","labels":["model","timeline"],"dependencies":[{"issue_id":"bb-xhm.1","depends_on_id":"bb-xhm","type":"parent-child","created_at":"2026-02-11T17:12:06.6791721-08:00","created_by":"zenchantlive"},{"issue_id":"bb-xhm.1","depends_on_id":"bb-xhm.4","type":"blocks","created_at":"2026-02-11T20:10:05.9709567-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-xhm.2","title":"Implement snapshot diffing for derived timeline events","description":"Build a diffing engine that compares two snapshots of BeadIssue[] (previous and current state of issues.jsonl) and emits an ActivityEvent[] for all detected changes. Integrates with the existing IssuesEventBus to trigger diffs when the file watcher fires.\n\nFiles:\n- [NEW] src/lib/snapshot-differ.ts -- Core diff logic\n- [MODIFY] src/lib/watcher.ts -- Wire differ into onFlush callback\n\nDiff algorithm:\n1. Read current issues.jsonl into Map\u003cid, BeadIssue\u003e\n2. Compare against previous snapshot (also Map\u003cid, BeadIssue\u003e):\n a. For each id in current but not previous: emit 'created'\n b. For each id in previous but not current: emit 'deleted'\n c. For each id in both: compare fields and emit specific events\n3. Store current as new 'previous' snapshot\n4. Return ActivityEvent[]\n\nField comparison rules:\n- status open-\u003eclosed: emit 'closed' (previousValue=old status, newValue='closed')\n- status closed-\u003eopen: emit 'reopened' (previousValue='closed', newValue=new status)\n- status other: emit 'status_changed' (previousValue=old, newValue=new)\n- title: emit 'title_changed' (previousValue=old title, newValue=new title)\n- priority: emit 'priority_changed' (previousValue=old as string, newValue=new as string)\n- assignee null-\u003eX: emit 'assigned' (previousValue=null, newValue=X)\n- assignee X-\u003enull: emit 'unassigned' (previousValue=X, newValue=null)\n- assignee X-\u003eY: emit 'reassigned' (previousValue=X, newValue=Y)\n- labels: emit 'labels_changed' (previousValue=removed labels comma-sep, newValue=added labels comma-sep)\n- dependencies: emit 'dependency_added'/'dependency_removed' (values are dep target strings)\n\nIntegration point:\nIssuesWatchManager.onFlush currently only emits IssuesChangedEvent (file-level). After this bead:\n1. onFlush reads new issues.jsonl\n2. Runs diffSnapshots(previous, current) to get ActivityEvent[]\n3. Emits activity events through a new ActivityEventBus (separate from IssuesEventBus)\n4. Stores current as new previous snapshot\n\nDepends on bb-xhm.1 for ActivityEvent types.\nDepends on bb-tpc.2 for coalesced watcher events.\n\nAcceptance criteria:\n- diffSnapshots(prev, curr, projectRoot) returns ActivityEvent[]\n- All field comparison rules above are implemented\n- Edge cases handled: empty previous (first load = all 'created'), identical snapshots (no events), issue with only updated_at changed (no event)\n- Unit tests: created, deleted, each field change, multiple changes per diff, no-op diff\n- Performance: diff of 200 issues completes in \u003c10ms\n- Typecheck passes (bun run typecheck)","acceptance_criteria":"Diff engine emits deterministic event records for relevant field changes.","status":"open","priority":2,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:07.5007059-08:00","created_by":"zenchantlive","updated_at":"2026-02-12T21:01:45.5437274-08:00","labels":["diff","timeline"],"dependencies":[{"issue_id":"bb-xhm.2","depends_on_id":"bb-xhm","type":"parent-child","created_at":"2026-02-11T17:12:07.501756-08:00","created_by":"zenchantlive"},{"issue_id":"bb-xhm.2","depends_on_id":"bb-xhm.1","type":"blocks","created_at":"2026-02-11T17:12:35.3430513-08:00","created_by":"zenchantlive"},{"issue_id":"bb-xhm.2","depends_on_id":"bb-tpc.2","type":"blocks","created_at":"2026-02-11T17:12:35.8495336-08:00","created_by":"zenchantlive"},{"issue_id":"bb-xhm.2","depends_on_id":"bb-xhm.4","type":"blocks","created_at":"2026-02-11T20:10:07.6688195-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-xhm.3","title":"Build timeline UI with date grouping and project/assignee/event filters","description":"Render a reverse-chronological activity feed as a new /timeline page. Date-grouped, filterable, and navigable to issue context in one click.\n\nFiles:\n- [NEW] src/app/timeline/page.tsx -- Server component (reads issues, computes initial snapshot)\n- [NEW] src/components/timeline/timeline-page.tsx -- Client component (SSE subscription, filters, rendering)\n- [NEW] src/components/timeline/timeline-event-row.tsx -- Single event row component\n- [NEW] src/components/timeline/timeline-filters.tsx -- Filter bar component\n\nUI contract:\n1. Date grouping: Events grouped by date headers ('Today', 'Yesterday', 'Feb 10', etc.)\n2. Event row: Shows icon (per event kind), issue ID (monospace), title, actor, relative timestamp\n3. Filters: Project (when multi-project), event kind (checkboxes), assignee, date range\n4. Navigation: Click any event row to navigate to the issue in Workflow Explorer (/graph)\n5. Live updates: Subscribe to ActivityEventBus via SSE for new events, prepend to feed\n6. Design: Follow existing visual system (dark theme, text-text-* tokens, rounded cards, border accents, same header/panel patterns as graph page)\n\nRouting:\n- Add /timeline Next.js route\n- Add link in Workflow Explorer header (next to existing Kanban link)\n\nDepends on bb-xhm.1 for ActivityEvent types.\nDepends on bb-xhm.2 for ActivityEventBus providing live events via SSE.\n\nAcceptance criteria:\n- /timeline page renders with date-grouped event feed\n- Each event kind displays appropriate icon and human-readable description\n- Filter by event kind works (toggle checkboxes)\n- Click on event row navigates to issue context in Workflow Explorer\n- Live SSE updates prepend new events at the top\n- Responsive: usable on mobile (single-column stack)\n- Typecheck and lint pass (bun run typecheck \u0026\u0026 bun run lint)\n- No regressions to existing Kanban or Graph pages","acceptance_criteria":"Timeline view supports grouping and filter combinations with acceptable performance.","status":"open","priority":2,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:08.3834905-08:00","created_by":"zenchantlive","updated_at":"2026-02-12T21:08:16.5334224-08:00","labels":["timeline","ui"],"dependencies":[{"issue_id":"bb-xhm.3","depends_on_id":"bb-xhm","type":"parent-child","created_at":"2026-02-11T17:12:08.3851144-08:00","created_by":"zenchantlive"},{"issue_id":"bb-xhm.3","depends_on_id":"bb-xhm.2","type":"blocks","created_at":"2026-02-11T17:12:36.3627477-08:00","created_by":"zenchantlive"},{"issue_id":"bb-xhm.3","depends_on_id":"bb-xhm.4","type":"blocks","created_at":"2026-02-11T20:10:06.8100606-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-xhm.4","title":"Epic Design Gate: scope, decisions, and acceptance contract","description":"Design/discovery gate for bb-xhm before further implementation.\n\nMust capture:\n- Product intent and user outcomes for this epic\n- Explicit architecture decisions and tradeoffs\n- API/data contracts and edge cases\n- Windows-specific constraints and path/process assumptions\n- Test strategy and verification commands\n- Non-goals and out-of-scope boundaries\n\nCompletion rule:\nDo not start new implementation tasks in this epic until this gate is closed with agreed decisions.","acceptance_criteria":"A written execution-grade plan exists for this epic and all child task descriptions are updated with concrete implementation details, dependencies, and testable acceptance criteria.","status":"closed","priority":1,"issue_type":"task","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T20:09:39.3625154-08:00","created_by":"zenchantlive","updated_at":"2026-02-12T21:09:39.2227447-08:00","closed_at":"2026-02-12T21:09:39.2227447-08:00","close_reason":"Design gate complete. All child beads (bb-xhm.1, bb-xhm.2, bb-xhm.3) updated with execution-grade specs: concrete implementation details, file paths, data contracts, field comparison rules, integration points, and testable acceptance criteria. Epic-level description and acceptance criteria also tightened with architecture decisions and non-goals.","dependencies":[{"issue_id":"bb-xhm.4","depends_on_id":"bb-xhm","type":"parent-child","created_at":"2026-02-11T20:09:39.3645827-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-ymg","title":"CLI Write-Back via bd.exe","description":"Write-back architecture for BeadBoard.\n\nScope:\n- All issue mutations must execute through bd.exe commands.\n- No direct writes to .beads/issues.jsonl are permitted anywhere in app code.\n- Mutation flow includes create/update/close/reopen/comment.\n\nDesign decisions:\n- Process execution uses child_process.execFile with arg arrays (no shell interpolation).\n- Commands run with project-scoped cwd so each request targets the intended repo.\n- Executable resolution supports explicit configured path and PATH fallback.\n- API responses are normalized with stable ok/error shape for frontend and tests.\n- UI writeback uses optimistic updates with rollback and authoritative re-read.\n\nImplemented artifacts:\n- src/lib/bd-path.ts\n- src/lib/bridge.ts\n- src/lib/mutations.ts\n- src/app/api/beads/{create,update,close,reopen,comment}/route.ts\n- src/app/api/beads/read/route.ts\n- src/lib/writeback.ts\n- Kanban drag-and-drop transition wiring in components.","acceptance_criteria":"Acceptance contract:\n1) Source tree has no direct issues.jsonl write path (guard test passes).\n2) Bridge returns structured command result including classification for timeout/not_found/non_zero_exit/bad_args.\n3) Mutation routes validate payloads and map operations to bd commands.\n4) Reopen and comment flows are supported and verified.\n5) Optimistic status updates rollback on failure and reconcile from authoritative read endpoint.\n6) typecheck + test + dev + mutation smoke lifecycle all pass.","status":"closed","priority":1,"issue_type":"epic","owner":"jordanlive121@gmail.com","created_at":"2026-02-11T17:12:00.9164956-08:00","created_by":"zenchantlive","updated_at":"2026-02-11T20:37:50.0859031-08:00","closed_at":"2026-02-11T20:37:50.0859031-08:00","close_reason":"Write-back epic unblocked and complete: bridge, mutation API, optimistic transitions, and drag/drop flows are implemented and verified.","labels":["bd-cli","mutation"],"dependencies":[{"issue_id":"bb-ymg","depends_on_id":"bb-trz","type":"blocks","created_at":"2026-02-11T17:12:21.1512868-08:00","created_by":"zenchantlive"},{"issue_id":"bb-ymg","depends_on_id":"bb-tpc","type":"blocks","created_at":"2026-02-11T17:12:21.6536312-08:00","created_by":"zenchantlive"}]}
|
||||
{"id":"bb-ymg.1","title":"Implement bd bridge using child_process.execFile with project-scoped cwd","description":"Bridge layer requirements and implementation.\n\nCommand execution contract:\n- Uses execFile(command, args, options) with windowsHide and timeout.\n- Uses projectRoot as cwd for all commands.\n- Returns structured payload: success, classification, command, args, cwd, stdout, stderr, code, durationMs, error.\n\nError model:\n- not_found: executable missing / ENOENT.\n- timeout: ETIMEDOUT, killed process, or SIGTERM timeout path.\n- bad_args: non-zero exits with invalid/unknown/usage style stderr.\n- non_zero_exit: non-zero exits not classified as bad_args.\n- unknown: fallback classification.\n\nVerification:\n- tests/lib/bridge.test.ts covers success and all key failure classes.","acceptance_criteria":"Acceptance contract:\n- Bridge command execution is shell-safe and Windows-path-safe.\n- Structured result schema is stable and consumed by mutation layer.\n- Timeout and failure classes are deterministic under test.","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-11T20:14:33.7176637-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":"Executable resolution contract.\n\nResolution order:\n1) explicit configured executable path (request/config)\n2) PATH scan for bd.exe, bd.cmd, bd.bat, bd\n\nFailure behavior:\n- Throw actionable guidance when missing, including install command:\n npm install -g @beads/bd\n- Error message explicitly mentions explicit path when provided but invalid.\n\nVerification:\n- tests/lib/bd-path.test.ts validates config-first behavior, PATH lookup, and missing executable guidance.","acceptance_criteria":"Acceptance contract:\n- Resolver is deterministic for config and PATH inputs.\n- Missing executable guidance is actionable and user-readable.","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-11T20:14:34.4905469-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":"Mutation API contract and mapping.\n\nSupported operations:\n- create, update, close, reopen, comment\n\nValidation:\n- Payload shape validation enforces required fields and basic constraints.\n- update requires at least one mutable field.\n- status values are constrained to board-supported statuses.\n\nCommand mapping:\n- create -\u003e bd create \u003ctitle\u003e [flags] --json\n- update -\u003e bd update \u003cid\u003e [flags] --json\n- close -\u003e bd close \u003cid\u003e [-r reason] --json\n- reopen -\u003e bd reopen \u003cid\u003e [-r reason] --json\n- comment -\u003e bd comments add \u003cid\u003e \u003ctext\u003e --json\n\nResponse shape:\n- { ok, operation, command, error? }\n- command field always includes normalized bridge result.\n\nVerification:\n- tests/lib/mutations.test.ts and tests/api/mutations-routes.test.ts\n- Runtime smoke lifecycle executed across create/update/close/reopen/comment.","acceptance_criteria":"Acceptance contract:\n- Every mutation route maps to bd.exe only.\n- Invalid payloads return explicit bad_args responses.\n- Reopen and comment operations are first-class and tested.","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-11T20:14:35.2552257-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":"Optimistic writeback behavior.\n\nFrontend mutation strategy:\n- Apply optimistic status update to local issue state.\n- Mark issue as pending during command execution.\n- On failure: rollback to previous issue snapshot and surface mutation error.\n- On success: fetch authoritative issue list from /api/beads/read and replace local state.\n\nRationale:\n- Preserves responsive UX without violating source-of-truth boundary.\n- Reconciliation avoids stale local drift when external agents mutate files.\n\nVerification:\n- tests/lib/writeback.test.ts for transition planning and optimistic updater helpers.\n- Runtime mutation smoke tests confirm end-to-end lifecycle.","acceptance_criteria":"Acceptance contract:\n- Failed mutation restores prior local state.\n- Successful mutation reconciles to authoritative read response.\n- Pending state prevents repeated conflicting transitions.","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-11T20:14:36.0400918-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":"Kanban drag-and-drop transition semantics.\n\nTransition planner rules:\n- Any -\u003e closed: use close command.\n- closed -\u003e open: use reopen command.\n- closed -\u003e in_progress|blocked|deferred: reopen then update target status.\n- open|in_progress|blocked|deferred between non-closed states: update status.\n\nUI behavior:\n- Drag start attaches issue id/status metadata.\n- Drop lane executes planned mutation steps in order.\n- Lane card pending state is shown while mutation is in flight.\n\nVerification:\n- tests/lib/writeback.test.ts transition planning cases.\n- Runtime smoke checks for close/reopen/update transition chain.","acceptance_criteria":"Acceptance contract:\n- DnD invokes valid bd command sequence for each source-\u003etarget status.\n- Invalid/no-op transitions do not emit unnecessary commands.\n- Pending safeguards prevent duplicate conflicting moves.","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-11T20:14:36.8114668-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"}]}
|
||||
|
|
|
|||
6
.eslintrc.json
Normal file
6
.eslintrc.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"extends": [
|
||||
"next/core-web-vitals",
|
||||
"next/typescript"
|
||||
]
|
||||
}
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
|
|
@ -3,3 +3,13 @@ node_modules/
|
|||
*.tsbuildinfo
|
||||
.worktrees/
|
||||
worktrees/
|
||||
|
||||
# bv (beads viewer) local config and caches
|
||||
.bv/
|
||||
|
||||
# beads lock artifact
|
||||
.beads/.bv.lock
|
||||
.beads/.jsonl.lock
|
||||
|
||||
# local screenshot artifacts
|
||||
artifacts/
|
||||
|
|
|
|||
61
README.md
61
README.md
|
|
@ -1,23 +1,50 @@
|
|||
# BeadBoard
|
||||
|
||||
Windows-native Beads dashboard built with Next.js 15, React 19, and TypeScript.
|
||||
**The Windows-native Control Center for Beads.**
|
||||
|
||||
## Core Rules
|
||||
- Read source of truth from `.beads/issues.jsonl`.
|
||||
- Perform all writes through `bd.exe`.
|
||||
- Never write directly to `.beads/issues.jsonl`.
|
||||
- Use Windows-safe path normalization for all project path operations.
|
||||
BeadBoard is a high-performance local dashboard for managing your software development tasks. Built on the Beads protocol, it provides a unified, visualization-rich interface over your distributed project landscape.
|
||||
|
||||
## Stack
|
||||
- Next.js 15 (App Router)
|
||||
- React 19
|
||||
- TypeScript (strict)
|
||||
## 🚀 Why BeadBoard?
|
||||
Most task managers are siloes. BeadBoard is a lens over your source code.
|
||||
- **Source of Truth**: Reads directly from `.beads/issues.jsonl` in your repo. No database sync skew.
|
||||
- **Windows Optimized**: Built from the ground up to handle Windows paths, drive letters, and filesystem performance.
|
||||
- **Zero Latency**: Optimistic UI updates make interactions feel instant.
|
||||
|
||||
## Local Development
|
||||
- `npm install`
|
||||
- `npm run dev`
|
||||
- `npm run typecheck`
|
||||
- `npm run test`
|
||||
## ✨ Core Features
|
||||
|
||||
## Scope
|
||||
BeadBoard provides Kanban, dependency graph, timeline, and agent-session views over one or more registered Windows project roots.
|
||||
### 1. Multi-Project Registry & Scanner
|
||||
Stop context switching between repos.
|
||||
- **Project Registry**: Persist your favorite project roots for one-click access.
|
||||
- **Auto-Discovery**: Built-in filesystem scanner finds Bead-enabled projects across your drives.
|
||||
- **Aggregate Mode**: View tasks from *all* registered projects in a single unified board.
|
||||
|
||||
### 2. Interactive Kanban Dashboard (`/`)
|
||||
Manage your flow state.
|
||||
- **Live Updates**: Boards refresh automatically when the underlying JSONL files change (e.g., via CLI).
|
||||
- **Progressive Disclosure**: Task details, metadata, and relations are tucked away until you need them.
|
||||
- **Smart Filtering**: Filter by priority, assignee, status, or full-text search across thousands of beads.
|
||||
|
||||
### 3. Dependency Graph Explorer (`/graph`)
|
||||
Understand the "Why" and "What's Next".
|
||||
- **Epic-Centric Layout**: Automatically groups tasks by Epic for logical clustering.
|
||||
- **True DAG Visualization**: Uses Dagre layout engine to enforce a strict Left-to-Right dependency flow.
|
||||
- *Left*: Incoming Blockers
|
||||
- *Center*: Focus Task
|
||||
- *Right*: Unlocks / Downstream
|
||||
- **Focus Mode**: Minimizable dependency strip and deep-linking support for sharing exact views.
|
||||
- **Smart Metadata**: See bead counts, priorities, and status health at a glance.
|
||||
|
||||
## 🛠️ Stack
|
||||
- **Framework**: Next.js 15 (App Router)
|
||||
- **UI Engine**: React 19 + Framer Motion
|
||||
- **Styling**: Tailwind CSS + Custom Design System
|
||||
- **Type Safety**: Strict TypeScript
|
||||
|
||||
## ⚡ Quick Start
|
||||
1. **Install**: `npm install`
|
||||
2. **Run**: `npm run dev`
|
||||
3. **Explore**: Open `http://localhost:3000`
|
||||
|
||||
## 🤝 Contribution
|
||||
- **Typecheck**: `npm run typecheck`
|
||||
- **Test**: `npm run test`
|
||||
|
|
|
|||
5
docs/archive/postcss.config.mjs.experimental
Normal file
5
docs/archive/postcss.config.mjs.experimental
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
};
|
||||
166
docs/archive/stashes/stash-0-root-leftovers.patch
Normal file
166
docs/archive/stashes/stash-0-root-leftovers.patch
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
diff --git a/.gitignore b/.gitignore
|
||||
index eb35607..596f42a 100644
|
||||
--- a/.gitignore
|
||||
+++ b/.gitignore
|
||||
@@ -3,3 +3,6 @@ node_modules/
|
||||
*.tsbuildinfo
|
||||
.worktrees/
|
||||
worktrees/
|
||||
+
|
||||
+# bv (beads viewer) local config and caches
|
||||
+.bv/
|
||||
diff --git a/src/app/globals.css b/src/app/globals.css
|
||||
index d17e938..a474080 100644
|
||||
--- a/src/app/globals.css
|
||||
+++ b/src/app/globals.css
|
||||
@@ -3,15 +3,15 @@
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
- --color-bg: #090c14;
|
||||
- --color-surface: #101827;
|
||||
- --color-surface-muted: #192336;
|
||||
- --color-surface-raised: #22314a;
|
||||
- --color-text-strong: #f6f8ff;
|
||||
- --color-text-body: #d8e0f1;
|
||||
- --color-text-muted: #9caccc;
|
||||
- --color-border-soft: rgba(145, 166, 204, 0.3);
|
||||
- --color-border-strong: rgba(187, 209, 246, 0.62);
|
||||
+ --color-bg: #090909;
|
||||
+ --color-surface: #161616;
|
||||
+ --color-surface-muted: #212121;
|
||||
+ --color-surface-raised: #2a2a2a;
|
||||
+ --color-text-strong: #f5f5f5;
|
||||
+ --color-text-body: #d0d0d0;
|
||||
+ --color-text-muted: #9a9a9a;
|
||||
+ --color-border-soft: rgba(255, 255, 255, 0.15);
|
||||
+ --color-border-strong: rgba(255, 255, 255, 0.3);
|
||||
|
||||
--status-open: #60a5fa;
|
||||
--status-progress: #fbbf24;
|
||||
@@ -38,10 +38,9 @@ body {
|
||||
|
||||
body {
|
||||
background:
|
||||
- radial-gradient(circle at 10% 12%, rgba(12, 138, 215, 0.34), transparent 36%),
|
||||
- radial-gradient(circle at 84% 20%, rgba(250, 122, 91, 0.18), transparent 30%),
|
||||
- radial-gradient(circle at 68% 88%, rgba(57, 189, 154, 0.14), transparent 36%),
|
||||
- linear-gradient(155deg, #05070d 0%, #0b1322 42%, #121e34 100%);
|
||||
+ radial-gradient(circle at 14% 12%, rgba(255, 255, 255, 0.05), transparent 36%),
|
||||
+ radial-gradient(circle at 84% 18%, rgba(255, 180, 80, 0.06), transparent 32%),
|
||||
+ linear-gradient(160deg, #070707 0%, #101010 48%, #161616 100%);
|
||||
color: var(--color-text-body);
|
||||
- font-family: 'Segoe UI', 'Aptos', Inter, system-ui, sans-serif;
|
||||
+ font-family: 'DM Sans', 'Segoe UI', Inter, system-ui, sans-serif;
|
||||
}
|
||||
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
|
||||
index ff1ad90..1417e77 100644
|
||||
--- a/src/app/layout.tsx
|
||||
+++ b/src/app/layout.tsx
|
||||
@@ -1,7 +1,18 @@
|
||||
import type { Metadata } from 'next';
|
||||
+import { DM_Sans, JetBrains_Mono } from 'next/font/google';
|
||||
import type { ReactNode } from 'react';
|
||||
import './globals.css';
|
||||
|
||||
+const dmSans = DM_Sans({
|
||||
+ subsets: ['latin'],
|
||||
+ variable: '--font-ui',
|
||||
+});
|
||||
+
|
||||
+const jetbrainsMono = JetBrains_Mono({
|
||||
+ subsets: ['latin'],
|
||||
+ variable: '--font-mono',
|
||||
+});
|
||||
+
|
||||
export const metadata: Metadata = {
|
||||
title: 'BeadBoard',
|
||||
description: 'Windows-native Beads dashboard',
|
||||
@@ -10,7 +21,7 @@ export const metadata: Metadata = {
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
- <body>{children}</body>
|
||||
+ <body className={`${dmSans.variable} ${jetbrainsMono.variable}`}>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
diff --git a/src/components/kanban/kanban-controls.tsx b/src/components/kanban/kanban-controls.tsx
|
||||
index 78b09f4..e1e04f9 100644
|
||||
--- a/src/components/kanban/kanban-controls.tsx
|
||||
+++ b/src/components/kanban/kanban-controls.tsx
|
||||
@@ -14,7 +14,7 @@ interface KanbanControlsProps {
|
||||
|
||||
export function KanbanControls({ filters, stats, onFiltersChange }: KanbanControlsProps) {
|
||||
const inputClass =
|
||||
- 'rounded-xl border border-border-soft bg-surface-muted/78 px-3 py-2.5 text-sm text-text-strong outline-none transition placeholder:text-text-muted focus:border-cyan-300/70 focus:ring-2 focus:ring-cyan-300/20';
|
||||
+ 'rounded-xl border border-border-soft bg-surface-muted/78 px-3 py-2.5 text-sm text-text-strong outline-none transition placeholder:text-text-muted focus:border-border-strong focus:ring-2 focus:ring-white/10';
|
||||
|
||||
return (
|
||||
<section className="grid gap-3">
|
||||
@@ -57,7 +57,7 @@ export function KanbanControls({ filters, stats, onFiltersChange }: KanbanContro
|
||||
type="checkbox"
|
||||
checked={filters.showClosed ?? false}
|
||||
onChange={(event) => onFiltersChange({ ...filters, showClosed: event.target.checked })}
|
||||
- className="h-4 w-4 accent-cyan-400"
|
||||
+ className="h-4 w-4 accent-amber-400"
|
||||
/>
|
||||
Show closed
|
||||
</label>
|
||||
diff --git a/src/components/shared/chip.tsx b/src/components/shared/chip.tsx
|
||||
index c1637e6..e29d49d 100644
|
||||
--- a/src/components/shared/chip.tsx
|
||||
+++ b/src/components/shared/chip.tsx
|
||||
@@ -7,7 +7,7 @@ interface ChipProps {
|
||||
|
||||
const CHIP_TONE_CLASS: Record<NonNullable<ChipProps['tone']>, string> = {
|
||||
default: 'border-border-soft bg-surface-muted/75 text-text-body',
|
||||
- status: 'border-cyan-300/30 bg-cyan-500/20 text-cyan-50',
|
||||
+ status: 'border-zinc-300/30 bg-zinc-500/20 text-zinc-100',
|
||||
priority: 'border-amber-300/30 bg-amber-500/20 text-amber-50',
|
||||
};
|
||||
|
||||
diff --git a/tailwind.config.ts b/tailwind.config.ts
|
||||
index 5ad9067..953965c 100644
|
||||
--- a/tailwind.config.ts
|
||||
+++ b/tailwind.config.ts
|
||||
@@ -5,8 +5,8 @@ const config: Config = {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
- ui: ['Segoe UI', 'Inter', 'system-ui', 'sans-serif'],
|
||||
- mono: ['JetBrains Mono', 'Consolas', 'monospace'],
|
||||
+ ui: ['var(--font-ui)', 'Segoe UI', 'Inter', 'system-ui', 'sans-serif'],
|
||||
+ mono: ['var(--font-mono)', 'Consolas', 'monospace'],
|
||||
},
|
||||
colors: {
|
||||
bg: 'var(--color-bg)',
|
||||
diff --git a/tests/guards/kanban-responsive-contract.test.mjs b/tests/guards/kanban-responsive-contract.test.mjs
|
||||
index 4e02f28..3efabf4 100644
|
||||
--- a/tests/guards/kanban-responsive-contract.test.mjs
|
||||
+++ b/tests/guards/kanban-responsive-contract.test.mjs
|
||||
@@ -9,11 +9,12 @@ async function read(relativePath) {
|
||||
return fs.readFile(path.join(ROOT, relativePath), 'utf8');
|
||||
}
|
||||
|
||||
-test('kanban board uses intentional horizontal scroll affordances', async () => {
|
||||
+test('kanban board uses expandable vertical swimlanes', async () => {
|
||||
const board = await read('src/components/kanban/kanban-board.tsx');
|
||||
|
||||
- assert.match(board, /snap-x/);
|
||||
- assert.match(board, /overflow-x-auto/);
|
||||
+ assert.match(board, /aria-expanded/);
|
||||
+ assert.match(board, /onActivateStatus/);
|
||||
+ assert.match(board, /max-h-\[50vh\]/);
|
||||
});
|
||||
|
||||
test('kanban page defines mobile detail drawer behavior', async () => {
|
||||
@@ -21,6 +22,8 @@ test('kanban page defines mobile detail drawer behavior', async () => {
|
||||
|
||||
assert.match(page, /fixed inset-0/);
|
||||
assert.match(page, /lg:hidden/);
|
||||
+ assert.match(page, /lg:grid-cols-\[minmax\(0,1fr\)_minmax\(22rem,26rem\)\]/);
|
||||
+ assert.match(page, /lg:border-l/);
|
||||
});
|
||||
|
||||
test('kanban controls use fluid full-width sizing on small viewports', async () => {
|
||||
1087
docs/archive/stashes/stash-1-agent-b-backlog.patch
Normal file
1087
docs/archive/stashes/stash-1-agent-b-backlog.patch
Normal file
File diff suppressed because it is too large
Load diff
134
docs/plans/2026-02-12-kanban-design-foundation.md
Normal file
134
docs/plans/2026-02-12-kanban-design-foundation.md
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
# BeadBoard Kanban Design System Plan
|
||||
|
||||
**Goal**
|
||||
Ship a production-ready baseline visual system for Tracer Bullet 1 (Kanban) before further feature expansion.
|
||||
|
||||
**Primary decision**
|
||||
Use **Tailwind CSS v4 + CSS tokens (`@theme`) + Framer Motion**.
|
||||
|
||||
## 1. Why This Stack
|
||||
|
||||
### Tailwind v4 is the right baseline
|
||||
- Fastest path to a coherent, reusable system in Next.js 15.
|
||||
- CSS-first token model (`@theme`) fits our need for semantic design tokens.
|
||||
- Lets us avoid scattered inline styles and ad-hoc CSS files.
|
||||
|
||||
### Framer Motion should be scoped
|
||||
- Use for state transitions (card changes, panel enter/exit, filtered results).
|
||||
- Avoid decorative over-animation that hurts readability in a dense dashboard.
|
||||
|
||||
### Risks and mitigations
|
||||
- Risk: “generic Tailwind look”
|
||||
- Mitigation: strict token palette + component contracts + typography rules.
|
||||
- Risk: visual inconsistency
|
||||
- Mitigation: no direct color literals in component markup except token definitions.
|
||||
|
||||
## 2. Baseline-First Sequencing
|
||||
|
||||
This should happen **now**, not later.
|
||||
|
||||
1. Foundation (tokens, layout, core components)
|
||||
2. Motion and interaction polish
|
||||
3. Accessibility and responsive hardening
|
||||
4. Continue other tracer bullets
|
||||
|
||||
## 3. Visual Language (v1)
|
||||
|
||||
### Product feel
|
||||
- High-signal operations UI.
|
||||
- Calm neutral surfaces with sharp status accents.
|
||||
- Dense information without visual clutter.
|
||||
|
||||
### Typography
|
||||
- Primary UI: `DM Sans` (or `Inter` fallback decision at implementation time)
|
||||
- Metadata/IDs: `JetBrains Mono`
|
||||
|
||||
### Color model
|
||||
- Semantic tokens only:
|
||||
- `background`, `foreground`, `surface`, `muted`
|
||||
- `status-open`, `status-in-progress`, `status-blocked`, `status-deferred`, `status-closed`
|
||||
- `priority-p0` ... `priority-p4`
|
||||
- Contrast target: at least WCAG AA for normal text.
|
||||
|
||||
## 4. Component Contract
|
||||
|
||||
Required first-class components:
|
||||
- `KanbanPageShell`
|
||||
- `KanbanControls`
|
||||
- `KanbanBoard`
|
||||
- `KanbanColumn`
|
||||
- `KanbanCard`
|
||||
- `KanbanDetailPanel`
|
||||
- shared: `Badge`, `Chip`, `StatPill`
|
||||
|
||||
Rules:
|
||||
- Component variants defined via class composition (CVA optional but preferred).
|
||||
- No inline style objects for production components.
|
||||
- All spacing/radius/shadow/color come from tokens/utilities.
|
||||
|
||||
## 5. Layout Contract
|
||||
|
||||
### Desktop
|
||||
- Sticky top header with filters + stats.
|
||||
- Main grid: board + detail panel.
|
||||
- Columns scroll horizontally as needed.
|
||||
|
||||
### Mobile
|
||||
- Stacked controls.
|
||||
- Board in horizontal swipe/scroll mode.
|
||||
- Detail panel becomes full-screen drawer.
|
||||
|
||||
## 6. Motion Contract (Framer Motion)
|
||||
|
||||
Use motion for:
|
||||
- Card appear/disappear on filtering.
|
||||
- Detail panel slide-in/out.
|
||||
- Subtle status count transitions.
|
||||
|
||||
Do not animate:
|
||||
- Global page container on every render.
|
||||
- Constant hover effects that reduce legibility/performance.
|
||||
|
||||
## 7. Tailwind v4 Implementation Plan
|
||||
|
||||
### Phase A: Design System Foundation (P0)
|
||||
- Add Tailwind v4 pipeline with `@import "tailwindcss"` in global stylesheet.
|
||||
- Define `@theme` token set (colors, radius, spacing aliases, shadows, motion tokens).
|
||||
- Add base layer typography/background defaults.
|
||||
- Replace inline styles in tracer-1 components with tokenized Tailwind classes.
|
||||
|
||||
Acceptance criteria:
|
||||
- No inline style usage in `src/components/kanban/*` and shared primitives (except truly dynamic edge cases).
|
||||
- UI at `localhost:3000` has coherent baseline styling.
|
||||
|
||||
### Phase B: Motion + Interaction Polish (P1)
|
||||
- Integrate Framer Motion transitions for board and panel.
|
||||
- Improve visual hierarchy of card metadata.
|
||||
- Add polished empty/loading/error states.
|
||||
|
||||
Acceptance criteria:
|
||||
- Motion communicates state changes without jitter.
|
||||
- Filtering and detail interactions feel intentional.
|
||||
|
||||
### Phase C: Accessibility + Responsive Hardening (P1)
|
||||
- Keyboard focus and traversal for cards/panel.
|
||||
- Verify color contrast and focus visibility.
|
||||
- Tune mobile breakpoints and touch targets.
|
||||
|
||||
Acceptance criteria:
|
||||
- Keyboard-only flow works for core Kanban actions.
|
||||
- Mobile experience is usable and visually consistent.
|
||||
|
||||
## 8. Technical Boundaries
|
||||
|
||||
- Read path remains `.beads/issues.jsonl` / `.beads/issues.jsonl.new`.
|
||||
- No direct write path to JSONL.
|
||||
- Styling changes must not alter read/write boundary behavior.
|
||||
|
||||
## 9. Definition of Done (Tracer-1 Design Baseline)
|
||||
|
||||
- Tailwind v4 configured and used as primary styling framework.
|
||||
- Tokenized design system applied across tracer-1 Kanban components.
|
||||
- Framer Motion integrated for key transitions.
|
||||
- Tests/typecheck pass, app runs on `localhost:3000`.
|
||||
- Visual result is clearly beyond prototype/demo quality.
|
||||
139
docs/plans/2026-02-13-multi-project-ui-contract.md
Normal file
139
docs/plans/2026-02-13-multi-project-ui-contract.md
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
# Multi-Project UI Contract (bb-6aj.6)
|
||||
|
||||
Date: 2026-02-13
|
||||
Owner: `bb-6aj.6`
|
||||
Scope: Convert existing registry/scanner backend into stable, user-facing project scope workflows for Kanban and Graph.
|
||||
|
||||
## 1) Product Workflow Contract
|
||||
|
||||
Primary user flow:
|
||||
1. Select project scope (`local` or one registry project).
|
||||
2. View project-scoped issues in Kanban and Graph using the same selected scope.
|
||||
3. Manage registry roots (add/remove) from a single shared manager panel.
|
||||
4. Discover import candidates from safe scan roots and add selected projects to registry.
|
||||
5. (Later phase) Optionally switch to aggregate mode for cross-project read-only views.
|
||||
|
||||
Rules:
|
||||
- Project scope is always explicit in URL query (`?project=`).
|
||||
- Scope change affects reads only; mutations continue to target current scope root.
|
||||
- Project scope must persist when navigating between `/` and `/graph`.
|
||||
- No direct JSONL writes at any point.
|
||||
|
||||
## 2) Screen Ownership
|
||||
|
||||
Kanban (`/`):
|
||||
- Primary triage and mutation workspace.
|
||||
- Owns project scope picker entry point and registry manager entry.
|
||||
- Displays selected project context in header.
|
||||
|
||||
Graph (`/graph`):
|
||||
- Readability-first dependency exploration for selected scope.
|
||||
- Shares same project scope query + model as Kanban.
|
||||
- Must preserve current scope when linking back to Kanban.
|
||||
|
||||
Shared controls:
|
||||
- Project scope resolver utility (server-safe).
|
||||
- URL serialization/parsing for project selection.
|
||||
- Scope badge/pill visual token (consistent across pages).
|
||||
|
||||
Out of scope for this contract:
|
||||
- Timeline/session epics behavior changes.
|
||||
- Full graph redesign beyond scope persistence/read correctness.
|
||||
|
||||
## 3) URL and State Contract
|
||||
|
||||
Query key:
|
||||
- `project`: scope key string.
|
||||
|
||||
Key encoding:
|
||||
- `local`: current workspace (`process.cwd()`).
|
||||
- registry project: `windowsPathKey(project.path)` derived from registry entry.
|
||||
|
||||
Resolution behavior:
|
||||
1. Build scope options: `local` + registry projects (dedup by key).
|
||||
2. If `project` query matches an option key, use that root.
|
||||
3. If missing or invalid, fallback to `local`.
|
||||
4. Reads use resolved root; returned issues include resolved `project` context.
|
||||
|
||||
Navigation behavior:
|
||||
- `Kanban -> Graph` carries `?project=<key>`.
|
||||
- `Graph -> Kanban` carries `?project=<key>`.
|
||||
- Any scope picker action rewrites URL query without losing active route.
|
||||
|
||||
## 4) Aggregate Mode Contract (Deferred to bb-6aj.11)
|
||||
|
||||
- Aggregate mode is read-only across multiple project roots.
|
||||
- Aggregate mode does not become default.
|
||||
- Aggregate mode must visually mark per-project source on each card/row.
|
||||
- Mutations from aggregate mode are disabled until explicit target scope is chosen.
|
||||
|
||||
## 5) Empty and Error States
|
||||
|
||||
No projects in registry:
|
||||
- Show local project as selected.
|
||||
- Show non-blocking empty hint in manager panel.
|
||||
|
||||
Invalid query key:
|
||||
- Fallback to local scope.
|
||||
- Keep page functional; do not hard fail.
|
||||
|
||||
Read failure for selected root:
|
||||
- Preserve selected scope in UI.
|
||||
- Show actionable error card with path and retry affordance.
|
||||
|
||||
Scanner failures:
|
||||
- Show root-level failure summary with skipped/error counts.
|
||||
- Do not alter current selection automatically.
|
||||
|
||||
## 6) Phased Execution Plan
|
||||
|
||||
Phase A (`bb-6aj.7`): Shared scope model + URL persistence
|
||||
- Add server utility to resolve active scope from query + registry.
|
||||
- Wire `/` and `/graph` to resolved scope.
|
||||
- Ensure cross-links preserve `?project=`.
|
||||
|
||||
Phase B (`bb-6aj.8`): Registry manager panel
|
||||
- List registry projects with add/remove actions.
|
||||
- Validate Windows absolute paths with clear error text.
|
||||
|
||||
Phase C (`bb-6aj.9`): Scanner UX
|
||||
- Scan safe roots with mode/depth controls.
|
||||
- Allow selective import to registry.
|
||||
|
||||
Phase D (`bb-6aj.10`): Project-scoped reads in core views
|
||||
- Ensure all read endpoints/components use selected scope root.
|
||||
- Validate no regressions in local-only flow.
|
||||
|
||||
Phase E (`bb-6aj.11`): Aggregate mode
|
||||
- Add cross-project read view with project badges.
|
||||
- Keep mutations disabled without explicit project selection.
|
||||
|
||||
Phase F (`bb-6aj.12`): Verification and guardrails
|
||||
- Typecheck + tests + Playwright evidence at required breakpoints.
|
||||
|
||||
## 7) Acceptance Criteria Matrix
|
||||
|
||||
Functional:
|
||||
- Same `project` key yields same root and same issue set on both pages.
|
||||
- Invalid `project` key falls back to local root cleanly.
|
||||
- Route transitions preserve selected scope.
|
||||
|
||||
Boundary safety:
|
||||
- No direct `.beads/issues.jsonl` writes.
|
||||
- Existing mutation API boundaries unchanged.
|
||||
|
||||
Verification:
|
||||
- Unit tests for scope resolution and URL behavior.
|
||||
- Existing tests remain green.
|
||||
- Playwright captures prove scope persistence on `/` and `/graph`.
|
||||
|
||||
## 8) Risks and Mitigations
|
||||
|
||||
Risk: Query value leaks absolute paths.
|
||||
- Mitigation: use opaque normalized key, not raw path.
|
||||
|
||||
Risk: Registry drift vs URL key stale.
|
||||
- Mitigation: deterministic fallback to local + visible selected-scope indicator.
|
||||
|
||||
Risk: UX churn from adding controls too early.
|
||||
- Mitigation: enforce phase order and keep Phase A minimal.
|
||||
4778
package-lock.json
generated
4778
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -9,9 +9,12 @@
|
|||
"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/project-context.test.ts && node --import tsx --test tests/lib/kanban.test.ts && node --import tsx --test tests/lib/read-issues.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/project-context.test.ts && node --import tsx --test tests/lib/project-scope.test.ts && node --import tsx --test tests/lib/aggregate-read.test.ts && node --import tsx --test tests/lib/kanban.test.ts && node --import tsx --test tests/lib/graph.test.ts && node --import tsx --test tests/lib/graph-view.test.ts && node --import tsx --test tests/lib/read-text-retry.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/issue-editor.test.ts && node --import tsx --test tests/lib/writeback.test.ts && node --import tsx --test tests/lib/realtime.test.ts && node --import tsx --test tests/lib/coalescer.test.ts && node --import tsx --test tests/lib/watcher.test.ts && node --import tsx --test tests/lib/registry.test.ts && node --import tsx --test tests/lib/scanner.test.ts && node --import tsx --test tests/api/projects-route.test.ts && node --import tsx --test tests/api/mutations-routes.test.ts && node --import tsx --test tests/api/events-route.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 && node --test tests/guards/graph-responsive-contract.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xyflow/react": "^12.10.0",
|
||||
"chokidar": "^5.0.0",
|
||||
"dagre": "^0.8.5",
|
||||
"framer-motion": "^11.18.2",
|
||||
"next": "15.5.7",
|
||||
"react": "19.2.1",
|
||||
|
|
@ -19,10 +22,13 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@types/dagre": "^0.7.53",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"eslint": "9.39.2",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"playwright": "^1.58.2",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.17",
|
||||
|
|
|
|||
41
scripts/capture-graph.mjs
Normal file
41
scripts/capture-graph.mjs
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { chromium } from 'playwright';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
const url = process.argv[2];
|
||||
if (!url) {
|
||||
console.error('Usage: node scripts/capture-graph.mjs <url>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const outputDir = path.join('artifacts');
|
||||
await fs.mkdir(outputDir, { recursive: true });
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
|
||||
async function screenshot(name, viewport, prepare) {
|
||||
const page = await browser.newPage({ viewport });
|
||||
await page.goto(url, { waitUntil: 'networkidle' });
|
||||
await page.waitForTimeout(1000);
|
||||
if (prepare) {
|
||||
await prepare(page);
|
||||
await page.waitForTimeout(450);
|
||||
}
|
||||
await page.screenshot({
|
||||
path: path.join(outputDir, name),
|
||||
fullPage: true,
|
||||
});
|
||||
await page.close();
|
||||
}
|
||||
|
||||
await screenshot('graph-next-1440.png', { width: 1440, height: 900 });
|
||||
await screenshot('graph-next-768.png', { width: 768, height: 1024 });
|
||||
await screenshot('graph-next-390-overview.png', { width: 390, height: 844 });
|
||||
await screenshot('graph-next-390-flow.png', { width: 390, height: 844 }, async (page) => {
|
||||
const flowButton = page.getByRole('button', { name: 'Switch to Graph' });
|
||||
if (await flowButton.count()) {
|
||||
await flowButton.first().click();
|
||||
}
|
||||
});
|
||||
|
||||
await browser.close();
|
||||
|
|
@ -19,7 +19,7 @@ const browser = await chromium.launch({ headless: true });
|
|||
|
||||
for (const shot of shots) {
|
||||
const page = await browser.newPage({ viewport: { width: shot.width, height: shot.height } });
|
||||
await page.goto(url, { waitUntil: 'networkidle' });
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(700);
|
||||
await page.screenshot({
|
||||
path: path.join('artifacts', `kanban-${shot.name}-${mode}.png`),
|
||||
|
|
|
|||
51
src/app/api/beads/_shared.ts
Normal file
51
src/app/api/beads/_shared.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { executeMutation, MutationValidationError, validateMutationPayload, type MutationOperation } from '../../../lib/mutations';
|
||||
|
||||
function badRequest(message: string, operation: MutationOperation) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
operation,
|
||||
error: {
|
||||
classification: 'bad_args',
|
||||
message,
|
||||
},
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
export async function handleMutationRequest(request: Request, operation: MutationOperation): Promise<Response> {
|
||||
let body: unknown;
|
||||
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return badRequest('Invalid JSON body.', operation);
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = validateMutationPayload(operation, body);
|
||||
const result = await executeMutation(operation, payload);
|
||||
|
||||
const status = result.ok ? 200 : result.error?.classification === 'not_found' ? 404 : 400;
|
||||
return NextResponse.json(result, { status });
|
||||
} catch (error) {
|
||||
if (error instanceof MutationValidationError) {
|
||||
return badRequest(error.message, operation);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
operation,
|
||||
error: {
|
||||
classification: 'unknown',
|
||||
message: error instanceof Error ? error.message : 'Unknown mutation error.',
|
||||
},
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
5
src/app/api/beads/close/route.ts
Normal file
5
src/app/api/beads/close/route.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { handleMutationRequest } from '../_shared';
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
return handleMutationRequest(request, 'close');
|
||||
}
|
||||
5
src/app/api/beads/comment/route.ts
Normal file
5
src/app/api/beads/comment/route.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { handleMutationRequest } from '../_shared';
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
return handleMutationRequest(request, 'comment');
|
||||
}
|
||||
5
src/app/api/beads/create/route.ts
Normal file
5
src/app/api/beads/create/route.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { handleMutationRequest } from '../_shared';
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
return handleMutationRequest(request, 'create');
|
||||
}
|
||||
24
src/app/api/beads/read/route.ts
Normal file
24
src/app/api/beads/read/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
5
src/app/api/beads/reopen/route.ts
Normal file
5
src/app/api/beads/reopen/route.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { handleMutationRequest } from '../_shared';
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
return handleMutationRequest(request, 'reopen');
|
||||
}
|
||||
5
src/app/api/beads/update/route.ts
Normal file
5
src/app/api/beads/update/route.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { handleMutationRequest } from '../_shared';
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
return handleMutationRequest(request, 'update');
|
||||
}
|
||||
99
src/app/api/events/route.ts
Normal file
99
src/app/api/events/route.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { canonicalizeWindowsPath } from '../../../lib/pathing';
|
||||
import { issuesEventBus, SSE_CONNECTED_FRAME, SSE_HEARTBEAT_FRAME, toSseFrame } from '../../../lib/realtime';
|
||||
import { getIssuesWatchManager } from '../../../lib/watcher';
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const HEARTBEAT_MS = 15_000;
|
||||
|
||||
export async function GET(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const projectRootSearchParam = url.searchParams.get('projectRoot');
|
||||
if (!projectRootSearchParam) {
|
||||
return Response.json(
|
||||
{
|
||||
ok: false,
|
||||
error: {
|
||||
classification: 'bad_args',
|
||||
message: 'The `projectRoot` query parameter is required.',
|
||||
},
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const projectRoot = canonicalizeWindowsPath(projectRootSearchParam);
|
||||
|
||||
try {
|
||||
getIssuesWatchManager().startWatch(projectRoot);
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{
|
||||
ok: false,
|
||||
error: {
|
||||
classification: 'unknown',
|
||||
message: error instanceof Error ? error.message : 'Failed to initialize watcher.',
|
||||
},
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
let cleanup = () => {};
|
||||
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
let closed = false;
|
||||
const write = (payload: string) => {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
controller.enqueue(encoder.encode(payload));
|
||||
};
|
||||
|
||||
write(SSE_CONNECTED_FRAME);
|
||||
|
||||
const unsubscribe = issuesEventBus.subscribe(
|
||||
(event) => {
|
||||
write(toSseFrame(event));
|
||||
},
|
||||
{ projectRoot },
|
||||
);
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
write(SSE_HEARTBEAT_FRAME);
|
||||
}, HEARTBEAT_MS);
|
||||
|
||||
const close = () => {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
closed = true;
|
||||
clearInterval(heartbeat);
|
||||
unsubscribe();
|
||||
request.signal.removeEventListener('abort', close);
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
// stream already closed
|
||||
}
|
||||
};
|
||||
cleanup = close;
|
||||
|
||||
request.signal.addEventListener('abort', close);
|
||||
},
|
||||
cancel() {
|
||||
// Called when client closes EventSource/reader.
|
||||
// Ensures heartbeat + subscriber cleanup always runs.
|
||||
cleanup();
|
||||
return Promise.resolve();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
});
|
||||
}
|
||||
60
src/app/api/projects/route.ts
Normal file
60
src/app/api/projects/route.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { addProject, listProjects, RegistryValidationError, removeProject } from '../../../lib/registry';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
function projectsPayload(projects: Array<{ path: string }>): { projects: Array<{ path: string }> } {
|
||||
return {
|
||||
projects: projects.map((project) => ({ path: project.path })),
|
||||
};
|
||||
}
|
||||
|
||||
async function readPathFromBody(request: Request): Promise<string> {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
throw new RegistryValidationError('Request body must be valid JSON.');
|
||||
}
|
||||
|
||||
const path = (body as { path?: unknown }).path;
|
||||
if (typeof path !== 'string' || path.trim().length === 0) {
|
||||
throw new RegistryValidationError('`path` is required and must be a non-empty string.');
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
export async function GET(): Promise<Response> {
|
||||
const projects = await listProjects();
|
||||
return NextResponse.json(projectsPayload(projects), { status: 200 });
|
||||
}
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
try {
|
||||
const projectPath = await readPathFromBody(request);
|
||||
const result = await addProject(projectPath);
|
||||
return NextResponse.json(projectsPayload(result.projects), { status: result.added ? 201 : 200 });
|
||||
} catch (error) {
|
||||
if (error instanceof RegistryValidationError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Failed to add project.' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request): Promise<Response> {
|
||||
try {
|
||||
const projectPath = await readPathFromBody(request);
|
||||
const result = await removeProject(projectPath);
|
||||
return NextResponse.json({ removed: result.removed, ...projectsPayload(result.projects) }, { status: 200 });
|
||||
} catch (error) {
|
||||
if (error instanceof RegistryValidationError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Failed to remove project.' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
44
src/app/api/scan/route.ts
Normal file
44
src/app/api/scan/route.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { scanForProjects } from '../../../lib/scanner';
|
||||
import type { ScanMode } from '../../../lib/scanner';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
function parseMode(value: string | null): ScanMode {
|
||||
if (!value || value === 'default') {
|
||||
return 'default';
|
||||
}
|
||||
|
||||
if (value === 'full-drive') {
|
||||
return 'full-drive';
|
||||
}
|
||||
|
||||
throw new Error('Invalid scan mode. Use mode=default or mode=full-drive.');
|
||||
}
|
||||
|
||||
function parseDepth(value: string | null): number | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
throw new Error('Depth must be a non-negative integer.');
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export async function GET(request: Request): Promise<Response> {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const mode = parseMode(url.searchParams.get('mode'));
|
||||
const maxDepth = parseDepth(url.searchParams.get('depth'));
|
||||
const result = await scanForProjects({ mode, maxDepth });
|
||||
return NextResponse.json(result, { status: 200 });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to scan projects.';
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
|
@ -3,21 +3,25 @@
|
|||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--color-bg: #090c14;
|
||||
--color-surface: #101827;
|
||||
--color-surface-muted: #192336;
|
||||
--color-surface-raised: #22314a;
|
||||
--color-text-strong: #f6f8ff;
|
||||
--color-text-body: #d8e0f1;
|
||||
--color-text-muted: #9caccc;
|
||||
--color-border-soft: rgba(145, 166, 204, 0.3);
|
||||
--color-border-strong: rgba(187, 209, 246, 0.62);
|
||||
--color-bg: #0b0c10;
|
||||
--color-surface: #14171f;
|
||||
--color-surface-muted: #1c212b;
|
||||
--color-surface-raised: #252b38;
|
||||
--color-text-strong: #f8fafc;
|
||||
--color-text-body: #cbd5e1;
|
||||
--color-text-muted: #94a3b8;
|
||||
--color-border-soft: rgba(255, 255, 255, 0.08);
|
||||
--color-border-strong: rgba(255, 255, 255, 0.18);
|
||||
|
||||
--aurora-blue: rgba(125, 175, 245, 0.14);
|
||||
--aurora-amber: rgba(235, 185, 125, 0.11);
|
||||
--aurora-purple: rgba(185, 125, 245, 0.08);
|
||||
|
||||
--status-open: #60a5fa;
|
||||
--status-open: #38bdf8;
|
||||
--status-progress: #fbbf24;
|
||||
--status-blocked: #fb7185;
|
||||
--status-blocked: #f43f5e;
|
||||
--status-deferred: #94a3b8;
|
||||
--status-closed: #34d399;
|
||||
--status-closed: #10b981;
|
||||
|
||||
--priority-p0: #f43f5e;
|
||||
--priority-p1: #f59e0b;
|
||||
|
|
@ -38,10 +42,173 @@ body {
|
|||
|
||||
body {
|
||||
background:
|
||||
radial-gradient(circle at 10% 12%, rgba(12, 138, 215, 0.34), transparent 36%),
|
||||
radial-gradient(circle at 84% 20%, rgba(250, 122, 91, 0.18), transparent 30%),
|
||||
radial-gradient(circle at 68% 88%, rgba(57, 189, 154, 0.14), transparent 36%),
|
||||
linear-gradient(155deg, #05070d 0%, #0b1322 42%, #121e34 100%);
|
||||
radial-gradient(circle at 10% 10%, var(--aurora-blue), transparent 40%),
|
||||
radial-gradient(circle at 90% 10%, var(--aurora-amber), transparent 40%),
|
||||
radial-gradient(circle at 50% 90%, var(--aurora-purple), transparent 50%),
|
||||
#0b0c10;
|
||||
color: var(--color-text-body);
|
||||
font-family: 'Segoe UI', 'Aptos', Inter, system-ui, sans-serif;
|
||||
font-family: 'DM Sans', 'Segoe UI', Inter, system-ui, sans-serif;
|
||||
letter-spacing: -0.011em;
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(148, 163, 184, 0.25) transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: rgba(148, 163, 184, 0.2);
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(148, 163, 184, 0.35);
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(148, 163, 184, 0.15);
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(148, 163, 184, 0.3);
|
||||
}
|
||||
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
|
||||
.workflow-card {
|
||||
border: 1px solid var(--color-border-soft);
|
||||
background: linear-gradient(165deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0.01));
|
||||
box-shadow: 0 4px 24px -2px rgba(0, 0, 0, 0.3), inset 0 1px 1px rgba(255, 255, 255, 0.03);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.workflow-card-selected {
|
||||
border-color: rgba(56, 189, 248, 0.4);
|
||||
background: linear-gradient(165deg, rgba(56, 189, 248, 0.12), rgba(15, 23, 42, 0.8));
|
||||
box-shadow: 0 12px 32px -4px rgba(0, 0, 0, 0.45), inset 0 1px 1px rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/* Shared dark form controls to avoid white-on-white browser defaults */
|
||||
.ui-field {
|
||||
border: 1px solid var(--color-border-soft);
|
||||
background: linear-gradient(160deg, rgba(28, 33, 43, 0.9), rgba(20, 23, 31, 0.92));
|
||||
color: var(--color-text-strong);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.ui-field::placeholder {
|
||||
color: color-mix(in srgb, var(--color-text-muted) 88%, transparent);
|
||||
}
|
||||
|
||||
.ui-field:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--color-border-strong);
|
||||
box-shadow:
|
||||
0 0 0 2px rgba(125, 175, 245, 0.12),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.ui-select {
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.ui-select option,
|
||||
.ui-option {
|
||||
background-color: #10141d;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
|
||||
.workflow-graph-legend {
|
||||
backdrop-filter: blur(12px);
|
||||
background: rgba(20, 23, 31, 0.72);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05), 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.workflow-graph-flow .react-flow__viewport {
|
||||
background:
|
||||
radial-gradient(circle at 20% 20%, var(--aurora-blue), transparent 45%),
|
||||
radial-gradient(circle at 80% 15%, var(--aurora-amber), transparent 45%);
|
||||
}
|
||||
|
||||
.workflow-graph-flow .react-flow__edges {
|
||||
z-index: 10 !important;
|
||||
}
|
||||
|
||||
.workflow-graph-flow .react-flow__nodes {
|
||||
z-index: 20 !important;
|
||||
}
|
||||
|
||||
.workflow-graph-flow .react-flow__edge-path {
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.workflow-graph-flow .workflow-edge-muted .react-flow__edge-path {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.workflow-graph-flow .workflow-edge-selected .react-flow__edge-path {
|
||||
opacity: 1;
|
||||
filter: drop-shadow(0 0 10px rgba(56, 189, 248, 0.6));
|
||||
}
|
||||
|
||||
.workflow-graph-flow .workflow-edge-cycle .react-flow__edge-path {
|
||||
opacity: 1;
|
||||
filter: drop-shadow(0 0 10px rgba(251, 113, 133, 0.65));
|
||||
}
|
||||
|
||||
.react-flow__edge-label {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Node selection pulse animation - sky-blue ring expands and fades */
|
||||
@keyframes node-select-pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(56, 189, 248, 0.4); }
|
||||
70% { box-shadow: 0 0 0 12px rgba(56, 189, 248, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(56, 189, 248, 0); }
|
||||
}
|
||||
.node-select-pulse {
|
||||
animation: node-select-pulse 1s ease-out;
|
||||
}
|
||||
|
||||
/* Tooltip fade-in animation */
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; transform: translateX(-50%) translateY(-4px); }
|
||||
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
}
|
||||
.animate-fade-in {
|
||||
animation: fade-in 200ms ease-out;
|
||||
}
|
||||
|
||||
/* Hide scrollbar but keep scrolling (for epic chip strip) */
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
|
|
|||
38
src/app/graph/page.tsx
Normal file
38
src/app/graph/page.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { DependencyGraphPage } from '../../components/graph/dependency-graph-page';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
import { readIssuesForScope } from '../../lib/aggregate-read';
|
||||
import { resolveProjectScope } from '../../lib/project-scope';
|
||||
import { listProjects } from '../../lib/registry';
|
||||
|
||||
interface GraphPageProps {
|
||||
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
||||
}
|
||||
|
||||
export default async function GraphPage({ searchParams }: GraphPageProps) {
|
||||
const params = (await searchParams) ?? {};
|
||||
const requestedProjectKey = typeof params.project === 'string' ? params.project : null;
|
||||
const requestedMode = typeof params.mode === 'string' ? params.mode : null;
|
||||
const registryProjects = await listProjects();
|
||||
const scope = resolveProjectScope({
|
||||
currentProjectRoot: process.cwd(),
|
||||
registryProjects,
|
||||
requestedProjectKey,
|
||||
requestedMode,
|
||||
});
|
||||
|
||||
const issues = await readIssuesForScope({
|
||||
mode: scope.mode,
|
||||
selected: scope.selected,
|
||||
scopeOptions: scope.options,
|
||||
});
|
||||
return (
|
||||
<DependencyGraphPage
|
||||
issues={issues}
|
||||
projectRoot={scope.selected.root}
|
||||
projectScopeKey={scope.selected.key}
|
||||
projectScopeOptions={scope.options}
|
||||
projectScopeMode={scope.mode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,18 @@
|
|||
import type { Metadata } from 'next';
|
||||
import { DM_Sans, JetBrains_Mono } from 'next/font/google';
|
||||
import type { ReactNode } from 'react';
|
||||
import './globals.css';
|
||||
|
||||
const dmSans = DM_Sans({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-ui',
|
||||
});
|
||||
|
||||
const jetbrainsMono = JetBrains_Mono({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-mono',
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'BeadBoard',
|
||||
description: 'Windows-native Beads dashboard',
|
||||
|
|
@ -10,7 +21,7 @@ export const metadata: Metadata = {
|
|||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
<body className={`${dmSans.variable} ${jetbrainsMono.variable}`}>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,36 @@
|
|||
import { KanbanPage } from '../components/kanban/kanban-page';
|
||||
import { readIssuesFromDisk } from '../lib/read-issues';
|
||||
import { readIssuesForScope } from '../lib/aggregate-read';
|
||||
import { resolveProjectScope } from '../lib/project-scope';
|
||||
import { listProjects } from '../lib/registry';
|
||||
|
||||
export default async function Page() {
|
||||
const issues = await readIssuesFromDisk();
|
||||
return <KanbanPage issues={issues} />;
|
||||
interface PageProps {
|
||||
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
||||
}
|
||||
|
||||
export default async function Page({ searchParams }: PageProps) {
|
||||
const params = (await searchParams) ?? {};
|
||||
const requestedProjectKey = typeof params.project === 'string' ? params.project : null;
|
||||
const requestedMode = typeof params.mode === 'string' ? params.mode : null;
|
||||
const registryProjects = await listProjects();
|
||||
const scope = resolveProjectScope({
|
||||
currentProjectRoot: process.cwd(),
|
||||
registryProjects,
|
||||
requestedProjectKey,
|
||||
requestedMode,
|
||||
});
|
||||
|
||||
const issues = await readIssuesForScope({
|
||||
mode: scope.mode,
|
||||
selected: scope.selected,
|
||||
scopeOptions: scope.options,
|
||||
});
|
||||
return (
|
||||
<KanbanPage
|
||||
issues={issues}
|
||||
projectRoot={scope.selected.root}
|
||||
projectScopeKey={scope.selected.key}
|
||||
projectScopeOptions={scope.options}
|
||||
projectScopeMode={scope.mode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
219
src/components/graph/dependency-flow-strip.tsx
Normal file
219
src/components/graph/dependency-flow-strip.tsx
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
'use client';
|
||||
|
||||
import type { GraphNode } from '../../lib/graph';
|
||||
import type { PathWorkspace } from '../../lib/graph-view';
|
||||
import type { BeadIssue } from '../../lib/types';
|
||||
|
||||
/** Props for an individual flow card in the dependency strip. */
|
||||
interface FlowCardProps {
|
||||
/** The graph node data for this card. */
|
||||
node: GraphNode;
|
||||
/** Whether this card is the currently selected/focused task. */
|
||||
selected: boolean;
|
||||
/** Number of issues blocking this node. */
|
||||
blockedBy: number;
|
||||
/** Number of issues this node blocks. */
|
||||
blocks: number;
|
||||
/** Callback fired when the user clicks this card. */
|
||||
onSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
/** Props for the DependencyFlowStrip component. */
|
||||
interface DependencyFlowStripProps {
|
||||
/** The computed path workspace containing blockers, focus, and dependents. */
|
||||
workspace: PathWorkspace;
|
||||
/** ID of the currently selected task, or null. */
|
||||
selectedId: string | null;
|
||||
/** Map of issue ID to blocker/blocks counts. */
|
||||
signalById: Map<string, { blockedBy: number; blocks: number }>;
|
||||
/** Callback fired when the user selects a card. */
|
||||
onSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Tailwind background color class for a status dot indicator.
|
||||
*/
|
||||
function statusDot(status: BeadIssue['status']): string {
|
||||
switch (status) {
|
||||
case 'open':
|
||||
return 'bg-sky-400';
|
||||
case 'in_progress':
|
||||
return 'bg-amber-400';
|
||||
case 'blocked':
|
||||
return 'bg-rose-500';
|
||||
case 'deferred':
|
||||
return 'bg-slate-400';
|
||||
case 'closed':
|
||||
return 'bg-emerald-400';
|
||||
case 'pinned':
|
||||
return 'bg-violet-400';
|
||||
case 'hooked':
|
||||
return 'bg-orange-400';
|
||||
default:
|
||||
return 'bg-zinc-500';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A compact card representing a single node in the dependency flow.
|
||||
* Shows ID, title, status, and blocker/blocks counts.
|
||||
*/
|
||||
function FlowCard({ node, selected, blockedBy, blocks, onSelect }: FlowCardProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(node.id)}
|
||||
className={`workflow-card w-full rounded-xl px-3 py-2.5 text-left transition duration-200 ${selected
|
||||
? 'workflow-card-selected'
|
||||
: 'hover:border-sky-300/40 hover:bg-[linear-gradient(165deg,rgba(76,94,134,0.2),rgba(18,20,30,0.84))]'
|
||||
}`}
|
||||
>
|
||||
{/* Header: node ID + status dot */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-mono text-[9px] tracking-[0.04em] text-text-muted/80">{node.id}</span>
|
||||
<span className={`h-2 w-2 shrink-0 rounded-full ${statusDot(node.status)}`} />
|
||||
</div>
|
||||
{/* Node title - truncates at 2 lines */}
|
||||
<p className="mt-1 text-[12px] font-semibold leading-tight text-text-strong line-clamp-2">{node.title}</p>
|
||||
{/* Dependency signal counts */}
|
||||
<p className="mt-1 text-[10px] text-text-body">
|
||||
{blockedBy} blockers • {blocks} dependents
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a section header with a count badge.
|
||||
*/
|
||||
function SectionHeader({ label, count, color }: { label: string; count: number; color: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className={`text-[10px] font-bold uppercase tracking-[0.15em] ${color}`}>{label}</span>
|
||||
<span className="rounded-md bg-white/5 px-1.5 py-0.5 text-[9px] font-bold text-text-muted/60">{count}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the dependency flow as three responsive sections stacked vertically:
|
||||
* Blocked By, Selected/Focus, and Blocks (Dependents).
|
||||
* Each section uses a responsive wrapping grid so cards never overflow.
|
||||
* On larger screens the three sections sit side-by-side; on smaller screens they stack.
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
|
||||
// ... (FlowCardProps, DependencyFlowStripProps, statusDot, FlowCard, SectionHeader definitions remain unchanged)
|
||||
|
||||
/**
|
||||
* Renders the dependency flow as three responsive sections stacked vertically:
|
||||
* Blocked By, Selected/Focus, and Blocks (Dependents).
|
||||
* Each section uses a responsive wrapping grid so cards never overflow.
|
||||
* On larger screens the three sections sit side-by-side; on smaller screens they stack.
|
||||
*/
|
||||
export function DependencyFlowStrip({ workspace, selectedId, signalById, onSelect }: DependencyFlowStripProps) {
|
||||
const [minimized, setMinimized] = useState(false);
|
||||
|
||||
// Flatten the multi-hop blocker/dependent arrays for display
|
||||
const blockerNodes = workspace.blockers.flat();
|
||||
const dependentNodes = workspace.dependents.flat();
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/5 bg-white/[0.01] px-5 py-4 ring-1 ring-white/5 transition-all duration-300">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-[10px] font-bold uppercase tracking-[0.2em] text-text-muted/70">
|
||||
Dependency Flow
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setMinimized(!minimized)}
|
||||
className="rounded p-1 hover:bg-white/5 text-text-muted transition-colors"
|
||||
title={minimized ? "Expand" : "Minimize"}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={`transition-transform duration-200 ${minimized ? 'rotate-180' : ''}`}
|
||||
>
|
||||
<polyline points="18 15 12 9 6 15"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Responsive three-column layout: stacks on mobile, side-by-side on desktop */}
|
||||
{!minimized && (
|
||||
<div className="grid gap-4 md:grid-cols-3 animate-in fade-in slide-in-from-top-2 duration-200">
|
||||
{/* Blocked By section */}
|
||||
<div>
|
||||
<SectionHeader label="Blocked By" count={blockerNodes.length} color="text-rose-400/70" />
|
||||
{blockerNodes.length > 0 ? (
|
||||
<div className="grid gap-2 grid-cols-[repeat(auto-fill,minmax(12rem,1fr))]">
|
||||
{blockerNodes.map((node) => (
|
||||
<FlowCard
|
||||
key={node.id}
|
||||
node={node}
|
||||
selected={selectedId === node.id}
|
||||
blockedBy={signalById.get(node.id)?.blockedBy ?? 0}
|
||||
blocks={signalById.get(node.id)?.blocks ?? 0}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-white/5 bg-white/[0.01] px-3 py-4 text-center">
|
||||
<p className="text-[10px] text-text-muted/40">No blockers</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selected / Focused task section */}
|
||||
<div>
|
||||
<SectionHeader label="Selected" count={workspace.focus ? 1 : 0} color="text-sky-400/70" />
|
||||
{workspace.focus ? (
|
||||
<FlowCard
|
||||
node={workspace.focus}
|
||||
selected
|
||||
blockedBy={signalById.get(workspace.focus.id)?.blockedBy ?? 0}
|
||||
blocks={signalById.get(workspace.focus.id)?.blocks ?? 0}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-white/5 bg-white/[0.01] px-3 py-4 text-center">
|
||||
<p className="text-[10px] text-text-muted/40">Select a task</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Blocks (Dependents) section */}
|
||||
<div>
|
||||
<SectionHeader label="Blocks" count={dependentNodes.length} color="text-amber-400/70" />
|
||||
{dependentNodes.length > 0 ? (
|
||||
<div className="grid gap-2 grid-cols-[repeat(auto-fill,minmax(12rem,1fr))]">
|
||||
{dependentNodes.map((node) => (
|
||||
<FlowCard
|
||||
key={node.id}
|
||||
node={node}
|
||||
selected={selectedId === node.id}
|
||||
blockedBy={signalById.get(node.id)?.blockedBy ?? 0}
|
||||
blocks={signalById.get(node.id)?.blocks ?? 0}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-white/5 bg-white/[0.01] px-3 py-4 text-center">
|
||||
<p className="text-[10px] text-text-muted/40">No dependents</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
920
src/components/graph/dependency-graph-page.tsx
Normal file
920
src/components/graph/dependency-graph-page.tsx
Normal file
|
|
@ -0,0 +1,920 @@
|
|||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
MarkerType,
|
||||
Position,
|
||||
ReactFlowProvider,
|
||||
type Edge,
|
||||
type Node,
|
||||
type NodeMouseHandler,
|
||||
type NodeTypes,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import dagre from 'dagre';
|
||||
|
||||
import { EpicChipStrip } from './epic-chip-strip';
|
||||
import { WorkflowTabs, type WorkflowTab } from './workflow-tabs';
|
||||
import { TaskCardGrid, type BlockerDetail } from './task-card-grid';
|
||||
import { TaskDetailsDrawer } from './task-details-drawer';
|
||||
import { DependencyFlowStrip } from './dependency-flow-strip';
|
||||
import { GraphNodeCard, type GraphNodeData } from './graph-node-card';
|
||||
import { GraphSection } from './graph-section';
|
||||
import { ProjectScopeControls } from '../shared/project-scope-controls';
|
||||
|
||||
import { buildGraphModel, type GraphNode } from '../../lib/graph';
|
||||
import {
|
||||
buildPathWorkspace,
|
||||
type GraphHopDepth,
|
||||
analyzeBlockedChain,
|
||||
detectDependencyCycles,
|
||||
} from '../../lib/graph-view';
|
||||
import { buildBlockedByTree, type BlockedTreeNode } from '../../lib/kanban';
|
||||
import { type BeadIssue } from '../../lib/types';
|
||||
import type { ProjectScopeOption } from '../../lib/project-scope';
|
||||
|
||||
/** Props for the DependencyGraphPage component. */
|
||||
interface DependencyGraphPageProps {
|
||||
/** All issues in the project. */
|
||||
issues: BeadIssue[];
|
||||
/** The project root key for graph model construction. */
|
||||
projectRoot: string;
|
||||
/** URL scope key (local or registry key). */
|
||||
projectScopeKey: string;
|
||||
/** Available scope options for context rendering. */
|
||||
projectScopeOptions: ProjectScopeOption[];
|
||||
/** Scope mode selection from URL (single/aggregate). */
|
||||
projectScopeMode: 'single' | 'aggregate';
|
||||
}
|
||||
|
||||
/** Available hop depth values for the depth selector. */
|
||||
const DEPTH_OPTIONS: GraphHopDepth[] = [1, 2, 'full'];
|
||||
|
||||
|
||||
/**
|
||||
* Positions nodes using the Dagre graph layout engine.
|
||||
* This respects dependency direction (Left-to-Right) and creates a true flowchart.
|
||||
*/
|
||||
function layoutDagre(nodes: Node<GraphNodeData>[], edges: Edge[]): Node<GraphNodeData>[] {
|
||||
const dagreGraph = new dagre.graphlib.Graph();
|
||||
dagreGraph.setDefaultEdgeLabel(() => ({}));
|
||||
|
||||
// Set layout direction: 'LR' = Left-to-Right (Blocker -> Blocked)
|
||||
dagreGraph.setGraph({ rankdir: 'LR' });
|
||||
|
||||
// Node dimensions (must match Card dimensions + some padding?)
|
||||
// Card is ~280x120?
|
||||
// We can be precise or approximate.
|
||||
const nodeWidth = 320;
|
||||
const nodeHeight = 150;
|
||||
|
||||
for (const node of nodes) {
|
||||
dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight });
|
||||
}
|
||||
|
||||
for (const edge of edges) {
|
||||
dagreGraph.setEdge(edge.source, edge.target);
|
||||
}
|
||||
|
||||
dagre.layout(dagreGraph);
|
||||
|
||||
// Apply positions back to nodes
|
||||
// Dagre gives center coordinates (x, y). ReactFlow expects top-left?
|
||||
// ReactFlow handles position as top-left by default.
|
||||
// Wait, Dagre node `x,y` is the CENTER of the node?
|
||||
// Let's check docs or common knowledge. Yes, Dagre usually returns center.
|
||||
// ReactFlow nodes position is Top-Left.
|
||||
return nodes.map((node) => {
|
||||
const nodeWithPosition = dagreGraph.node(node.id);
|
||||
return {
|
||||
...node,
|
||||
position: {
|
||||
x: nodeWithPosition.x - nodeWidth / 2,
|
||||
y: nodeWithPosition.y - nodeHeight / 2,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Main Workflow Explorer page component.
|
||||
* Provides a tabbed interface for browsing tasks and visualizing dependencies.
|
||||
*
|
||||
* Layout structure:
|
||||
* - Header: title + navigation
|
||||
* - Toolbar: hop depth, filters, epic chips, tab switcher
|
||||
* - Tasks tab: responsive card grid + details drawer
|
||||
* - Dependencies tab: flow strip + ReactFlow graph
|
||||
*/
|
||||
export function DependencyGraphPage({
|
||||
issues,
|
||||
projectRoot,
|
||||
projectScopeKey,
|
||||
projectScopeOptions,
|
||||
projectScopeMode,
|
||||
}: DependencyGraphPageProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
// --- State ---
|
||||
const [selectedEpicId, setSelectedEpicId] = useState<string | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [depth, setDepth] = useState<GraphHopDepth>(2);
|
||||
const [hideClosed, setHideClosed] = useState(false);
|
||||
const [showBlockingOnly, setShowBlockingOnly] = useState(false);
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<WorkflowTab>('tasks');
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
// Task-specific: sort ready (actionable) tasks to the top
|
||||
const [sortReadyFirst, setSortReadyFirst] = useState(true);
|
||||
// Mobile panel toggle (preserved for mobile responsiveness)
|
||||
const [mobilePanel, setMobilePanel] = useState<'overview' | 'flow'>('overview');
|
||||
const requestedEpicId = searchParams.get('epic');
|
||||
const requestedTaskId = searchParams.get('task');
|
||||
const requestedTab = searchParams.get('tab');
|
||||
const kanbanHref = useMemo(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (projectScopeMode !== 'single') {
|
||||
params.set('mode', projectScopeMode);
|
||||
}
|
||||
if (projectScopeKey !== 'local') {
|
||||
params.set('project', projectScopeKey);
|
||||
}
|
||||
const query = params.toString();
|
||||
return query ? `/?${query}` : '/';
|
||||
}, [projectScopeKey, projectScopeMode]);
|
||||
const activeScope = useMemo(
|
||||
() => projectScopeOptions.find((option) => option.key === projectScopeKey) ?? projectScopeOptions[0] ?? null,
|
||||
[projectScopeKey, projectScopeOptions],
|
||||
);
|
||||
|
||||
// --- Derived data: epics ---
|
||||
const epics = useMemo(
|
||||
() =>
|
||||
issues
|
||||
.filter((issue) => issue.issue_type === 'epic')
|
||||
.sort((a, b) => {
|
||||
// Push closed epics to the end
|
||||
if (a.status === 'closed' && b.status !== 'closed') return 1;
|
||||
if (b.status === 'closed' && a.status !== 'closed') return -1;
|
||||
return a.id.localeCompare(b.id);
|
||||
}),
|
||||
[issues],
|
||||
);
|
||||
|
||||
// --- Derived data: tasks grouped by parent epic ---
|
||||
const tasksByEpic = useMemo(() => {
|
||||
const map = new Map<string, BeadIssue[]>();
|
||||
// Initialize empty arrays for each epic
|
||||
for (const epic of epics) {
|
||||
map.set(epic.id, []);
|
||||
}
|
||||
|
||||
// Assign each non-epic issue to its parent epic
|
||||
for (const issue of issues) {
|
||||
if (issue.issue_type === 'epic') continue;
|
||||
const parentDep = issue.dependencies.find((dep) => dep.type === 'parent');
|
||||
const candidateEpicId = parentDep?.target ?? (issue.id.includes('.') ? issue.id.split('.')[0] : null);
|
||||
if (candidateEpicId && map.has(candidateEpicId)) {
|
||||
map.get(candidateEpicId)?.push(issue);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort tasks within each epic: filter by closed status, then by priority
|
||||
for (const [epicId, children] of map.entries()) {
|
||||
map.set(
|
||||
epicId,
|
||||
children
|
||||
.filter((x) => (!hideClosed ? true : x.status !== 'closed'))
|
||||
.sort((a, b) => {
|
||||
const priorityDiff = a.priority - b.priority;
|
||||
if (priorityDiff !== 0) return priorityDiff;
|
||||
return a.id.localeCompare(b.id);
|
||||
}),
|
||||
);
|
||||
}
|
||||
return map;
|
||||
}, [epics, hideClosed, issues]);
|
||||
|
||||
const beadCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const epic of epics) {
|
||||
counts.set(epic.id, tasksByEpic.get(epic.id)?.length ?? 0);
|
||||
}
|
||||
return counts;
|
||||
}, [epics, tasksByEpic]);
|
||||
|
||||
// --- Derived: Map task ID to its Epic (for easy lookup) ---
|
||||
const epicByTaskId = useMemo(() => {
|
||||
const map = new Map<string, BeadIssue>();
|
||||
// Iterate tasksByEpic map
|
||||
for (const [epicId, tasks] of tasksByEpic.entries()) {
|
||||
const epic = epics.find((e) => e.id === epicId);
|
||||
if (!epic) continue;
|
||||
for (const t of tasks) {
|
||||
map.set(t.id, epic);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [epics, tasksByEpic]);
|
||||
|
||||
// --- Auto-select first epic if none selected ---
|
||||
useEffect(() => {
|
||||
if (epics.length === 0) {
|
||||
if (selectedEpicId !== null) {
|
||||
setSelectedEpicId(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const hasSelectedEpic = selectedEpicId ? epics.some((epic) => epic.id === selectedEpicId) : false;
|
||||
if (!hasSelectedEpic) {
|
||||
setSelectedEpicId(epics[0].id);
|
||||
}
|
||||
}, [epics, selectedEpicId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (requestedTab === 'tasks' || requestedTab === 'dependencies') {
|
||||
setActiveTab(requestedTab);
|
||||
}
|
||||
}, [requestedTab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!requestedEpicId) return;
|
||||
if (!epics.some((epic) => epic.id === requestedEpicId)) return;
|
||||
setSelectedEpicId(requestedEpicId);
|
||||
}, [epics, requestedEpicId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!requestedTaskId) {
|
||||
return;
|
||||
}
|
||||
if (!issues.some((issue) => issue.id === requestedTaskId)) {
|
||||
return;
|
||||
}
|
||||
setSelectedId(requestedTaskId);
|
||||
}, [issues, requestedTaskId]);
|
||||
|
||||
// If project scope changes and the selected task no longer exists, reset selection.
|
||||
useEffect(() => {
|
||||
if (!selectedId) {
|
||||
return;
|
||||
}
|
||||
if (!issues.some((issue) => issue.id === selectedId)) {
|
||||
setSelectedId(null);
|
||||
}
|
||||
}, [issues, selectedId]);
|
||||
|
||||
// --- Derived: selected epic and its tasks ---
|
||||
const selectedEpic = useMemo(() => epics.find((epic) => epic.id === selectedEpicId) ?? null, [epics, selectedEpicId]);
|
||||
const projectLevelTasks = useMemo(
|
||||
() =>
|
||||
issues
|
||||
.filter((issue) => issue.issue_type !== 'epic')
|
||||
.filter((issue) => (!hideClosed ? true : issue.status !== 'closed'))
|
||||
.sort((a, b) => {
|
||||
const priorityDiff = a.priority - b.priority;
|
||||
if (priorityDiff !== 0) {
|
||||
return priorityDiff;
|
||||
}
|
||||
return a.id.localeCompare(b.id);
|
||||
}),
|
||||
[hideClosed, issues],
|
||||
);
|
||||
|
||||
const selectedEpicTasks = useMemo(() => {
|
||||
const epicChildren = selectedEpic ? tasksByEpic.get(selectedEpic.id) ?? [] : [];
|
||||
if (epicChildren.length > 0) {
|
||||
return epicChildren;
|
||||
}
|
||||
|
||||
// Fallback: some projects have tasks but weak/missing parent links.
|
||||
// Keep the page usable by showing project-level tasks instead of a blank view.
|
||||
if (projectLevelTasks.length > 0) {
|
||||
return projectLevelTasks;
|
||||
}
|
||||
|
||||
// Last-resort fallback: if there are only epics, render epics as selectable items.
|
||||
return epics.filter((epic) => (!hideClosed ? true : epic.status !== 'closed'));
|
||||
}, [epics, hideClosed, projectLevelTasks, selectedEpic, tasksByEpic]);
|
||||
|
||||
const selectedEpicHasChildren = useMemo(() => {
|
||||
if (selectedEpic) {
|
||||
return (tasksByEpic.get(selectedEpic.id) ?? []).length > 0;
|
||||
}
|
||||
return false;
|
||||
}, [selectedEpic, tasksByEpic]);
|
||||
|
||||
// --- Auto-select best task when epic changes ---
|
||||
useEffect(() => {
|
||||
// Keep current selection if it remains visible in the current scope.
|
||||
if (selectedId && selectedEpicTasks.some((task) => task.id === selectedId)) {
|
||||
return;
|
||||
}
|
||||
const best = selectedEpicTasks.find((task) => task.status !== 'closed') ?? selectedEpicTasks[0] ?? null;
|
||||
if (best?.id !== selectedId) {
|
||||
setSelectedId(best?.id ?? null);
|
||||
}
|
||||
}, [selectedEpic, selectedEpicTasks, selectedId]);
|
||||
|
||||
// --- Graph model ---
|
||||
const graphModel = useMemo(() => buildGraphModel(issues, { projectKey: projectRoot }), [issues, projectRoot]);
|
||||
|
||||
// --- Signal map: blocker/blocks counts per issue ---
|
||||
const signalById = useMemo(() => {
|
||||
const map = new Map<string, { blockedBy: number; blocks: number }>();
|
||||
for (const issue of issues) {
|
||||
const adjacency = graphModel.adjacency[issue.id];
|
||||
map.set(issue.id, {
|
||||
blockedBy: adjacency?.incoming.length ?? 0,
|
||||
blocks: adjacency?.outgoing.length ?? 0,
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}, [graphModel.adjacency, issues]);
|
||||
|
||||
|
||||
|
||||
// --- Blocker chain analysis for selected node ---
|
||||
const blockerAnalysis = useMemo(() => {
|
||||
if (!selectedId) return null;
|
||||
return analyzeBlockedChain(graphModel, { focusId: selectedId });
|
||||
}, [graphModel, selectedId]);
|
||||
|
||||
// --- Cycle detection across the entire graph ---
|
||||
const cycleAnalysis = useMemo(() => {
|
||||
return detectDependencyCycles(graphModel);
|
||||
}, [graphModel]);
|
||||
const cycleNodeIdSet = useMemo(() => new Set(cycleAnalysis.cycleNodeIds), [cycleAnalysis]);
|
||||
|
||||
// --- Path workspace: blockers and dependents for the selected node ---
|
||||
const workspace = useMemo(
|
||||
() =>
|
||||
buildPathWorkspace(graphModel, {
|
||||
focusId: selectedId,
|
||||
depth,
|
||||
hideClosed,
|
||||
}),
|
||||
[depth, graphModel, hideClosed, selectedId],
|
||||
);
|
||||
|
||||
// --- Currently selected issue object ---
|
||||
const selectedIssue = useMemo(() => issues.find((issue) => issue.id === selectedId) ?? null, [issues, selectedId]);
|
||||
|
||||
// --- Compute which node IDs are in the selected dependency chain (for dimming) ---
|
||||
const chainNodeIds = useMemo(() => {
|
||||
if (!selectedId || !blockerAnalysis) return new Set<string>();
|
||||
const ids = new Set<string>([selectedId, ...blockerAnalysis.blockerNodeIds]);
|
||||
// Also include dependents
|
||||
for (const node of workspace.dependents.flat()) {
|
||||
ids.add(node.id);
|
||||
}
|
||||
return ids;
|
||||
}, [selectedId, blockerAnalysis, workspace.dependents]);
|
||||
|
||||
// --- Compute actionable (unblocked) status for each node ---
|
||||
const actionableNodeIds = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
for (const issue of issues) {
|
||||
if (issue.status === 'closed') continue;
|
||||
const adjacency = graphModel.adjacency[issue.id];
|
||||
if (!adjacency) continue;
|
||||
// A node is actionable if none of its incoming "blocks" edges come from non-closed nodes
|
||||
const hasOpenBlocker = adjacency.incoming.some((edge) => {
|
||||
if (edge.type !== 'blocks') return false;
|
||||
const sourceNode = issues.find((i) => i.id === edge.source);
|
||||
return sourceNode ? sourceNode.status !== 'closed' : false;
|
||||
});
|
||||
if (!hasOpenBlocker) {
|
||||
ids.add(issue.id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}, [graphModel.adjacency, issues]);
|
||||
|
||||
// --- Sorted epic tasks: optionally sort ready/actionable tasks first ---
|
||||
const sortedEpicTasks = useMemo(() => {
|
||||
if (!sortReadyFirst) return selectedEpicTasks;
|
||||
// Partition: ready (actionable + open) first, then in-progress, then blocked, then closed
|
||||
return [...selectedEpicTasks].sort((a, b) => {
|
||||
const aReady = actionableNodeIds.has(a.id) && a.status !== 'closed';
|
||||
const bReady = actionableNodeIds.has(b.id) && b.status !== 'closed';
|
||||
// Ready tasks bubble to the top
|
||||
if (aReady && !bReady) return -1;
|
||||
if (!aReady && bReady) return 1;
|
||||
// Within same readiness group, keep original priority order
|
||||
return 0;
|
||||
});
|
||||
}, [selectedEpicTasks, actionableNodeIds, sortReadyFirst]);
|
||||
|
||||
// --- Build blocker tooltip data per node ---
|
||||
const blockerTooltipMap = useMemo(() => {
|
||||
const map = new Map<string, string[]>();
|
||||
for (const issue of issues) {
|
||||
const adjacency = graphModel.adjacency[issue.id];
|
||||
if (!adjacency) continue;
|
||||
const lines: string[] = [];
|
||||
for (const edge of adjacency.incoming) {
|
||||
if (edge.type !== 'blocks') continue;
|
||||
const source = issues.find((i) => i.id === edge.source);
|
||||
if (source && source.status !== 'closed') {
|
||||
lines.push(`${source.id} (${source.status}) - "${source.title}"`);
|
||||
}
|
||||
}
|
||||
map.set(issue.id, lines);
|
||||
}
|
||||
return map;
|
||||
}, [graphModel.adjacency, issues]);
|
||||
|
||||
// --- Detailed blocker info for task cards ---
|
||||
const blockerDetailsMap = useMemo(() => {
|
||||
const map = new Map<string, BlockerDetail[]>();
|
||||
for (const task of selectedEpicTasks) {
|
||||
const adjacency = graphModel.adjacency[task.id];
|
||||
if (!adjacency) continue;
|
||||
const details: BlockerDetail[] = [];
|
||||
for (const edge of adjacency.incoming) {
|
||||
if (edge.type !== 'blocks') continue;
|
||||
const source = issues.find((i) => i.id === edge.source);
|
||||
if (source && source.status !== 'closed') {
|
||||
const sourceEpic = epicByTaskId.get(source.id);
|
||||
details.push({
|
||||
id: source.id,
|
||||
title: source.title,
|
||||
status: source.status,
|
||||
priority: source.priority,
|
||||
epicTitle: sourceEpic?.title,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (details.length > 0) map.set(task.id, details);
|
||||
}
|
||||
return map;
|
||||
}, [graphModel.adjacency, issues, selectedEpicTasks, epicByTaskId]);
|
||||
|
||||
// --- External blocker names for each task (shown inline on nodes) ---
|
||||
const externalBlockerNames = useMemo(() => {
|
||||
const epicTaskIds = new Set(selectedEpicTasks.map((t) => t.id));
|
||||
const map = new Map<string, string[]>();
|
||||
|
||||
for (const task of selectedEpicTasks) {
|
||||
const adjacency = graphModel.adjacency[task.id];
|
||||
if (!adjacency) continue;
|
||||
const externalNames: string[] = [];
|
||||
for (const edge of adjacency.incoming) {
|
||||
if (edge.type !== 'blocks') continue;
|
||||
// Only include blockers from OUTSIDE this epic
|
||||
if (!epicTaskIds.has(edge.source) && edge.source !== selectedEpicId) {
|
||||
const source = issues.find((i) => i.id === edge.source);
|
||||
if (source && source.status !== 'closed') {
|
||||
externalNames.push(`${source.id}: ${source.title}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (externalNames.length > 0) map.set(task.id, externalNames);
|
||||
}
|
||||
return map;
|
||||
}, [graphModel.adjacency, issues, selectedEpicId, selectedEpicTasks]);
|
||||
|
||||
// --- Detailed downstream blocking info for task cards ---
|
||||
const blocksDetailsMap = useMemo(() => {
|
||||
const map = new Map<string, BlockerDetail[]>();
|
||||
for (const task of selectedEpicTasks) {
|
||||
const adjacency = graphModel.adjacency[task.id];
|
||||
if (!adjacency) continue;
|
||||
const details: BlockerDetail[] = [];
|
||||
for (const edge of adjacency.outgoing) {
|
||||
if (edge.type !== 'blocks') continue;
|
||||
const target = issues.find((i) => i.id === edge.target);
|
||||
if (target && target.status !== 'closed') {
|
||||
const targetEpic = epicByTaskId.get(target.id);
|
||||
details.push({
|
||||
id: target.id,
|
||||
title: target.title,
|
||||
status: target.status,
|
||||
priority: target.priority,
|
||||
epicTitle: targetEpic?.title,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (details.length > 0) map.set(task.id, details);
|
||||
}
|
||||
return map;
|
||||
}, [graphModel.adjacency, issues, selectedEpicTasks, epicByTaskId]);
|
||||
|
||||
// --- ReactFlow model: ONLY this epic's tasks in status lanes ---
|
||||
const flowModel = useMemo(() => {
|
||||
if (selectedEpicTasks.length === 0) {
|
||||
return { nodes: [] as Node<GraphNodeData>[], edges: [] as Edge[] };
|
||||
}
|
||||
|
||||
// SCOPED: Only the epic's own child tasks (no cross-epic workspace nodes)
|
||||
const visibleTasks = selectedEpicTasks
|
||||
.filter((issue) => (!hideClosed ? true : issue.status !== 'closed'));
|
||||
|
||||
// Build ReactFlow nodes with our custom GraphNodeData
|
||||
const baseNodes: Node<GraphNodeData>[] = visibleTasks
|
||||
.map((issue) => ({
|
||||
id: issue.id,
|
||||
data: {
|
||||
title: issue.title,
|
||||
kind: 'issue' as const,
|
||||
status: issue.status,
|
||||
priority: issue.priority,
|
||||
blockedBy: signalById.get(issue.id)?.blockedBy ?? 0,
|
||||
blocks: signalById.get(issue.id)?.blocks ?? 0,
|
||||
isActionable: actionableNodeIds.has(issue.id),
|
||||
isCycleNode: cycleNodeIdSet.has(issue.id),
|
||||
isDimmed: selectedId ? !chainNodeIds.has(issue.id) : false,
|
||||
blockerTooltipLines: externalBlockerNames.get(issue.id) ?? blockerTooltipMap.get(issue.id) ?? [],
|
||||
},
|
||||
position: { x: 0, y: 0 },
|
||||
sourcePosition: Position.Right,
|
||||
targetPosition: Position.Left,
|
||||
type: 'flowNode',
|
||||
}));
|
||||
|
||||
const visibleIds = new Set(baseNodes.map((node) => node.id));
|
||||
const graphEdges: Edge[] = [];
|
||||
|
||||
// Search ALL issues for blocking edges between visible nodes.
|
||||
// Dependencies may be stored on issues outside visibleTasks but
|
||||
// still connect two nodes that are both visible in the graph.
|
||||
for (const issue of issues) {
|
||||
for (const dep of issue.dependencies) {
|
||||
// Both endpoints must be visible in the graph
|
||||
if (!visibleIds.has(issue.id) && !visibleIds.has(dep.target)) continue;
|
||||
if (!visibleIds.has(issue.id) || !visibleIds.has(dep.target)) continue;
|
||||
// Only show blocking edges (skip parent, relates_to, etc.)
|
||||
if (dep.type !== 'blocks') continue;
|
||||
// Avoid self-loops
|
||||
if (issue.id === dep.target) continue;
|
||||
const edgeId = `${issue.id}:blocks:${dep.target}`;
|
||||
|
||||
const linkedToSelection = selectedId ? issue.id === selectedId || dep.target === selectedId : false;
|
||||
|
||||
graphEdges.push({
|
||||
id: edgeId,
|
||||
source: issue.id,
|
||||
target: dep.target,
|
||||
className: linkedToSelection ? 'workflow-edge-selected' : 'workflow-edge-muted',
|
||||
animated: linkedToSelection,
|
||||
style: {
|
||||
stroke: linkedToSelection ? '#7dd3fc' : '#fbbf24',
|
||||
strokeWidth: linkedToSelection ? 2.5 : 1.8,
|
||||
opacity: linkedToSelection ? 1 : 0.55,
|
||||
},
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: linkedToSelection ? '#7dd3fc' : '#fbbf24', width: 14, height: 14 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nodes: layoutDagre(baseNodes, graphEdges),
|
||||
edges: graphEdges,
|
||||
};
|
||||
}, [
|
||||
hideClosed, issues, selectedEpicTasks, selectedId,
|
||||
signalById, actionableNodeIds, cycleNodeIdSet,
|
||||
chainNodeIds, blockerTooltipMap, externalBlockerNames,
|
||||
]);
|
||||
|
||||
const nodeTypes: NodeTypes = useMemo(
|
||||
() => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
flowNode: GraphNodeCard as any,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
// --- Handle node click in the graph (also opens detail drawer) ---
|
||||
const handleFlowNodeClick: NodeMouseHandler = useCallback((_, node) => {
|
||||
setSelectedId(node.id);
|
||||
setDrawerOpen(true);
|
||||
}, []);
|
||||
|
||||
// --- Default edge rendering options ---
|
||||
const defaultEdgeOptions = useMemo(
|
||||
() => ({
|
||||
type: 'smoothstep' as const,
|
||||
zIndex: 40,
|
||||
interactionWidth: 24,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
// --- Handle task selection (opens drawer on Tasks tab) ---
|
||||
// If the target is in another epic or IS an epic, switch to that epic first.
|
||||
const handleTaskSelect = useCallback((id: string, shouldOpenDrawer = true) => {
|
||||
// 1. If task is already visible in current epic view, just select it
|
||||
if (selectedEpicTasks.some((t) => t.id === id)) {
|
||||
setSelectedId(id);
|
||||
if (shouldOpenDrawer) setDrawerOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. If the target IS an epic itself, switch to that epic
|
||||
const targetIsEpic = epics.some((e) => e.id === id);
|
||||
if (targetIsEpic) {
|
||||
setSelectedEpicId(id);
|
||||
// Select the epic itself so the drawer shows its details
|
||||
setSelectedId(id);
|
||||
if (shouldOpenDrawer) setDrawerOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Target is a task in another epic -- find which epic owns it
|
||||
const targetIssue = issues.find((i) => i.id === id);
|
||||
if (targetIssue) {
|
||||
// Determine parent epic: explicit parent dependency, or convention (id prefix before first dot)
|
||||
const parentDep = targetIssue.dependencies.find((dep) => dep.type === 'parent');
|
||||
const epicId = parentDep?.target ?? (targetIssue.id.includes('.') ? targetIssue.id.split('.')[0] : null);
|
||||
|
||||
if (epicId && epicId !== selectedEpicId) {
|
||||
const epicExists = epics.some((e) => e.id === epicId);
|
||||
if (epicExists) {
|
||||
// If the target is closed and we are hiding closed tasks, unhide so we can see it
|
||||
if (targetIssue.status === 'closed' && hideClosed) {
|
||||
setHideClosed(false);
|
||||
}
|
||||
|
||||
setSelectedEpicId(epicId);
|
||||
setSelectedId(id);
|
||||
if (shouldOpenDrawer) setDrawerOpen(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Fallback: select the id directly (might be orphan)
|
||||
setSelectedId(id);
|
||||
if (shouldOpenDrawer) setDrawerOpen(true);
|
||||
}, [selectedEpicTasks, selectedEpicId, issues, epics, hideClosed]);
|
||||
|
||||
// --- Handle drawer close ---
|
||||
const handleDrawerClose = useCallback(() => {
|
||||
setDrawerOpen(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-[1880px] px-4 py-4 sm:px-6 sm:py-6 lg:px-10">
|
||||
{/* Page header */}
|
||||
<header className="mb-6 rounded-3xl border border-white/5 bg-[radial-gradient(circle_at_2%_2%,rgba(56,189,248,0.12),transparent_40%),linear-gradient(170deg,rgba(15,23,42,0.92),rgba(11,12,16,0.95))] px-5 py-5 sm:px-8 sm:py-8 shadow-[0_32px_64px_-16px_rgba(0,0,0,0.6)] backdrop-blur-2xl">
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.2em] text-sky-400/70 font-bold">BeadBoard Workspace</p>
|
||||
<div className="mt-2 flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-text-strong sm:text-4xl">Workflow Explorer</h1>
|
||||
<Link href={kanbanHref} className="rounded-xl border border-white/10 bg-white/5 px-3 py-1.5 text-[10px] font-bold text-text-body transition-all hover:bg-white/10 hover:border-white/20 sm:px-4 sm:text-xs">
|
||||
← Kanban
|
||||
</Link>
|
||||
</div>
|
||||
<p className="hidden max-w-md text-sm leading-relaxed text-text-muted/90 sm:block">
|
||||
Epic-driven dependency visualization. Drill into task relationships, triage blockers, and understand downstream impact at a glance.
|
||||
</p>
|
||||
</div>
|
||||
{activeScope ? (
|
||||
<p className="mt-3 text-xs text-text-muted/90">
|
||||
Scope:{' '}
|
||||
<span className="rounded-md border border-white/10 bg-white/5 px-2 py-0.5 font-mono text-[11px] text-text-body">
|
||||
{activeScope.source === 'local' ? 'local workspace' : activeScope.displayPath}
|
||||
</span>
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-3">
|
||||
<ProjectScopeControls
|
||||
projectScopeKey={projectScopeKey}
|
||||
projectScopeMode={projectScopeMode}
|
||||
projectScopeOptions={projectScopeOptions}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main content area */}
|
||||
<section className="rounded-[2.5rem] border border-white/5 bg-[linear-gradient(180deg,rgba(255,255,255,0.015),rgba(255,255,255,0.005))] shadow-2xl backdrop-blur-sm overflow-hidden">
|
||||
{/* Toolbar row: epic chips + tab switcher */}
|
||||
<div className="flex flex-wrap items-center gap-4 border-b border-white/5 px-6 py-4 bg-white/[0.02]">
|
||||
{/* Epic chip strip - shows titles, not just IDs */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<EpicChipStrip
|
||||
epics={epics}
|
||||
selectedEpicId={selectedEpicId}
|
||||
beadCounts={beadCounts}
|
||||
onSelect={setSelectedEpicId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right side: filter toggle + stats + mobile toggle */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Filters toggle - hides power-user controls behind a button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowFilters((current) => !current)}
|
||||
className={`rounded-xl border px-3 py-1.5 text-xs font-bold transition-all ${showFilters
|
||||
? 'border-sky-400/30 bg-sky-400/10 text-sky-300'
|
||||
: 'border-white/10 bg-white/5 text-text-muted hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
Filters {showFilters ? '▴' : '▾'}
|
||||
</button>
|
||||
|
||||
{/* Mobile panel toggle */}
|
||||
<div className="md:hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMobilePanel((current) => (current === 'overview' ? 'flow' : 'overview'))}
|
||||
className="rounded-xl border border-sky-400/30 bg-sky-400/10 px-4 py-1.5 text-xs font-bold text-sky-300 transition-all hover:bg-sky-400/20"
|
||||
>
|
||||
{mobilePanel === 'overview' ? 'Switch to Graph' : 'Back to Selection'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Collapsible filters row - tab-aware: different filters per tab */}
|
||||
{showFilters ? (
|
||||
<div className="flex flex-wrap items-center gap-4 border-b border-white/5 px-6 py-3 bg-white/[0.01]">
|
||||
{/* Shared filter: Hide closed */}
|
||||
<label className="inline-flex cursor-pointer items-center gap-3 rounded-xl border border-white/10 bg-black/40 px-4 py-1.5 text-xs font-medium text-text-body transition-all hover:bg-white/5">
|
||||
<input type="checkbox" className="h-3.5 w-3.5 rounded border-white/20 bg-white/5 text-sky-500" checked={hideClosed} onChange={(event) => setHideClosed(event.target.checked)} />
|
||||
Hide closed
|
||||
</label>
|
||||
|
||||
{/* Tasks-specific filters */}
|
||||
{activeTab === 'tasks' ? (
|
||||
<label className="inline-flex cursor-pointer items-center gap-3 rounded-xl border border-white/10 bg-black/40 px-4 py-1.5 text-xs font-medium text-text-body transition-all hover:bg-white/5">
|
||||
<input type="checkbox" className="h-3.5 w-3.5 rounded border-white/20 bg-white/5 text-sky-500" checked={sortReadyFirst} onChange={(event) => setSortReadyFirst(event.target.checked)} />
|
||||
Ready first
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
{/* Dependencies-specific filters */}
|
||||
{activeTab === 'dependencies' ? (
|
||||
<>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-text-muted" htmlFor="depth-select">
|
||||
Hop Depth
|
||||
</label>
|
||||
<select
|
||||
id="depth-select"
|
||||
className="ui-field ui-select rounded-xl px-3 py-1.5 text-xs font-medium ring-sky-400/20 focus:ring-2 outline-none transition-all"
|
||||
value={String(depth)}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value;
|
||||
setDepth(value === 'full' ? 'full' : (Number(value) as 1 | 2));
|
||||
}}
|
||||
>
|
||||
{DEPTH_OPTIONS.map((option) => (
|
||||
<option className="ui-option" key={String(option)} value={String(option)}>
|
||||
{option === 'full' ? 'Infinite' : `${option} hop${option === 1 ? '' : 's'}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<label className="inline-flex cursor-pointer items-center gap-3 rounded-xl border border-white/10 bg-black/40 px-4 py-1.5 text-xs font-medium text-text-body transition-all hover:bg-white/5">
|
||||
<input type="checkbox" className="h-3.5 w-3.5 rounded border-white/20 bg-white/5 text-sky-500" checked={showBlockingOnly} onChange={(event) => setShowBlockingOnly(event.target.checked)} />
|
||||
Blocking path only
|
||||
</label>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Tab switcher row + selected epic context */}
|
||||
<div className="hidden md:flex items-center justify-between border-b border-white/5 px-6 py-3 bg-white/[0.01]">
|
||||
<WorkflowTabs activeTab={activeTab} onTabChange={setActiveTab} />
|
||||
{selectedEpic ? (
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<span className="font-mono text-[10px] text-text-muted/50">{selectedEpic.id}</span>
|
||||
<span className="font-medium text-text-body truncate max-w-[20rem]">{selectedEpic.title}</span>
|
||||
{!selectedEpicHasChildren && projectLevelTasks.length > 0 ? (
|
||||
<span className="rounded-md bg-sky-400/10 px-2 py-0.5 text-[9px] font-bold uppercase tracking-wider text-sky-300/90">
|
||||
project tasks fallback
|
||||
</span>
|
||||
) : null}
|
||||
<span className="rounded-md bg-white/5 px-2 py-0.5 text-[9px] font-bold uppercase tracking-wider text-text-muted/60">
|
||||
{selectedEpicTasks.length} tasks
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* ====== MOBILE LAYOUT ====== */}
|
||||
{/* Mobile: overview panel (epic selection + task cards + dep flow) */}
|
||||
<div className={`${mobilePanel === 'overview' ? 'flex' : 'hidden'} flex-col gap-6 p-6 md:hidden`}>
|
||||
<section className="space-y-6 rounded-3xl bg-[rgba(14,20,33,0.88)] p-6 ring-1 ring-white/10 backdrop-blur-xl">
|
||||
{/* Epic selector as horizontal scroll */}
|
||||
<div>
|
||||
<h2 className="text-[10px] font-bold uppercase tracking-[0.2em] text-text-muted/70">1) Select Epic</h2>
|
||||
<div className="mt-4">
|
||||
<EpicChipStrip
|
||||
epics={epics}
|
||||
selectedEpicId={selectedEpicId}
|
||||
beadCounts={beadCounts}
|
||||
onSelect={setSelectedEpicId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selected epic info */}
|
||||
<section className="rounded-2xl bg-white/5 p-5 ring-1 ring-white/5">
|
||||
<h3 className="text-base font-bold text-text-strong">{selectedEpic?.title ?? 'No epic selected'}</h3>
|
||||
<p className="mt-1 text-xs font-medium text-text-muted/80">
|
||||
{selectedEpicTasks.length} tasks • <span className="uppercase">{selectedEpic?.status ?? 'unknown'}</span>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Task cards */}
|
||||
<section className="rounded-2xl bg-white/[0.02] p-5 ring-1 ring-white/5">
|
||||
<h3 className="mb-4 text-[10px] font-bold uppercase tracking-[0.2em] text-text-muted/70">2) Pick Task</h3>
|
||||
<div className="max-h-[30vh] overflow-y-auto overscroll-contain custom-scrollbar">
|
||||
<TaskCardGrid
|
||||
tasks={sortedEpicTasks}
|
||||
selectedId={selectedId}
|
||||
signalById={signalById}
|
||||
blockerDetailsMap={blockerDetailsMap}
|
||||
blocksDetailsMap={blocksDetailsMap}
|
||||
actionableIds={actionableNodeIds}
|
||||
onSelect={handleTaskSelect}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* ====== DESKTOP LAYOUT ====== */}
|
||||
{/* Desktop: Tasks tab content - use conditional rendering, not Tailwind dynamic classes */}
|
||||
{activeTab === 'tasks' ? (
|
||||
<div className="hidden md:block p-6">
|
||||
<TaskCardGrid
|
||||
tasks={sortedEpicTasks}
|
||||
selectedId={selectedId}
|
||||
signalById={signalById}
|
||||
blockerDetailsMap={blockerDetailsMap}
|
||||
blocksDetailsMap={blocksDetailsMap}
|
||||
actionableIds={actionableNodeIds}
|
||||
onSelect={handleTaskSelect}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Desktop: Dependencies tab content (graph only, no flow strip) */}
|
||||
{activeTab === 'dependencies' ? (
|
||||
<div className="hidden md:flex min-h-0 flex-col p-6">
|
||||
{/* Dependency Flow Strip - above graph */}
|
||||
<div className="mb-6">
|
||||
<DependencyFlowStrip
|
||||
workspace={workspace}
|
||||
selectedId={selectedId}
|
||||
signalById={signalById}
|
||||
onSelect={setSelectedId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ReactFlowProvider>
|
||||
<GraphSection
|
||||
nodes={flowModel.nodes}
|
||||
edges={flowModel.edges}
|
||||
nodeTypes={nodeTypes}
|
||||
defaultEdgeOptions={defaultEdgeOptions}
|
||||
onNodeClick={handleFlowNodeClick}
|
||||
blockerAnalysis={blockerAnalysis}
|
||||
hideClosed={hideClosed}
|
||||
/>
|
||||
</ReactFlowProvider>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Mobile: graph panel */}
|
||||
<section className={`${mobilePanel === 'flow' ? 'flex' : 'hidden'} min-h-0 flex-col border-t border-white/10 bg-[rgba(8,12,20,0.9)] p-6 backdrop-blur-xl md:hidden`}>
|
||||
<ReactFlowProvider>
|
||||
<GraphSection
|
||||
nodes={flowModel.nodes}
|
||||
edges={flowModel.edges}
|
||||
nodeTypes={nodeTypes}
|
||||
defaultEdgeOptions={defaultEdgeOptions}
|
||||
onNodeClick={handleFlowNodeClick}
|
||||
blockerAnalysis={blockerAnalysis}
|
||||
hideClosed={hideClosed}
|
||||
/>
|
||||
</ReactFlowProvider>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
{/* Task details drawer - slides in from right on task selection */}
|
||||
<TaskDetailsDrawer
|
||||
issue={selectedIssue}
|
||||
open={drawerOpen}
|
||||
onClose={handleDrawerClose}
|
||||
projectRoot={projectRoot}
|
||||
editable={projectScopeMode === 'single'}
|
||||
onIssueUpdated={() => router.refresh()}
|
||||
blockedTree={selectedIssue ? buildBlockedByTree(issues, selectedIssue.id) : undefined}
|
||||
outgoingBlocks={selectedId ? blocksDetailsMap.get(selectedId) ?? [] : []}
|
||||
onSelectBlockedIssue={handleTaskSelect}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
138
src/components/graph/epic-chip-strip.tsx
Normal file
138
src/components/graph/epic-chip-strip.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { BeadIssue } from '../../lib/types';
|
||||
|
||||
/** Props for the EpicChipStrip component. */
|
||||
interface EpicChipStripProps {
|
||||
/** List of all epic issues to display as selectable chips. */
|
||||
epics: BeadIssue[];
|
||||
/** Currently selected epic ID, or null if none selected. */
|
||||
selectedEpicId: string | null;
|
||||
/** Map of epic ID to total bead (task) count. */
|
||||
beadCounts: Map<string, number>;
|
||||
/** Callback fired when the user clicks an epic chip. */
|
||||
onSelect: (epicId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the label and color for an epic's status.
|
||||
*/
|
||||
function statusStyle(status: BeadIssue['status']): { label: string; dot: string } {
|
||||
switch (status) {
|
||||
case 'open':
|
||||
return { label: 'Open', dot: 'bg-sky-400' };
|
||||
case 'in_progress':
|
||||
return { label: 'In Progress', dot: 'bg-amber-400' };
|
||||
case 'blocked':
|
||||
return { label: 'Blocked', dot: 'bg-rose-500' };
|
||||
case 'closed':
|
||||
return { label: 'Done', dot: 'bg-emerald-400' };
|
||||
case 'deferred':
|
||||
return { label: 'Deferred', dot: 'bg-slate-400' };
|
||||
default:
|
||||
return { label: status, dot: 'bg-zinc-500' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders an epic selector as a dropdown button that expands an inline selection panel.
|
||||
* When collapsed: shows the selected epic's title as a button.
|
||||
* When expanded: shows a horizontal strip of epic cards with ID, title, and status,
|
||||
* pushing page content down naturally.
|
||||
*/
|
||||
export function EpicChipStrip({ epics, selectedEpicId, beadCounts, onSelect }: EpicChipStripProps) {
|
||||
// Track whether the epic selector panel is expanded
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
// Find the currently selected epic for the button label
|
||||
const selectedEpic = epics.find((epic) => epic.id === selectedEpicId);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Collapsed state: button showing selected epic */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
className="flex items-center gap-2.5 rounded-xl border border-white/10 bg-white/[0.04] px-4 py-2 text-left transition-all hover:bg-white/[0.07] hover:border-white/15 active:scale-[0.98] w-full"
|
||||
>
|
||||
{/* Status dot */}
|
||||
{selectedEpic ? (
|
||||
<span className={`h-2.5 w-2.5 shrink-0 rounded-full ${statusStyle(selectedEpic.status).dot}`} />
|
||||
) : null}
|
||||
|
||||
{/* Selected epic label */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="block text-[10px] font-bold uppercase tracking-wider text-text-muted/50">
|
||||
Epic
|
||||
</span>
|
||||
<span className="block truncate text-sm font-semibold text-text-strong">
|
||||
{selectedEpic ? selectedEpic.title : 'Select an epic'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Expand/collapse chevron */}
|
||||
<span className="text-text-muted/50 text-sm shrink-0">
|
||||
{expanded ? '\u25b2' : '\u25bc'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Expanded state: horizontal card strip */}
|
||||
{expanded ? (
|
||||
<div className="mt-2 rounded-2xl border border-white/8 bg-[#0c0e14]/95 p-3 shadow-[0_16px_48px_rgba(0,0,0,0.5)] backdrop-blur-lg animate-fade-in">
|
||||
<div className="grid gap-2 grid-cols-[repeat(auto-fill,minmax(14rem,1fr))]">
|
||||
{epics.map((epic) => {
|
||||
// Determine if this card is the currently selected epic
|
||||
const isSelected = epic.id === selectedEpicId;
|
||||
// Closed epics get a muted visual treatment
|
||||
const isClosed = epic.status === 'closed';
|
||||
const style = statusStyle(epic.status);
|
||||
const count = beadCounts.get(epic.id) ?? 0;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={epic.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSelect(epic.id);
|
||||
setExpanded(false);
|
||||
}}
|
||||
className={`flex flex-col gap-2 rounded-xl border px-3 py-2.5 text-left transition-all duration-200 ${isSelected
|
||||
? 'border-sky-400/40 bg-sky-400/10 ring-1 ring-sky-400/15'
|
||||
: isClosed
|
||||
? 'border-white/5 bg-white/[0.02] opacity-50 hover:opacity-80'
|
||||
: 'border-white/8 bg-white/[0.03] hover:bg-white/[0.06] hover:border-white/15'
|
||||
}`}
|
||||
>
|
||||
{/* Top row: ID + Status + Priority */}
|
||||
<div className="flex items-center justify-between gap-2 w-full">
|
||||
<span className="font-mono text-[9px] uppercase tracking-wider text-text-muted/60">{epic.id}</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex items-center gap-1 rounded-md bg-white/5 px-1.5 py-0.5">
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${style.dot}`} />
|
||||
<span className="text-[9px] font-bold uppercase tracking-wider text-text-muted/70">{style.label}</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-bold text-amber-400/80 bg-amber-400/10 px-1.5 py-0.5 rounded">P{epic.priority}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Epic title */}
|
||||
<p className={`text-[12px] font-semibold leading-tight text-text-strong line-clamp-2 ${isClosed ? 'line-through' : ''}`}>
|
||||
{epic.title}
|
||||
</p>
|
||||
|
||||
{/* Metadata Row: Bead Count */}
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-[10px] text-text-muted bg-white/5 px-2 py-0.5 rounded-full border border-white/5">
|
||||
{count} {count === 1 ? 'bead' : 'beads'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
199
src/components/graph/graph-node-card.tsx
Normal file
199
src/components/graph/graph-node-card.tsx
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Handle, Position, type NodeProps, type Node } from '@xyflow/react';
|
||||
|
||||
import type { BeadIssue } from '../../lib/types';
|
||||
|
||||
/** Data payload for each custom ReactFlow node. */
|
||||
export interface GraphNodeData {
|
||||
/** Index signature required by ReactFlow's Node<Record<string, unknown>> constraint. */
|
||||
[key: string]: unknown;
|
||||
/** Display title of the task/epic. */
|
||||
title: string;
|
||||
/** Whether this is an epic or a regular issue. */
|
||||
kind: 'epic' | 'issue';
|
||||
/** Current workflow status. */
|
||||
status: BeadIssue['status'];
|
||||
/** Priority level (0 = highest). */
|
||||
priority: number;
|
||||
/** Number of issues blocking this node. */
|
||||
blockedBy: number;
|
||||
/** Number of issues this node blocks. */
|
||||
blocks: number;
|
||||
/** Whether this node has zero open blockers and is actionable. */
|
||||
isActionable: boolean;
|
||||
/** Whether this node is part of a dependency cycle. */
|
||||
isCycleNode: boolean;
|
||||
/** Whether this node should appear dimmed (not in selected chain). */
|
||||
isDimmed: boolean;
|
||||
/** Tooltip lines describing blocker details for hover display. */
|
||||
blockerTooltipLines: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Tailwind background color class for a status dot indicator.
|
||||
*/
|
||||
function statusDot(status: BeadIssue['status']): string {
|
||||
switch (status) {
|
||||
case 'open':
|
||||
return 'bg-sky-400';
|
||||
case 'in_progress':
|
||||
return 'bg-amber-400';
|
||||
case 'blocked':
|
||||
return 'bg-rose-500';
|
||||
case 'deferred':
|
||||
return 'bg-slate-400';
|
||||
case 'closed':
|
||||
return 'bg-emerald-400';
|
||||
case 'pinned':
|
||||
return 'bg-violet-400';
|
||||
case 'hooked':
|
||||
return 'bg-orange-400';
|
||||
default:
|
||||
return 'bg-zinc-500';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the base card style class based on the node kind (epic vs issue).
|
||||
*/
|
||||
function nodeStyle(kind: GraphNodeData['kind']): string {
|
||||
return kind === 'epic'
|
||||
? 'bg-[linear-gradient(160deg,rgba(56,189,248,0.06),rgba(15,23,42,0.9))] border-sky-400/15'
|
||||
: 'bg-[linear-gradient(160deg,rgba(255,255,255,0.03),rgba(15,23,42,0.85))] border-white/8';
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom ReactFlow node component with:
|
||||
* - Status-aware styling (green glow for actionable, red ring for cycles)
|
||||
* - Hover tooltip showing blocker details or "Ready to work"
|
||||
* - Pulse animation on selection
|
||||
* - Dim effect when not in the selected dependency chain
|
||||
*/
|
||||
export function GraphNodeCard({ id, data, selected }: NodeProps<Node<GraphNodeData>>) {
|
||||
// Track hover state for tooltip visibility
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
{/* Target handle for incoming edges (from the left) */}
|
||||
<Handle type="target" position={Position.Left} className="!opacity-0" />
|
||||
|
||||
{/* Main card body */}
|
||||
<div
|
||||
className={`group w-[18.5rem] rounded-xl border px-3 py-3 text-left transition-all duration-300 ${nodeStyle(data.kind)} ${
|
||||
// Status-based left border accent for visual scanning
|
||||
data.status === 'in_progress' ? 'border-l-2 border-l-amber-400/60' :
|
||||
data.status === 'blocked' ? 'border-l-2 border-l-rose-500/60' :
|
||||
data.status === 'closed' ? 'border-l-2 border-l-emerald-400/40 opacity-60' : ''
|
||||
} ${
|
||||
// Cycle detection ring
|
||||
data.isCycleNode ? 'ring-2 ring-rose-400/55' : ''
|
||||
} ${
|
||||
// Actionable / "ready to work" glow effect
|
||||
data.isActionable && !selected
|
||||
? 'ring-1 ring-emerald-400/30 shadow-[0_0_20px_rgba(16,185,129,0.12)]'
|
||||
: ''
|
||||
} ${
|
||||
// Selected state with pulse animation
|
||||
selected
|
||||
? 'border-sky-400/50 shadow-[0_20px_48px_-8px_rgba(0,0,0,0.5)] ring-1 ring-sky-400/20 node-select-pulse'
|
||||
: 'hover:border-white/20 hover:shadow-[0_8px_32px_-4px_rgba(0,0,0,0.3)]'
|
||||
} ${
|
||||
// Dim effect for nodes not in the selected chain
|
||||
data.isDimmed ? 'opacity-30' : 'opacity-100'
|
||||
}`}
|
||||
>
|
||||
{/* Header: ID + priority + status badges */}
|
||||
<div className="flex items-center justify-between gap-2 border-b border-white/5 pb-1.5 mb-1.5">
|
||||
<span className="font-mono text-[9px] uppercase tracking-[0.12em] text-text-muted/60">{id}</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* "READY" badge for actionable nodes */}
|
||||
{data.isActionable ? (
|
||||
<span className="rounded-md bg-emerald-500/15 px-1.5 py-0.5 text-[7px] font-bold uppercase tracking-wider text-emerald-400 ring-1 ring-emerald-500/20">
|
||||
Ready
|
||||
</span>
|
||||
) : null}
|
||||
{/* Status badge: IN PROGRESS, BLOCKED, DONE */}
|
||||
{data.status === 'in_progress' ? (
|
||||
<span className="rounded-md bg-amber-400/10 px-1.5 py-0.5 text-[7px] font-bold uppercase tracking-wider text-amber-400">
|
||||
In Progress
|
||||
</span>
|
||||
) : data.status === 'blocked' ? (
|
||||
<span className="rounded-md bg-rose-400/10 px-1.5 py-0.5 text-[7px] font-bold uppercase tracking-wider text-rose-400">
|
||||
Blocked
|
||||
</span>
|
||||
) : data.status === 'closed' ? (
|
||||
<span className="rounded-md bg-emerald-400/10 px-1.5 py-0.5 text-[7px] font-bold uppercase tracking-wider text-emerald-400">
|
||||
Done
|
||||
</span>
|
||||
) : null}
|
||||
<span className="text-[9px] font-bold uppercase tracking-wider text-text-muted/40">p{data.priority}</span>
|
||||
<span className={`h-2 w-2 rounded-full ring-2 ring-black/40 ${statusDot(data.status)}`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title - strikethrough for closed tasks */}
|
||||
<p className={`text-[15px] font-bold leading-[1.2] tracking-tight text-text-strong group-hover:text-sky-100 transition-colors ${data.status === 'closed' ? 'line-through opacity-70' : ''}`}>
|
||||
{data.title}
|
||||
</p>
|
||||
|
||||
{/* Footer: show blocker names for blocked tasks, click hint for others */}
|
||||
{data.blockerTooltipLines.length > 0 ? (
|
||||
<div className="mt-2 border-t border-white/5 pt-1.5">
|
||||
<p className="text-[8px] font-bold uppercase tracking-widest text-rose-400/70 mb-0.5">Waiting on</p>
|
||||
{data.blockerTooltipLines.slice(0, 2).map((line) => (
|
||||
<p key={line} className="text-[9px] text-text-muted/70 truncate leading-tight">
|
||||
{line}
|
||||
</p>
|
||||
))}
|
||||
{data.blockerTooltipLines.length > 2 ? (
|
||||
<p className="text-[8px] text-text-muted/50">
|
||||
+{data.blockerTooltipLines.length - 2} more
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Tooltip: shown on hover with 300ms CSS delay */}
|
||||
{hovered ? (
|
||||
<div className="absolute left-1/2 top-full z-50 mt-2 -translate-x-1/2 animate-fade-in">
|
||||
<div className="max-w-xs rounded-lg border border-white/10 bg-[#0d0f14]/95 px-3 py-2 shadow-[0_12px_32px_rgba(0,0,0,0.6)] backdrop-blur-lg">
|
||||
{data.isActionable ? (
|
||||
<>
|
||||
<p className="text-[10px] font-bold text-emerald-400">Ready to work</p>
|
||||
<p className="mt-0.5 text-[10px] text-text-muted/80">
|
||||
No open blockers. {data.blocks} task{data.blocks === 1 ? '' : 's'} depend{data.blocks === 1 ? 's' : ''} on this.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-[10px] font-bold text-rose-400">
|
||||
Blocked by {data.blockedBy} task{data.blockedBy === 1 ? '' : 's'}
|
||||
</p>
|
||||
{data.blockerTooltipLines.length > 0 ? (
|
||||
<ul className="mt-1 space-y-0.5">
|
||||
{data.blockerTooltipLines.map((line) => (
|
||||
<li key={line} className="text-[9px] text-text-muted/80">
|
||||
• {line}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Source handle for outgoing edges (to the right) */}
|
||||
<Handle type="source" position={Position.Right} className="!opacity-0" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
115
src/components/graph/graph-section.tsx
Normal file
115
src/components/graph/graph-section.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
'use client';
|
||||
|
||||
import {
|
||||
Background,
|
||||
ReactFlow,
|
||||
type Edge,
|
||||
type Node,
|
||||
type NodeMouseHandler,
|
||||
type NodeTypes,
|
||||
} from '@xyflow/react';
|
||||
|
||||
import type { BlockedChainAnalysis } from '../../lib/graph-view';
|
||||
import type { GraphNodeData } from './graph-node-card';
|
||||
|
||||
/** Props for the GraphSection component. */
|
||||
interface GraphSectionProps {
|
||||
/** ReactFlow nodes with layout positions applied. */
|
||||
nodes: Node<GraphNodeData>[];
|
||||
/** ReactFlow edges connecting the nodes. */
|
||||
edges: Edge[];
|
||||
/** Map of custom node type names to their React components. */
|
||||
nodeTypes: NodeTypes;
|
||||
/** Default edge rendering options. */
|
||||
defaultEdgeOptions: {
|
||||
type: 'smoothstep';
|
||||
zIndex: number;
|
||||
interactionWidth: number;
|
||||
};
|
||||
/** Callback fired when a node is clicked in the graph. */
|
||||
onNodeClick: NodeMouseHandler;
|
||||
/** Optional blocker summary for the currently selected task. */
|
||||
blockerAnalysis?: BlockedChainAnalysis | null;
|
||||
/** Whether closed items are hidden from the graph workspace. */
|
||||
hideClosed?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the ReactFlow graph with status-lane layout.
|
||||
* Shows a compact legend and full graph viewport.
|
||||
* Nodes are positioned in columns by status: Done | In Progress | Ready | Blocked.
|
||||
*/
|
||||
export function GraphSection({
|
||||
nodes,
|
||||
edges,
|
||||
nodeTypes,
|
||||
defaultEdgeOptions,
|
||||
onNodeClick,
|
||||
blockerAnalysis,
|
||||
hideClosed = false,
|
||||
}: GraphSectionProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Compact legend + tip */}
|
||||
<div className="workflow-graph-legend flex flex-wrap items-center justify-between gap-3 rounded-xl border border-white/5 bg-white/[0.02] px-3 py-2">
|
||||
<p className="text-[10px] text-text-muted/60">
|
||||
<span className="font-bold uppercase tracking-[0.15em]">Legend</span>
|
||||
{' '}
|
||||
{!hideClosed ? (
|
||||
<>
|
||||
<span className="text-emerald-400">Done</span>
|
||||
{' \u2192 '}
|
||||
</>
|
||||
) : null}
|
||||
<span className="text-amber-400">In Progress</span>
|
||||
{' \u2192 '}
|
||||
<span className="text-cyan-400">Ready</span>
|
||||
{' \u2192 '}
|
||||
<span className="text-rose-400">Blocked</span>
|
||||
</p>
|
||||
<p className="text-[10px] text-text-muted/40">
|
||||
Click a task to see details •{' '}
|
||||
<span className="inline-block h-1 w-4 rounded bg-amber-400 align-middle" /> = blocks
|
||||
</p>
|
||||
{blockerAnalysis ? (
|
||||
<p className="text-[10px] text-text-muted/60">
|
||||
Open blockers: {blockerAnalysis.openBlockerCount}
|
||||
{' | '}
|
||||
In progress blockers: {blockerAnalysis.inProgressBlockerCount}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="w-full text-[10px] text-text-muted/55 md:w-auto md:max-w-[26rem]">
|
||||
<span className="font-semibold text-text-muted/75">Read left to right:</span>{' '}
|
||||
Left = blockers, middle = selected task, Right = work unblocked by this task.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ReactFlow graph viewport */}
|
||||
<div className="relative h-[60vh] min-h-[35rem] overflow-hidden rounded-2xl border border-white/5 bg-[radial-gradient(circle_at_50%_50%,rgba(15,23,42,0.4),rgba(5,8,15,0.8))] shadow-inner">
|
||||
<ReactFlow
|
||||
className="workflow-graph-flow"
|
||||
defaultEdgeOptions={defaultEdgeOptions}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.3 }}
|
||||
minZoom={0.3}
|
||||
maxZoom={1.5}
|
||||
translateExtent={[
|
||||
[-500, -500],
|
||||
[3000, 2500],
|
||||
]}
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
nodesDraggable={false}
|
||||
nodesConnectable={false}
|
||||
elementsSelectable
|
||||
onlyRenderVisibleElements
|
||||
onNodeClick={onNodeClick}
|
||||
>
|
||||
<Background gap={32} size={1} color="rgba(255,255,255,0.03)" />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
366
src/components/graph/task-card-grid.tsx
Normal file
366
src/components/graph/task-card-grid.tsx
Normal file
|
|
@ -0,0 +1,366 @@
|
|||
'use client';
|
||||
|
||||
import type { BeadIssue } from '../../lib/types';
|
||||
|
||||
/** Props for an individual task card in the grid. */
|
||||
/** Details for a blocker task shown on the card. */
|
||||
export interface BlockerDetail {
|
||||
id: string;
|
||||
title: string;
|
||||
status: BeadIssue['status'];
|
||||
priority: BeadIssue['priority'];
|
||||
epicTitle?: string;
|
||||
}
|
||||
|
||||
/** Props for an individual task card in the grid. */
|
||||
interface TaskCardProps {
|
||||
/** The issue data for this card. */
|
||||
issue: BeadIssue;
|
||||
/** Whether this card is the currently selected task. */
|
||||
selected: boolean;
|
||||
/** Number of issues blocking this task. */
|
||||
blockedBy: number;
|
||||
/** Number of issues this task blocks. */
|
||||
blocks: number;
|
||||
/** List of issues blocking this task. */
|
||||
blockers: BlockerDetail[];
|
||||
/** List of issues this task blocks. */
|
||||
blocking: BlockerDetail[];
|
||||
/** Whether this task is actionable (unblocked). */
|
||||
isActionable: boolean;
|
||||
/** Callback fired when the user clicks this card (or a blocker). */
|
||||
onSelect: (id: string, shouldOpenDrawer?: boolean) => void;
|
||||
}
|
||||
|
||||
/** Props for the TaskCardGrid component. */
|
||||
interface TaskCardGridProps {
|
||||
/** List of tasks to display in the grid. */
|
||||
tasks: BeadIssue[];
|
||||
/** ID of the currently selected task, or null. */
|
||||
selectedId: string | null;
|
||||
/** Map of issue ID to blocker/blocks counts. */
|
||||
signalById: Map<string, { blockedBy: number; blocks: number }>;
|
||||
/** Map of issue ID to detailed blocker info. */
|
||||
blockerDetailsMap: Map<string, BlockerDetail[]>;
|
||||
/** Map of issue ID to detailed downstream blocking info. */
|
||||
blocksDetailsMap: Map<string, BlockerDetail[]>;
|
||||
/** Set of actionable (unblocked) task IDs. */
|
||||
actionableIds: Set<string>;
|
||||
/** Callback fired when the user selects a task. */
|
||||
onSelect: (id: string, shouldOpenDrawer?: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Tailwind background color class for a status dot indicator.
|
||||
* Mirrors the statusDot function from the original monolith.
|
||||
*/
|
||||
function statusDot(status: BeadIssue['status']): string {
|
||||
switch (status) {
|
||||
case 'open':
|
||||
return 'bg-sky-400';
|
||||
case 'in_progress':
|
||||
return 'bg-amber-400';
|
||||
case 'blocked':
|
||||
return 'bg-rose-500';
|
||||
case 'deferred':
|
||||
return 'bg-slate-400';
|
||||
case 'closed':
|
||||
return 'bg-emerald-400';
|
||||
case 'pinned':
|
||||
return 'bg-violet-400';
|
||||
case 'hooked':
|
||||
return 'bg-orange-400';
|
||||
default:
|
||||
return 'bg-zinc-500';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a human-friendly label and text color class for a status.
|
||||
*/
|
||||
function statusBadge(status: BeadIssue['status'], isActionable: boolean, hasBlockers: boolean): { label: string; textColor: string; bgColor: string } {
|
||||
// If effectively blocked (has open blockers), show Blocked (unless closed/done)
|
||||
if (hasBlockers && status !== 'closed' && status !== 'in_progress') {
|
||||
return { label: 'Blocked', textColor: 'text-rose-400', bgColor: 'bg-rose-400/10' };
|
||||
}
|
||||
|
||||
// Special case: "Blocked Now Open" -> Ready
|
||||
if (status === 'blocked' && isActionable) {
|
||||
return { label: 'Ready', textColor: 'text-cyan-400', bgColor: 'bg-cyan-400/10' };
|
||||
}
|
||||
|
||||
switch (status) {
|
||||
case 'in_progress':
|
||||
return { label: 'In Progress', textColor: 'text-amber-400', bgColor: 'bg-amber-400/10' };
|
||||
case 'blocked':
|
||||
return { label: 'Blocked', textColor: 'text-rose-400', bgColor: 'bg-rose-400/10' };
|
||||
case 'closed':
|
||||
return { label: 'Done', textColor: 'text-emerald-400', bgColor: 'bg-emerald-400/10' };
|
||||
case 'deferred':
|
||||
return { label: 'Deferred', textColor: 'text-slate-400', bgColor: 'bg-slate-400/10' };
|
||||
case 'open':
|
||||
// Open with no blockers -> Ready
|
||||
return { label: 'Ready', textColor: 'text-cyan-400', bgColor: 'bg-cyan-400/10' };
|
||||
default:
|
||||
return { label: status, textColor: 'text-zinc-400', bgColor: 'bg-zinc-400/10' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a card-level border class based on status for visual distinction.
|
||||
*/
|
||||
function statusBorder(status: BeadIssue['status'], isActionable: boolean, hasBlockers: boolean): string {
|
||||
if (hasBlockers && status !== 'closed' && status !== 'in_progress') {
|
||||
return 'border-l-2 border-l-rose-500/60';
|
||||
}
|
||||
if (status === 'blocked' && isActionable) {
|
||||
return 'border-l-2 border-l-cyan-400/60';
|
||||
}
|
||||
if (status === 'open') {
|
||||
return 'border-l-2 border-l-cyan-400/60';
|
||||
}
|
||||
switch (status) {
|
||||
case 'in_progress':
|
||||
return 'border-l-2 border-l-amber-400/60';
|
||||
case 'blocked':
|
||||
return 'border-l-2 border-l-rose-500/60';
|
||||
case 'closed':
|
||||
return 'border-l-2 border-l-emerald-400/40 opacity-60';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A single task card displaying the issue ID, title, priority, type, assignee,
|
||||
* and detailed blocker list (interactive).
|
||||
*/
|
||||
function TaskCard({ issue, selected, blockedBy, blocks, blockers, blocking, isActionable, onSelect }: TaskCardProps) {
|
||||
const hasBlockers = blockers.length > 0; // Note: blockers list only contains OPEN blockers (computed in page)
|
||||
const badge = statusBadge(issue.status, isActionable, hasBlockers);
|
||||
const projectName = (issue as BeadIssue & { project?: { name?: string } }).project?.name ?? null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onSelect(issue.id, false)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onSelect(issue.id, false);
|
||||
}
|
||||
}}
|
||||
className={`workflow-card group relative flex w-full flex-col rounded-xl px-4 py-4 text-left transition duration-200 ${statusBorder(
|
||||
issue.status,
|
||||
isActionable,
|
||||
hasBlockers,
|
||||
)} ${selected
|
||||
? 'workflow-card-selected'
|
||||
: 'hover:border-sky-300/40 hover:bg-[linear-gradient(165deg,rgba(76,94,134,0.2),rgba(18,20,30,0.84))]'
|
||||
}`}
|
||||
>
|
||||
{/* Expand / Open Drawer Button */}
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-2 z-10 rounded p-1.5 text-text-muted/50 hover:bg-white/10 hover:text-sky-300 transition-colors hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelect(issue.id, true);
|
||||
}}
|
||||
title="Open Details"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="opacity-70 group-hover:opacity-100"><polyline points="15 3 21 3 21 9" /><polyline points="9 21 3 21 3 15" /><line x1="21" y1="3" x2="14" y2="10" /><line x1="3" y1="21" x2="10" y2="14" /></svg>
|
||||
</button>
|
||||
|
||||
<div className="flex w-full items-start justify-between gap-3 pr-6">
|
||||
<div className="flex flex-col gap-1.5 min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`h-2 w-2 rounded-full ${statusDot(issue.status)} ring-1 ring-white/10`} />
|
||||
<span className="font-mono text-[10px] text-text-muted">{issue.id}</span>
|
||||
{/* Status Badge */}
|
||||
<span className={`rounded px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wider ${badge.textColor} ${badge.bgColor}`}>
|
||||
{badge.label}
|
||||
</span>
|
||||
</div>
|
||||
{projectName ? (
|
||||
<div className="inline-flex w-fit rounded border border-sky-300/25 bg-sky-500/10 px-1.5 py-0.5 font-mono text-[9px] text-sky-200">
|
||||
project: {projectName}
|
||||
</div>
|
||||
) : null}
|
||||
<h3 className="line-clamp-3 text-sm font-medium leading-snug text-text-strong">
|
||||
{issue.title}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Labels */}
|
||||
{issue.labels?.length > 0 ? (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{issue.labels.map((label) => (
|
||||
<span key={label} className="rounded bg-white/5 px-1.5 py-0.5 text-[9px] font-medium text-text-muted/80 backdrop-blur-sm border border-white/5">
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* "Waiting On" section for blockers */}
|
||||
{blockers.length > 0 ? (
|
||||
<div className="mt-auto pt-2 w-full">
|
||||
<div className="rounded-lg bg-black/20 p-2 border border-white/5">
|
||||
<p className="mb-1.5 text-[9px] font-bold uppercase tracking-widest text-rose-400/80">Waiting On</p>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{blockers.map((blocker) => (
|
||||
<div
|
||||
key={blocker.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelect(blocker.id, false);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.stopPropagation();
|
||||
onSelect(blocker.id, false);
|
||||
}
|
||||
}}
|
||||
className="group relative flex flex-col gap-0.5 rounded border border-white/5 bg-white/5 px-2.5 py-2 hover:border-sky-400/30 hover:bg-white/10 transition-colors"
|
||||
>
|
||||
{/* Expand Button */}
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-1 top-1 z-10 rounded p-1 text-text-muted/50 hover:bg-white/10 hover:text-sky-300 transition-colors hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelect(blocker.id, true);
|
||||
}}
|
||||
title="Open Details"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="opacity-70 group-hover:opacity-100"><polyline points="15 3 21 3 21 9" /><polyline points="9 21 3 21 3 15" /><line x1="21" y1="3" x2="14" y2="10" /><line x1="3" y1="21" x2="10" y2="14" /></svg>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2 pr-5">
|
||||
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${statusDot(blocker.status)}`} />
|
||||
<span className="font-mono text-[9px] text-text-muted">{blocker.id}</span>
|
||||
<span className="line-clamp-1 text-[10px] font-medium text-text-body">{blocker.title}</span>
|
||||
</div>
|
||||
{blocker.epicTitle ? (
|
||||
<div className="pl-3.5 text-[9px] text-text-muted/60 truncate max-w-full pr-5">
|
||||
<span className="group-hover:text-sky-300/70 transition-colors">↳ {blocker.epicTitle}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* "Blocking" section (downstream) */}
|
||||
{blocking.length > 0 ? (
|
||||
<div className={`${blockers.length > 0 ? 'mt-2' : 'mt-auto'} w-full`}>
|
||||
<div className="rounded-lg bg-black/20 p-2 border border-white/5">
|
||||
<p className="mb-1.5 text-[9px] font-bold uppercase tracking-widest text-amber-400/80">Blocking</p>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{blocking.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelect(item.id, false);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.stopPropagation();
|
||||
onSelect(item.id, false);
|
||||
}
|
||||
}}
|
||||
className="group relative flex flex-col gap-0.5 rounded border border-white/5 bg-white/5 px-2.5 py-2 hover:border-sky-400/30 hover:bg-white/10 transition-colors"
|
||||
>
|
||||
{/* Expand Button */}
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-1 top-1 z-10 rounded p-1 text-text-muted/50 hover:bg-white/10 hover:text-sky-300 transition-colors hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelect(item.id, true);
|
||||
}}
|
||||
title="Open Details"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="opacity-70 group-hover:opacity-100"><polyline points="15 3 21 3 21 9" /><polyline points="9 21 3 21 3 15" /><line x1="21" y1="3" x2="14" y2="10" /><line x1="3" y1="21" x2="10" y2="14" /></svg>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2 pr-5">
|
||||
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${statusDot(item.status)}`} />
|
||||
<span className="font-mono text-[9px] text-text-muted">{item.id}</span>
|
||||
<span className="line-clamp-1 text-[10px] font-medium text-text-body">{item.title}</span>
|
||||
</div>
|
||||
{item.epicTitle ? (
|
||||
<div className="pl-3.5 text-[9px] text-text-muted/60 truncate max-w-full pr-5">
|
||||
<span className="group-hover:text-sky-300/70 transition-colors">↳ {item.epicTitle}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Footer Metadata: Assignee, Due Date */}
|
||||
<div className={`mt-3 flex w-full items-center justify-between border-t border-white/5 pt-3 text-[10px] text-text-muted/60`}>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Assignee */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="i-lucide-user h-3 w-3 opacity-70" />
|
||||
<span>{issue.assignee ?? 'Unassigned'}</span>
|
||||
</div>
|
||||
{/* Due Date (if exists) */}
|
||||
{issue.due_at ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="i-lucide-calendar h-3 w-3 opacity-70" />
|
||||
<span>{new Date(issue.due_at as string).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a responsive grid of task cards.
|
||||
* Uses auto-fill with minmax to prevent cards from being too narrow to read.
|
||||
*/
|
||||
export function TaskCardGrid({ tasks, selectedId, signalById, blockerDetailsMap, blocksDetailsMap, actionableIds, onSelect }: TaskCardGridProps) {
|
||||
// Show an empty state when no tasks exist in the selected epic
|
||||
if (tasks.length === 0) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] px-6 py-12 text-center">
|
||||
<p className="text-xs font-medium uppercase tracking-widest text-text-muted/50">No tasks in this epic</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 overflow-y-auto overscroll-contain pr-1 custom-scrollbar grid-cols-[repeat(auto-fill,minmax(18rem,1fr))]">
|
||||
{tasks.map((task) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
issue={task}
|
||||
selected={selectedId === task.id}
|
||||
blockedBy={signalById.get(task.id)?.blockedBy ?? 0}
|
||||
blocks={signalById.get(task.id)?.blocks ?? 0}
|
||||
blockers={blockerDetailsMap?.get(task.id) ?? []}
|
||||
blocking={blocksDetailsMap?.get(task.id) ?? []}
|
||||
isActionable={actionableIds?.has(task.id) ?? false}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
117
src/components/graph/task-details-drawer.tsx
Normal file
117
src/components/graph/task-details-drawer.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import type { BeadIssue } from '../../lib/types';
|
||||
import type { BlockedTreeNode } from '../../lib/kanban';
|
||||
import { KanbanDetail } from '../kanban/kanban-detail';
|
||||
|
||||
/** Props for the TaskDetailsDrawer component. */
|
||||
interface TaskDetailsDrawerProps {
|
||||
/** The issue to display, or null if nothing is selected. */
|
||||
issue: BeadIssue | null;
|
||||
/** Whether the drawer is open (visible). */
|
||||
open: boolean;
|
||||
/** Callback fired when the user closes the drawer. */
|
||||
onClose: () => void;
|
||||
/** Project root for mutation requests. */
|
||||
projectRoot?: string;
|
||||
/** Whether editing is enabled for the drawer. */
|
||||
editable?: boolean;
|
||||
/** Callback fired after successful save. */
|
||||
onIssueUpdated?: (issueId: string) => Promise<void> | void;
|
||||
|
||||
/** Tree of blocked issues (incoming). */
|
||||
blockedTree?: { total: number; nodes: BlockedTreeNode[] };
|
||||
/** List of issues blocked by this one (outgoing). */
|
||||
outgoingBlocks?: { id: string; title: string; status: string }[];
|
||||
/** Callback when a blocked/blocking issue is clicked. */
|
||||
onSelectBlockedIssue?: (issueId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A slide-in drawer panel from the right side that shows full task details.
|
||||
* Opens when a task is selected, closes via the X button or clicking the backdrop.
|
||||
* Uses CSS translate for the slide animation.
|
||||
*/
|
||||
export function TaskDetailsDrawer({
|
||||
issue,
|
||||
open,
|
||||
onClose,
|
||||
projectRoot,
|
||||
editable = true,
|
||||
onIssueUpdated,
|
||||
blockedTree,
|
||||
outgoingBlocks,
|
||||
onSelectBlockedIssue
|
||||
}: TaskDetailsDrawerProps) {
|
||||
// Reference for the drawer panel to manage focus trapping
|
||||
const drawerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close drawer on Escape key press
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [open, onClose]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop overlay - click to close */}
|
||||
<div
|
||||
className={`fixed inset-0 z-40 bg-black/40 backdrop-blur-sm transition-opacity duration-300 ${open ? 'opacity-100' : 'pointer-events-none opacity-0'
|
||||
}`}
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Drawer panel - slides in from right */}
|
||||
<div
|
||||
ref={drawerRef}
|
||||
className={`fixed right-0 top-0 z-50 flex h-full w-full max-w-md flex-col border-l border-white/10 bg-[#0b0c10]/95 backdrop-blur-xl shadow-[-32px_0_64px_rgba(0,0,0,0.5)] transition-transform duration-300 ease-out ${open ? 'translate-x-0' : 'translate-x-full'
|
||||
}`}
|
||||
>
|
||||
{/* Drawer header with close button */}
|
||||
<div className="flex items-center justify-between border-b border-white/5 bg-white/[0.02] px-6 py-4">
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.2em] text-text-muted/70">Task Details</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-white/10 bg-white/5 px-3 py-1 text-xs font-bold text-text-body transition-all hover:bg-white/10 active:scale-95"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable content area */}
|
||||
<div className="flex-1 overflow-y-auto overscroll-contain p-6 custom-scrollbar">
|
||||
{issue ? (
|
||||
<KanbanDetail
|
||||
issue={issue}
|
||||
framed={false}
|
||||
projectRoot={projectRoot}
|
||||
editable={editable}
|
||||
onIssueUpdated={onIssueUpdated}
|
||||
blockedTree={blockedTree}
|
||||
outgoingBlocks={outgoingBlocks}
|
||||
onSelectBlockedIssue={onSelectBlockedIssue}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-xs font-medium uppercase tracking-widest text-text-muted/50">
|
||||
Select a task to view details
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
47
src/components/graph/workflow-tabs.tsx
Normal file
47
src/components/graph/workflow-tabs.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
'use client';
|
||||
|
||||
/** The two available view tabs in the Workflow Explorer. */
|
||||
export type WorkflowTab = 'tasks' | 'dependencies';
|
||||
|
||||
/** Props for the WorkflowTabs component. */
|
||||
interface WorkflowTabsProps {
|
||||
/** The currently active tab. */
|
||||
activeTab: WorkflowTab;
|
||||
/** Callback fired when the user switches tabs. */
|
||||
onTabChange: (tab: WorkflowTab) => void;
|
||||
}
|
||||
|
||||
/** Tab label and key pairs for rendering. */
|
||||
const TAB_OPTIONS: { key: WorkflowTab; label: string }[] = [
|
||||
{ key: 'tasks', label: 'Tasks' },
|
||||
{ key: 'dependencies', label: 'Dependencies' },
|
||||
];
|
||||
|
||||
/**
|
||||
* A two-tab switcher for toggling between the Tasks view and Dependencies view.
|
||||
* Uses a pill-style indicator that slides to the active tab.
|
||||
*/
|
||||
export function WorkflowTabs({ activeTab, onTabChange }: WorkflowTabsProps) {
|
||||
return (
|
||||
<div className="inline-flex items-center gap-1 rounded-xl border border-white/8 bg-white/[0.02] p-1">
|
||||
{TAB_OPTIONS.map((tab) => {
|
||||
// Determine if this tab is currently active
|
||||
const isActive = activeTab === tab.key;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
onClick={() => onTabChange(tab.key)}
|
||||
className={`rounded-lg px-4 py-1.5 text-xs font-bold uppercase tracking-wider transition-all duration-200 ${isActive
|
||||
? 'bg-sky-400/10 text-sky-200 shadow-[0_2px_8px_rgba(56,189,248,0.1)]'
|
||||
: 'text-text-muted/60 hover:text-text-body hover:bg-white/[0.04]'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,56 +1,178 @@
|
|||
'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';
|
||||
|
||||
interface KanbanBoardProps {
|
||||
columns: Record<(typeof KANBAN_STATUSES)[number], BeadIssue[]>;
|
||||
parentEpicByIssueId: Map<string, { id: string; title: string }>;
|
||||
graphBaseHref: string;
|
||||
showClosed: boolean;
|
||||
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' },
|
||||
ready: { label: 'Ready', dot: 'bg-sky-300' },
|
||||
in_progress: { label: 'In Progress', dot: 'bg-amber-300' },
|
||||
blocked: { label: 'Blocked', dot: 'bg-rose-300' },
|
||||
deferred: { label: 'Deferred', dot: 'bg-slate-300' },
|
||||
closed: { label: 'Done', dot: 'bg-emerald-300' },
|
||||
};
|
||||
|
||||
const STATUS_COLUMN_CLASS: Record<(typeof KANBAN_STATUSES)[number], string> = {
|
||||
open: 'bg-sky-500/10',
|
||||
ready: 'bg-sky-500/10',
|
||||
in_progress: 'bg-amber-500/10',
|
||||
blocked: 'bg-rose-500/10',
|
||||
deferred: 'bg-slate-500/10',
|
||||
closed: 'bg-emerald-500/10',
|
||||
};
|
||||
|
||||
export function KanbanBoard({ columns, selectedIssueId, onSelect }: KanbanBoardProps) {
|
||||
export function KanbanBoard({
|
||||
columns,
|
||||
parentEpicByIssueId,
|
||||
graphBaseHref,
|
||||
showClosed,
|
||||
selectedIssueId,
|
||||
pendingIssueIds,
|
||||
activeStatus,
|
||||
onActivateStatus,
|
||||
onMoveIssue,
|
||||
onSelect,
|
||||
}: KanbanBoardProps) {
|
||||
const allIssues = KANBAN_STATUSES.flatMap((status) => columns[status]);
|
||||
const visibleStatuses = KANBAN_STATUSES.filter((status) => status !== 'closed' || showClosed);
|
||||
|
||||
const issueLookup = new Map(allIssues.map((issue) => [issue.id, issue]));
|
||||
|
||||
const handleExpandAndSelect = (status: KanbanStatus, issue: BeadIssue) => {
|
||||
onActivateStatus(status);
|
||||
onSelect(issue);
|
||||
};
|
||||
|
||||
const onDragStart = (issue: BeadIssue, sourceLane: KanbanStatus, event: DragEvent<HTMLElement>) => {
|
||||
event.dataTransfer.setData('application/x-bead-id', issue.id);
|
||||
event.dataTransfer.setData('application/x-bead-lane', sourceLane);
|
||||
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-lane') 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">
|
||||
{KANBAN_STATUSES.map((status) => (
|
||||
<section className="grid min-h-[58vh] gap-2.5">
|
||||
{visibleStatuses.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}
|
||||
parentEpic={parentEpicByIssueId.get(issue.id) ?? null}
|
||||
graphBaseHref={graphBaseHref}
|
||||
pending={pendingIssueIds.has(issue.id)}
|
||||
selected={selectedIssueId === issue.id}
|
||||
draggable={!pendingIssueIds.has(issue.id)}
|
||||
onNativeDragStart={(dragIssue, event) => onDragStart(dragIssue, status, event)}
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,22 @@
|
|||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { motion } from 'framer-motion';
|
||||
import type { DragEvent } from 'react';
|
||||
|
||||
import { formatUpdatedRecency } from '../../lib/kanban';
|
||||
import type { BeadIssue } from '../../lib/types';
|
||||
|
||||
import { Chip } from '../shared/chip';
|
||||
|
||||
interface KanbanCardProps {
|
||||
issue: BeadIssue;
|
||||
parentEpic?: { id: string; title: string } | null;
|
||||
graphBaseHref: string;
|
||||
selected: boolean;
|
||||
pending?: boolean;
|
||||
draggable?: boolean;
|
||||
onNativeDragStart?: (issue: BeadIssue, event: DragEvent<HTMLElement>) => void;
|
||||
onSelect: (issue: BeadIssue) => void;
|
||||
}
|
||||
|
||||
|
|
@ -19,7 +27,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,20 +35,61 @@ function priorityClass(priority: number): string {
|
|||
}
|
||||
}
|
||||
|
||||
export function KanbanCard({ issue, selected, onSelect }: KanbanCardProps) {
|
||||
export function KanbanCard({
|
||||
issue,
|
||||
parentEpic = null,
|
||||
graphBaseHref,
|
||||
selected,
|
||||
pending = false,
|
||||
draggable = false,
|
||||
onNativeDragStart,
|
||||
onSelect,
|
||||
}: KanbanCardProps) {
|
||||
const projectName = (issue as BeadIssue & { project?: { name?: string } }).project?.name ?? null;
|
||||
const unblocksCount = new Set(
|
||||
issue.dependencies.filter((dependency) => dependency.type === 'blocks').map((dependency) => dependency.target),
|
||||
).size;
|
||||
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';
|
||||
|
||||
const graphDetailHref = parentEpic
|
||||
? (() => {
|
||||
const url = new URL(graphBaseHref, 'http://localhost');
|
||||
url.searchParams.set('epic', parentEpic.id);
|
||||
url.searchParams.set('task', issue.id);
|
||||
url.searchParams.set('tab', 'tasks');
|
||||
return `${url.pathname}${url.search}`;
|
||||
})()
|
||||
: null;
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
<motion.article
|
||||
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}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
onSelect(issue);
|
||||
}
|
||||
}}
|
||||
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>
|
||||
{projectName ? (
|
||||
<div className="mt-1">
|
||||
<span className="rounded-md border border-sky-300/25 bg-sky-500/10 px-1.5 py-0.5 font-mono text-[10px] text-sky-200">
|
||||
project: {projectName}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-1 text-sm font-semibold leading-5 text-text-strong break-words">{issue.title}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
<span
|
||||
|
|
@ -50,10 +99,23 @@ export function KanbanCard({ issue, selected, onSelect }: KanbanCardProps) {
|
|||
</span>
|
||||
<Chip>{issue.issue_type}</Chip>
|
||||
<Chip tone="status">deps {issue.dependencies.length}</Chip>
|
||||
{unblocksCount > 0 ? <Chip tone="status">Unblocks {unblocksCount}</Chip> : null}
|
||||
</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>
|
||||
<div className="mt-1 font-mono text-[11px] text-text-muted">{formatUpdatedRecency(issue.updated_at)}</div>
|
||||
{parentEpic ? (
|
||||
<div className="mt-2">
|
||||
<Link
|
||||
href={graphDetailHref ?? graphBaseHref}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-sky-300/25 bg-sky-500/10 px-2 py-1 font-mono text-[11px] text-sky-200 hover:border-sky-300/45 hover:bg-sky-500/15"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
epic: {parentEpic.title}
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
{issue.labels.length > 0 ? (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{issue.labels.slice(0, 3).map((label) => (
|
||||
|
|
@ -61,6 +123,7 @@ export function KanbanCard({ issue, selected, onSelect }: KanbanCardProps) {
|
|||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</motion.button>
|
||||
{pending ? <div className="mt-2 text-[11px] font-medium text-amber-200">Saving…</div> : null}
|
||||
</motion.article>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,11 +10,19 @@ interface KanbanControlsProps {
|
|||
filters: KanbanFilterOptions;
|
||||
stats: KanbanStats;
|
||||
onFiltersChange: (filters: KanbanFilterOptions) => void;
|
||||
onNextActionable: () => void;
|
||||
nextActionableFeedback?: string | null;
|
||||
}
|
||||
|
||||
export function KanbanControls({ filters, stats, onFiltersChange }: KanbanControlsProps) {
|
||||
export function KanbanControls({
|
||||
filters,
|
||||
stats,
|
||||
onFiltersChange,
|
||||
onNextActionable,
|
||||
nextActionableFeedback = null,
|
||||
}: KanbanControlsProps) {
|
||||
const inputClass =
|
||||
'rounded-xl border border-border-soft bg-surface-muted/78 px-3 py-2.5 text-sm text-text-strong outline-none transition placeholder:text-text-muted focus:border-cyan-300/70 focus:ring-2 focus:ring-cyan-300/20';
|
||||
'ui-field rounded-xl px-3 py-2.5 text-sm outline-none transition';
|
||||
|
||||
return (
|
||||
<section className="grid gap-3">
|
||||
|
|
@ -29,47 +37,57 @@ export function KanbanControls({ filters, stats, onFiltersChange }: KanbanContro
|
|||
<select
|
||||
value={filters.type ?? ''}
|
||||
onChange={(event) => onFiltersChange({ ...filters, type: event.target.value })}
|
||||
className={`${inputClass} w-full sm:w-44`}
|
||||
className={`${inputClass} ui-select w-full sm:w-44`}
|
||||
aria-label="Type filter"
|
||||
>
|
||||
<option value="">All types</option>
|
||||
<option value="task">Task</option>
|
||||
<option value="bug">Bug</option>
|
||||
<option value="feature">Feature</option>
|
||||
<option value="epic">Epic</option>
|
||||
<option value="chore">Chore</option>
|
||||
<option className="ui-option" value="">All types</option>
|
||||
<option className="ui-option" value="task">Task</option>
|
||||
<option className="ui-option" value="bug">Bug</option>
|
||||
<option className="ui-option" value="feature">Feature</option>
|
||||
<option className="ui-option" value="epic">Epic</option>
|
||||
<option className="ui-option" value="chore">Chore</option>
|
||||
</select>
|
||||
<select
|
||||
value={filters.priority ?? ''}
|
||||
onChange={(event) => onFiltersChange({ ...filters, priority: event.target.value })}
|
||||
className={`${inputClass} w-full sm:w-36`}
|
||||
className={`${inputClass} ui-select w-full sm:w-36`}
|
||||
aria-label="Priority filter"
|
||||
>
|
||||
<option value="">All priorities</option>
|
||||
<option value="0">P0</option>
|
||||
<option value="1">P1</option>
|
||||
<option value="2">P2</option>
|
||||
<option value="3">P3</option>
|
||||
<option value="4">P4</option>
|
||||
<option className="ui-option" value="">All priorities</option>
|
||||
<option className="ui-option" value="0">P0</option>
|
||||
<option className="ui-option" value="1">P1</option>
|
||||
<option className="ui-option" value="2">P2</option>
|
||||
<option className="ui-option" value="3">P3</option>
|
||||
<option className="ui-option" value="4">P4</option>
|
||||
</select>
|
||||
<label className="inline-flex w-full items-center justify-center gap-2 rounded-xl border border-border-soft bg-surface-muted/60 px-3 py-2 text-sm text-text-body sm:w-auto sm:justify-start">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.showClosed ?? false}
|
||||
onChange={(event) => onFiltersChange({ ...filters, showClosed: event.target.checked })}
|
||||
className="h-4 w-4 accent-cyan-400"
|
||||
className="h-4 w-4 accent-amber-400"
|
||||
/>
|
||||
Show closed
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNextActionable}
|
||||
className="w-full rounded-xl border border-border-soft bg-surface-muted/70 px-3 py-2 text-sm font-semibold text-text-body transition hover:border-border-strong hover:bg-surface-raised sm:w-auto"
|
||||
>
|
||||
Next Actionable
|
||||
</button>
|
||||
</motion.div>
|
||||
<motion.div layout className="flex flex-wrap gap-2">
|
||||
<StatPill label="Total" value={stats.total} />
|
||||
<StatPill label="Open" value={stats.open} />
|
||||
<StatPill label="Ready" value={stats.ready} />
|
||||
<StatPill label="Active" value={stats.active} />
|
||||
<StatPill label="Blocked" value={stats.blocked} />
|
||||
<StatPill label="Done" value={stats.done} />
|
||||
<StatPill label="P0" value={stats.p0} tone={stats.p0 > 0 ? 'critical' : 'default'} />
|
||||
</motion.div>
|
||||
{nextActionableFeedback ? (
|
||||
<p className="text-xs text-text-muted">{nextActionableFeedback}</p>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,65 +1,434 @@
|
|||
'use client';
|
||||
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { buildExecutionChecklist, type BlockedTreeNode } from '../../lib/kanban';
|
||||
import {
|
||||
buildEditableIssueDraft,
|
||||
buildIssueUpdatePayload,
|
||||
validateEditableIssueDraft,
|
||||
type EditableIssueDraft,
|
||||
type EditableIssueFieldErrors,
|
||||
} from '../../lib/issue-editor';
|
||||
import type { UpdateMutationPayload } from '../../lib/mutations';
|
||||
import type { BeadIssue } from '../../lib/types';
|
||||
|
||||
import { Chip } from '../shared/chip';
|
||||
|
||||
interface KanbanDetailProps {
|
||||
issue: BeadIssue | null;
|
||||
issues?: BeadIssue[];
|
||||
framed?: boolean;
|
||||
blockedTree?: { total: number; nodes: BlockedTreeNode[] };
|
||||
outgoingBlocks?: { id: string; title: string; status: string }[];
|
||||
onSelectBlockedIssue?: (issueId: string) => void;
|
||||
projectRoot?: string;
|
||||
editable?: boolean;
|
||||
onIssueUpdated?: (issueId: string) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export function KanbanDetail({ issue, framed = true }: KanbanDetailProps) {
|
||||
const LEVEL_INDENT: Record<number, string> = {
|
||||
1: 'ml-0',
|
||||
2: 'ml-3',
|
||||
3: 'ml-6',
|
||||
};
|
||||
|
||||
async function postIssueUpdate(body: UpdateMutationPayload): Promise<void> {
|
||||
const response = await fetch('/api/beads/update', {
|
||||
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 ?? 'Update failed');
|
||||
}
|
||||
}
|
||||
|
||||
export function KanbanDetail({
|
||||
issue,
|
||||
issues = [],
|
||||
framed = true,
|
||||
blockedTree,
|
||||
outgoingBlocks = [],
|
||||
onSelectBlockedIssue,
|
||||
projectRoot,
|
||||
editable = true,
|
||||
onIssueUpdated,
|
||||
}: KanbanDetailProps) {
|
||||
const frameClass = framed ? 'rounded-2xl border border-border-soft bg-surface/90 p-4 shadow-panel' : 'p-1';
|
||||
const sectionClass = 'rounded-xl border border-border-soft/70 bg-surface/55 px-2.5 py-2';
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [draft, setDraft] = useState<EditableIssueDraft | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [fieldErrors, setFieldErrors] = useState<EditableIssueFieldErrors>({});
|
||||
const [optimisticIssue, setOptimisticIssue] = useState<BeadIssue | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setEditMode(false);
|
||||
setSaving(false);
|
||||
setSaveError(null);
|
||||
setFieldErrors({});
|
||||
setDraft(issue ? buildEditableIssueDraft(issue) : null);
|
||||
setOptimisticIssue(issue);
|
||||
}, [issue]);
|
||||
|
||||
const effectiveIssue = optimisticIssue ?? issue;
|
||||
const executionChecklist = useMemo(
|
||||
() => (effectiveIssue ? buildExecutionChecklist(effectiveIssue, issues) : []),
|
||||
[effectiveIssue, issues],
|
||||
);
|
||||
const projectName = (effectiveIssue as BeadIssue & { project?: { name?: string } } | null)?.project?.name ?? null;
|
||||
const formattedSummary = effectiveIssue?.description?.replace(/\s-\s/g, '\n- ') ?? '';
|
||||
|
||||
const beginEdit = () => {
|
||||
if (!effectiveIssue) {
|
||||
return;
|
||||
}
|
||||
setEditMode(true);
|
||||
setDraft(buildEditableIssueDraft(effectiveIssue));
|
||||
setFieldErrors({});
|
||||
setSaveError(null);
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
if (effectiveIssue) {
|
||||
setDraft(buildEditableIssueDraft(effectiveIssue));
|
||||
}
|
||||
setEditMode(false);
|
||||
setFieldErrors({});
|
||||
setSaveError(null);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!effectiveIssue || !draft || !projectRoot || !editable) {
|
||||
return;
|
||||
}
|
||||
|
||||
const validation = validateEditableIssueDraft(draft);
|
||||
if (!validation.ok) {
|
||||
setFieldErrors(validation.errors);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = buildIssueUpdatePayload(effectiveIssue, draft, projectRoot);
|
||||
if (!payload) {
|
||||
setEditMode(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
setFieldErrors({});
|
||||
try {
|
||||
await postIssueUpdate(payload);
|
||||
setOptimisticIssue((current) => {
|
||||
if (!current) {
|
||||
return current;
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
title: payload.title ?? current.title,
|
||||
description: payload.description ?? current.description,
|
||||
status: payload.status ?? current.status,
|
||||
priority: payload.priority ?? current.priority,
|
||||
issue_type: payload.issueType ?? current.issue_type,
|
||||
assignee: payload.assignee ?? current.assignee,
|
||||
labels: payload.labels ?? current.labels,
|
||||
};
|
||||
});
|
||||
await onIssueUpdated?.(effectiveIssue.id);
|
||||
setEditMode(false);
|
||||
} catch (error) {
|
||||
setSaveError(error instanceof Error ? error.message : 'Save failed');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{issue ? (
|
||||
{effectiveIssue ? (
|
||||
<motion.aside
|
||||
key={issue.id}
|
||||
key={effectiveIssue.id}
|
||||
initial={{ opacity: 0, x: 24 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 24 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
className={frameClass}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<div className="font-mono text-xs text-text-muted break-all">{issue.id}</div>
|
||||
<h2 className="mt-1 text-lg font-semibold leading-7 text-text-strong sm:text-xl">{issue.title}</h2>
|
||||
<div className="flex flex-col gap-2 border-b border-border-soft/60 pb-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-mono text-xs text-text-muted break-all">{effectiveIssue.id}</div>
|
||||
<h2 className="mt-1 break-words text-lg font-semibold leading-7 text-text-strong sm:text-xl">{effectiveIssue.title}</h2>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 self-start">
|
||||
<Chip tone="status">{effectiveIssue.status}</Chip>
|
||||
<Chip tone="priority">P{effectiveIssue.priority}</Chip>
|
||||
{effectiveIssue.assignee ? <Chip>@{effectiveIssue.assignee}</Chip> : null}
|
||||
{projectName ? <Chip tone="status">project {projectName}</Chip> : null}
|
||||
{projectRoot && editable ? (
|
||||
<button
|
||||
type="button"
|
||||
className="whitespace-nowrap rounded-md border border-border-soft bg-surface-muted/70 px-2 py-1 text-xs text-text-body hover:bg-surface-raised"
|
||||
onClick={editMode ? cancelEdit : beginEdit}
|
||||
>
|
||||
{editMode ? 'Cancel edit' : 'Edit'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<Chip tone="status">{issue.status}</Chip>
|
||||
</div>
|
||||
{issue.description ? <p className="mt-3 text-sm leading-6 text-text-body break-words">{issue.description}</p> : null}
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
<Chip tone="priority">priority {issue.priority}</Chip>
|
||||
<Chip>{issue.issue_type}</Chip>
|
||||
<Chip>{issue.assignee ? `@${issue.assignee}` : 'unassigned'}</Chip>
|
||||
<Chip>{issue.dependencies.length} dependencies</Chip>
|
||||
|
||||
{editMode && draft ? (
|
||||
<section className={`mt-3 ${sectionClass}`}>
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-text-muted">Edit fields</p>
|
||||
{saving ? <span className="text-xs text-sky-100">Saving...</span> : null}
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className="text-xs text-text-muted">
|
||||
Title
|
||||
<input
|
||||
className="ui-field mt-1 w-full rounded-md px-2 py-1 text-sm"
|
||||
value={draft.title}
|
||||
onChange={(event) => setDraft((current) => (current ? { ...current, title: event.target.value } : current))}
|
||||
/>
|
||||
</label>
|
||||
{fieldErrors.title ? <p className="text-xs text-rose-200">{fieldErrors.title}</p> : null}
|
||||
|
||||
<label className="text-xs text-text-muted">
|
||||
Description
|
||||
<textarea
|
||||
className="ui-field mt-1 min-h-20 w-full rounded-md px-2 py-1 text-sm"
|
||||
value={draft.description}
|
||||
onChange={(event) => setDraft((current) => (current ? { ...current, description: event.target.value } : current))}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<label className="text-xs text-text-muted">
|
||||
Status
|
||||
<select
|
||||
className="ui-field ui-select mt-1 w-full rounded-md px-2 py-1 text-sm"
|
||||
value={draft.status}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => (current ? { ...current, status: event.target.value as EditableIssueDraft['status'] } : current))
|
||||
}
|
||||
>
|
||||
<option value="open">open</option>
|
||||
<option value="in_progress">in_progress</option>
|
||||
<option value="blocked">blocked</option>
|
||||
<option value="deferred">deferred</option>
|
||||
<option value="closed">closed</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-xs text-text-muted">
|
||||
Priority
|
||||
<select
|
||||
className="ui-field ui-select mt-1 w-full rounded-md px-2 py-1 text-sm"
|
||||
value={String(draft.priority)}
|
||||
onChange={(event) => setDraft((current) => (current ? { ...current, priority: Number(event.target.value) } : current))}
|
||||
>
|
||||
{[0, 1, 2, 3, 4].map((priority) => (
|
||||
<option key={priority} value={priority}>
|
||||
{priority}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label className="text-xs text-text-muted">
|
||||
Type
|
||||
<input
|
||||
className="ui-field mt-1 w-full rounded-md px-2 py-1 text-sm"
|
||||
value={draft.issueType}
|
||||
onChange={(event) => setDraft((current) => (current ? { ...current, issueType: event.target.value } : current))}
|
||||
/>
|
||||
</label>
|
||||
{fieldErrors.status ? <p className="text-xs text-rose-200">{fieldErrors.status}</p> : null}
|
||||
{fieldErrors.priority ? <p className="text-xs text-rose-200">{fieldErrors.priority}</p> : null}
|
||||
{fieldErrors.issueType ? <p className="text-xs text-rose-200">{fieldErrors.issueType}</p> : null}
|
||||
|
||||
<label className="text-xs text-text-muted">
|
||||
Assignee
|
||||
<input
|
||||
className="ui-field mt-1 w-full rounded-md px-2 py-1 text-sm"
|
||||
value={draft.assignee}
|
||||
onChange={(event) => setDraft((current) => (current ? { ...current, assignee: event.target.value } : current))}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="text-xs text-text-muted">
|
||||
Owner (read-only from beads)
|
||||
<input className="ui-field mt-1 w-full rounded-md px-2 py-1 text-sm opacity-70" value={draft.owner} disabled />
|
||||
</label>
|
||||
|
||||
<label className="text-xs text-text-muted">
|
||||
Labels (comma separated)
|
||||
<input
|
||||
className="ui-field mt-1 w-full rounded-md px-2 py-1 text-sm"
|
||||
value={draft.labelsInput}
|
||||
onChange={(event) => setDraft((current) => (current ? { ...current, labelsInput: event.target.value } : current))}
|
||||
/>
|
||||
</label>
|
||||
{fieldErrors.labelsInput ? <p className="text-xs text-rose-200">{fieldErrors.labelsInput}</p> : null}
|
||||
</div>
|
||||
{saveError ? <p className="mt-2 text-xs text-rose-200">{saveError}</p> : null}
|
||||
<div className="mt-3 flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-border-soft bg-surface-muted/70 px-2 py-1 text-xs text-text-body hover:bg-surface-raised"
|
||||
onClick={cancelEdit}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving}
|
||||
className="rounded-md border border-sky-300/50 bg-sky-500/15 px-2 py-1 text-xs text-sky-100 hover:bg-sky-500/25 disabled:opacity-60"
|
||||
onClick={() => void handleSave()}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save changes'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<div className="mt-3 space-y-3">
|
||||
<section className={sectionClass}>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-text-muted">Summary</p>
|
||||
{effectiveIssue.description ? (
|
||||
<p className="mt-1.5 whitespace-pre-line text-sm leading-6 text-text-body break-words">{formattedSummary}</p>
|
||||
) : (
|
||||
<p className="mt-1.5 text-sm text-text-muted">No description provided.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className={sectionClass}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-text-muted">Execution checklist</p>
|
||||
<span className="rounded-md border border-border-soft/70 bg-surface-muted/60 px-1.5 py-0.5 text-[10px] font-mono text-text-body">
|
||||
{executionChecklist.filter((item) => item.passed).length}/{executionChecklist.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1.5 space-y-1">
|
||||
{executionChecklist.map((item) => (
|
||||
<div
|
||||
key={`${effectiveIssue.id}-${item.key}`}
|
||||
className="flex items-center justify-between rounded-md border border-border-soft/60 bg-surface-muted/40 px-2 py-1 text-xs"
|
||||
>
|
||||
<span className="text-text-body">{item.label}</span>
|
||||
<span className={`font-mono ${item.passed ? 'text-emerald-200' : 'text-rose-200'}`}>
|
||||
{item.passed ? 'pass' : 'fail'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<dl className="mt-4 grid gap-1.5 text-sm text-text-body">
|
||||
<div>
|
||||
<dt className="inline font-semibold text-text-strong">Created:</dt>{' '}
|
||||
<dd className="inline break-all">{issue.created_at || '-'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="inline font-semibold text-text-strong">Updated:</dt>{' '}
|
||||
<dd className="inline break-all">{issue.updated_at || '-'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="inline font-semibold text-text-strong">Closed:</dt>{' '}
|
||||
<dd className="inline">{issue.closed_at || '-'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{issue.labels.length > 0 ? (
|
||||
<div className="mt-4 flex flex-wrap gap-1.5">
|
||||
{issue.labels.map((label) => (
|
||||
<Chip key={`${issue.id}-${label}`}>#{label}</Chip>
|
||||
))}
|
||||
|
||||
{blockedTree && blockedTree.total > 0 ? (
|
||||
<div className={`mt-3 rounded-xl border border-rose-500/20 bg-rose-500/5 px-2.5 py-2`}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-rose-200">Blocked by</p>
|
||||
<span className="rounded-md border border-rose-500/30 bg-rose-500/10 px-1.5 py-0.5 text-[10px] font-mono text-rose-100">
|
||||
{blockedTree.total}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1.5 space-y-1">
|
||||
{blockedTree.nodes.map((node) => (
|
||||
<button
|
||||
key={node.id}
|
||||
type="button"
|
||||
onClick={() => onSelectBlockedIssue?.(node.id)}
|
||||
className={`block w-full rounded-md border border-rose-500/20 bg-rose-500/5 px-2 py-1 text-left hover:border-rose-500/40 hover:bg-rose-500/10 ${LEVEL_INDENT[node.level] ?? 'ml-6'
|
||||
}`}
|
||||
>
|
||||
<div className="font-mono text-[10px] text-rose-300/70">{node.id}</div>
|
||||
<div className="line-clamp-1 text-xs text-rose-100">{node.title}</div>
|
||||
</button>
|
||||
))}
|
||||
{blockedTree.total > blockedTree.nodes.length ? (
|
||||
<p className="text-[10px] text-rose-300/50">+{blockedTree.total - blockedTree.nodes.length} more blockers</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Outgoing Blocks (Unlocks) */}
|
||||
{outgoingBlocks.length > 0 ? (
|
||||
<div className={`mt-3 rounded-xl border border-emerald-500/20 bg-emerald-500/5 px-2.5 py-2`}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-emerald-200">Unlocks</p>
|
||||
<span className="rounded-md border border-emerald-500/30 bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-mono text-emerald-100">
|
||||
{outgoingBlocks.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1.5 space-y-1">
|
||||
{outgoingBlocks.map((block) => (
|
||||
<button
|
||||
key={block.id}
|
||||
type="button"
|
||||
onClick={() => onSelectBlockedIssue?.(block.id)}
|
||||
className="block w-full rounded-md border border-emerald-500/20 bg-emerald-500/5 px-2 py-1 text-left hover:border-emerald-500/40 hover:bg-emerald-500/10"
|
||||
>
|
||||
<div className="font-mono text-[10px] text-emerald-300/70">{block.id}</div>
|
||||
<div className="line-clamp-1 text-xs text-emerald-100">{block.title}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Collapsible Metadata Section */}
|
||||
<details className="group mt-3">
|
||||
<summary className="flex cursor-pointer items-center gap-2 rounded-lg border border-white/5 bg-white/[0.02] p-2 text-[10px] font-semibold uppercase tracking-widest text-text-muted transition-colors hover:bg-white/5">
|
||||
<span>Task metadata</span>
|
||||
<span className="ml-auto transition-transform group-open:rotate-180">▼</span>
|
||||
</summary>
|
||||
<div className="mt-2 space-y-3 pl-1">
|
||||
<section className={sectionClass}>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-text-muted">Properties</p>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||
<Chip>{effectiveIssue.issue_type}</Chip>
|
||||
<Chip>{effectiveIssue.dependencies.length} dependencies</Chip>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={sectionClass}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-text-muted">Timeline</p>
|
||||
</div>
|
||||
<dl className="mt-1.5 grid gap-1.5 text-sm text-text-body">
|
||||
<div>
|
||||
<dt className="inline font-semibold text-text-strong">Created:</dt>{' '}
|
||||
<dd className="inline break-all">{effectiveIssue.created_at || '-'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="inline font-semibold text-text-strong">Updated:</dt>{' '}
|
||||
<dd className="inline break-all">{effectiveIssue.updated_at || '-'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="inline font-semibold text-text-strong">Closed:</dt>{' '}
|
||||
<dd className="inline">{effectiveIssue.closed_at || '-'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
{effectiveIssue.labels.length > 0 ? (
|
||||
<section className={sectionClass}>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-text-muted">Labels</p>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||
{effectiveIssue.labels.map((label) => (
|
||||
<Chip key={`${effectiveIssue.id}-${label}`}>#{label}</Chip>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
</details>
|
||||
</motion.aside>
|
||||
) : (
|
||||
<motion.aside
|
||||
|
|
|
|||
|
|
@ -1,68 +1,347 @@
|
|||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type { KanbanFilterOptions } from '../../lib/kanban';
|
||||
import { buildKanbanColumns, buildKanbanStats, filterKanbanIssues } from '../../lib/kanban';
|
||||
import type { KanbanFilterOptions, KanbanStatus } from '../../lib/kanban';
|
||||
import {
|
||||
buildBlockedByTree,
|
||||
buildKanbanColumns,
|
||||
buildKanbanStats,
|
||||
filterKanbanIssues,
|
||||
findIssueLane,
|
||||
laneToMutationStatus,
|
||||
pickNextActionableIssue,
|
||||
} from '../../lib/kanban';
|
||||
import type { BeadIssue } from '../../lib/types';
|
||||
import type { ProjectScopeOption } from '../../lib/project-scope';
|
||||
import { applyOptimisticStatus, planStatusTransition } from '../../lib/writeback';
|
||||
|
||||
import { KanbanBoard } from './kanban-board';
|
||||
import { KanbanControls } from './kanban-controls';
|
||||
import { KanbanDetail } from './kanban-detail';
|
||||
import { ProjectScopeControls } from '../shared/project-scope-controls';
|
||||
|
||||
interface KanbanPageProps {
|
||||
issues: BeadIssue[];
|
||||
projectRoot: string;
|
||||
projectScopeKey: string;
|
||||
projectScopeOptions: ProjectScopeOption[];
|
||||
projectScopeMode: 'single' | 'aggregate';
|
||||
}
|
||||
|
||||
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,
|
||||
projectScopeKey,
|
||||
projectScopeOptions,
|
||||
projectScopeMode,
|
||||
}: 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>('ready');
|
||||
const [desktopDetailMinimized, setDesktopDetailMinimized] = useState(false);
|
||||
const [nextActionableFeedback, setNextActionableFeedback] = useState<string | null>(null);
|
||||
const [pendingIssueIds, setPendingIssueIds] = useState<Set<string>>(new Set());
|
||||
const [mutationError, setMutationError] = useState<string | null>(null);
|
||||
const refreshInFlightRef = useRef(false);
|
||||
|
||||
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 parentEpicByIssueId = useMemo(() => {
|
||||
const epicById = new Map(
|
||||
localIssues.filter((issue) => issue.issue_type === 'epic').map((epic) => [epic.id, epic]),
|
||||
);
|
||||
const map = new Map<string, { id: string; title: string }>();
|
||||
|
||||
const selectedIssue = useMemo(
|
||||
() => filteredIssues.find((issue) => issue.id === selectedIssueId) ?? filteredIssues[0] ?? null,
|
||||
[filteredIssues, selectedIssueId],
|
||||
for (const issue of localIssues) {
|
||||
if (issue.issue_type === 'epic') {
|
||||
continue;
|
||||
}
|
||||
const parentDep = issue.dependencies.find((dependency) => dependency.type === 'parent');
|
||||
const inferredParent = issue.id.includes('.') ? issue.id.split('.')[0] : null;
|
||||
const parentEpicId = parentDep?.target ?? inferredParent;
|
||||
if (!parentEpicId) {
|
||||
continue;
|
||||
}
|
||||
const parentEpic = epicById.get(parentEpicId);
|
||||
if (!parentEpic) {
|
||||
continue;
|
||||
}
|
||||
map.set(issue.id, { id: parentEpic.id, title: parentEpic.title });
|
||||
}
|
||||
|
||||
return map;
|
||||
}, [localIssues]);
|
||||
|
||||
const selectedIssue = useMemo(() => filteredIssues.find((issue) => issue.id === selectedIssueId) ?? null, [filteredIssues, selectedIssueId]);
|
||||
const activeScope = useMemo(
|
||||
() => projectScopeOptions.find((option) => option.key === projectScopeKey) ?? projectScopeOptions[0] ?? null,
|
||||
[projectScopeKey, projectScopeOptions],
|
||||
);
|
||||
const graphHref = useMemo(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (projectScopeMode !== 'single') {
|
||||
params.set('mode', projectScopeMode);
|
||||
}
|
||||
if (projectScopeKey !== 'local') {
|
||||
params.set('project', projectScopeKey);
|
||||
}
|
||||
const query = params.toString();
|
||||
return query ? `/graph?${query}` : '/graph';
|
||||
}, [projectScopeKey, projectScopeMode]);
|
||||
const allowMutations = projectScopeMode === 'single';
|
||||
const blockedTree = useMemo(
|
||||
() => buildBlockedByTree(filteredIssues, selectedIssue?.id ?? null, { maxNodes: 8 }),
|
||||
[filteredIssues, selectedIssue?.id],
|
||||
);
|
||||
const nextActionableIssue = useMemo(
|
||||
() => pickNextActionableIssue(columns, filteredIssues),
|
||||
[columns, filteredIssues],
|
||||
);
|
||||
const showDesktopDetail = Boolean(selectedIssue) && !desktopDetailMinimized;
|
||||
const focusIssueFromDetailLink = useCallback(
|
||||
(issueId: string) => {
|
||||
setSelectedIssueId(issueId);
|
||||
setDesktopDetailMinimized(false);
|
||||
const lane = findIssueLane(columns, issueId);
|
||||
setActiveStatus(lane ?? 'ready');
|
||||
},
|
||||
[columns],
|
||||
);
|
||||
|
||||
const selectIssueWithDetailBehavior = useCallback((issueId: string, lane: KanbanStatus = 'ready') => {
|
||||
setSelectedIssueId(issueId);
|
||||
setActiveStatus(lane);
|
||||
setDesktopDetailMinimized(false);
|
||||
setMobileDetailOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleNextActionable = useCallback(() => {
|
||||
if (!nextActionableIssue) {
|
||||
setNextActionableFeedback('No ready issue available for current filters.');
|
||||
return;
|
||||
}
|
||||
setNextActionableFeedback(null);
|
||||
selectIssueWithDetailBehavior(nextActionableIssue.id, 'ready');
|
||||
}, [nextActionableIssue, selectIssueWithDetailBehavior]);
|
||||
|
||||
const refreshIssues = useCallback(async (options: { silent?: boolean } = {}) => {
|
||||
if (refreshInFlightRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
refreshInFlightRef.current = true;
|
||||
try {
|
||||
const reconciled = await fetchIssues(projectRoot);
|
||||
setLocalIssues(reconciled);
|
||||
} catch (error) {
|
||||
if (!options.silent) {
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
refreshInFlightRef.current = false;
|
||||
}
|
||||
}, [projectRoot]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!allowMutations) {
|
||||
return;
|
||||
}
|
||||
const source = new EventSource(`/api/events?projectRoot=${encodeURIComponent(projectRoot)}`);
|
||||
const onIssues = () => {
|
||||
void refreshIssues({ silent: true });
|
||||
};
|
||||
|
||||
source.addEventListener('issues', onIssues as EventListener);
|
||||
|
||||
return () => {
|
||||
source.removeEventListener('issues', onIssues as EventListener);
|
||||
source.close();
|
||||
};
|
||||
}, [allowMutations, projectRoot, refreshIssues]);
|
||||
|
||||
const mutateStatus = async (issue: BeadIssue, targetStatus: KanbanStatus) => {
|
||||
if (!allowMutations) {
|
||||
return;
|
||||
}
|
||||
const mutationStatus = laneToMutationStatus(targetStatus);
|
||||
const steps = planStatusTransition(issue, mutationStatus);
|
||||
if (steps.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setMutationError(null);
|
||||
const previous = localIssues;
|
||||
setPendingIssueIds((value) => new Set(value).add(issue.id));
|
||||
setLocalIssues((current) => applyOptimisticStatus(current, issue.id, mutationStatus));
|
||||
|
||||
try {
|
||||
for (const step of steps) {
|
||||
await postMutation(step.operation, {
|
||||
projectRoot,
|
||||
...step.payload,
|
||||
});
|
||||
}
|
||||
|
||||
await refreshIssues();
|
||||
} 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>
|
||||
<h1 className="mt-1 text-2xl font-semibold text-text-strong sm:text-3xl">Kanban Dashboard</h1>
|
||||
<p className="font-mono text-xs uppercase tracking-[0.14em] text-text-muted">BeadBoard</p>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-3">
|
||||
<h1 className="text-2xl font-semibold text-text-strong sm:text-3xl">Kanban Dashboard</h1>
|
||||
<Link href={graphHref} className="rounded-lg border border-border-soft bg-surface-muted/70 px-2.5 py-1 text-xs text-text-body hover:bg-surface-raised">
|
||||
Open Graph
|
||||
</Link>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-text-muted">Tracer Bullet 1 from live `.beads/issues.jsonl` on Windows-native paths.</p>
|
||||
{activeScope ? (
|
||||
<p className="mt-2 text-xs text-text-muted">
|
||||
Scope:{' '}
|
||||
<span className="rounded-md border border-border-soft bg-surface-muted/50 px-2 py-0.5 font-mono text-[11px] text-text-body">
|
||||
{activeScope.source === 'local' ? 'local workspace' : activeScope.displayPath}
|
||||
</span>
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-3">
|
||||
<ProjectScopeControls
|
||||
projectScopeKey={projectScopeKey}
|
||||
projectScopeMode={projectScopeMode}
|
||||
projectScopeOptions={projectScopeOptions}
|
||||
/>
|
||||
</div>
|
||||
{!allowMutations ? (
|
||||
<p className="mt-2 text-xs text-amber-200/90">Aggregate mode is read-only. Switch to single project mode to edit status/details.</p>
|
||||
) : null}
|
||||
</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">
|
||||
<KanbanControls
|
||||
filters={filters}
|
||||
stats={stats}
|
||||
onFiltersChange={setFilters}
|
||||
onNextActionable={handleNextActionable}
|
||||
nextActionableFeedback={nextActionableFeedback}
|
||||
/>
|
||||
{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}
|
||||
parentEpicByIssueId={parentEpicByIssueId}
|
||||
graphBaseHref={graphHref}
|
||||
showClosed={Boolean(filters.showClosed)}
|
||||
selectedIssueId={selectedIssue?.id ?? null}
|
||||
pendingIssueIds={pendingIssueIds}
|
||||
activeStatus={activeStatus}
|
||||
onActivateStatus={setActiveStatus}
|
||||
onMoveIssue={mutateStatus}
|
||||
onSelect={(issue) => {
|
||||
setSelectedIssueId(issue.id);
|
||||
setMobileDetailOpen(true);
|
||||
const lane = findIssueLane(columns, issue.id) ?? 'ready';
|
||||
selectIssueWithDetailBehavior(issue.id, lane);
|
||||
}}
|
||||
/>
|
||||
</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}
|
||||
issues={filteredIssues}
|
||||
framed={false}
|
||||
blockedTree={blockedTree}
|
||||
onSelectBlockedIssue={focusIssueFromDetailLink}
|
||||
projectRoot={allowMutations ? projectRoot : undefined}
|
||||
onIssueUpdated={() => refreshIssues()}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{mobileDetailOpen && selectedIssue ? (
|
||||
<div className="fixed inset-0 z-40 lg:hidden">
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 bg-black/55"
|
||||
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
|
||||
aria-label="Close details"
|
||||
onClick={() => setMobileDetailOpen(false)}
|
||||
/>
|
||||
|
|
@ -71,7 +350,7 @@ export function KanbanPage({ issues }: KanbanPageProps) {
|
|||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 36, opacity: 0 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
className="absolute inset-x-3 bottom-3 top-20 overflow-y-auto rounded-2xl border border-border-soft bg-surface/95 p-3 shadow-panel"
|
||||
className="absolute inset-x-3 bottom-3 top-20 overflow-y-auto rounded-2xl border border-border-soft bg-surface/98 p-3 shadow-panel backdrop-blur-2xl"
|
||||
>
|
||||
<div className="mb-2 flex justify-end">
|
||||
<button
|
||||
|
|
@ -82,7 +361,15 @@ export function KanbanPage({ issues }: KanbanPageProps) {
|
|||
Close
|
||||
</button>
|
||||
</div>
|
||||
<KanbanDetail issue={selectedIssue} framed={false} />
|
||||
<KanbanDetail
|
||||
issue={selectedIssue}
|
||||
issues={filteredIssues}
|
||||
framed={false}
|
||||
blockedTree={blockedTree}
|
||||
onSelectBlockedIssue={focusIssueFromDetailLink}
|
||||
projectRoot={allowMutations ? projectRoot : undefined}
|
||||
onIssueUpdated={() => refreshIssues()}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ interface ChipProps {
|
|||
|
||||
const CHIP_TONE_CLASS: Record<NonNullable<ChipProps['tone']>, string> = {
|
||||
default: 'border-border-soft bg-surface-muted/75 text-text-body',
|
||||
status: 'border-cyan-300/30 bg-cyan-500/20 text-cyan-50',
|
||||
status: 'border-zinc-300/30 bg-zinc-500/20 text-zinc-100',
|
||||
priority: 'border-amber-300/30 bg-amber-500/20 text-amber-50',
|
||||
};
|
||||
|
||||
|
|
|
|||
322
src/components/shared/project-scope-controls.tsx
Normal file
322
src/components/shared/project-scope-controls.tsx
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
import type { ProjectScopeMode, ProjectScopeOption } from '../../lib/project-scope';
|
||||
|
||||
interface ScannerProject {
|
||||
key: string;
|
||||
displayPath: string;
|
||||
}
|
||||
|
||||
interface ScannerPayload {
|
||||
projects: ScannerProject[];
|
||||
stats: {
|
||||
scannedDirectories: number;
|
||||
ignoredDirectories: number;
|
||||
skippedDirectories: number;
|
||||
elapsedMs: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface ProjectScopeControlsProps {
|
||||
projectScopeKey: string;
|
||||
projectScopeMode: ProjectScopeMode;
|
||||
projectScopeOptions: ProjectScopeOption[];
|
||||
}
|
||||
|
||||
function buildHref(pathname: string, mode: ProjectScopeMode, key: string): string {
|
||||
const params = new URLSearchParams();
|
||||
if (mode !== 'single') {
|
||||
params.set('mode', mode);
|
||||
}
|
||||
if (key !== 'local') {
|
||||
params.set('project', key);
|
||||
}
|
||||
const query = params.toString();
|
||||
return query ? `${pathname}?${query}` : pathname;
|
||||
}
|
||||
|
||||
export function ProjectScopeControls({
|
||||
projectScopeKey,
|
||||
projectScopeMode,
|
||||
projectScopeOptions,
|
||||
}: ProjectScopeControlsProps) {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [addPath, setAddPath] = useState('');
|
||||
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [scanResult, setScanResult] = useState<ScannerPayload | null>(null);
|
||||
const [scanMode, setScanMode] = useState<'default' | 'full-drive'>('default');
|
||||
|
||||
const selected = useMemo(
|
||||
() => projectScopeOptions.find((option) => option.key === projectScopeKey) ?? projectScopeOptions[0] ?? null,
|
||||
[projectScopeKey, projectScopeOptions],
|
||||
);
|
||||
|
||||
const discovered = useMemo(() => {
|
||||
if (!scanResult) {
|
||||
return [];
|
||||
}
|
||||
const registered = new Set(projectScopeOptions.map((option) => option.key));
|
||||
return scanResult.projects.filter((project) => !registered.has(project.key));
|
||||
}, [projectScopeOptions, scanResult]);
|
||||
|
||||
const navigate = (mode: ProjectScopeMode, key: string) => {
|
||||
const href = buildHref(pathname, mode, key);
|
||||
router.push(href);
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
const setMode = (mode: ProjectScopeMode) => {
|
||||
navigate(mode, projectScopeKey);
|
||||
};
|
||||
|
||||
const setProjectKey = (key: string) => {
|
||||
navigate(projectScopeMode, key);
|
||||
};
|
||||
|
||||
const addProject = async () => {
|
||||
if (!addPath.trim()) {
|
||||
setStatusMessage('Enter an absolute Windows path (example: C:/Repos/MyProject).');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setStatusMessage(null);
|
||||
try {
|
||||
const response = await fetch('/api/projects', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ path: addPath.trim() }),
|
||||
});
|
||||
const payload = (await response.json()) as { error?: string };
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error ?? 'Failed to add project.');
|
||||
}
|
||||
setAddPath('');
|
||||
setStatusMessage('Project added.');
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
setStatusMessage(error instanceof Error ? error.message : 'Failed to add project.');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeProject = async (path: string, key: string) => {
|
||||
setBusy(true);
|
||||
setStatusMessage(null);
|
||||
try {
|
||||
const response = await fetch('/api/projects', {
|
||||
method: 'DELETE',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ path }),
|
||||
});
|
||||
const payload = (await response.json()) as { error?: string };
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error ?? 'Failed to remove project.');
|
||||
}
|
||||
if (projectScopeKey === key) {
|
||||
navigate(projectScopeMode, 'local');
|
||||
} else {
|
||||
router.refresh();
|
||||
}
|
||||
setStatusMessage('Project removed.');
|
||||
} catch (error) {
|
||||
setStatusMessage(error instanceof Error ? error.message : 'Failed to remove project.');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runScan = async () => {
|
||||
setBusy(true);
|
||||
setStatusMessage(null);
|
||||
try {
|
||||
const response = await fetch(`/api/scan?mode=${scanMode}`, { cache: 'no-store' });
|
||||
const payload = (await response.json()) as ScannerPayload & { error?: string };
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error ?? 'Scan failed.');
|
||||
}
|
||||
setScanResult(payload);
|
||||
setStatusMessage(`Scan complete. Found ${payload.projects.length} projects.`);
|
||||
} catch (error) {
|
||||
setStatusMessage(error instanceof Error ? error.message : 'Scan failed.');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const importProject = async (project: ScannerProject) => {
|
||||
setBusy(true);
|
||||
setStatusMessage(null);
|
||||
try {
|
||||
const response = await fetch('/api/projects', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ path: project.displayPath }),
|
||||
});
|
||||
const payload = (await response.json()) as { error?: string };
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error ?? 'Import failed.');
|
||||
}
|
||||
setStatusMessage(`Imported ${project.displayPath}`);
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
setStatusMessage(error instanceof Error ? error.message : 'Import failed.');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-border-soft bg-surface/65 p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<label className="text-[11px] font-semibold uppercase tracking-[0.12em] text-text-muted">Scope</label>
|
||||
<select
|
||||
className="ui-field ui-select rounded-lg px-2 py-1 text-xs"
|
||||
value={projectScopeKey}
|
||||
onChange={(event) => setProjectKey(event.target.value)}
|
||||
>
|
||||
{projectScopeOptions.map((option) => (
|
||||
<option className="ui-option" key={option.key} value={option.key}>
|
||||
{option.source === 'local' ? 'Local workspace' : option.displayPath}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="text-[11px] font-semibold uppercase tracking-[0.12em] text-text-muted">Mode</label>
|
||||
<select
|
||||
className="ui-field ui-select rounded-lg px-2 py-1 text-xs"
|
||||
value={projectScopeMode}
|
||||
onChange={(event) => setMode(event.target.value as ProjectScopeMode)}
|
||||
>
|
||||
<option className="ui-option" value="single">Single project</option>
|
||||
<option className="ui-option" value="aggregate">Aggregate</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className="rounded-lg border border-border-soft bg-surface-muted/70 px-2.5 py-1 text-xs text-text-body"
|
||||
>
|
||||
{open ? 'Hide manager' : 'Manage projects'}
|
||||
</button>
|
||||
</div>
|
||||
{selected ? (
|
||||
<p className="mt-2 text-xs text-text-muted">
|
||||
Active: <span className="font-mono text-text-body">{selected.source === 'local' ? 'local workspace' : selected.displayPath}</span>
|
||||
</p>
|
||||
) : null}
|
||||
{open ? (
|
||||
<div className="mt-3 grid gap-3 border-t border-border-soft pt-3">
|
||||
<div className="rounded-lg border border-border-soft/80 bg-surface/55 p-2.5">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-text-muted">Add project root</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<input
|
||||
type="text"
|
||||
className="ui-field flex-1 rounded-lg px-2 py-1.5 text-xs"
|
||||
placeholder="C:/Repos/MyProject"
|
||||
value={addPath}
|
||||
onChange={(event) => setAddPath(event.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void addProject()}
|
||||
className="rounded-lg border border-border-soft bg-surface-muted/70 px-2.5 py-1.5 text-xs text-text-body disabled:opacity-60"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border-soft/80 bg-surface/55 p-2.5">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-text-muted">Registered projects</p>
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{projectScopeOptions.filter((option) => option.source === 'registry').map((option) => (
|
||||
<div key={option.key} className="flex items-center justify-between gap-2 rounded-md border border-border-soft/80 bg-surface-muted/40 px-2 py-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setProjectKey(option.key)}
|
||||
className="truncate text-left font-mono text-xs text-text-body hover:text-sky-100"
|
||||
title={option.displayPath}
|
||||
>
|
||||
{option.displayPath}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void removeProject(option.displayPath, option.key)}
|
||||
className="rounded border border-rose-300/30 bg-rose-500/10 px-1.5 py-0.5 text-[11px] text-rose-100 disabled:opacity-60"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{projectScopeOptions.every((option) => option.source !== 'registry') ? (
|
||||
<p className="text-xs text-text-muted">No registered projects yet.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border-soft/80 bg-surface/55 p-2.5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-text-muted">Scanner</p>
|
||||
<select
|
||||
className="ui-field ui-select rounded-lg px-2 py-1 text-xs"
|
||||
value={scanMode}
|
||||
onChange={(event) => setScanMode(event.target.value as 'default' | 'full-drive')}
|
||||
>
|
||||
<option className="ui-option" value="default">Safe roots</option>
|
||||
<option className="ui-option" value="full-drive">Full drive</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void runScan()}
|
||||
className="rounded-lg border border-border-soft bg-surface-muted/70 px-2.5 py-1 text-xs text-text-body disabled:opacity-60"
|
||||
>
|
||||
Run scan
|
||||
</button>
|
||||
</div>
|
||||
{scanResult ? (
|
||||
<p className="mt-2 text-[11px] text-text-muted">
|
||||
scanned {scanResult.stats.scannedDirectories}, ignored {scanResult.stats.ignoredDirectories}, skipped {scanResult.stats.skippedDirectories} ({scanResult.stats.elapsedMs}ms)
|
||||
</p>
|
||||
) : null}
|
||||
{discovered.length > 0 ? (
|
||||
<div className="mt-2 max-h-40 space-y-1.5 overflow-y-auto pr-1">
|
||||
{discovered.map((project) => (
|
||||
<div key={project.key} className="flex items-center justify-between gap-2 rounded-md border border-border-soft/80 bg-surface-muted/40 px-2 py-1.5">
|
||||
<span className="truncate font-mono text-xs text-text-body" title={project.displayPath}>
|
||||
{project.displayPath}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void importProject(project)}
|
||||
className="rounded border border-sky-300/30 bg-sky-500/10 px-1.5 py-0.5 text-[11px] text-sky-100 disabled:opacity-60"
|
||||
>
|
||||
Import
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : scanResult ? (
|
||||
<p className="mt-2 text-xs text-text-muted">No new projects to import.</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{statusMessage ? <p className="text-xs text-text-muted">{statusMessage}</p> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{searchParams.get('project') && projectScopeKey === 'local' ? (
|
||||
<p className="mt-2 text-[11px] text-amber-200/90">Unknown project key in URL; fell back to local workspace.</p>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
72
src/lib/aggregate-read.ts
Normal file
72
src/lib/aggregate-read.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import type { BeadDependency, BeadIssueWithProject } from './types';
|
||||
import type { ProjectScopeOption } from './project-scope';
|
||||
import { readIssuesFromDisk } from './read-issues';
|
||||
|
||||
function scopeIssueId(projectKey: string, issueId: string): string {
|
||||
if (issueId.includes('::')) {
|
||||
return issueId;
|
||||
}
|
||||
return `${projectKey}::${issueId}`;
|
||||
}
|
||||
|
||||
function remapDependencies(
|
||||
dependencies: BeadDependency[],
|
||||
scopedIssueByOriginalId: Map<string, string>,
|
||||
): BeadDependency[] {
|
||||
return dependencies.map((dependency) => ({
|
||||
...dependency,
|
||||
target: scopedIssueByOriginalId.get(dependency.target) ?? dependency.target,
|
||||
}));
|
||||
}
|
||||
|
||||
function scopeIssuesForProject(
|
||||
project: ProjectScopeOption,
|
||||
issues: BeadIssueWithProject[],
|
||||
): BeadIssueWithProject[] {
|
||||
const scopedIssueByOriginalId = new Map<string, string>();
|
||||
for (const issue of issues) {
|
||||
scopedIssueByOriginalId.set(issue.id, scopeIssueId(project.key, issue.id));
|
||||
}
|
||||
|
||||
return issues.map((issue) => {
|
||||
const scopedId = scopedIssueByOriginalId.get(issue.id) ?? scopeIssueId(project.key, issue.id);
|
||||
return {
|
||||
...issue,
|
||||
id: scopedId,
|
||||
dependencies: remapDependencies(issue.dependencies, scopedIssueByOriginalId),
|
||||
metadata: {
|
||||
...issue.metadata,
|
||||
original_id: issue.id,
|
||||
project_key: project.key,
|
||||
},
|
||||
project: {
|
||||
...issue.project,
|
||||
key: project.key,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function readIssuesForScope(options: {
|
||||
mode: 'single' | 'aggregate';
|
||||
selected: ProjectScopeOption;
|
||||
scopeOptions: ProjectScopeOption[];
|
||||
}): Promise<BeadIssueWithProject[]> {
|
||||
if (options.mode === 'single') {
|
||||
return readIssuesFromDisk({
|
||||
projectRoot: options.selected.root,
|
||||
projectSource: options.selected.source,
|
||||
});
|
||||
}
|
||||
|
||||
const result = await Promise.all(
|
||||
options.scopeOptions.map(async (project) => {
|
||||
const issues = await readIssuesFromDisk({
|
||||
projectRoot: project.root,
|
||||
projectSource: project.source,
|
||||
});
|
||||
return scopeIssuesForProject(project, issues);
|
||||
}),
|
||||
);
|
||||
return result.flat();
|
||||
}
|
||||
78
src/lib/bd-path.ts
Normal file
78
src/lib/bd-path.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
export interface ResolveBdExecutableOptions {
|
||||
explicitPath?: string | null;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
export interface BdExecutableResolution {
|
||||
executable: string;
|
||||
source: 'config' | 'path';
|
||||
}
|
||||
|
||||
export class BdExecutableNotFoundError extends Error {
|
||||
readonly code = 'BD_NOT_FOUND';
|
||||
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'BdExecutableNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function splitEnvPath(env: NodeJS.ProcessEnv = process.env): string[] {
|
||||
const value = env.Path ?? env.PATH ?? '';
|
||||
if (!value.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value.split(';').map((segment) => segment.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function executableCandidates(directory: string): string[] {
|
||||
return ['bd.exe', 'bd.cmd', 'bd.bat', 'bd'].map((name) => path.join(directory, name));
|
||||
}
|
||||
|
||||
function buildNotFoundMessage(explicitPath?: string | null): string {
|
||||
const lines = [
|
||||
'bd.exe was not found.',
|
||||
'Install it with: npm install -g @beads/bd',
|
||||
'Or configure an explicit executable path in request payload/config.',
|
||||
];
|
||||
|
||||
if (explicitPath) {
|
||||
lines.push(`Configured path was not found: ${explicitPath}`);
|
||||
}
|
||||
|
||||
return lines.join(' ');
|
||||
}
|
||||
|
||||
export async function resolveBdExecutable(options: ResolveBdExecutableOptions = {}): Promise<BdExecutableResolution> {
|
||||
if (options.explicitPath && options.explicitPath.trim()) {
|
||||
const explicit = path.resolve(options.explicitPath);
|
||||
if (await fileExists(explicit)) {
|
||||
return { executable: explicit, source: 'config' };
|
||||
}
|
||||
|
||||
throw new BdExecutableNotFoundError(buildNotFoundMessage(options.explicitPath));
|
||||
}
|
||||
|
||||
for (const dir of splitEnvPath(options.env)) {
|
||||
for (const candidate of executableCandidates(dir)) {
|
||||
if (await fileExists(candidate)) {
|
||||
return { executable: candidate, source: 'path' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new BdExecutableNotFoundError(buildNotFoundMessage());
|
||||
}
|
||||
163
src/lib/bridge.ts
Normal file
163
src/lib/bridge.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import { execFile as nodeExecFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import { BdExecutableNotFoundError, resolveBdExecutable } from './bd-path';
|
||||
|
||||
const execFileAsync = promisify(nodeExecFile);
|
||||
|
||||
export type BdFailureClassification = 'not_found' | 'timeout' | 'non_zero_exit' | 'bad_args' | 'unknown';
|
||||
|
||||
export interface RunBdCommandOptions {
|
||||
projectRoot: string;
|
||||
args: string[];
|
||||
timeoutMs?: number;
|
||||
explicitBdPath?: string | null;
|
||||
}
|
||||
|
||||
export interface RunBdCommandResult {
|
||||
success: boolean;
|
||||
classification: BdFailureClassification | null;
|
||||
command: string;
|
||||
args: string[];
|
||||
cwd: string;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number | null;
|
||||
durationMs: number;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
type ExecFileOptions = {
|
||||
cwd: string;
|
||||
timeout: number;
|
||||
windowsHide: boolean;
|
||||
env: NodeJS.ProcessEnv;
|
||||
};
|
||||
|
||||
type ExecFileLike = (
|
||||
command: string,
|
||||
args: string[],
|
||||
options: ExecFileOptions,
|
||||
) => Promise<{ stdout: string; stderr: string }>;
|
||||
|
||||
interface RunBdCommandDeps {
|
||||
resolveBdExecutable: typeof resolveBdExecutable;
|
||||
execFile: ExecFileLike;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
function normalizeOutput(text: unknown): string {
|
||||
if (typeof text !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return text.replaceAll('\r\n', '\n').trim();
|
||||
}
|
||||
|
||||
function toErrorMessage(value: unknown): string {
|
||||
if (value instanceof Error) {
|
||||
return value.message;
|
||||
}
|
||||
return String(value ?? 'Unknown error');
|
||||
}
|
||||
|
||||
function classifyFailure(error: NodeJS.ErrnoException & { stderr?: string; killed?: boolean; signal?: string }): BdFailureClassification {
|
||||
if (error.code === 'ENOENT') {
|
||||
return 'not_found';
|
||||
}
|
||||
|
||||
if (error.code === 'ETIMEDOUT' || error.killed || error.signal === 'SIGTERM') {
|
||||
return 'timeout';
|
||||
}
|
||||
|
||||
const stderr = normalizeOutput(error.stderr);
|
||||
if (typeof error.code === 'number') {
|
||||
if (/(unknown|invalid|required|usage)/i.test(stderr)) {
|
||||
return 'bad_args';
|
||||
}
|
||||
return 'non_zero_exit';
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
export async function runBdCommand(
|
||||
options: RunBdCommandOptions,
|
||||
injectedDeps?: Partial<RunBdCommandDeps>,
|
||||
): Promise<RunBdCommandResult> {
|
||||
const startedAt = Date.now();
|
||||
const timeoutMs = options.timeoutMs ?? 30_000;
|
||||
const cwd = options.projectRoot;
|
||||
const args = [...options.args];
|
||||
|
||||
const deps: RunBdCommandDeps = {
|
||||
resolveBdExecutable: injectedDeps?.resolveBdExecutable ?? resolveBdExecutable,
|
||||
execFile: injectedDeps?.execFile ?? execFileAsync,
|
||||
env: injectedDeps?.env ?? process.env,
|
||||
};
|
||||
|
||||
let command = options.explicitBdPath ?? 'bd.exe';
|
||||
|
||||
try {
|
||||
const resolved = await deps.resolveBdExecutable({
|
||||
explicitPath: options.explicitBdPath,
|
||||
env: deps.env,
|
||||
});
|
||||
command = resolved.executable;
|
||||
|
||||
const { stdout, stderr } = await deps.execFile(command, args, {
|
||||
cwd,
|
||||
timeout: timeoutMs,
|
||||
windowsHide: true,
|
||||
env: deps.env,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
classification: null,
|
||||
command,
|
||||
args,
|
||||
cwd,
|
||||
stdout: normalizeOutput(stdout),
|
||||
stderr: normalizeOutput(stderr),
|
||||
code: 0,
|
||||
durationMs: Date.now() - startedAt,
|
||||
error: null,
|
||||
};
|
||||
} catch (rawError) {
|
||||
if (rawError instanceof BdExecutableNotFoundError) {
|
||||
return {
|
||||
success: false,
|
||||
classification: 'not_found',
|
||||
command,
|
||||
args,
|
||||
cwd,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
code: null,
|
||||
durationMs: Date.now() - startedAt,
|
||||
error: rawError.message,
|
||||
};
|
||||
}
|
||||
|
||||
const error = rawError as NodeJS.ErrnoException & {
|
||||
stderr?: string;
|
||||
stdout?: string;
|
||||
killed?: boolean;
|
||||
signal?: string;
|
||||
};
|
||||
|
||||
return {
|
||||
success: false,
|
||||
classification: classifyFailure(error),
|
||||
command,
|
||||
args,
|
||||
cwd,
|
||||
stdout: normalizeOutput(error.stdout),
|
||||
stderr: normalizeOutput(error.stderr),
|
||||
code: typeof error.code === 'number' ? error.code : null,
|
||||
durationMs: Date.now() - startedAt,
|
||||
error: toErrorMessage(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
76
src/lib/coalescer.ts
Normal file
76
src/lib/coalescer.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { windowsPathKey } from './pathing';
|
||||
|
||||
export interface CoalescedEventInput<T> {
|
||||
projectRoot: string;
|
||||
payload: T;
|
||||
}
|
||||
|
||||
interface PendingEvent<T> {
|
||||
timer: NodeJS.Timeout;
|
||||
projectRoot: string;
|
||||
payload: T;
|
||||
}
|
||||
|
||||
export class ProjectEventCoalescer<T> {
|
||||
private readonly pending = new Map<string, PendingEvent<T>>();
|
||||
|
||||
private readonly debounceMs: number;
|
||||
|
||||
private readonly onFlush: (event: CoalescedEventInput<T>) => void;
|
||||
|
||||
constructor(debounceMs: number, onFlush: (event: CoalescedEventInput<T>) => void) {
|
||||
this.debounceMs = debounceMs;
|
||||
this.onFlush = onFlush;
|
||||
}
|
||||
|
||||
queue(projectRoot: string, payload: T): void {
|
||||
const projectKey = windowsPathKey(projectRoot);
|
||||
const existing = this.pending.get(projectKey);
|
||||
if (existing) {
|
||||
clearTimeout(existing.timer);
|
||||
existing.projectRoot = projectRoot;
|
||||
existing.payload = payload;
|
||||
existing.timer = setTimeout(() => this.flush(projectKey), this.debounceMs);
|
||||
return;
|
||||
}
|
||||
|
||||
this.pending.set(projectKey, {
|
||||
projectRoot,
|
||||
payload,
|
||||
timer: setTimeout(() => this.flush(projectKey), this.debounceMs),
|
||||
});
|
||||
}
|
||||
|
||||
cancel(projectRoot: string): void {
|
||||
const projectKey = windowsPathKey(projectRoot);
|
||||
const pending = this.pending.get(projectKey);
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(pending.timer);
|
||||
this.pending.delete(projectKey);
|
||||
}
|
||||
|
||||
cancelAll(): void {
|
||||
for (const pending of this.pending.values()) {
|
||||
clearTimeout(pending.timer);
|
||||
}
|
||||
this.pending.clear();
|
||||
}
|
||||
|
||||
pendingCount(): number {
|
||||
return this.pending.size;
|
||||
}
|
||||
|
||||
private flush(projectKey: string): void {
|
||||
const pending = this.pending.get(projectKey);
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
this.pending.delete(projectKey);
|
||||
this.onFlush({
|
||||
projectRoot: pending.projectRoot,
|
||||
payload: pending.payload,
|
||||
});
|
||||
}
|
||||
}
|
||||
500
src/lib/graph-view.ts
Normal file
500
src/lib/graph-view.ts
Normal file
|
|
@ -0,0 +1,500 @@
|
|||
import dagre from 'dagre';
|
||||
|
||||
import type { GraphEdge, GraphModel, GraphNode } from './graph';
|
||||
|
||||
export type GraphHopDepth = 1 | 2 | 'full';
|
||||
|
||||
export interface GraphViewOptions {
|
||||
focusId: string | null;
|
||||
depth: GraphHopDepth;
|
||||
hideClosed: boolean;
|
||||
}
|
||||
|
||||
export interface PositionedGraphNode extends GraphNode {
|
||||
position: { x: number; y: number };
|
||||
}
|
||||
|
||||
export interface GraphViewModel {
|
||||
nodes: PositionedGraphNode[];
|
||||
edges: GraphEdge[];
|
||||
}
|
||||
|
||||
export interface PathWorkspace {
|
||||
focus: GraphNode | null;
|
||||
blockers: GraphNode[][];
|
||||
dependents: GraphNode[][];
|
||||
}
|
||||
|
||||
const NODE_WIDTH = 340;
|
||||
const NODE_HEIGHT = 132;
|
||||
|
||||
function sortEdges(a: GraphEdge, b: GraphEdge): number {
|
||||
if (a.source !== b.source) {
|
||||
return a.source.localeCompare(b.source);
|
||||
}
|
||||
if (a.type !== b.type) {
|
||||
return a.type.localeCompare(b.type);
|
||||
}
|
||||
return a.target.localeCompare(b.target);
|
||||
}
|
||||
|
||||
function sortNodes(a: GraphNode, b: GraphNode): number {
|
||||
return a.id.localeCompare(b.id);
|
||||
}
|
||||
|
||||
function collectIdsWithDepth(model: GraphModel, focusId: string, depth: Exclude<GraphHopDepth, 'full'>): Set<string> {
|
||||
const visited = new Set<string>([focusId]);
|
||||
let frontier = new Set<string>([focusId]);
|
||||
|
||||
for (let step = 0; step < depth; step += 1) {
|
||||
const next = new Set<string>();
|
||||
for (const nodeId of frontier) {
|
||||
const adjacency = model.adjacency[nodeId];
|
||||
if (!adjacency) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const edge of adjacency.outgoing) {
|
||||
if (!visited.has(edge.target)) {
|
||||
visited.add(edge.target);
|
||||
next.add(edge.target);
|
||||
}
|
||||
}
|
||||
for (const edge of adjacency.incoming) {
|
||||
if (!visited.has(edge.source)) {
|
||||
visited.add(edge.source);
|
||||
next.add(edge.source);
|
||||
}
|
||||
}
|
||||
}
|
||||
frontier = next;
|
||||
if (frontier.size === 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return visited;
|
||||
}
|
||||
|
||||
function applyFocusWorkspaceLayout(nodes: GraphNode[], edges: GraphEdge[], focusId: string): PositionedGraphNode[] {
|
||||
const incomingDepth = new Map<string, number>([[focusId, 0]]);
|
||||
const outgoingDepth = new Map<string, number>([[focusId, 0]]);
|
||||
|
||||
let incomingFrontier = new Set<string>([focusId]);
|
||||
let outgoingFrontier = new Set<string>([focusId]);
|
||||
let incomingStep = 0;
|
||||
let outgoingStep = 0;
|
||||
|
||||
while (incomingFrontier.size > 0) {
|
||||
incomingStep += 1;
|
||||
const next = new Set<string>();
|
||||
for (const nodeId of incomingFrontier) {
|
||||
for (const edge of edges) {
|
||||
if (edge.target !== nodeId) {
|
||||
continue;
|
||||
}
|
||||
if (!incomingDepth.has(edge.source)) {
|
||||
incomingDepth.set(edge.source, incomingStep);
|
||||
next.add(edge.source);
|
||||
}
|
||||
}
|
||||
}
|
||||
incomingFrontier = next;
|
||||
}
|
||||
|
||||
while (outgoingFrontier.size > 0) {
|
||||
outgoingStep += 1;
|
||||
const next = new Set<string>();
|
||||
for (const nodeId of outgoingFrontier) {
|
||||
for (const edge of edges) {
|
||||
if (edge.source !== nodeId) {
|
||||
continue;
|
||||
}
|
||||
if (!outgoingDepth.has(edge.target)) {
|
||||
outgoingDepth.set(edge.target, outgoingStep);
|
||||
next.add(edge.target);
|
||||
}
|
||||
}
|
||||
}
|
||||
outgoingFrontier = next;
|
||||
}
|
||||
|
||||
const columns = new Map<number, GraphNode[]>();
|
||||
for (const node of nodes) {
|
||||
let column = 0;
|
||||
if (node.id !== focusId) {
|
||||
const inDepth = incomingDepth.get(node.id);
|
||||
const outDepth = outgoingDepth.get(node.id);
|
||||
if (inDepth && outDepth) {
|
||||
column = inDepth <= outDepth ? -inDepth : outDepth;
|
||||
} else if (inDepth) {
|
||||
column = -inDepth;
|
||||
} else if (outDepth) {
|
||||
column = outDepth;
|
||||
}
|
||||
}
|
||||
|
||||
const bucket = columns.get(column) ?? [];
|
||||
bucket.push(node);
|
||||
columns.set(column, bucket);
|
||||
}
|
||||
|
||||
const columnKeys = [...columns.keys()].sort((a, b) => a - b);
|
||||
const positioned: PositionedGraphNode[] = [];
|
||||
|
||||
for (const columnKey of columnKeys) {
|
||||
const columnNodes = (columns.get(columnKey) ?? []).sort((a, b) => a.id.localeCompare(b.id));
|
||||
columnNodes.forEach((node, rowIndex) => {
|
||||
positioned.push({
|
||||
...node,
|
||||
position: {
|
||||
x: (columnKey + 3) * (NODE_WIDTH + 60),
|
||||
y: rowIndex * (NODE_HEIGHT + 26),
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return positioned.sort((a, b) => {
|
||||
if (a.id === focusId) {
|
||||
return -1;
|
||||
}
|
||||
if (b.id === focusId) {
|
||||
return 1;
|
||||
}
|
||||
if (a.position.x !== b.position.x) {
|
||||
return a.position.x - b.position.x;
|
||||
}
|
||||
if (a.position.x !== b.position.x) {
|
||||
return a.position.x - b.position.x;
|
||||
}
|
||||
return a.position.y - b.position.y;
|
||||
});
|
||||
}
|
||||
|
||||
function applyLayout(nodes: GraphNode[], edges: GraphEdge[], focusId: string | null): PositionedGraphNode[] {
|
||||
if (focusId) {
|
||||
return applyFocusWorkspaceLayout(nodes, edges, focusId);
|
||||
}
|
||||
|
||||
if (edges.length === 0) {
|
||||
const columns = Math.max(1, Math.ceil(Math.sqrt(nodes.length)));
|
||||
return nodes.map((node, index) => {
|
||||
const col = index % columns;
|
||||
const row = Math.floor(index / columns);
|
||||
return {
|
||||
...node,
|
||||
position: {
|
||||
x: col * (NODE_WIDTH + 36),
|
||||
y: row * (NODE_HEIGHT + 28),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const graph = new dagre.graphlib.Graph();
|
||||
graph.setDefaultEdgeLabel(() => ({}));
|
||||
graph.setGraph({
|
||||
rankdir: 'LR',
|
||||
ranksep: 110,
|
||||
nodesep: 36,
|
||||
});
|
||||
|
||||
for (const node of nodes) {
|
||||
graph.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT });
|
||||
}
|
||||
|
||||
for (const edge of edges) {
|
||||
graph.setEdge(edge.source, edge.target);
|
||||
}
|
||||
|
||||
dagre.layout(graph);
|
||||
|
||||
const positioned = nodes
|
||||
.map((node) => {
|
||||
const point = graph.node(node.id);
|
||||
return {
|
||||
...node,
|
||||
position: {
|
||||
x: Math.round((point?.x ?? 0) - NODE_WIDTH / 2),
|
||||
y: Math.round((point?.y ?? 0) - NODE_HEIGHT / 2),
|
||||
},
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (focusId && a.id === focusId) {
|
||||
return -1;
|
||||
}
|
||||
if (focusId && b.id === focusId) {
|
||||
return 1;
|
||||
}
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
|
||||
return positioned;
|
||||
}
|
||||
|
||||
export function buildGraphViewModel(model: GraphModel, options: GraphViewOptions): GraphViewModel {
|
||||
const nodeById = new Map(model.nodes.map((node) => [node.id, node]));
|
||||
|
||||
const baseVisibleIds = options.focusId
|
||||
? options.depth === 'full'
|
||||
? new Set(model.nodes.map((node) => node.id))
|
||||
: collectIdsWithDepth(model, options.focusId, options.depth)
|
||||
: new Set(model.nodes.map((node) => node.id));
|
||||
|
||||
const filteredIds = new Set(
|
||||
[...baseVisibleIds].filter((id) => {
|
||||
const node = nodeById.get(id);
|
||||
if (!node) {
|
||||
return false;
|
||||
}
|
||||
if (!options.hideClosed) {
|
||||
return true;
|
||||
}
|
||||
if (id === options.focusId) {
|
||||
return true;
|
||||
}
|
||||
return node.status !== 'closed';
|
||||
}),
|
||||
);
|
||||
|
||||
const nodes = model.nodes.filter((node) => filteredIds.has(node.id)).sort(sortNodes);
|
||||
const edges = model.edges
|
||||
.filter((edge) => filteredIds.has(edge.source) && filteredIds.has(edge.target))
|
||||
.sort(sortEdges);
|
||||
|
||||
return {
|
||||
nodes: applyLayout(nodes, edges, options.focusId),
|
||||
edges,
|
||||
};
|
||||
}
|
||||
|
||||
function includeByClosedFilter(node: GraphNode, hideClosed: boolean, forceInclude: boolean): boolean {
|
||||
if (forceInclude) {
|
||||
return true;
|
||||
}
|
||||
if (!hideClosed) {
|
||||
return true;
|
||||
}
|
||||
return node.status !== 'closed';
|
||||
}
|
||||
|
||||
export function buildPathWorkspace(model: GraphModel, options: GraphViewOptions): PathWorkspace {
|
||||
const nodeById = new Map(model.nodes.map((node) => [node.id, node]));
|
||||
const focusId = options.focusId;
|
||||
const focusNode = focusId ? nodeById.get(focusId) ?? null : null;
|
||||
|
||||
if (!focusNode || !focusId) {
|
||||
return { focus: null, blockers: [], dependents: [] };
|
||||
}
|
||||
|
||||
const maxDepth = options.depth === 'full' ? Number.POSITIVE_INFINITY : options.depth;
|
||||
|
||||
const blockers: GraphNode[][] = [];
|
||||
const dependents: GraphNode[][] = [];
|
||||
|
||||
const blockerSeen = new Set<string>([focusId]);
|
||||
const dependentSeen = new Set<string>([focusId]);
|
||||
|
||||
let blockerFrontier = new Set<string>([focusId]);
|
||||
let dependentFrontier = new Set<string>([focusId]);
|
||||
|
||||
for (let depth = 1; depth <= maxDepth; depth += 1) {
|
||||
const nextBlockerFrontier = new Set<string>();
|
||||
const nextDependentFrontier = new Set<string>();
|
||||
const blockerLevel: GraphNode[] = [];
|
||||
const dependentLevel: GraphNode[] = [];
|
||||
|
||||
for (const nodeId of blockerFrontier) {
|
||||
const adjacency = model.adjacency[nodeId];
|
||||
if (!adjacency) {
|
||||
continue;
|
||||
}
|
||||
for (const edge of adjacency.incoming) {
|
||||
if (blockerSeen.has(edge.source)) {
|
||||
continue;
|
||||
}
|
||||
blockerSeen.add(edge.source);
|
||||
nextBlockerFrontier.add(edge.source);
|
||||
const node = nodeById.get(edge.source);
|
||||
if (node && includeByClosedFilter(node, options.hideClosed, false)) {
|
||||
blockerLevel.push(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const nodeId of dependentFrontier) {
|
||||
const adjacency = model.adjacency[nodeId];
|
||||
if (!adjacency) {
|
||||
continue;
|
||||
}
|
||||
for (const edge of adjacency.outgoing) {
|
||||
if (dependentSeen.has(edge.target)) {
|
||||
continue;
|
||||
}
|
||||
dependentSeen.add(edge.target);
|
||||
nextDependentFrontier.add(edge.target);
|
||||
const node = nodeById.get(edge.target);
|
||||
if (node && includeByClosedFilter(node, options.hideClosed, false)) {
|
||||
dependentLevel.push(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
blockerLevel.sort(sortNodes);
|
||||
dependentLevel.sort(sortNodes);
|
||||
|
||||
if (blockerLevel.length > 0) {
|
||||
blockers.push(blockerLevel);
|
||||
}
|
||||
if (dependentLevel.length > 0) {
|
||||
dependents.push(dependentLevel);
|
||||
}
|
||||
|
||||
blockerFrontier = nextBlockerFrontier;
|
||||
dependentFrontier = nextDependentFrontier;
|
||||
|
||||
if (blockerFrontier.size === 0 && dependentFrontier.size === 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { focus: focusNode, blockers, dependents };
|
||||
}
|
||||
|
||||
export interface BlockedChainAnalysis {
|
||||
blockerNodeIds: string[];
|
||||
openBlockerCount: number;
|
||||
inProgressBlockerCount: number;
|
||||
firstActionableBlockerId: string | null;
|
||||
chainEdgeIds: string[];
|
||||
}
|
||||
|
||||
export function analyzeBlockedChain(model: GraphModel, options: { focusId: string }): BlockedChainAnalysis {
|
||||
const focusId = options.focusId;
|
||||
const visited = new Set<string>([focusId]);
|
||||
let queue = [focusId];
|
||||
const chainEdgeIds: string[] = [];
|
||||
const blockerNodeIds: string[] = [];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const nodeId = queue.shift()!;
|
||||
const adjacency = model.adjacency[nodeId];
|
||||
if (!adjacency) continue;
|
||||
|
||||
for (const edge of adjacency.incoming) {
|
||||
if (edge.type !== 'blocks') continue;
|
||||
chainEdgeIds.push(`${edge.source}:${edge.type}:${edge.target}`);
|
||||
if (!visited.has(edge.source)) {
|
||||
visited.add(edge.source);
|
||||
queue.push(edge.source);
|
||||
blockerNodeIds.push(edge.source);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nodeById = new Map(model.nodes.map((n) => [n.id, n]));
|
||||
const blockers = blockerNodeIds.map((id) => nodeById.get(id)).filter(Boolean) as GraphNode[];
|
||||
const nonClosedBlockers = blockers.filter((b) => b.status !== 'closed');
|
||||
const openBlockers = blockers.filter((b) => b.status === 'open');
|
||||
const inProgress = blockers.filter((b) => b.status === 'in_progress');
|
||||
|
||||
const firstActionable = nonClosedBlockers.find((b) => {
|
||||
const adj = model.adjacency[b.id];
|
||||
if (!adj) return true;
|
||||
return !adj.incoming.some((e) => {
|
||||
if (e.type !== 'blocks') return false;
|
||||
const sourceNode = nodeById.get(e.source);
|
||||
return sourceNode?.status !== 'closed';
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
blockerNodeIds: blockerNodeIds.sort(),
|
||||
openBlockerCount: openBlockers.length,
|
||||
inProgressBlockerCount: inProgress.length,
|
||||
firstActionableBlockerId: firstActionable?.id ?? null,
|
||||
chainEdgeIds: chainEdgeIds.sort(),
|
||||
};
|
||||
}
|
||||
|
||||
export interface CycleAnomaly {
|
||||
cycles: string[][];
|
||||
cycleNodeIds: string[];
|
||||
cycleEdgeIds: string[];
|
||||
}
|
||||
|
||||
export function detectDependencyCycles(model: GraphModel): CycleAnomaly {
|
||||
const cycleNodeIdsSet = new Set<string>();
|
||||
const cycleEdgeIdsSet = new Set<string>();
|
||||
const cycleKeys = new Set<string>();
|
||||
const cycles: string[][] = [];
|
||||
|
||||
const relevantEdges = [...model.edges]; // include all edge types for cycle detection
|
||||
const adj = new Map<string, string[]>();
|
||||
for (const node of model.nodes) {
|
||||
adj.set(node.id, []);
|
||||
}
|
||||
for (const edge of relevantEdges) {
|
||||
const list = adj.get(edge.source) ?? [];
|
||||
list.push(edge.target);
|
||||
adj.set(edge.source, list);
|
||||
}
|
||||
for (const [nodeId, neighbors] of adj.entries()) {
|
||||
adj.set(
|
||||
nodeId,
|
||||
[...neighbors].sort((a, b) => a.localeCompare(b)),
|
||||
);
|
||||
}
|
||||
|
||||
const visited = new Set<string>();
|
||||
const recStack = new Set<string>();
|
||||
const path: string[] = [];
|
||||
|
||||
function walk(nodeId: string): void {
|
||||
visited.add(nodeId);
|
||||
recStack.add(nodeId);
|
||||
path.push(nodeId);
|
||||
|
||||
const neighbors = adj.get(nodeId) ?? [];
|
||||
for (const nextId of neighbors) {
|
||||
if (!visited.has(nextId)) {
|
||||
walk(nextId);
|
||||
} else if (recStack.has(nextId)) {
|
||||
const cycleStartIndex = path.indexOf(nextId);
|
||||
if (cycleStartIndex >= 0) {
|
||||
const cycleNodes = path.slice(cycleStartIndex);
|
||||
const canonical = [...cycleNodes].sort((a, b) => a.localeCompare(b));
|
||||
const cycleKey = canonical.join('|');
|
||||
if (!cycleKeys.has(cycleKey)) {
|
||||
cycleKeys.add(cycleKey);
|
||||
cycles.push(canonical);
|
||||
}
|
||||
canonical.forEach((id) => cycleNodeIdsSet.add(id));
|
||||
for (let i = 0; i < cycleNodes.length; i += 1) {
|
||||
const s = cycleNodes[i];
|
||||
const t = cycleNodes[(i + 1) % cycleNodes.length];
|
||||
cycleEdgeIdsSet.add(`${s}:blocks:${t}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
recStack.delete(nodeId);
|
||||
path.pop();
|
||||
}
|
||||
|
||||
for (const node of model.nodes) {
|
||||
if (!visited.has(node.id)) {
|
||||
walk(node.id);
|
||||
}
|
||||
}
|
||||
|
||||
cycles.sort((a, b) => a.join('|').localeCompare(b.join('|')));
|
||||
return {
|
||||
cycles,
|
||||
cycleNodeIds: [...cycleNodeIdsSet].sort(),
|
||||
cycleEdgeIds: [...cycleEdgeIdsSet].sort(),
|
||||
};
|
||||
}
|
||||
137
src/lib/graph.ts
Normal file
137
src/lib/graph.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import type { BeadDependencyType, BeadIssue } from './types';
|
||||
|
||||
type SupportedGraphEdgeType = Extract<
|
||||
BeadDependencyType,
|
||||
'blocks' | 'parent' | 'relates_to' | 'duplicates' | 'supersedes'
|
||||
>;
|
||||
|
||||
const SUPPORTED_EDGE_TYPES = new Set<BeadDependencyType>([
|
||||
'blocks',
|
||||
'parent',
|
||||
'relates_to',
|
||||
'duplicates',
|
||||
'supersedes',
|
||||
]);
|
||||
|
||||
export interface GraphNode {
|
||||
id: string;
|
||||
title: string;
|
||||
status: BeadIssue['status'];
|
||||
priority: number;
|
||||
issueType: string;
|
||||
assignee: string | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface GraphEdge {
|
||||
source: string;
|
||||
target: string;
|
||||
type: SupportedGraphEdgeType;
|
||||
}
|
||||
|
||||
export interface GraphAdjacencyEntry {
|
||||
incoming: GraphEdge[];
|
||||
outgoing: GraphEdge[];
|
||||
}
|
||||
|
||||
export interface GraphModelDiagnostics {
|
||||
missingTargets: number;
|
||||
droppedDuplicates: number;
|
||||
unsupportedTypes: number;
|
||||
}
|
||||
|
||||
export interface GraphModel {
|
||||
nodes: GraphNode[];
|
||||
edges: GraphEdge[];
|
||||
adjacency: Record<string, GraphAdjacencyEntry>;
|
||||
diagnostics: GraphModelDiagnostics;
|
||||
projectKey: string | null;
|
||||
}
|
||||
|
||||
export interface BuildGraphModelOptions {
|
||||
projectKey?: string;
|
||||
}
|
||||
|
||||
function edgeSort(a: GraphEdge, b: GraphEdge): number {
|
||||
if (a.source !== b.source) {
|
||||
return a.source.localeCompare(b.source);
|
||||
}
|
||||
if (a.type !== b.type) {
|
||||
return a.type.localeCompare(b.type);
|
||||
}
|
||||
return a.target.localeCompare(b.target);
|
||||
}
|
||||
|
||||
function isSupportedEdgeType(type: BeadDependencyType): type is SupportedGraphEdgeType {
|
||||
return SUPPORTED_EDGE_TYPES.has(type);
|
||||
}
|
||||
|
||||
export function buildGraphModel(issues: BeadIssue[], options: BuildGraphModelOptions = {}): GraphModel {
|
||||
const nodes = issues
|
||||
.map((issue) => ({
|
||||
id: issue.id,
|
||||
title: issue.title,
|
||||
status: issue.status,
|
||||
priority: issue.priority,
|
||||
issueType: issue.issue_type,
|
||||
assignee: issue.assignee,
|
||||
updatedAt: issue.updated_at,
|
||||
}))
|
||||
.sort((a, b) => a.id.localeCompare(b.id));
|
||||
|
||||
const nodeIds = new Set(nodes.map((node) => node.id));
|
||||
const edgeKeys = new Set<string>();
|
||||
const edges: GraphEdge[] = [];
|
||||
const diagnostics: GraphModelDiagnostics = {
|
||||
missingTargets: 0,
|
||||
droppedDuplicates: 0,
|
||||
unsupportedTypes: 0,
|
||||
};
|
||||
|
||||
for (const issue of issues) {
|
||||
for (const dependency of issue.dependencies) {
|
||||
if (!isSupportedEdgeType(dependency.type)) {
|
||||
diagnostics.unsupportedTypes += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!nodeIds.has(dependency.target)) {
|
||||
diagnostics.missingTargets += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const edgeKey = `${issue.id}::${dependency.type}::${dependency.target}`;
|
||||
if (edgeKeys.has(edgeKey)) {
|
||||
diagnostics.droppedDuplicates += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
edgeKeys.add(edgeKey);
|
||||
edges.push({
|
||||
source: issue.id,
|
||||
target: dependency.target,
|
||||
type: dependency.type,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
edges.sort(edgeSort);
|
||||
|
||||
const adjacency: Record<string, GraphAdjacencyEntry> = {};
|
||||
for (const node of nodes) {
|
||||
adjacency[node.id] = { incoming: [], outgoing: [] };
|
||||
}
|
||||
|
||||
for (const edge of edges) {
|
||||
adjacency[edge.source].outgoing.push(edge);
|
||||
adjacency[edge.target].incoming.push(edge);
|
||||
}
|
||||
|
||||
return {
|
||||
nodes,
|
||||
edges,
|
||||
adjacency,
|
||||
diagnostics,
|
||||
projectKey: options.projectKey ?? null,
|
||||
};
|
||||
}
|
||||
168
src/lib/issue-editor.ts
Normal file
168
src/lib/issue-editor.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import type { MutationStatus, UpdateMutationPayload } from './mutations';
|
||||
import type { BeadIssue } from './types';
|
||||
|
||||
export interface EditableIssueDraft {
|
||||
title: string;
|
||||
description: string;
|
||||
status: MutationStatus;
|
||||
priority: number;
|
||||
issueType: string;
|
||||
assignee: string;
|
||||
owner: string;
|
||||
labelsInput: string;
|
||||
}
|
||||
|
||||
export type EditableIssueFieldErrors = Partial<Record<keyof EditableIssueDraft, string>>;
|
||||
|
||||
export type EditState = 'pristine' | 'dirty' | 'saving' | 'error';
|
||||
|
||||
export function parseLabelsInput(labelsInput: string): string[] {
|
||||
const seen = new Set<string>();
|
||||
const labels: string[] = [];
|
||||
for (const rawPart of labelsInput.split(',')) {
|
||||
const part = rawPart.trim();
|
||||
if (!part || seen.has(part)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(part);
|
||||
labels.push(part);
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
export function buildEditableIssueDraft(issue: BeadIssue): EditableIssueDraft {
|
||||
const editableStatus: MutationStatus =
|
||||
issue.status === 'open' ||
|
||||
issue.status === 'in_progress' ||
|
||||
issue.status === 'blocked' ||
|
||||
issue.status === 'deferred' ||
|
||||
issue.status === 'closed'
|
||||
? issue.status
|
||||
: 'open';
|
||||
|
||||
return {
|
||||
title: issue.title,
|
||||
description: issue.description ?? '',
|
||||
status: editableStatus,
|
||||
priority: issue.priority,
|
||||
issueType: issue.issue_type,
|
||||
assignee: issue.assignee ?? '',
|
||||
owner: issue.owner ?? '',
|
||||
labelsInput: issue.labels.map((label) => label.trim()).filter(Boolean).join(', '),
|
||||
};
|
||||
}
|
||||
|
||||
export function validateEditableIssueDraft(draft: EditableIssueDraft): { ok: true; errors: {} } | { ok: false; errors: EditableIssueFieldErrors } {
|
||||
const errors: EditableIssueFieldErrors = {};
|
||||
if (!draft.title.trim()) {
|
||||
errors.title = 'Title is required.';
|
||||
}
|
||||
if (!Number.isInteger(draft.priority) || draft.priority < 0 || draft.priority > 4) {
|
||||
errors.priority = 'Priority must be between 0 and 4.';
|
||||
}
|
||||
if (!['open', 'in_progress', 'blocked', 'deferred', 'closed'].includes(draft.status)) {
|
||||
errors.status = 'Status must be open, in progress, blocked, deferred, or closed.';
|
||||
}
|
||||
if (!draft.issueType.trim()) {
|
||||
errors.issueType = 'Issue type is required.';
|
||||
}
|
||||
|
||||
const parts = draft.labelsInput.split(',').map((part) => part.trim());
|
||||
if (parts.some((part) => part.length === 0) && draft.labelsInput.trim().length > 0) {
|
||||
errors.labelsInput = 'Labels must be comma-separated non-empty values.';
|
||||
}
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
return { ok: false, errors };
|
||||
}
|
||||
return { ok: true, errors: {} };
|
||||
}
|
||||
|
||||
function normalizeNullable(value: string): string | undefined {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function labelsChanged(current: string[], next: string[]): boolean {
|
||||
if (current.length !== next.length) {
|
||||
return true;
|
||||
}
|
||||
for (let i = 0; i < current.length; i += 1) {
|
||||
if (current[i] !== next[i]) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function buildIssueUpdatePayload(
|
||||
issue: BeadIssue,
|
||||
draft: EditableIssueDraft,
|
||||
projectRoot: string,
|
||||
): UpdateMutationPayload | null {
|
||||
const nextTitle = draft.title.trim();
|
||||
const nextDescription = draft.description.trim();
|
||||
const nextAssignee = normalizeNullable(draft.assignee);
|
||||
const nextIssueType = draft.issueType.trim();
|
||||
const nextLabels = parseLabelsInput(draft.labelsInput);
|
||||
|
||||
const payload: UpdateMutationPayload = {
|
||||
projectRoot,
|
||||
id: issue.id,
|
||||
};
|
||||
|
||||
if (nextTitle !== issue.title) {
|
||||
payload.title = nextTitle;
|
||||
}
|
||||
|
||||
if (nextDescription !== (issue.description ?? '')) {
|
||||
payload.description = nextDescription;
|
||||
}
|
||||
|
||||
if (draft.priority !== issue.priority) {
|
||||
payload.priority = draft.priority;
|
||||
}
|
||||
|
||||
if (draft.status !== issue.status) {
|
||||
payload.status = draft.status;
|
||||
}
|
||||
|
||||
if (nextIssueType !== issue.issue_type) {
|
||||
payload.issueType = nextIssueType;
|
||||
}
|
||||
|
||||
if (nextAssignee !== (issue.assignee ?? undefined)) {
|
||||
payload.assignee = nextAssignee;
|
||||
}
|
||||
|
||||
if (labelsChanged(issue.labels, nextLabels)) {
|
||||
payload.labels = nextLabels;
|
||||
}
|
||||
|
||||
if (
|
||||
payload.title === undefined &&
|
||||
payload.description === undefined &&
|
||||
payload.status === undefined &&
|
||||
payload.priority === undefined &&
|
||||
payload.issueType === undefined &&
|
||||
payload.assignee === undefined &&
|
||||
payload.labels === undefined
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function classifyEditState(input: { dirty: boolean; saving: boolean; error: string | null }): EditState {
|
||||
if (input.saving) {
|
||||
return 'saving';
|
||||
}
|
||||
if (input.error) {
|
||||
return 'error';
|
||||
}
|
||||
if (input.dirty) {
|
||||
return 'dirty';
|
||||
}
|
||||
return 'pristine';
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import type { BeadIssue } from './types';
|
||||
|
||||
export const KANBAN_STATUSES = ['open', 'in_progress', 'blocked', 'deferred', 'closed'] as const;
|
||||
export const KANBAN_STATUSES = ['ready', 'in_progress', 'blocked', 'closed'] as const;
|
||||
|
||||
export type KanbanStatus = (typeof KANBAN_STATUSES)[number];
|
||||
|
||||
|
|
@ -15,15 +15,29 @@ export interface KanbanFilterOptions {
|
|||
|
||||
export interface KanbanStats {
|
||||
total: number;
|
||||
open: number;
|
||||
ready: number;
|
||||
active: number;
|
||||
blocked: number;
|
||||
done: number;
|
||||
p0: number;
|
||||
}
|
||||
|
||||
function isKanbanStatus(status: string): status is KanbanStatus {
|
||||
return KANBAN_STATUSES.includes(status as KanbanStatus);
|
||||
export type BoardMutationStatus = 'open' | 'in_progress' | 'blocked' | 'closed';
|
||||
|
||||
export interface BlockedTreeNode {
|
||||
id: string;
|
||||
title: string;
|
||||
level: number;
|
||||
}
|
||||
|
||||
export interface ExecutionChecklistItem {
|
||||
key: 'owner_assigned' | 'no_open_blockers' | 'quality_signal' | 'execution_compatible';
|
||||
label: string;
|
||||
passed: boolean;
|
||||
}
|
||||
|
||||
function isReviewStatus(status: string): boolean {
|
||||
return status.toLowerCase().includes('review');
|
||||
}
|
||||
|
||||
function issueSort(a: BeadIssue, b: BeadIssue): number {
|
||||
|
|
@ -35,6 +49,65 @@ function issueSort(a: BeadIssue, b: BeadIssue): number {
|
|||
return b.updated_at.localeCompare(a.updated_at);
|
||||
}
|
||||
|
||||
function hasOpenBlockers(issues: BeadIssue[], targetId: string): boolean {
|
||||
return issues.some(
|
||||
(issue) =>
|
||||
issue.status !== 'closed' &&
|
||||
issue.dependencies.some((dep) => dep.type === 'blocks' && dep.target === targetId),
|
||||
);
|
||||
}
|
||||
|
||||
function hasQualitySignal(issue: BeadIssue): boolean {
|
||||
const description = issue.description?.trim() ?? '';
|
||||
if (description.length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (issue.labels.some((label) => label.toLowerCase().includes('accept'))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const acceptance = issue.metadata.acceptance;
|
||||
if (typeof acceptance === 'string') {
|
||||
return acceptance.trim().length > 0;
|
||||
}
|
||||
if (Array.isArray(acceptance)) {
|
||||
return acceptance.length > 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function deriveBlockedIds(issues: BeadIssue[]): Set<string> {
|
||||
const issueById = new Map(issues.map((issue) => [issue.id, issue]));
|
||||
const blockedIds = new Set<string>();
|
||||
|
||||
for (const issue of issues) {
|
||||
for (const dep of issue.dependencies) {
|
||||
if (dep.type !== 'blocks') continue;
|
||||
const blocker = issueById.get(issue.id);
|
||||
if (!blocker) continue;
|
||||
if (blocker.status === 'closed') continue;
|
||||
blockedIds.add(dep.target);
|
||||
}
|
||||
}
|
||||
|
||||
return blockedIds;
|
||||
}
|
||||
|
||||
function laneForIssue(issue: BeadIssue, blockedIds: Set<string>): KanbanStatus {
|
||||
if (issue.status === 'closed') {
|
||||
return 'closed';
|
||||
}
|
||||
if (issue.status === 'blocked' || blockedIds.has(issue.id)) {
|
||||
return 'blocked';
|
||||
}
|
||||
if (issue.status === 'in_progress' || isReviewStatus(issue.status)) {
|
||||
return 'in_progress';
|
||||
}
|
||||
return 'ready';
|
||||
}
|
||||
|
||||
export function filterKanbanIssues(issues: BeadIssue[], filters: KanbanFilterOptions): BeadIssue[] {
|
||||
const query = (filters.query ?? '').trim().toLowerCase();
|
||||
const type = (filters.type ?? '').trim().toLowerCase();
|
||||
|
|
@ -67,17 +140,19 @@ export function filterKanbanIssues(issues: BeadIssue[], filters: KanbanFilterOpt
|
|||
|
||||
export function buildKanbanColumns(issues: BeadIssue[]): KanbanColumns {
|
||||
const columns = {
|
||||
open: [],
|
||||
ready: [],
|
||||
in_progress: [],
|
||||
blocked: [],
|
||||
deferred: [],
|
||||
closed: [],
|
||||
} as KanbanColumns;
|
||||
|
||||
const blockedIds = deriveBlockedIds(issues);
|
||||
for (const issue of issues) {
|
||||
if (isKanbanStatus(issue.status)) {
|
||||
columns[issue.status].push(issue);
|
||||
const lane = laneForIssue(issue, blockedIds);
|
||||
if (lane === 'ready' && issue.issue_type === 'epic') {
|
||||
continue;
|
||||
}
|
||||
columns[lane].push(issue);
|
||||
}
|
||||
|
||||
for (const status of KANBAN_STATUSES) {
|
||||
|
|
@ -88,12 +163,176 @@ export function buildKanbanColumns(issues: BeadIssue[]): KanbanColumns {
|
|||
}
|
||||
|
||||
export function buildKanbanStats(issues: BeadIssue[]): KanbanStats {
|
||||
const columns = buildKanbanColumns(issues);
|
||||
return {
|
||||
total: issues.length,
|
||||
open: issues.filter((x) => x.status === 'open').length,
|
||||
active: issues.filter((x) => x.status === 'in_progress').length,
|
||||
blocked: issues.filter((x) => x.status === 'blocked').length,
|
||||
done: issues.filter((x) => x.status === 'closed').length,
|
||||
ready: columns.ready.length,
|
||||
active: columns.in_progress.length,
|
||||
blocked: columns.blocked.length,
|
||||
done: columns.closed.length,
|
||||
p0: issues.filter((x) => x.priority === 0).length,
|
||||
};
|
||||
}
|
||||
|
||||
export function laneToMutationStatus(status: KanbanStatus): BoardMutationStatus {
|
||||
switch (status) {
|
||||
case 'ready':
|
||||
return 'open';
|
||||
case 'in_progress':
|
||||
return 'in_progress';
|
||||
case 'blocked':
|
||||
return 'blocked';
|
||||
case 'closed':
|
||||
return 'closed';
|
||||
default:
|
||||
return 'open';
|
||||
}
|
||||
}
|
||||
|
||||
export function buildBlockedByTree(
|
||||
issues: BeadIssue[],
|
||||
focusId: string | null,
|
||||
options: { maxNodes?: number } = {},
|
||||
): { total: number; nodes: BlockedTreeNode[] } {
|
||||
if (!focusId) {
|
||||
return { total: 0, nodes: [] };
|
||||
}
|
||||
|
||||
const issueById = new Map(issues.map((issue) => [issue.id, issue]));
|
||||
const incomingByTarget = new Map<string, string[]>();
|
||||
for (const issue of issues) {
|
||||
for (const dep of issue.dependencies) {
|
||||
if (dep.type !== 'blocks') continue;
|
||||
const list = incomingByTarget.get(dep.target) ?? [];
|
||||
list.push(issue.id);
|
||||
incomingByTarget.set(dep.target, list);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [targetId, blockerIds] of incomingByTarget.entries()) {
|
||||
incomingByTarget.set(
|
||||
targetId,
|
||||
[...new Set(blockerIds)].sort((a, b) => a.localeCompare(b)),
|
||||
);
|
||||
}
|
||||
|
||||
const maxNodes = Math.max(1, options.maxNodes ?? 12);
|
||||
const visited = new Set<string>([focusId]);
|
||||
const queued = new Set<string>();
|
||||
const queue: Array<{ id: string; level: number }> = [{ id: focusId, level: 0 }];
|
||||
const nodes: BlockedTreeNode[] = [];
|
||||
let total = 0;
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift() as { id: string; level: number };
|
||||
const blockers = incomingByTarget.get(current.id) ?? [];
|
||||
for (const blockerId of blockers) {
|
||||
if (visited.has(blockerId) || queued.has(blockerId)) continue;
|
||||
queued.add(blockerId);
|
||||
total += 1;
|
||||
const blocker = issueById.get(blockerId);
|
||||
if (nodes.length < maxNodes) {
|
||||
nodes.push({
|
||||
id: blockerId,
|
||||
title: blocker?.title ?? blockerId,
|
||||
level: current.level + 1,
|
||||
});
|
||||
}
|
||||
queue.push({ id: blockerId, level: current.level + 1 });
|
||||
}
|
||||
visited.add(current.id);
|
||||
}
|
||||
|
||||
return { total, nodes };
|
||||
}
|
||||
|
||||
export function findIssueLane(columns: KanbanColumns, issueId: string): KanbanStatus | null {
|
||||
for (const status of KANBAN_STATUSES) {
|
||||
if (columns[status].some((issue) => issue.id === issueId)) {
|
||||
return status;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildUnblocksCountByIssue(issues: BeadIssue[]): Map<string, number> {
|
||||
const unblocksByIssue = new Map<string, number>();
|
||||
for (const issue of issues) {
|
||||
const targets = new Set(
|
||||
issue.dependencies.filter((dep) => dep.type === 'blocks').map((dep) => dep.target),
|
||||
);
|
||||
unblocksByIssue.set(issue.id, targets.size);
|
||||
}
|
||||
return unblocksByIssue;
|
||||
}
|
||||
|
||||
export function pickNextActionableIssue(columns: KanbanColumns, issues: BeadIssue[]): BeadIssue | null {
|
||||
if (columns.ready.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const unblocksByIssue = buildUnblocksCountByIssue(issues);
|
||||
const ranked = [...columns.ready].sort((a, b) => {
|
||||
const priorityDiff = a.priority - b.priority;
|
||||
if (priorityDiff !== 0) {
|
||||
return priorityDiff;
|
||||
}
|
||||
|
||||
const unblocksDiff = (unblocksByIssue.get(b.id) ?? 0) - (unblocksByIssue.get(a.id) ?? 0);
|
||||
if (unblocksDiff !== 0) {
|
||||
return unblocksDiff;
|
||||
}
|
||||
|
||||
const updatedDiff = b.updated_at.localeCompare(a.updated_at);
|
||||
if (updatedDiff !== 0) {
|
||||
return updatedDiff;
|
||||
}
|
||||
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
|
||||
return ranked[0] ?? null;
|
||||
}
|
||||
|
||||
export function formatUpdatedRecency(updatedAt: string | null | undefined, nowMs = Date.now()): string {
|
||||
if (!updatedAt) {
|
||||
return 'updated unknown';
|
||||
}
|
||||
|
||||
const parsed = Date.parse(updatedAt);
|
||||
if (Number.isNaN(parsed)) {
|
||||
return 'updated unknown';
|
||||
}
|
||||
|
||||
const elapsedSeconds = Math.max(0, Math.floor((nowMs - parsed) / 1000));
|
||||
if (elapsedSeconds < 60) {
|
||||
return 'updated now';
|
||||
}
|
||||
if (elapsedSeconds < 3600) {
|
||||
return `updated ${Math.floor(elapsedSeconds / 60)}m`;
|
||||
}
|
||||
if (elapsedSeconds < 86400) {
|
||||
return `updated ${Math.floor(elapsedSeconds / 3600)}h`;
|
||||
}
|
||||
if (elapsedSeconds < 604800) {
|
||||
return `updated ${Math.floor(elapsedSeconds / 86400)}d`;
|
||||
}
|
||||
return `updated ${Math.floor(elapsedSeconds / 604800)}w`;
|
||||
}
|
||||
|
||||
export function buildExecutionChecklist(issue: BeadIssue, issues: BeadIssue[]): ExecutionChecklistItem[] {
|
||||
const columns = buildKanbanColumns(issues);
|
||||
const lane = findIssueLane(columns, issue.id);
|
||||
const openBlockers = hasOpenBlockers(issues, issue.id);
|
||||
|
||||
return [
|
||||
{ key: 'owner_assigned', label: 'Owner assigned', passed: Boolean(issue.owner?.trim()) },
|
||||
{ key: 'no_open_blockers', label: 'Not blocked by open blockers', passed: !openBlockers },
|
||||
{ key: 'quality_signal', label: 'Has acceptance or description signal', passed: hasQualitySignal(issue) },
|
||||
{
|
||||
key: 'execution_compatible',
|
||||
label: 'Execution-compatible status (ready or in progress)',
|
||||
passed: lane === 'ready' || lane === 'in_progress',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
|
|||
306
src/lib/mutations.ts
Normal file
306
src/lib/mutations.ts
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
import { runBdCommand, type RunBdCommandResult } from './bridge';
|
||||
|
||||
export type MutationOperation = 'create' | 'update' | 'close' | 'reopen' | 'comment';
|
||||
export type MutationStatus = 'open' | 'in_progress' | 'blocked' | 'deferred' | 'closed';
|
||||
|
||||
interface MutationBasePayload {
|
||||
projectRoot: string;
|
||||
bdPath?: string;
|
||||
}
|
||||
|
||||
export interface CreateMutationPayload extends MutationBasePayload {
|
||||
title: string;
|
||||
description?: string;
|
||||
priority?: number;
|
||||
issueType?: string;
|
||||
assignee?: string;
|
||||
labels?: string[];
|
||||
}
|
||||
|
||||
export interface UpdateMutationPayload extends MutationBasePayload {
|
||||
id: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
status?: MutationStatus;
|
||||
priority?: number;
|
||||
issueType?: string;
|
||||
assignee?: string;
|
||||
labels?: string[];
|
||||
}
|
||||
|
||||
export interface CloseMutationPayload extends MutationBasePayload {
|
||||
id: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface ReopenMutationPayload extends MutationBasePayload {
|
||||
id: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface CommentMutationPayload extends MutationBasePayload {
|
||||
id: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export type MutationPayload =
|
||||
| CreateMutationPayload
|
||||
| UpdateMutationPayload
|
||||
| CloseMutationPayload
|
||||
| ReopenMutationPayload
|
||||
| CommentMutationPayload;
|
||||
|
||||
export interface MutationErrorShape {
|
||||
classification: 'bad_args' | 'not_found' | 'timeout' | 'non_zero_exit' | 'unknown';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface MutationResponse {
|
||||
ok: boolean;
|
||||
operation: MutationOperation;
|
||||
command: RunBdCommandResult;
|
||||
error?: MutationErrorShape;
|
||||
}
|
||||
|
||||
export class MutationValidationError extends Error {
|
||||
readonly code = 'MUTATION_VALIDATION_ERROR';
|
||||
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'MutationValidationError';
|
||||
}
|
||||
}
|
||||
|
||||
function asNonEmptyString(value: unknown, field: string): string {
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
throw new MutationValidationError(`"${field}" is required.`);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function asOptionalString(value: unknown): string | undefined {
|
||||
if (value === undefined || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
throw new MutationValidationError('Expected a string value.');
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function asOptionalPriority(value: unknown): number | undefined {
|
||||
if (value === undefined || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value !== 'number' || Number.isNaN(value) || value < 0 || value > 4) {
|
||||
throw new MutationValidationError('"priority" must be a number between 0 and 4.');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function asOptionalLabels(value: unknown): string[] | undefined {
|
||||
if (value === undefined || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
if (!Array.isArray(value)) {
|
||||
throw new MutationValidationError('"labels" must be an array of strings.');
|
||||
}
|
||||
const labels = value.map((label) => {
|
||||
if (typeof label !== 'string' || !label.trim()) {
|
||||
throw new MutationValidationError('"labels" must be an array of non-empty strings.');
|
||||
}
|
||||
return label.trim();
|
||||
});
|
||||
|
||||
return labels.length ? labels : undefined;
|
||||
}
|
||||
|
||||
function asOptionalStatus(value: unknown): MutationStatus | undefined {
|
||||
if (value === undefined || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
const status = asNonEmptyString(value, 'status');
|
||||
if (!['open', 'in_progress', 'blocked', 'deferred', 'closed'].includes(status)) {
|
||||
throw new MutationValidationError('"status" is invalid.');
|
||||
}
|
||||
return status as MutationStatus;
|
||||
}
|
||||
|
||||
function parseBasePayload(raw: unknown): MutationBasePayload {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
throw new MutationValidationError('Payload must be a JSON object.');
|
||||
}
|
||||
|
||||
const data = raw as Record<string, unknown>;
|
||||
return {
|
||||
projectRoot: asNonEmptyString(data.projectRoot, 'projectRoot'),
|
||||
bdPath: asOptionalString(data.bdPath),
|
||||
};
|
||||
}
|
||||
|
||||
export function validateMutationPayload(operation: MutationOperation, payload: unknown): MutationPayload {
|
||||
const base = parseBasePayload(payload);
|
||||
const data = payload as Record<string, unknown>;
|
||||
|
||||
if (operation === 'create') {
|
||||
return {
|
||||
...base,
|
||||
title: asNonEmptyString(data.title, 'title'),
|
||||
description: asOptionalString(data.description),
|
||||
priority: asOptionalPriority(data.priority),
|
||||
issueType: asOptionalString(data.issueType),
|
||||
assignee: asOptionalString(data.assignee),
|
||||
labels: asOptionalLabels(data.labels),
|
||||
};
|
||||
}
|
||||
|
||||
if (operation === 'update') {
|
||||
const mapped: UpdateMutationPayload = {
|
||||
...base,
|
||||
id: asNonEmptyString(data.id, 'id'),
|
||||
title: asOptionalString(data.title),
|
||||
description: asOptionalString(data.description),
|
||||
status: asOptionalStatus(data.status),
|
||||
priority: asOptionalPriority(data.priority),
|
||||
issueType: asOptionalString(data.issueType),
|
||||
assignee: asOptionalString(data.assignee),
|
||||
labels: asOptionalLabels(data.labels),
|
||||
};
|
||||
|
||||
if (
|
||||
!mapped.title &&
|
||||
!mapped.description &&
|
||||
!mapped.status &&
|
||||
mapped.priority === undefined &&
|
||||
!mapped.issueType &&
|
||||
!mapped.assignee &&
|
||||
!mapped.labels
|
||||
) {
|
||||
throw new MutationValidationError('At least one update field is required.');
|
||||
}
|
||||
|
||||
return mapped;
|
||||
}
|
||||
|
||||
if (operation === 'close') {
|
||||
return {
|
||||
...base,
|
||||
id: asNonEmptyString(data.id, 'id'),
|
||||
reason: asOptionalString(data.reason),
|
||||
};
|
||||
}
|
||||
|
||||
if (operation === 'reopen') {
|
||||
return {
|
||||
...base,
|
||||
id: asNonEmptyString(data.id, 'id'),
|
||||
reason: asOptionalString(data.reason),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
id: asNonEmptyString(data.id, 'id'),
|
||||
text: asNonEmptyString(data.text, 'text'),
|
||||
};
|
||||
}
|
||||
|
||||
function pushOptionalArg(args: string[], flag: string, value: string | undefined): void {
|
||||
if (value) {
|
||||
args.push(flag, value);
|
||||
}
|
||||
}
|
||||
|
||||
function pushOptionalLabels(args: string[], labels: string[] | undefined): void {
|
||||
if (labels && labels.length > 0) {
|
||||
args.push('-l', labels.join(','));
|
||||
}
|
||||
}
|
||||
|
||||
export function buildBdMutationArgs(operation: MutationOperation, payload: MutationPayload): string[] {
|
||||
if (operation === 'create') {
|
||||
const data = payload as CreateMutationPayload;
|
||||
const args = ['create', data.title];
|
||||
pushOptionalArg(args, '-d', data.description);
|
||||
if (data.priority !== undefined) {
|
||||
args.push('-p', String(data.priority));
|
||||
}
|
||||
pushOptionalArg(args, '-t', data.issueType);
|
||||
pushOptionalArg(args, '-a', data.assignee);
|
||||
pushOptionalLabels(args, data.labels);
|
||||
args.push('--json');
|
||||
return args;
|
||||
}
|
||||
|
||||
if (operation === 'update') {
|
||||
const data = payload as UpdateMutationPayload;
|
||||
const args = ['update', data.id];
|
||||
pushOptionalArg(args, '--title', data.title);
|
||||
pushOptionalArg(args, '-d', data.description);
|
||||
pushOptionalArg(args, '-s', data.status);
|
||||
if (data.priority !== undefined) {
|
||||
args.push('-p', String(data.priority));
|
||||
}
|
||||
pushOptionalArg(args, '-t', data.issueType);
|
||||
pushOptionalArg(args, '-a', data.assignee);
|
||||
pushOptionalLabels(args, data.labels);
|
||||
args.push('--json');
|
||||
return args;
|
||||
}
|
||||
|
||||
if (operation === 'close') {
|
||||
const data = payload as CloseMutationPayload;
|
||||
const args = ['close', data.id];
|
||||
pushOptionalArg(args, '-r', data.reason);
|
||||
args.push('--json');
|
||||
return args;
|
||||
}
|
||||
|
||||
if (operation === 'reopen') {
|
||||
const data = payload as ReopenMutationPayload;
|
||||
const args = ['reopen', data.id];
|
||||
pushOptionalArg(args, '-r', data.reason);
|
||||
args.push('--json');
|
||||
return args;
|
||||
}
|
||||
|
||||
const data = payload as CommentMutationPayload;
|
||||
return ['comments', 'add', data.id, data.text, '--json'];
|
||||
}
|
||||
|
||||
interface ExecuteMutationDeps {
|
||||
runBdCommand: typeof runBdCommand;
|
||||
}
|
||||
|
||||
export async function executeMutation(
|
||||
operation: MutationOperation,
|
||||
payload: MutationPayload,
|
||||
deps: Partial<ExecuteMutationDeps> = {},
|
||||
): Promise<MutationResponse> {
|
||||
const runner = deps.runBdCommand ?? runBdCommand;
|
||||
const args = buildBdMutationArgs(operation, payload);
|
||||
const command = await runner({
|
||||
projectRoot: payload.projectRoot,
|
||||
args,
|
||||
explicitBdPath: payload.bdPath,
|
||||
});
|
||||
|
||||
if (!command.success) {
|
||||
return {
|
||||
ok: false,
|
||||
operation,
|
||||
command,
|
||||
error: {
|
||||
classification: command.classification ?? 'unknown',
|
||||
message: command.error ?? (command.stderr || 'Mutation command failed.'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
operation,
|
||||
command,
|
||||
};
|
||||
}
|
||||
|
|
@ -15,14 +15,21 @@ function normalizeDependencies(value: unknown): BeadDependency[] {
|
|||
return null;
|
||||
}
|
||||
|
||||
const dep = item as { type?: unknown; target?: unknown };
|
||||
if (typeof dep.type !== 'string' || typeof dep.target !== 'string') {
|
||||
const dep = item as { type?: unknown; target?: unknown; depends_on_id?: unknown };
|
||||
if (typeof dep.type !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const target = typeof dep.target === 'string' ? dep.target : typeof dep.depends_on_id === 'string' ? dep.depends_on_id : null;
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedType = dep.type === 'parent-child' ? 'parent' : dep.type;
|
||||
|
||||
return {
|
||||
type: dep.type as BeadDependency['type'],
|
||||
target: dep.target,
|
||||
type: normalizedType as BeadDependency['type'],
|
||||
target,
|
||||
};
|
||||
})
|
||||
.filter((dep): dep is BeadDependency => dep !== null);
|
||||
|
|
|
|||
104
src/lib/project-scope.ts
Normal file
104
src/lib/project-scope.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { canonicalizeWindowsPath, toDisplayPath, windowsPathKey } from './pathing';
|
||||
|
||||
export interface ProjectScopeRegistryEntry {
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface ProjectScopeOption {
|
||||
key: string;
|
||||
root: string;
|
||||
displayPath: string;
|
||||
source: 'local' | 'registry';
|
||||
}
|
||||
|
||||
export type ProjectScopeMode = 'single' | 'aggregate';
|
||||
|
||||
export interface ResolveProjectScopeInput {
|
||||
currentProjectRoot: string;
|
||||
registryProjects: ProjectScopeRegistryEntry[];
|
||||
requestedProjectKey?: string | null;
|
||||
requestedMode?: string | null;
|
||||
}
|
||||
|
||||
export interface ResolvedProjectScope {
|
||||
mode: ProjectScopeMode;
|
||||
selected: ProjectScopeOption;
|
||||
readRoots: string[];
|
||||
options: ProjectScopeOption[];
|
||||
}
|
||||
|
||||
function normalizeRequestedKey(input?: string | null): string | null {
|
||||
if (typeof input !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
|
||||
function buildLocalOption(currentProjectRoot: string): ProjectScopeOption {
|
||||
const root = canonicalizeWindowsPath(currentProjectRoot);
|
||||
return {
|
||||
key: 'local',
|
||||
root,
|
||||
displayPath: toDisplayPath(root),
|
||||
source: 'local',
|
||||
};
|
||||
}
|
||||
|
||||
function buildRegistryOptions(registryProjects: ProjectScopeRegistryEntry[]): ProjectScopeOption[] {
|
||||
const seen = new Set<string>();
|
||||
const options: ProjectScopeOption[] = [];
|
||||
|
||||
for (const project of registryProjects) {
|
||||
const root = canonicalizeWindowsPath(project.path);
|
||||
const key = windowsPathKey(root);
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
options.push({
|
||||
key,
|
||||
root,
|
||||
displayPath: toDisplayPath(root),
|
||||
source: 'registry',
|
||||
});
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function normalizeMode(input?: string | null): ProjectScopeMode {
|
||||
if (input === 'aggregate') {
|
||||
return 'aggregate';
|
||||
}
|
||||
return 'single';
|
||||
}
|
||||
|
||||
export function resolveProjectScope(input: ResolveProjectScopeInput): ResolvedProjectScope {
|
||||
const local = buildLocalOption(input.currentProjectRoot);
|
||||
const registry = buildRegistryOptions(input.registryProjects);
|
||||
const options = [local, ...registry];
|
||||
const requestedKey = normalizeRequestedKey(input.requestedProjectKey);
|
||||
const mode = normalizeMode(input.requestedMode);
|
||||
const readRoots =
|
||||
mode === 'aggregate' ? options.map((option) => option.root) : [local.root];
|
||||
|
||||
if (!requestedKey || requestedKey === 'local') {
|
||||
return { mode, selected: local, readRoots, options };
|
||||
}
|
||||
|
||||
const selected = options.find((option) => option.key === requestedKey);
|
||||
const resolvedSelected = selected ?? local;
|
||||
const resolvedReadRoots =
|
||||
mode === 'aggregate' ? readRoots : [resolvedSelected.root];
|
||||
|
||||
return {
|
||||
mode,
|
||||
selected: resolvedSelected,
|
||||
readRoots: resolvedReadRoots,
|
||||
options,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { parseIssuesJsonl } from './parser';
|
||||
import { canonicalizeWindowsPath } from './pathing';
|
||||
import { readTextFileWithRetry } from './read-text-retry';
|
||||
import { buildProjectContext } from './project-context';
|
||||
import type { BeadIssueWithProject, ProjectSource } from './types';
|
||||
|
||||
|
|
@ -34,7 +34,7 @@ export async function readIssuesFromDisk(options: ReadIssuesOptions = {}): Promi
|
|||
|
||||
for (const issuesPath of candidates) {
|
||||
try {
|
||||
const jsonl = await fs.readFile(issuesPath, 'utf8');
|
||||
const jsonl = await readTextFileWithRetry(issuesPath);
|
||||
return parseIssuesJsonl(jsonl, {
|
||||
includeTombstones: options.includeTombstones ?? false,
|
||||
}).map((issue) => ({
|
||||
|
|
|
|||
41
src/lib/read-text-retry.ts
Normal file
41
src/lib/read-text-retry.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import fs from 'node:fs/promises';
|
||||
|
||||
const DEFAULT_RETRY_CODES = new Set(['EBUSY', 'EPERM']);
|
||||
|
||||
export interface ReadTextRetryOptions {
|
||||
retries?: number;
|
||||
delayMs?: number;
|
||||
retryCodes?: Set<string>;
|
||||
}
|
||||
|
||||
function sleep(delayMs: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
}
|
||||
|
||||
function shouldRetry(error: unknown, retryCodes: Set<string>): boolean {
|
||||
const code = (error as NodeJS.ErrnoException | undefined)?.code;
|
||||
return typeof code === 'string' && retryCodes.has(code);
|
||||
}
|
||||
|
||||
export async function readTextFileWithRetry(
|
||||
filePath: string,
|
||||
options: ReadTextRetryOptions = {},
|
||||
): Promise<string> {
|
||||
const retries = options.retries ?? 2;
|
||||
const delayMs = options.delayMs ?? 40;
|
||||
const retryCodes = options.retryCodes ?? DEFAULT_RETRY_CODES;
|
||||
|
||||
let attempt = 0;
|
||||
while (true) {
|
||||
try {
|
||||
return await fs.readFile(filePath, 'utf8');
|
||||
} catch (error) {
|
||||
if (attempt >= retries || !shouldRetry(error, retryCodes)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
attempt += 1;
|
||||
await sleep(delayMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
83
src/lib/realtime.ts
Normal file
83
src/lib/realtime.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { canonicalizeWindowsPath, windowsPathKey } from './pathing';
|
||||
|
||||
export type IssuesChangeKind = 'changed' | 'renamed';
|
||||
|
||||
export interface IssuesChangedEvent {
|
||||
id: number;
|
||||
projectRoot: string;
|
||||
changedPath?: string;
|
||||
kind: IssuesChangeKind;
|
||||
at: string;
|
||||
}
|
||||
|
||||
interface Subscriber {
|
||||
projectKey?: string;
|
||||
listener: (event: IssuesChangedEvent) => void;
|
||||
}
|
||||
|
||||
export interface SubscribeOptions {
|
||||
projectRoot?: string;
|
||||
}
|
||||
|
||||
export class IssuesEventBus {
|
||||
private nextEventId = 1;
|
||||
|
||||
private readonly subscribers = new Map<number, Subscriber>();
|
||||
|
||||
private nextSubscriberId = 1;
|
||||
|
||||
emit(projectRoot: string, changedPath?: string, kind: IssuesChangeKind = 'changed'): IssuesChangedEvent {
|
||||
const canonicalProjectRoot = canonicalizeWindowsPath(projectRoot);
|
||||
const projectKey = windowsPathKey(canonicalProjectRoot);
|
||||
const event: IssuesChangedEvent = {
|
||||
id: this.nextEventId,
|
||||
projectRoot: canonicalProjectRoot,
|
||||
changedPath: changedPath ? canonicalizeWindowsPath(changedPath) : undefined,
|
||||
kind,
|
||||
at: new Date().toISOString(),
|
||||
};
|
||||
this.nextEventId += 1;
|
||||
|
||||
for (const subscriber of this.subscribers.values()) {
|
||||
if (!subscriber.projectKey || subscriber.projectKey === projectKey) {
|
||||
subscriber.listener(event);
|
||||
}
|
||||
}
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
subscribe(listener: (event: IssuesChangedEvent) => void, options: SubscribeOptions = {}): () => void {
|
||||
const id = this.nextSubscriberId;
|
||||
this.nextSubscriberId += 1;
|
||||
const canonicalRoot = options.projectRoot ? canonicalizeWindowsPath(options.projectRoot) : undefined;
|
||||
|
||||
this.subscribers.set(id, {
|
||||
listener,
|
||||
projectKey: canonicalRoot ? windowsPathKey(canonicalRoot) : undefined,
|
||||
});
|
||||
|
||||
return () => {
|
||||
this.subscribers.delete(id);
|
||||
};
|
||||
}
|
||||
|
||||
getSubscriberCount(): number {
|
||||
return this.subscribers.size;
|
||||
}
|
||||
|
||||
resetForTests(): void {
|
||||
this.subscribers.clear();
|
||||
this.nextSubscriberId = 1;
|
||||
this.nextEventId = 1;
|
||||
}
|
||||
}
|
||||
|
||||
export const issuesEventBus = new IssuesEventBus();
|
||||
|
||||
export function toSseFrame(event: IssuesChangedEvent): string {
|
||||
return `id: ${event.id}\nevent: issues\ndata: ${JSON.stringify(event)}\n\n`;
|
||||
}
|
||||
|
||||
export const SSE_HEARTBEAT_FRAME = ': heartbeat\n\n';
|
||||
export const SSE_CONNECTED_FRAME = ': connected\n\n';
|
||||
140
src/lib/registry.ts
Normal file
140
src/lib/registry.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { canonicalizeWindowsPath, toDisplayPath, windowsPathKey } from './pathing';
|
||||
|
||||
export interface RegistryProject {
|
||||
path: string;
|
||||
key: string;
|
||||
}
|
||||
|
||||
interface RegistryDocument {
|
||||
version: 1;
|
||||
projects: RegistryProject[];
|
||||
}
|
||||
|
||||
export class RegistryValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'RegistryValidationError';
|
||||
}
|
||||
}
|
||||
|
||||
function userProfileRoot(): string {
|
||||
return process.env.USERPROFILE?.trim() || os.homedir();
|
||||
}
|
||||
|
||||
export function registryFilePath(): string {
|
||||
return path.join(userProfileRoot(), '.beadboard', 'projects.json');
|
||||
}
|
||||
|
||||
function ensureWindowsAbsolutePath(input: string): string {
|
||||
const normalized = canonicalizeWindowsPath(input.trim());
|
||||
if (!/^[A-Za-z]:\\/.test(normalized)) {
|
||||
throw new RegistryValidationError('Project path must be a Windows absolute path (e.g. C:\\Repos\\Project).');
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeProject(input: string): RegistryProject {
|
||||
const normalized = ensureWindowsAbsolutePath(input);
|
||||
return {
|
||||
path: toDisplayPath(normalized),
|
||||
key: windowsPathKey(normalized),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeProjects(input: unknown): RegistryProject[] {
|
||||
if (!Array.isArray(input)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const normalized: RegistryProject[] = [];
|
||||
|
||||
for (const item of input) {
|
||||
if (!item || typeof item !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const candidate = item as { path?: unknown };
|
||||
if (typeof candidate.path !== 'string') {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const project = normalizeProject(candidate.path);
|
||||
if (!seen.has(project.key)) {
|
||||
seen.add(project.key);
|
||||
normalized.push(project);
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function readRegistryDocument(): Promise<RegistryDocument> {
|
||||
const filePath = registryFilePath();
|
||||
|
||||
try {
|
||||
const raw = await fs.readFile(filePath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as { projects?: unknown };
|
||||
return {
|
||||
version: 1,
|
||||
projects: normalizeProjects(parsed.projects),
|
||||
};
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return { version: 1, projects: [] };
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeRegistryDocument(document: RegistryDocument): Promise<void> {
|
||||
const filePath = registryFilePath();
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, `${JSON.stringify(document, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
export async function listProjects(): Promise<RegistryProject[]> {
|
||||
const document = await readRegistryDocument();
|
||||
return document.projects;
|
||||
}
|
||||
|
||||
export async function addProject(projectPath: string): Promise<{ added: boolean; projects: RegistryProject[] }> {
|
||||
const document = await readRegistryDocument();
|
||||
const project = normalizeProject(projectPath);
|
||||
|
||||
if (document.projects.some((entry) => entry.key === project.key)) {
|
||||
return { added: false, projects: document.projects };
|
||||
}
|
||||
|
||||
document.projects.push(project);
|
||||
await writeRegistryDocument(document);
|
||||
return { added: true, projects: document.projects };
|
||||
}
|
||||
|
||||
export async function removeProject(projectPath: string): Promise<{ removed: boolean; projects: RegistryProject[] }> {
|
||||
const document = await readRegistryDocument();
|
||||
const project = normalizeProject(projectPath);
|
||||
const nextProjects = document.projects.filter((entry) => entry.key !== project.key);
|
||||
|
||||
if (nextProjects.length === document.projects.length) {
|
||||
return { removed: false, projects: document.projects };
|
||||
}
|
||||
|
||||
const nextDocument: RegistryDocument = {
|
||||
version: 1,
|
||||
projects: nextProjects,
|
||||
};
|
||||
|
||||
await writeRegistryDocument(nextDocument);
|
||||
return { removed: true, projects: nextDocument.projects };
|
||||
}
|
||||
275
src/lib/scanner.ts
Normal file
275
src/lib/scanner.ts
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import type { Dirent } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { canonicalizeWindowsPath, toDisplayPath, windowsPathKey } from './pathing';
|
||||
import { listProjects } from './registry';
|
||||
|
||||
export type ScanMode = 'default' | 'full-drive';
|
||||
|
||||
export interface ScannerProject {
|
||||
root: string;
|
||||
key: string;
|
||||
displayPath: string;
|
||||
}
|
||||
|
||||
export interface ScanStats {
|
||||
scannedDirectories: number;
|
||||
ignoredDirectories: number;
|
||||
skippedDirectories: number;
|
||||
elapsedMs: number;
|
||||
}
|
||||
|
||||
export interface ScanOptions {
|
||||
mode?: ScanMode;
|
||||
maxDepth?: number;
|
||||
roots?: string[];
|
||||
ignoreDirectories?: string[];
|
||||
}
|
||||
|
||||
export interface ScanResult {
|
||||
mode: ScanMode;
|
||||
roots: string[];
|
||||
projects: ScannerProject[];
|
||||
stats: ScanStats;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_DEPTH = 6;
|
||||
const DEFAULT_IGNORE_DIRECTORIES = [
|
||||
'node_modules',
|
||||
'.git',
|
||||
'.next',
|
||||
'dist',
|
||||
'build',
|
||||
'out',
|
||||
'coverage',
|
||||
'artifacts',
|
||||
'logs',
|
||||
'.worktrees', // TODO: confirm whether worktrees should be scan targets.
|
||||
'worktrees',
|
||||
'.agents',
|
||||
'.kimi',
|
||||
'.zenflow',
|
||||
'.gemini',
|
||||
'appdata',
|
||||
];
|
||||
|
||||
const DEFAULT_IGNORE_PATH_FRAGMENTS = [
|
||||
'\\go\\pkg\\mod\\',
|
||||
'\\.agents\\skills\\',
|
||||
'\\.kimi\\skills\\',
|
||||
'\\.gemini\\skills\\',
|
||||
'\\.zenflow\\worktrees\\',
|
||||
];
|
||||
|
||||
function userProfileRoot(): string {
|
||||
return process.env.USERPROFILE?.trim() || os.homedir();
|
||||
}
|
||||
|
||||
function toCanonicalRoot(input: string): string {
|
||||
return canonicalizeWindowsPath(input);
|
||||
}
|
||||
|
||||
function shouldSkipFsError(error: NodeJS.ErrnoException): boolean {
|
||||
return error.code === 'ENOENT' || error.code === 'ENOTDIR' || error.code === 'EACCES' || error.code === 'EPERM';
|
||||
}
|
||||
|
||||
async function ensureDirectoryExists(input: string): Promise<string | null> {
|
||||
try {
|
||||
const stat = await fs.stat(input);
|
||||
return stat.isDirectory() ? input : null;
|
||||
} catch (error) {
|
||||
if (shouldSkipFsError(error as NodeJS.ErrnoException)) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function fileExists(input: string): Promise<boolean> {
|
||||
try {
|
||||
const stat = await fs.stat(input);
|
||||
return stat.isFile();
|
||||
} catch (error) {
|
||||
if (shouldSkipFsError(error as NodeJS.ErrnoException)) {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveFullDriveRoots(): Promise<string[]> {
|
||||
const candidates = ['C:\\', 'D:\\'];
|
||||
const roots: string[] = [];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const existing = await ensureDirectoryExists(candidate);
|
||||
if (existing) {
|
||||
roots.push(existing);
|
||||
}
|
||||
}
|
||||
|
||||
return roots;
|
||||
}
|
||||
|
||||
export async function resolveScanRoots(options: ScanOptions = {}): Promise<string[]> {
|
||||
const mode: ScanMode = options.mode ?? 'default';
|
||||
const registryProjects = await listProjects();
|
||||
const roots = [
|
||||
userProfileRoot(),
|
||||
...registryProjects.map((project) => project.path),
|
||||
...(options.roots ?? []),
|
||||
];
|
||||
|
||||
if (mode === 'full-drive') {
|
||||
roots.push(...(await resolveFullDriveRoots()));
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const normalizedRoots: string[] = [];
|
||||
|
||||
for (const root of roots) {
|
||||
const normalized = toCanonicalRoot(root);
|
||||
const key = windowsPathKey(normalized);
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = await ensureDirectoryExists(normalized);
|
||||
if (!existing) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(key);
|
||||
normalizedRoots.push(existing);
|
||||
}
|
||||
|
||||
return normalizedRoots;
|
||||
}
|
||||
|
||||
function buildIgnoreSet(additional: string[] = []): Set<string> {
|
||||
return new Set(
|
||||
[...DEFAULT_IGNORE_DIRECTORIES, ...additional].map((entry) => entry.trim().toLowerCase()).filter(Boolean),
|
||||
);
|
||||
}
|
||||
|
||||
function shouldIgnorePath(dir: string): boolean {
|
||||
const normalized = toCanonicalRoot(dir).toLowerCase();
|
||||
return DEFAULT_IGNORE_PATH_FRAGMENTS.some((fragment) => normalized.includes(fragment));
|
||||
}
|
||||
|
||||
function shouldIgnoreDirectoryName(name: string): boolean {
|
||||
const normalized = name.trim().toLowerCase();
|
||||
return (
|
||||
normalized.startsWith('beadboard-read-') ||
|
||||
normalized.startsWith('beadboard-watch-') ||
|
||||
normalized.startsWith('skills-')
|
||||
);
|
||||
}
|
||||
|
||||
function recordProject(projects: Map<string, ScannerProject>, root: string): void {
|
||||
const normalized = toCanonicalRoot(root);
|
||||
const key = windowsPathKey(normalized);
|
||||
if (!projects.has(key)) {
|
||||
projects.set(key, {
|
||||
root: normalized,
|
||||
key,
|
||||
displayPath: toDisplayPath(normalized),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function scanRoot(
|
||||
root: string,
|
||||
maxDepth: number,
|
||||
ignoreSet: Set<string>,
|
||||
projects: Map<string, ScannerProject>,
|
||||
stats: ScanStats,
|
||||
): Promise<void> {
|
||||
const queue: Array<{ dir: string; depth: number }> = [{ dir: root, depth: 0 }];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift();
|
||||
if (!current) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current.depth > 0 && shouldIgnorePath(current.dir)) {
|
||||
stats.ignoredDirectories += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
stats.scannedDirectories += 1;
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(current.dir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (shouldSkipFsError(error as NodeJS.ErrnoException)) {
|
||||
stats.skippedDirectories += 1;
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
let hasBeads = false;
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.name === '.beads') {
|
||||
hasBeads = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const entryName = entry.name.toLowerCase();
|
||||
if (ignoreSet.has(entryName) || shouldIgnoreDirectoryName(entryName)) {
|
||||
stats.ignoredDirectories += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current.depth < maxDepth) {
|
||||
queue.push({ dir: path.join(current.dir, entry.name), depth: current.depth + 1 });
|
||||
}
|
||||
}
|
||||
|
||||
if (hasBeads) {
|
||||
const issuesPath = path.join(current.dir, '.beads', 'issues.jsonl');
|
||||
const fallbackIssuesPath = path.join(current.dir, '.beads', 'issues.jsonl.new');
|
||||
const [primaryExists, fallbackExists] = await Promise.all([fileExists(issuesPath), fileExists(fallbackIssuesPath)]);
|
||||
|
||||
if (primaryExists || fallbackExists) {
|
||||
recordProject(projects, current.dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function scanForProjects(options: ScanOptions = {}): Promise<ScanResult> {
|
||||
const mode: ScanMode = options.mode ?? 'default';
|
||||
const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
|
||||
const ignoreSet = buildIgnoreSet(options.ignoreDirectories);
|
||||
const roots = await resolveScanRoots(options);
|
||||
const projects = new Map<string, ScannerProject>();
|
||||
const stats: ScanStats = {
|
||||
scannedDirectories: 0,
|
||||
ignoredDirectories: 0,
|
||||
skippedDirectories: 0,
|
||||
elapsedMs: 0,
|
||||
};
|
||||
const start = Date.now();
|
||||
|
||||
for (const root of roots) {
|
||||
await scanRoot(root, maxDepth, ignoreSet, projects, stats);
|
||||
}
|
||||
|
||||
stats.elapsedMs = Date.now() - start;
|
||||
|
||||
return {
|
||||
mode,
|
||||
roots,
|
||||
projects: Array.from(projects.values()),
|
||||
stats,
|
||||
};
|
||||
}
|
||||
114
src/lib/watcher.ts
Normal file
114
src/lib/watcher.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import chokidar, { type FSWatcher } from 'chokidar';
|
||||
|
||||
import { ProjectEventCoalescer } from './coalescer';
|
||||
import { windowsPathKey } from './pathing';
|
||||
import { issuesEventBus, type IssuesChangeKind, type IssuesEventBus } from './realtime';
|
||||
import { resolveIssuesJsonlPathCandidates } from './read-issues';
|
||||
|
||||
type FileEventName = 'add' | 'change' | 'unlink';
|
||||
|
||||
interface WatchRegistration {
|
||||
projectRoot: string;
|
||||
watcher: FSWatcher;
|
||||
}
|
||||
|
||||
export interface WatchManagerOptions {
|
||||
debounceMs?: number;
|
||||
eventBus?: IssuesEventBus;
|
||||
}
|
||||
|
||||
export class IssuesWatchManager {
|
||||
private readonly registrations = new Map<string, WatchRegistration>();
|
||||
|
||||
private readonly eventBus: IssuesEventBus;
|
||||
|
||||
private readonly coalescer: ProjectEventCoalescer<{
|
||||
changedPath?: string;
|
||||
kind: IssuesChangeKind;
|
||||
}>;
|
||||
|
||||
constructor(options: WatchManagerOptions = {}) {
|
||||
const debounceMs = options.debounceMs ?? 150;
|
||||
this.eventBus = options.eventBus ?? issuesEventBus;
|
||||
this.coalescer = new ProjectEventCoalescer(debounceMs, ({ projectRoot, payload }) => {
|
||||
this.eventBus.emit(projectRoot, payload.changedPath, payload.kind);
|
||||
});
|
||||
}
|
||||
|
||||
startWatch(projectRoot: string): void {
|
||||
const projectKey = windowsPathKey(projectRoot);
|
||||
if (this.registrations.has(projectKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const watchedPaths = resolveIssuesJsonlPathCandidates(projectRoot);
|
||||
const watcher = chokidar.watch(watchedPaths, {
|
||||
ignoreInitial: true,
|
||||
awaitWriteFinish: {
|
||||
stabilityThreshold: 80,
|
||||
pollInterval: 15,
|
||||
},
|
||||
});
|
||||
|
||||
const onFileEvent = (eventName: FileEventName, changedPath: string) => {
|
||||
const kind: IssuesChangeKind = eventName === 'unlink' ? 'renamed' : 'changed';
|
||||
this.queueCoalescedEvent(projectRoot, changedPath, kind);
|
||||
};
|
||||
|
||||
watcher.on('add', (changedPath) => onFileEvent('add', changedPath));
|
||||
watcher.on('change', (changedPath) => onFileEvent('change', changedPath));
|
||||
watcher.on('unlink', (changedPath) => onFileEvent('unlink', changedPath));
|
||||
|
||||
this.registrations.set(projectKey, {
|
||||
projectRoot,
|
||||
watcher,
|
||||
});
|
||||
}
|
||||
|
||||
async stopWatch(projectRoot: string): Promise<void> {
|
||||
const projectKey = windowsPathKey(projectRoot);
|
||||
const registration = this.registrations.get(projectKey);
|
||||
if (!registration) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.coalescer.cancel(projectRoot);
|
||||
this.registrations.delete(projectKey);
|
||||
await registration.watcher.close();
|
||||
}
|
||||
|
||||
async stopAll(): Promise<void> {
|
||||
const closeOps: Promise<void>[] = [];
|
||||
|
||||
for (const registration of this.registrations.values()) {
|
||||
closeOps.push(registration.watcher.close());
|
||||
}
|
||||
|
||||
this.coalescer.cancelAll();
|
||||
this.registrations.clear();
|
||||
await Promise.all(closeOps);
|
||||
}
|
||||
|
||||
getWatchedProjectCount(): number {
|
||||
return this.registrations.size;
|
||||
}
|
||||
|
||||
private queueCoalescedEvent(projectRoot: string, changedPath: string, kind: IssuesChangeKind): void {
|
||||
this.coalescer.queue(projectRoot, {
|
||||
changedPath,
|
||||
kind,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const globalRegistry = globalThis as typeof globalThis & {
|
||||
__beadboardWatchManager?: IssuesWatchManager;
|
||||
};
|
||||
|
||||
export function getIssuesWatchManager(): IssuesWatchManager {
|
||||
if (!globalRegistry.__beadboardWatchManager) {
|
||||
globalRegistry.__beadboardWatchManager = new IssuesWatchManager();
|
||||
}
|
||||
|
||||
return globalRegistry.__beadboardWatchManager;
|
||||
}
|
||||
56
src/lib/writeback.ts
Normal file
56
src/lib/writeback.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -5,8 +5,8 @@ const config: Config = {
|
|||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
ui: ['Segoe UI', 'Inter', 'system-ui', 'sans-serif'],
|
||||
mono: ['JetBrains Mono', 'Consolas', 'monospace'],
|
||||
ui: ['var(--font-ui)', 'Segoe UI', 'Inter', 'system-ui', 'sans-serif'],
|
||||
mono: ['var(--font-mono)', 'Consolas', 'monospace'],
|
||||
},
|
||||
colors: {
|
||||
bg: 'var(--color-bg)',
|
||||
|
|
|
|||
34
tests/api/events-route.test.ts
Normal file
34
tests/api/events-route.test.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { GET as eventsGet } from '../../src/app/api/events/route';
|
||||
import { getIssuesWatchManager } from '../../src/lib/watcher';
|
||||
|
||||
test.after(async () => {
|
||||
await getIssuesWatchManager().stopAll();
|
||||
});
|
||||
|
||||
test('events route returns SSE response with expected headers', async () => {
|
||||
const response = await eventsGet(new Request('http://localhost/api/events?projectRoot=C:/Repo/Test'));
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.headers.get('content-type')?.includes('text/event-stream'), true);
|
||||
assert.equal(response.headers.get('cache-control')?.includes('no-cache'), true);
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (reader) {
|
||||
await reader.cancel();
|
||||
}
|
||||
});
|
||||
|
||||
test('events route emits initial connected frame', async () => {
|
||||
const response = await eventsGet(new Request('http://localhost/api/events?projectRoot=C:/Repo/Test'));
|
||||
const reader = response.body?.getReader();
|
||||
assert.equal(Boolean(reader), true);
|
||||
|
||||
const first = await reader!.read();
|
||||
const chunk = new TextDecoder().decode(first.value);
|
||||
assert.equal(chunk.includes(': connected'), true);
|
||||
|
||||
await reader!.cancel();
|
||||
});
|
||||
55
tests/api/mutations-routes.test.ts
Normal file
55
tests/api/mutations-routes.test.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { POST as createPost } from '../../src/app/api/beads/create/route';
|
||||
import { POST as reopenPost } from '../../src/app/api/beads/reopen/route';
|
||||
import { POST as commentPost } from '../../src/app/api/beads/comment/route';
|
||||
|
||||
async function readJson(response: Response): Promise<any> {
|
||||
const text = await response.text();
|
||||
return text ? JSON.parse(text) : {};
|
||||
}
|
||||
|
||||
test('create route returns 400 for invalid payload', async () => {
|
||||
const response = await createPost(
|
||||
new Request('http://localhost/api/beads/create', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ projectRoot: '', title: '' }),
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
const data = await readJson(response);
|
||||
assert.equal(data.ok, false);
|
||||
assert.equal(data.error.classification, 'bad_args');
|
||||
});
|
||||
|
||||
test('reopen route returns 400 for missing id', async () => {
|
||||
const response = await reopenPost(
|
||||
new Request('http://localhost/api/beads/reopen', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ projectRoot: 'C:/repo' }),
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
const data = await readJson(response);
|
||||
assert.equal(data.ok, false);
|
||||
});
|
||||
|
||||
test('comment route returns 400 for missing comment text', async () => {
|
||||
const response = await commentPost(
|
||||
new Request('http://localhost/api/beads/comment', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ projectRoot: 'C:/repo', id: 'bb-1' }),
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
const data = await readJson(response);
|
||||
assert.equal(data.ok, false);
|
||||
assert.equal(typeof data.error.message, 'string');
|
||||
});
|
||||
109
tests/api/projects-route.test.ts
Normal file
109
tests/api/projects-route.test.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { DELETE, GET, POST } from '../../src/app/api/projects/route';
|
||||
|
||||
async function withTempUserProfile(run: (userProfile: string) => Promise<void>): Promise<void> {
|
||||
const previous = process.env.USERPROFILE;
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-api-'));
|
||||
process.env.USERPROFILE = tempDir;
|
||||
|
||||
try {
|
||||
await run(tempDir);
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.USERPROFILE;
|
||||
} else {
|
||||
process.env.USERPROFILE = previous;
|
||||
}
|
||||
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function readJson(response: Response): Promise<unknown> {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
test('GET /api/projects returns empty list initially', async () => {
|
||||
await withTempUserProfile(async () => {
|
||||
const response = await GET();
|
||||
assert.equal(response.status, 200);
|
||||
|
||||
const body = (await readJson(response)) as { projects: unknown[] };
|
||||
assert.deepEqual(body.projects, []);
|
||||
});
|
||||
});
|
||||
|
||||
test('POST /api/projects validates payload and path', async () => {
|
||||
await withTempUserProfile(async () => {
|
||||
const missing = await POST(new Request('http://localhost/api/projects', { method: 'POST', body: '{}' }));
|
||||
assert.equal(missing.status, 400);
|
||||
|
||||
const missingBody = (await readJson(missing)) as { error: string };
|
||||
assert.match(missingBody.error, /path/i);
|
||||
|
||||
const invalidPath = await POST(
|
||||
new Request('http://localhost/api/projects', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ path: '/tmp/project' }),
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
assert.equal(invalidPath.status, 400);
|
||||
});
|
||||
});
|
||||
|
||||
test('POST deduplicates and GET returns normalized path', async () => {
|
||||
await withTempUserProfile(async () => {
|
||||
const first = await POST(
|
||||
new Request('http://localhost/api/projects', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ path: 'c:/Users/Zenchant/codex/beadboard/' }),
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
assert.equal(first.status, 201);
|
||||
|
||||
const dup = await POST(
|
||||
new Request('http://localhost/api/projects', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ path: 'C:\\users\\zenchant\\codex\\beadboard' }),
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
assert.equal(dup.status, 200);
|
||||
|
||||
const list = await GET();
|
||||
const body = (await readJson(list)) as { projects: Array<{ path: string }> };
|
||||
assert.deepEqual(body.projects, [{ path: 'C:/Users/Zenchant/codex/beadboard' }]);
|
||||
});
|
||||
});
|
||||
|
||||
test('DELETE /api/projects removes by normalized path', async () => {
|
||||
await withTempUserProfile(async () => {
|
||||
await POST(
|
||||
new Request('http://localhost/api/projects', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ path: 'D:/Repos/One' }),
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
|
||||
const removed = await DELETE(
|
||||
new Request('http://localhost/api/projects', {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ path: 'd:\\repos\\one\\' }),
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
assert.equal(removed.status, 200);
|
||||
|
||||
const list = await GET();
|
||||
const body = (await readJson(list)) as { projects: unknown[] };
|
||||
assert.deepEqual(body.projects, []);
|
||||
});
|
||||
});
|
||||
75
tests/guards/graph-responsive-contract.test.mjs
Normal file
75
tests/guards/graph-responsive-contract.test.mjs
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const ROOT = process.cwd();
|
||||
|
||||
async function read(relativePath) {
|
||||
return fs.readFile(path.join(ROOT, relativePath), 'utf8');
|
||||
}
|
||||
|
||||
test('graph page defines tabbed layout, epic chips, and mobile fallback', async () => {
|
||||
const graphPage = await read('src/components/graph/dependency-graph-page.tsx');
|
||||
|
||||
// Tabbed layout: Tasks and Dependencies tabs
|
||||
assert.match(graphPage, /WorkflowTabs/, 'should use WorkflowTabs component');
|
||||
assert.match(graphPage, /activeTab/, 'should track active tab state');
|
||||
|
||||
// Epic chip strip replaces sidebar
|
||||
assert.match(graphPage, /EpicChipStrip/, 'should use EpicChipStrip component');
|
||||
|
||||
// Mobile panel toggle preserved
|
||||
assert.match(graphPage, /Switch to Graph/);
|
||||
assert.match(graphPage, /Back to Selection/);
|
||||
|
||||
// Task card grid extracted
|
||||
assert.match(graphPage, /TaskCardGrid/, 'should use TaskCardGrid component');
|
||||
|
||||
// Task details drawer
|
||||
assert.match(graphPage, /TaskDetailsDrawer/, 'should use TaskDetailsDrawer drawer');
|
||||
assert.match(graphPage, /projectRoot=\{projectRoot\}/, 'drawer should receive project root for edits');
|
||||
assert.match(graphPage, /onIssueUpdated=\{\(\) => router.refresh\(\)\}/, 'drawer should trigger refresh after edits');
|
||||
|
||||
// Dependency flow strip
|
||||
assert.match(graphPage, /DependencyFlowStrip/, 'should use DependencyFlowStrip component');
|
||||
|
||||
// Graph section with ReactFlow
|
||||
assert.match(graphPage, /GraphSection/, 'should use GraphSection component');
|
||||
assert.match(graphPage, /ReactFlowProvider/, 'should wrap graph in ReactFlowProvider');
|
||||
|
||||
// Edge options and node types still configured
|
||||
assert.match(graphPage, /defaultEdgeOptions/);
|
||||
assert.match(graphPage, /nodeTypes/);
|
||||
|
||||
// Actionable node detection
|
||||
assert.match(graphPage, /actionableNodeIds/, 'should compute actionable (unblocked) nodes');
|
||||
assert.match(graphPage, /ui-field/, 'graph filters should use shared dark field styling');
|
||||
assert.match(graphPage, /ui-select/, 'graph select should use shared dark select styling');
|
||||
});
|
||||
|
||||
test('extracted graph section has viewport and legend', async () => {
|
||||
const graphSection = await read('src/components/graph/graph-section.tsx');
|
||||
|
||||
assert.match(graphSection, /className=\"workflow-graph-flow\"/, 'graph should have workflow-graph-flow class');
|
||||
assert.match(graphSection, /workflow-graph-legend/, 'should have legend');
|
||||
assert.match(graphSection, /translateExtent=\{\[/, 'should set translate extent');
|
||||
assert.match(graphSection, /defaultEdgeOptions=\{/, 'should pass edge options');
|
||||
assert.match(graphSection, /blockerAnalysis/, 'should show blocker stats');
|
||||
assert.match(graphSection, /hideClosed/, 'should support hideClosed state in legend');
|
||||
assert.match(graphSection, /!hideClosed/, 'done legend should be hidden when closed items are hidden');
|
||||
assert.match(graphSection, /Read left to right/, 'legend should include plain directional hint');
|
||||
assert.match(graphSection, /Left = blockers/, 'legend should include left/right dependency meaning');
|
||||
assert.match(graphSection, /Right = work unblocked by this task/, 'legend should include downstream meaning');
|
||||
});
|
||||
|
||||
test('graph node card supports tooltips and actionable glow', async () => {
|
||||
const nodeCard = await read('src/components/graph/graph-node-card.tsx');
|
||||
|
||||
assert.match(nodeCard, /isActionable/, 'should check actionable state');
|
||||
assert.match(nodeCard, /ring-emerald-400/, 'actionable nodes should have green glow');
|
||||
assert.match(nodeCard, /node-select-pulse/, 'selected nodes should pulse');
|
||||
assert.match(nodeCard, /blockerTooltipLines/, 'should display blocker tooltip');
|
||||
assert.match(nodeCard, /isDimmed/, 'should support dimming non-chain nodes');
|
||||
assert.match(nodeCard, /Ready to work/, 'actionable tooltip text');
|
||||
});
|
||||
|
|
@ -9,11 +9,14 @@ async function read(relativePath) {
|
|||
return fs.readFile(path.join(ROOT, relativePath), 'utf8');
|
||||
}
|
||||
|
||||
test('kanban board uses intentional horizontal scroll affordances', async () => {
|
||||
test('kanban board uses expandable vertical swimlanes', async () => {
|
||||
const board = await read('src/components/kanban/kanban-board.tsx');
|
||||
|
||||
assert.match(board, /snap-x/);
|
||||
assert.match(board, /overflow-x-auto/);
|
||||
assert.match(board, /aria-expanded/);
|
||||
assert.match(board, /onActivateStatus/);
|
||||
assert.match(board, /max-h-\[50vh\]/);
|
||||
assert.match(board, /showClosed/, 'board should accept showClosed control');
|
||||
assert.match(board, /status !== 'closed' \|\| showClosed/, 'done lane should be hidden when showClosed is false');
|
||||
});
|
||||
|
||||
test('kanban page defines mobile detail drawer behavior', async () => {
|
||||
|
|
@ -21,6 +24,8 @@ test('kanban page defines mobile detail drawer behavior', async () => {
|
|||
|
||||
assert.match(page, /fixed inset-0/);
|
||||
assert.match(page, /lg:hidden/);
|
||||
assert.match(page, /lg:grid-cols-\[minmax\(0,1fr\)_minmax\(22rem,26rem\)\]/);
|
||||
assert.match(page, /lg:border-l/);
|
||||
});
|
||||
|
||||
test('kanban controls use fluid full-width sizing on small viewports', async () => {
|
||||
|
|
@ -28,4 +33,20 @@ test('kanban controls use fluid full-width sizing on small viewports', async ()
|
|||
|
||||
assert.match(controls, /w-full/);
|
||||
assert.match(controls, /sm:w-/);
|
||||
assert.match(controls, /ui-field/, 'controls should use shared dark field styling');
|
||||
assert.match(controls, /ui-select/, 'selects should use shared dark select styling');
|
||||
assert.match(controls, /Next Actionable/);
|
||||
assert.match(controls, /nextActionableFeedback/);
|
||||
});
|
||||
|
||||
test('kanban detail includes execution checklist rendering', async () => {
|
||||
const detail = await read('src/components/kanban/kanban-detail.tsx');
|
||||
|
||||
assert.match(detail, /Execution checklist/i);
|
||||
assert.match(detail, /Summary/i);
|
||||
assert.match(detail, /Task metadata/i);
|
||||
assert.match(detail, /Timeline/i);
|
||||
assert.match(detail, /Edit fields/i);
|
||||
assert.match(detail, /Save changes/i);
|
||||
assert.match(detail, /projectRoot\?/i);
|
||||
});
|
||||
|
|
|
|||
87
tests/lib/aggregate-read.test.ts
Normal file
87
tests/lib/aggregate-read.test.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { readIssuesForScope } from '../../src/lib/aggregate-read';
|
||||
import type { ProjectScopeOption } from '../../src/lib/project-scope';
|
||||
|
||||
async function writeIssues(root: string, lines: unknown[]): Promise<void> {
|
||||
const beadsDir = path.join(root, '.beads');
|
||||
await fs.mkdir(beadsDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(beadsDir, 'issues.jsonl'),
|
||||
lines.map((line) => JSON.stringify(line)).join('\n'),
|
||||
'utf8',
|
||||
);
|
||||
}
|
||||
|
||||
test('readIssuesForScope reads selected project in single mode', async () => {
|
||||
const localRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-scope-single-'));
|
||||
await writeIssues(localRoot, [{ id: 'bb-1', title: 'Local issue', issue_type: 'task', status: 'open', priority: 1 }]);
|
||||
|
||||
const local: ProjectScopeOption = {
|
||||
key: 'local',
|
||||
root: localRoot,
|
||||
displayPath: localRoot.replaceAll('\\', '/'),
|
||||
source: 'local',
|
||||
};
|
||||
|
||||
const issues = await readIssuesForScope({
|
||||
mode: 'single',
|
||||
selected: local,
|
||||
scopeOptions: [local],
|
||||
});
|
||||
|
||||
assert.equal(issues.length, 1);
|
||||
assert.equal(issues[0].id, 'bb-1');
|
||||
});
|
||||
|
||||
test('readIssuesForScope scopes ids and dependencies in aggregate mode', async () => {
|
||||
const localRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-scope-agg-local-'));
|
||||
const registryRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-scope-agg-reg-'));
|
||||
|
||||
await writeIssues(localRoot, [
|
||||
{ id: 'bb-epic', title: 'Local epic', issue_type: 'epic', status: 'open', priority: 1 },
|
||||
{
|
||||
id: 'bb-epic.1',
|
||||
title: 'Local task',
|
||||
issue_type: 'task',
|
||||
status: 'open',
|
||||
priority: 1,
|
||||
dependencies: [{ type: 'parent-child', depends_on_id: 'bb-epic' }],
|
||||
},
|
||||
]);
|
||||
|
||||
await writeIssues(registryRoot, [
|
||||
{ id: 'bb-epic', title: 'Registry epic', issue_type: 'epic', status: 'open', priority: 1 },
|
||||
]);
|
||||
|
||||
const local: ProjectScopeOption = {
|
||||
key: 'local',
|
||||
root: localRoot,
|
||||
displayPath: localRoot.replaceAll('\\', '/'),
|
||||
source: 'local',
|
||||
};
|
||||
const registry: ProjectScopeOption = {
|
||||
key: 'd:\\repos\\alpha',
|
||||
root: registryRoot,
|
||||
displayPath: registryRoot.replaceAll('\\', '/'),
|
||||
source: 'registry',
|
||||
};
|
||||
|
||||
const issues = await readIssuesForScope({
|
||||
mode: 'aggregate',
|
||||
selected: local,
|
||||
scopeOptions: [local, registry],
|
||||
});
|
||||
|
||||
assert.equal(issues.length, 3);
|
||||
assert.equal(issues.some((issue) => issue.id === 'local::bb-epic'), true);
|
||||
assert.equal(issues.some((issue) => issue.id === 'd:\\repos\\alpha::bb-epic'), true);
|
||||
const localTask = issues.find((issue) => issue.id === 'local::bb-epic.1');
|
||||
assert.ok(localTask);
|
||||
assert.equal(localTask.dependencies.some((dependency) => dependency.target === 'local::bb-epic'), true);
|
||||
assert.equal(localTask.metadata.original_id, 'bb-epic.1');
|
||||
});
|
||||
43
tests/lib/bd-path.test.ts
Normal file
43
tests/lib/bd-path.test.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { BdExecutableNotFoundError, resolveBdExecutable } from '../../src/lib/bd-path';
|
||||
|
||||
test('resolveBdExecutable prefers explicit configured path when provided', async () => {
|
||||
const temp = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-bd-path-'));
|
||||
const explicit = path.join(temp, 'tools', 'bd.exe');
|
||||
await fs.mkdir(path.dirname(explicit), { recursive: true });
|
||||
await fs.writeFile(explicit, '');
|
||||
|
||||
const resolved = await resolveBdExecutable({ explicitPath: explicit, env: { Path: '', NODE_ENV: 'test' } });
|
||||
|
||||
assert.equal(resolved.executable, explicit);
|
||||
assert.equal(resolved.source, 'config');
|
||||
});
|
||||
|
||||
test('resolveBdExecutable finds bd.exe on PATH when explicit path is not set', async () => {
|
||||
const temp = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-bd-path-env-'));
|
||||
const candidate = path.join(temp, 'bd.exe');
|
||||
await fs.writeFile(candidate, '');
|
||||
|
||||
const resolved = await resolveBdExecutable({ env: { Path: temp, NODE_ENV: 'test' } });
|
||||
|
||||
assert.equal(resolved.executable, candidate);
|
||||
assert.equal(resolved.source, 'path');
|
||||
});
|
||||
|
||||
test('resolveBdExecutable throws actionable setup guidance when executable is missing', async () => {
|
||||
await assert.rejects(
|
||||
() => resolveBdExecutable({ env: { Path: '', NODE_ENV: 'test' } }),
|
||||
(error: unknown) => {
|
||||
assert.equal(error instanceof BdExecutableNotFoundError, true);
|
||||
const message = String((error as Error).message).toLowerCase();
|
||||
assert.equal(message.includes('npm install -g @beads/bd'), true);
|
||||
assert.equal(message.includes('bd.exe'), true);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
86
tests/lib/bridge.test.ts
Normal file
86
tests/lib/bridge.test.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { runBdCommand } from '../../src/lib/bridge';
|
||||
|
||||
test('runBdCommand returns structured success payload from execFile output', async () => {
|
||||
const result = await runBdCommand(
|
||||
{
|
||||
projectRoot: 'C:/repo/project',
|
||||
args: ['list', '--json'],
|
||||
timeoutMs: 2000,
|
||||
explicitBdPath: 'C:/tools/bd.exe',
|
||||
},
|
||||
{
|
||||
resolveBdExecutable: async () => ({ executable: 'C:/tools/bd.exe', source: 'config' }),
|
||||
execFile: async (command, args, options) => {
|
||||
assert.equal(command, 'C:/tools/bd.exe');
|
||||
assert.deepEqual(args, ['list', '--json']);
|
||||
assert.equal(options.cwd, 'C:/repo/project');
|
||||
return { stdout: '[{"id":"bb-1"}]\r\n', stderr: '' };
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(result.classification, null);
|
||||
assert.equal(result.stdout, '[{"id":"bb-1"}]');
|
||||
});
|
||||
|
||||
test('runBdCommand classifies missing executable as not_found', async () => {
|
||||
const result = await runBdCommand(
|
||||
{ projectRoot: 'C:/repo/project', args: ['list'] },
|
||||
{
|
||||
resolveBdExecutable: async () => ({ executable: 'C:/tools/bd.exe', source: 'config' }),
|
||||
execFile: async () => {
|
||||
const error = new Error('spawn ENOENT') as NodeJS.ErrnoException;
|
||||
error.code = 'ENOENT';
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result.success, false);
|
||||
assert.equal(result.classification, 'not_found');
|
||||
});
|
||||
|
||||
test('runBdCommand classifies timeout failures', async () => {
|
||||
const result = await runBdCommand(
|
||||
{ projectRoot: 'C:/repo/project', args: ['list'], timeoutMs: 5 },
|
||||
{
|
||||
resolveBdExecutable: async () => ({ executable: 'C:/tools/bd.exe', source: 'config' }),
|
||||
execFile: async () => {
|
||||
const error = new Error('timed out') as NodeJS.ErrnoException & { killed?: boolean; signal?: string };
|
||||
error.code = 'ETIMEDOUT';
|
||||
error.killed = true;
|
||||
error.signal = 'SIGTERM';
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result.success, false);
|
||||
assert.equal(result.classification, 'timeout');
|
||||
});
|
||||
|
||||
test('runBdCommand classifies non-zero bad-argument exits', async () => {
|
||||
const result = await runBdCommand(
|
||||
{ projectRoot: 'C:/repo/project', args: ['update', '--bad-flag'] },
|
||||
{
|
||||
resolveBdExecutable: async () => ({ executable: 'C:/tools/bd.exe', source: 'config' }),
|
||||
execFile: async () => {
|
||||
const error = new Error('exit code 1') as NodeJS.ErrnoException & {
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
};
|
||||
(error as any).code = 1;
|
||||
error.stderr = 'unknown flag: --bad-flag';
|
||||
error.stdout = '';
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result.success, false);
|
||||
assert.equal(result.classification, 'bad_args');
|
||||
});
|
||||
33
tests/lib/coalescer.test.ts
Normal file
33
tests/lib/coalescer.test.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { ProjectEventCoalescer } from '../../src/lib/coalescer';
|
||||
|
||||
test('coalescer emits latest payload once per project within debounce window', async () => {
|
||||
const flushed: Array<{ projectRoot: string; payload: { value: string } }> = [];
|
||||
const coalescer = new ProjectEventCoalescer<{ value: string }>(20, (event) => {
|
||||
flushed.push(event);
|
||||
});
|
||||
|
||||
coalescer.queue('C:/Repo/One', { value: 'first' });
|
||||
coalescer.queue('c:\\repo\\one', { value: 'second' });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 45));
|
||||
|
||||
assert.equal(flushed.length, 1);
|
||||
assert.equal(flushed[0].payload.value, 'second');
|
||||
});
|
||||
|
||||
test('coalescer keeps distinct projects separated', async () => {
|
||||
const flushed: Array<{ projectRoot: string; payload: { value: string } }> = [];
|
||||
const coalescer = new ProjectEventCoalescer<{ value: string }>(20, (event) => {
|
||||
flushed.push(event);
|
||||
});
|
||||
|
||||
coalescer.queue('C:/Repo/One', { value: 'one' });
|
||||
coalescer.queue('D:/Repo/Two', { value: 'two' });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 45));
|
||||
|
||||
assert.equal(flushed.length, 2);
|
||||
});
|
||||
174
tests/lib/graph-view.test.ts
Normal file
174
tests/lib/graph-view.test.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { buildGraphModel } from '../../src/lib/graph';
|
||||
import { analyzeBlockedChain, buildGraphViewModel, buildPathWorkspace, detectDependencyCycles } from '../../src/lib/graph-view';
|
||||
import type { BeadIssue } from '../../src/lib/types';
|
||||
|
||||
function issue(overrides: Partial<BeadIssue>): BeadIssue {
|
||||
return {
|
||||
id: overrides.id ?? 'bb-x',
|
||||
title: overrides.title ?? 'Issue',
|
||||
description: overrides.description ?? null,
|
||||
status: overrides.status ?? 'open',
|
||||
priority: overrides.priority ?? 2,
|
||||
issue_type: overrides.issue_type ?? 'task',
|
||||
assignee: overrides.assignee ?? null,
|
||||
owner: overrides.owner ?? null,
|
||||
labels: overrides.labels ?? [],
|
||||
dependencies: overrides.dependencies ?? [],
|
||||
created_at: overrides.created_at ?? '2026-02-12T00:00:00Z',
|
||||
updated_at: overrides.updated_at ?? '2026-02-12T00:00:00Z',
|
||||
closed_at: overrides.closed_at ?? null,
|
||||
close_reason: overrides.close_reason ?? null,
|
||||
closed_by_session: overrides.closed_by_session ?? null,
|
||||
created_by: overrides.created_by ?? null,
|
||||
due_at: overrides.due_at ?? null,
|
||||
estimated_minutes: overrides.estimated_minutes ?? null,
|
||||
external_ref: overrides.external_ref ?? null,
|
||||
metadata: overrides.metadata ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
test('buildGraphViewModel limits visible nodes by hop depth around focus', () => {
|
||||
const model = buildGraphModel([
|
||||
issue({ id: 'bb-1', dependencies: [{ type: 'blocks', target: 'bb-2' }] }),
|
||||
issue({ id: 'bb-2', dependencies: [{ type: 'blocks', target: 'bb-3' }] }),
|
||||
issue({ id: 'bb-3', dependencies: [{ type: 'blocks', target: 'bb-4' }] }),
|
||||
issue({ id: 'bb-4' }),
|
||||
]);
|
||||
|
||||
const depth1 = buildGraphViewModel(model, { focusId: 'bb-2', depth: 1, hideClosed: false });
|
||||
const depth2 = buildGraphViewModel(model, { focusId: 'bb-2', depth: 2, hideClosed: false });
|
||||
|
||||
assert.deepEqual(
|
||||
depth1.nodes.map((x) => x.id),
|
||||
['bb-2', 'bb-1', 'bb-3'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
depth2.nodes.map((x) => x.id),
|
||||
['bb-2', 'bb-1', 'bb-3', 'bb-4'],
|
||||
);
|
||||
});
|
||||
|
||||
test('buildGraphViewModel can hide closed nodes while preserving focused node', () => {
|
||||
const model = buildGraphModel([
|
||||
issue({ id: 'bb-1', status: 'closed', dependencies: [{ type: 'blocks', target: 'bb-2' }] }),
|
||||
issue({ id: 'bb-2', status: 'open' }),
|
||||
]);
|
||||
|
||||
const hidden = buildGraphViewModel(model, { focusId: null, depth: 'full', hideClosed: true });
|
||||
const focused = buildGraphViewModel(model, { focusId: 'bb-1', depth: 'full', hideClosed: true });
|
||||
|
||||
assert.deepEqual(hidden.nodes.map((x) => x.id), ['bb-2']);
|
||||
assert.deepEqual(focused.nodes.map((x) => x.id), ['bb-1', 'bb-2']);
|
||||
});
|
||||
|
||||
test('buildGraphViewModel keeps deterministic edge ordering', () => {
|
||||
const model = buildGraphModel([
|
||||
issue({
|
||||
id: 'bb-2',
|
||||
dependencies: [
|
||||
{ type: 'parent', target: 'bb-1' },
|
||||
{ type: 'blocks', target: 'bb-3' },
|
||||
],
|
||||
}),
|
||||
issue({ id: 'bb-1' }),
|
||||
issue({ id: 'bb-3' }),
|
||||
]);
|
||||
|
||||
const view = buildGraphViewModel(model, { focusId: null, depth: 'full', hideClosed: false });
|
||||
|
||||
assert.deepEqual(
|
||||
view.edges.map((x) => `${x.source}|${x.type}|${x.target}`),
|
||||
['bb-2|blocks|bb-3', 'bb-2|parent|bb-1'],
|
||||
);
|
||||
assert.equal(view.nodes.every((x) => Number.isFinite(x.position.x) && Number.isFinite(x.position.y)), true);
|
||||
});
|
||||
|
||||
test('buildPathWorkspace returns upstream/downstream levels around focus', () => {
|
||||
const model = buildGraphModel([
|
||||
issue({ id: 'bb-1', dependencies: [{ type: 'blocks', target: 'bb-2' }] }),
|
||||
issue({ id: 'bb-2', dependencies: [{ type: 'blocks', target: 'bb-3' }] }),
|
||||
issue({ id: 'bb-3' }),
|
||||
]);
|
||||
|
||||
const workspace = buildPathWorkspace(model, { focusId: 'bb-2', depth: 2, hideClosed: false });
|
||||
|
||||
assert.equal(workspace.focus?.id, 'bb-2');
|
||||
assert.deepEqual(workspace.blockers.map((level) => level.map((node) => node.id)), [['bb-1']]);
|
||||
assert.deepEqual(workspace.dependents.map((level) => level.map((node) => node.id)), [['bb-3']]);
|
||||
});
|
||||
|
||||
test('buildPathWorkspace hides closed nodes when requested', () => {
|
||||
const model = buildGraphModel([
|
||||
issue({ id: 'bb-1', status: 'closed', dependencies: [{ type: 'blocks', target: 'bb-2' }] }),
|
||||
issue({ id: 'bb-2' }),
|
||||
]);
|
||||
|
||||
const workspace = buildPathWorkspace(model, { focusId: 'bb-2', depth: 2, hideClosed: true });
|
||||
assert.equal(workspace.blockers.length, 0);
|
||||
assert.equal(workspace.focus?.id, 'bb-2');
|
||||
});
|
||||
|
||||
test('buildPathWorkspace full depth keeps deterministic blocker and dependent levels', () => {
|
||||
const model = buildGraphModel([
|
||||
issue({ id: 'bb-1', dependencies: [{ type: 'blocks', target: 'bb-2' }] }),
|
||||
issue({ id: 'bb-2', dependencies: [{ type: 'blocks', target: 'bb-3' }] }),
|
||||
issue({ id: 'bb-3', dependencies: [{ type: 'blocks', target: 'bb-4' }] }),
|
||||
issue({ id: 'bb-4', dependencies: [{ type: 'blocks', target: 'bb-5' }] }),
|
||||
issue({ id: 'bb-5' }),
|
||||
]);
|
||||
|
||||
const workspace = buildPathWorkspace(model, { focusId: 'bb-3', depth: 'full', hideClosed: false });
|
||||
|
||||
assert.deepEqual(workspace.blockers.map((level) => level.map((node) => node.id)), [['bb-2'], ['bb-1']]);
|
||||
assert.deepEqual(workspace.dependents.map((level) => level.map((node) => node.id)), [['bb-4'], ['bb-5']]);
|
||||
});
|
||||
|
||||
test('analyzeBlockedChain returns blocker counts, first actionable blocker, and chain edges', () => {
|
||||
const model = buildGraphModel([
|
||||
issue({ id: 'bb-1', status: 'open', dependencies: [{ type: 'blocks', target: 'bb-2' }] }),
|
||||
issue({ id: 'bb-2', status: 'in_progress', dependencies: [{ type: 'blocks', target: 'bb-3' }] }),
|
||||
issue({ id: 'bb-3', status: 'blocked' }),
|
||||
]);
|
||||
|
||||
const summary = analyzeBlockedChain(model, { focusId: 'bb-3' });
|
||||
|
||||
assert.equal(summary.blockerNodeIds.length, 2);
|
||||
assert.equal(summary.openBlockerCount, 1);
|
||||
assert.equal(summary.inProgressBlockerCount, 1);
|
||||
assert.equal(summary.firstActionableBlockerId, 'bb-1');
|
||||
assert.deepEqual(summary.chainEdgeIds, ['bb-1:blocks:bb-2', 'bb-2:blocks:bb-3']);
|
||||
});
|
||||
|
||||
test('detectDependencyCycles reports cycle nodes and edges for blocks relations', () => {
|
||||
const model = buildGraphModel([
|
||||
issue({ id: 'bb-1', dependencies: [{ type: 'blocks', target: 'bb-2' }] }),
|
||||
issue({ id: 'bb-2', dependencies: [{ type: 'blocks', target: 'bb-3' }] }),
|
||||
issue({ id: 'bb-3', dependencies: [{ type: 'blocks', target: 'bb-1' }] }),
|
||||
issue({ id: 'bb-4', dependencies: [{ type: 'blocks', target: 'bb-5' }] }),
|
||||
issue({ id: 'bb-5' }),
|
||||
]);
|
||||
|
||||
const anomaly = detectDependencyCycles(model);
|
||||
|
||||
assert.equal(anomaly.cycles.length, 1);
|
||||
assert.deepEqual(anomaly.cycleNodeIds, ['bb-1', 'bb-2', 'bb-3']);
|
||||
assert.deepEqual(anomaly.cycleEdgeIds, ['bb-1:blocks:bb-2', 'bb-2:blocks:bb-3', 'bb-3:blocks:bb-1']);
|
||||
});
|
||||
|
||||
test('detectDependencyCycles does not mark non-cycle predecessor as cyclic', () => {
|
||||
const model = buildGraphModel([
|
||||
issue({ id: 'bb-a', dependencies: [{ type: 'blocks', target: 'bb-b' }] }),
|
||||
issue({ id: 'bb-b', dependencies: [{ type: 'blocks', target: 'bb-c' }] }),
|
||||
issue({ id: 'bb-c', dependencies: [{ type: 'blocks', target: 'bb-a' }] }),
|
||||
issue({ id: 'bb-x', dependencies: [{ type: 'blocks', target: 'bb-a' }] }),
|
||||
]);
|
||||
|
||||
const anomaly = detectDependencyCycles(model);
|
||||
|
||||
assert.deepEqual(anomaly.cycleNodeIds, ['bb-a', 'bb-b', 'bb-c']);
|
||||
assert.equal(anomaly.cycleNodeIds.includes('bb-x'), false);
|
||||
assert.equal(anomaly.cycleEdgeIds.includes('bb-x:blocks:bb-a'), false);
|
||||
});
|
||||
127
tests/lib/graph.test.ts
Normal file
127
tests/lib/graph.test.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import type { BeadDependency, BeadIssue } from '../../src/lib/types';
|
||||
import { buildGraphModel } from '../../src/lib/graph';
|
||||
|
||||
function issue(overrides: Partial<BeadIssue>): BeadIssue {
|
||||
return {
|
||||
id: overrides.id ?? 'bb-x',
|
||||
title: overrides.title ?? 'Issue',
|
||||
description: overrides.description ?? null,
|
||||
status: overrides.status ?? 'open',
|
||||
priority: overrides.priority ?? 2,
|
||||
issue_type: overrides.issue_type ?? 'task',
|
||||
assignee: overrides.assignee ?? null,
|
||||
owner: overrides.owner ?? null,
|
||||
labels: overrides.labels ?? [],
|
||||
dependencies: overrides.dependencies ?? [],
|
||||
created_at: overrides.created_at ?? '2026-02-12T00:00:00Z',
|
||||
updated_at: overrides.updated_at ?? '2026-02-12T00:00:00Z',
|
||||
closed_at: overrides.closed_at ?? null,
|
||||
close_reason: overrides.close_reason ?? null,
|
||||
closed_by_session: overrides.closed_by_session ?? null,
|
||||
created_by: overrides.created_by ?? null,
|
||||
due_at: overrides.due_at ?? null,
|
||||
estimated_minutes: overrides.estimated_minutes ?? null,
|
||||
external_ref: overrides.external_ref ?? null,
|
||||
metadata: overrides.metadata ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
function dep(type: BeadDependency['type'], target: string): BeadDependency {
|
||||
return { type, target };
|
||||
}
|
||||
|
||||
test('buildGraphModel extracts supported dependency types with deterministic ordering', () => {
|
||||
const issues = [
|
||||
issue({
|
||||
id: 'bb-2',
|
||||
dependencies: [
|
||||
dep('parent', 'bb-1'),
|
||||
dep('blocks', 'bb-3'),
|
||||
],
|
||||
}),
|
||||
issue({
|
||||
id: 'bb-1',
|
||||
dependencies: [dep('supersedes', 'bb-3')],
|
||||
}),
|
||||
issue({
|
||||
id: 'bb-3',
|
||||
dependencies: [
|
||||
dep('duplicates', 'bb-1'),
|
||||
dep('relates_to', 'bb-2'),
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
const model = buildGraphModel(issues, { projectKey: 'demo' });
|
||||
|
||||
assert.deepEqual(model.nodes.map((x) => x.id), ['bb-1', 'bb-2', 'bb-3']);
|
||||
assert.deepEqual(
|
||||
model.edges.map((x) => `${x.source}|${x.type}|${x.target}`),
|
||||
[
|
||||
'bb-1|supersedes|bb-3',
|
||||
'bb-2|blocks|bb-3',
|
||||
'bb-2|parent|bb-1',
|
||||
'bb-3|duplicates|bb-1',
|
||||
'bb-3|relates_to|bb-2',
|
||||
],
|
||||
);
|
||||
assert.equal(model.projectKey, 'demo');
|
||||
});
|
||||
|
||||
test('buildGraphModel deduplicates duplicate edges and tracks diagnostics', () => {
|
||||
const issues = [
|
||||
issue({
|
||||
id: 'bb-1',
|
||||
dependencies: [
|
||||
dep('blocks', 'bb-2'),
|
||||
dep('blocks', 'bb-2'),
|
||||
dep('blocks', 'bb-2'),
|
||||
],
|
||||
}),
|
||||
issue({ id: 'bb-2' }),
|
||||
];
|
||||
|
||||
const model = buildGraphModel(issues);
|
||||
|
||||
assert.equal(model.edges.length, 1);
|
||||
assert.equal(model.diagnostics.droppedDuplicates, 2);
|
||||
assert.equal(model.diagnostics.missingTargets, 0);
|
||||
});
|
||||
|
||||
test('buildGraphModel ignores missing-target edges and unsupported types', () => {
|
||||
const issues = [
|
||||
issue({
|
||||
id: 'bb-1',
|
||||
dependencies: [
|
||||
dep('blocks', 'bb-missing'),
|
||||
dep('replies_to', 'bb-2'),
|
||||
],
|
||||
}),
|
||||
issue({ id: 'bb-2' }),
|
||||
];
|
||||
|
||||
const model = buildGraphModel(issues);
|
||||
|
||||
assert.equal(model.edges.length, 0);
|
||||
assert.equal(model.diagnostics.missingTargets, 1);
|
||||
assert.equal(model.diagnostics.unsupportedTypes, 1);
|
||||
});
|
||||
|
||||
test('buildGraphModel builds incoming/outgoing adjacency maps', () => {
|
||||
const issues = [
|
||||
issue({ id: 'bb-1', dependencies: [dep('blocks', 'bb-2')] }),
|
||||
issue({ id: 'bb-2', dependencies: [dep('parent', 'bb-3')] }),
|
||||
issue({ id: 'bb-3' }),
|
||||
];
|
||||
|
||||
const model = buildGraphModel(issues);
|
||||
|
||||
assert.deepEqual(model.adjacency['bb-1'].outgoing.map((x) => x.target), ['bb-2']);
|
||||
assert.deepEqual(model.adjacency['bb-1'].incoming.map((x) => x.source), []);
|
||||
assert.deepEqual(model.adjacency['bb-2'].incoming.map((x) => x.source), ['bb-1']);
|
||||
assert.deepEqual(model.adjacency['bb-2'].outgoing.map((x) => x.target), ['bb-3']);
|
||||
assert.deepEqual(model.adjacency['bb-3'].incoming.map((x) => x.source), ['bb-2']);
|
||||
});
|
||||
142
tests/lib/issue-editor.test.ts
Normal file
142
tests/lib/issue-editor.test.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
buildEditableIssueDraft,
|
||||
buildIssueUpdatePayload,
|
||||
classifyEditState,
|
||||
parseLabelsInput,
|
||||
validateEditableIssueDraft,
|
||||
type EditableIssueDraft,
|
||||
} from '../../src/lib/issue-editor';
|
||||
import type { BeadIssue } from '../../src/lib/types';
|
||||
|
||||
function makeIssue(overrides: Partial<BeadIssue> = {}): BeadIssue {
|
||||
const has = (key: keyof BeadIssue) => Object.prototype.hasOwnProperty.call(overrides, key);
|
||||
return {
|
||||
id: overrides.id ?? 'bb-101',
|
||||
title: overrides.title ?? 'Implement shared edit surface',
|
||||
description: has('description') ? (overrides.description as string | null) : 'First line',
|
||||
status: overrides.status ?? 'open',
|
||||
priority: overrides.priority ?? 2,
|
||||
issue_type: overrides.issue_type ?? 'task',
|
||||
assignee: has('assignee') ? (overrides.assignee as string | null) : null,
|
||||
owner: has('owner') ? (overrides.owner as string | null) : null,
|
||||
labels: overrides.labels ?? ['ux', 'graph'],
|
||||
dependencies: overrides.dependencies ?? [],
|
||||
created_at: overrides.created_at ?? '2026-02-13T00:00:00.000Z',
|
||||
updated_at: overrides.updated_at ?? '2026-02-13T00:00:00.000Z',
|
||||
closed_at: overrides.closed_at ?? null,
|
||||
close_reason: overrides.close_reason ?? null,
|
||||
closed_by_session: overrides.closed_by_session ?? null,
|
||||
created_by: overrides.created_by ?? 'zenchantlive',
|
||||
due_at: overrides.due_at ?? null,
|
||||
estimated_minutes: overrides.estimated_minutes ?? null,
|
||||
external_ref: overrides.external_ref ?? null,
|
||||
metadata: overrides.metadata ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
test('buildEditableIssueDraft normalizes nullable issue fields', () => {
|
||||
const issue = makeIssue({
|
||||
description: null,
|
||||
assignee: null,
|
||||
owner: null,
|
||||
labels: ['graph', ' ux ', ''],
|
||||
});
|
||||
|
||||
const draft = buildEditableIssueDraft(issue);
|
||||
|
||||
assert.equal(draft.title, issue.title);
|
||||
assert.equal(draft.description, '');
|
||||
assert.equal(draft.status, issue.status);
|
||||
assert.equal(draft.assignee, '');
|
||||
assert.equal(draft.owner, '');
|
||||
assert.equal(draft.labelsInput, 'graph, ux');
|
||||
});
|
||||
|
||||
test('validateEditableIssueDraft returns validation errors for invalid input', () => {
|
||||
const draft = {
|
||||
title: ' ',
|
||||
description: 'desc',
|
||||
status: 'tombstone',
|
||||
priority: 7,
|
||||
issueType: '',
|
||||
assignee: 'dev-a',
|
||||
owner: '',
|
||||
labelsInput: 'ok, , invalid',
|
||||
} as unknown as EditableIssueDraft;
|
||||
|
||||
const result = validateEditableIssueDraft(draft);
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.errors.title, 'Title is required.');
|
||||
assert.equal(result.errors.status, 'Status must be open, in progress, blocked, deferred, or closed.');
|
||||
assert.equal(result.errors.priority, 'Priority must be between 0 and 4.');
|
||||
assert.equal(result.errors.issueType, 'Issue type is required.');
|
||||
assert.equal(result.errors.labelsInput, 'Labels must be comma-separated non-empty values.');
|
||||
});
|
||||
|
||||
test('buildIssueUpdatePayload includes only changed mutable fields', () => {
|
||||
const issue = makeIssue({
|
||||
title: 'Old title',
|
||||
description: 'Old description',
|
||||
priority: 2,
|
||||
issue_type: 'task',
|
||||
assignee: 'old-assignee',
|
||||
labels: ['legacy'],
|
||||
owner: 'owner-a',
|
||||
});
|
||||
const draft: EditableIssueDraft = {
|
||||
title: 'New title',
|
||||
description: 'Old description',
|
||||
status: 'in_progress',
|
||||
priority: 1,
|
||||
issueType: 'feature',
|
||||
assignee: 'new-assignee',
|
||||
owner: 'owner-b',
|
||||
labelsInput: 'legacy,ui',
|
||||
};
|
||||
|
||||
const payload = buildIssueUpdatePayload(issue, draft, 'C:/repo');
|
||||
|
||||
assert.deepEqual(payload, {
|
||||
projectRoot: 'C:/repo',
|
||||
id: issue.id,
|
||||
title: 'New title',
|
||||
status: 'in_progress',
|
||||
priority: 1,
|
||||
issueType: 'feature',
|
||||
assignee: 'new-assignee',
|
||||
labels: ['legacy', 'ui'],
|
||||
});
|
||||
});
|
||||
|
||||
test('buildIssueUpdatePayload returns null when no mutable fields changed', () => {
|
||||
const issue = makeIssue({
|
||||
title: 'Same title',
|
||||
description: 'Same description',
|
||||
status: 'open',
|
||||
priority: 2,
|
||||
issue_type: 'task',
|
||||
assignee: null,
|
||||
owner: 'owner-a',
|
||||
labels: ['a', 'b'],
|
||||
});
|
||||
const draft = buildEditableIssueDraft(issue);
|
||||
|
||||
const payload = buildIssueUpdatePayload(issue, draft, 'C:/repo');
|
||||
|
||||
assert.equal(payload, null);
|
||||
});
|
||||
|
||||
test('parseLabelsInput deduplicates, trims, and preserves order', () => {
|
||||
assert.deepEqual(parseLabelsInput(' alpha, beta,alpha, gamma , ,beta'), ['alpha', 'beta', 'gamma']);
|
||||
});
|
||||
|
||||
test('classifyEditState derives ui state from dirty/saving/error flags', () => {
|
||||
assert.equal(classifyEditState({ dirty: false, saving: false, error: null }), 'pristine');
|
||||
assert.equal(classifyEditState({ dirty: true, saving: false, error: null }), 'dirty');
|
||||
assert.equal(classifyEditState({ dirty: true, saving: true, error: null }), 'saving');
|
||||
assert.equal(classifyEditState({ dirty: true, saving: false, error: 'boom' }), 'error');
|
||||
});
|
||||
|
|
@ -4,9 +4,14 @@ import assert from 'node:assert/strict';
|
|||
import type { BeadIssue } from '../../src/lib/types';
|
||||
import {
|
||||
KANBAN_STATUSES,
|
||||
buildExecutionChecklist,
|
||||
buildBlockedByTree,
|
||||
buildKanbanColumns,
|
||||
buildKanbanStats,
|
||||
buildUnblocksCountByIssue,
|
||||
findIssueLane,
|
||||
filterKanbanIssues,
|
||||
pickNextActionableIssue,
|
||||
} from '../../src/lib/kanban';
|
||||
|
||||
function issue(overrides: Partial<BeadIssue>): BeadIssue {
|
||||
|
|
@ -56,19 +61,24 @@ test('buildKanbanColumns groups by core statuses and sorts by priority ascending
|
|||
const issues = [
|
||||
issue({ id: 'bb-1', status: 'open', priority: 2 }),
|
||||
issue({ id: 'bb-2', status: 'open', priority: 0 }),
|
||||
issue({ id: 'bb-3', status: 'blocked', priority: 1 }),
|
||||
issue({ id: 'bb-4', status: 'pinned', priority: 1 }),
|
||||
issue({ id: 'bb-epic', status: 'open', priority: 0, issue_type: 'epic' }),
|
||||
issue({ id: 'bb-3', status: 'in_progress', priority: 1 }),
|
||||
issue({ id: 'bb-4', status: 'deferred', priority: 1 }),
|
||||
issue({ id: 'bb-5', status: 'open', priority: 3, dependencies: [{ type: 'blocks', target: 'bb-1' }] }),
|
||||
issue({ id: 'bb-6', status: 'open', priority: 4, dependencies: [{ type: 'blocks', target: 'bb-2' }] }),
|
||||
];
|
||||
|
||||
const columns = buildKanbanColumns(issues);
|
||||
|
||||
assert.deepEqual(Object.keys(columns), KANBAN_STATUSES);
|
||||
assert.deepEqual(columns.open.map((x) => x.id), ['bb-2', 'bb-1']);
|
||||
assert.deepEqual(columns.blocked.map((x) => x.id), ['bb-3']);
|
||||
assert.deepEqual(columns.ready.map((x) => x.id), ['bb-4', 'bb-5', 'bb-6']);
|
||||
assert.equal(columns.ready.some((x) => x.issue_type === 'epic'), false);
|
||||
assert.deepEqual(columns.in_progress.map((x) => x.id), ['bb-3']);
|
||||
assert.deepEqual(columns.blocked.map((x) => x.id), ['bb-2', 'bb-1']);
|
||||
assert.equal(columns.closed.length, 0);
|
||||
});
|
||||
|
||||
test('buildKanbanStats reports total/open/active/blocked/done/p0', () => {
|
||||
test('buildKanbanStats reports total/ready/active/blocked/done/p0', () => {
|
||||
const issues = [
|
||||
issue({ status: 'open', priority: 0 }),
|
||||
issue({ status: 'open', priority: 2 }),
|
||||
|
|
@ -80,9 +90,121 @@ test('buildKanbanStats reports total/open/active/blocked/done/p0', () => {
|
|||
const stats = buildKanbanStats(issues);
|
||||
|
||||
assert.equal(stats.total, 5);
|
||||
assert.equal(stats.open, 2);
|
||||
assert.equal(stats.ready, 2);
|
||||
assert.equal(stats.active, 1);
|
||||
assert.equal(stats.blocked, 1);
|
||||
assert.equal(stats.done, 1);
|
||||
assert.equal(stats.p0, 1);
|
||||
});
|
||||
|
||||
test('buildBlockedByTree returns compact blocker tree with depth and total', () => {
|
||||
const issues = [
|
||||
issue({ id: 'bb-1', title: 'Target issue' }),
|
||||
issue({ id: 'bb-2', title: 'Direct blocker A', dependencies: [{ type: 'blocks', target: 'bb-1' }] }),
|
||||
issue({ id: 'bb-3', title: 'Direct blocker B', dependencies: [{ type: 'blocks', target: 'bb-1' }] }),
|
||||
issue({ id: 'bb-4', title: 'Nested blocker', dependencies: [{ type: 'blocks', target: 'bb-2' }] }),
|
||||
];
|
||||
|
||||
const tree = buildBlockedByTree(issues, 'bb-1');
|
||||
|
||||
assert.equal(tree.total, 3);
|
||||
assert.deepEqual(
|
||||
tree.nodes.map((node) => `${node.id}:${node.level}`),
|
||||
['bb-2:1', 'bb-3:1', 'bb-4:2'],
|
||||
);
|
||||
});
|
||||
|
||||
test('findIssueLane resolves ready lane for unblocked linked issues', () => {
|
||||
const issues = [
|
||||
issue({ id: 'bb-1', status: 'open' }),
|
||||
issue({ id: 'bb-2', status: 'blocked' }),
|
||||
issue({ id: 'bb-3', status: 'closed' }),
|
||||
];
|
||||
|
||||
const columns = buildKanbanColumns(issues);
|
||||
|
||||
assert.equal(findIssueLane(columns, 'bb-1'), 'ready');
|
||||
assert.equal(findIssueLane(columns, 'bb-2'), 'blocked');
|
||||
assert.equal(findIssueLane(columns, 'bb-3'), 'closed');
|
||||
assert.equal(findIssueLane(columns, 'bb-404'), null);
|
||||
});
|
||||
|
||||
test('pickNextActionableIssue is deterministic by priority asc, unblocks desc, updated desc, id asc', () => {
|
||||
const issues = [
|
||||
issue({
|
||||
id: 'bb-1',
|
||||
status: 'open',
|
||||
priority: 1,
|
||||
updated_at: '2026-02-10T01:00:00Z',
|
||||
dependencies: [{ type: 'blocks', target: 'bb-10' }],
|
||||
}),
|
||||
issue({
|
||||
id: 'bb-2',
|
||||
status: 'open',
|
||||
priority: 1,
|
||||
updated_at: '2026-02-10T02:00:00Z',
|
||||
dependencies: [{ type: 'blocks', target: 'bb-11' }, { type: 'blocks', target: 'bb-12' }],
|
||||
}),
|
||||
issue({
|
||||
id: 'bb-3',
|
||||
status: 'open',
|
||||
priority: 1,
|
||||
updated_at: '2026-02-10T02:00:00Z',
|
||||
dependencies: [{ type: 'blocks', target: 'bb-13' }, { type: 'blocks', target: 'bb-14' }],
|
||||
}),
|
||||
issue({ id: 'bb-10', status: 'blocked' }),
|
||||
issue({ id: 'bb-11', status: 'blocked' }),
|
||||
issue({ id: 'bb-12', status: 'blocked' }),
|
||||
issue({ id: 'bb-13', status: 'blocked' }),
|
||||
issue({ id: 'bb-14', status: 'blocked' }),
|
||||
];
|
||||
|
||||
const columns = buildKanbanColumns(issues);
|
||||
const next = pickNextActionableIssue(columns, issues);
|
||||
|
||||
assert.equal(next?.id, 'bb-2');
|
||||
});
|
||||
|
||||
test('pickNextActionableIssue returns null when no ready issue exists', () => {
|
||||
const issues = [issue({ id: 'bb-1', status: 'in_progress' }), issue({ id: 'bb-2', status: 'closed' })];
|
||||
const columns = buildKanbanColumns(issues);
|
||||
|
||||
assert.equal(pickNextActionableIssue(columns, issues), null);
|
||||
});
|
||||
|
||||
test('buildUnblocksCountByIssue counts unique blocks dependencies per issue', () => {
|
||||
const issues = [
|
||||
issue({
|
||||
id: 'bb-1',
|
||||
dependencies: [
|
||||
{ type: 'blocks', target: 'bb-2' },
|
||||
{ type: 'blocks', target: 'bb-2' },
|
||||
{ type: 'blocks', target: 'bb-3' },
|
||||
{ type: 'relates_to', target: 'bb-4' },
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
const map = buildUnblocksCountByIssue(issues);
|
||||
|
||||
assert.equal(map.get('bb-1'), 2);
|
||||
});
|
||||
|
||||
test('buildExecutionChecklist evaluates owner, blockers, quality signal, and execution-compatible lane', () => {
|
||||
const issues = [
|
||||
issue({
|
||||
id: 'bb-1',
|
||||
status: 'open',
|
||||
owner: 'dev-a',
|
||||
description: 'Implements acceptance criteria with rollback notes',
|
||||
}),
|
||||
issue({ id: 'bb-2', status: 'closed', dependencies: [{ type: 'blocks', target: 'bb-1' }] }),
|
||||
];
|
||||
|
||||
const checklist = buildExecutionChecklist(issues[0], issues);
|
||||
|
||||
assert.deepEqual(
|
||||
checklist.map((item) => item.passed),
|
||||
[true, true, true, true],
|
||||
);
|
||||
});
|
||||
|
|
|
|||
112
tests/lib/mutations.test.ts
Normal file
112
tests/lib/mutations.test.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
MutationValidationError,
|
||||
buildBdMutationArgs,
|
||||
validateMutationPayload,
|
||||
executeMutation,
|
||||
type MutationOperation,
|
||||
} from '../../src/lib/mutations';
|
||||
|
||||
const root = 'C:/Users/Zenchant/codex/beadboard';
|
||||
|
||||
test('validateMutationPayload rejects invalid payloads', () => {
|
||||
assert.throws(
|
||||
() => validateMutationPayload('create', { projectRoot: '', title: '' }),
|
||||
(error: unknown) => error instanceof MutationValidationError,
|
||||
);
|
||||
});
|
||||
|
||||
test('buildBdMutationArgs maps reopen correctly', () => {
|
||||
const payload = validateMutationPayload('reopen', {
|
||||
projectRoot: root,
|
||||
id: 'bb-123',
|
||||
reason: 'retry work',
|
||||
});
|
||||
|
||||
const args = buildBdMutationArgs('reopen', payload);
|
||||
assert.deepEqual(args, ['reopen', 'bb-123', '-r', 'retry work', '--json']);
|
||||
});
|
||||
|
||||
test('buildBdMutationArgs maps update issue type correctly', () => {
|
||||
const payload = validateMutationPayload('update', {
|
||||
projectRoot: root,
|
||||
id: 'bb-123',
|
||||
issueType: 'feature',
|
||||
});
|
||||
|
||||
const args = buildBdMutationArgs('update', payload);
|
||||
assert.deepEqual(args, ['update', 'bb-123', '-t', 'feature', '--json']);
|
||||
});
|
||||
|
||||
test('buildBdMutationArgs maps comment correctly', () => {
|
||||
const payload = validateMutationPayload('comment', {
|
||||
projectRoot: root,
|
||||
id: 'bb-123',
|
||||
text: 'Added notes',
|
||||
});
|
||||
|
||||
const args = buildBdMutationArgs('comment', payload);
|
||||
assert.deepEqual(args, ['comments', 'add', 'bb-123', 'Added notes', '--json']);
|
||||
});
|
||||
|
||||
test('executeMutation surfaces bridge failures in normalized response', async () => {
|
||||
const payload = validateMutationPayload('close', {
|
||||
projectRoot: root,
|
||||
id: 'bb-123',
|
||||
reason: 'completed',
|
||||
});
|
||||
|
||||
const result = await executeMutation('close', payload, {
|
||||
runBdCommand: async ({ args }) => {
|
||||
assert.deepEqual(args, ['close', 'bb-123', '-r', 'completed', '--json']);
|
||||
return {
|
||||
success: false,
|
||||
classification: 'non_zero_exit',
|
||||
command: 'bd.exe',
|
||||
args,
|
||||
cwd: root,
|
||||
stdout: '',
|
||||
stderr: 'cannot close',
|
||||
code: 1,
|
||||
durationMs: 3,
|
||||
error: 'cannot close',
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.error?.classification, 'non_zero_exit');
|
||||
});
|
||||
|
||||
test('executeMutation returns successful normalized response', async () => {
|
||||
const payload = validateMutationPayload('update', {
|
||||
projectRoot: root,
|
||||
id: 'bb-123',
|
||||
status: 'in_progress',
|
||||
priority: 1,
|
||||
});
|
||||
|
||||
const result = await executeMutation('update', payload, {
|
||||
runBdCommand: async ({ args }) => {
|
||||
assert.deepEqual(args, ['update', 'bb-123', '-s', 'in_progress', '-p', '1', '--json']);
|
||||
return {
|
||||
success: true,
|
||||
classification: null,
|
||||
command: 'bd.exe',
|
||||
args,
|
||||
cwd: root,
|
||||
stdout: '{"id":"bb-123"}',
|
||||
stderr: '',
|
||||
code: 0,
|
||||
durationMs: 2,
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.operation, 'update');
|
||||
assert.equal(result.command.success, true);
|
||||
});
|
||||
|
|
@ -49,3 +49,24 @@ test('parseIssuesJsonl can include tombstones when requested', () => {
|
|||
|
||||
assert.equal(result.length, 2);
|
||||
});
|
||||
|
||||
test('parseIssuesJsonl supports beads dependency schema with depends_on_id and parent-child', () => {
|
||||
const input = JSON.stringify({
|
||||
id: 'bb-6',
|
||||
title: 'Dependency test',
|
||||
dependencies: [
|
||||
{ type: 'blocks', depends_on_id: 'bb-1' },
|
||||
{ type: 'parent-child', depends_on_id: 'bb-epic' },
|
||||
{ type: 'relates_to', target: 'bb-2' },
|
||||
],
|
||||
});
|
||||
|
||||
const result = parseIssuesJsonl(input);
|
||||
|
||||
assert.equal(result.length, 1);
|
||||
assert.deepEqual(result[0].dependencies, [
|
||||
{ type: 'blocks', target: 'bb-1' },
|
||||
{ type: 'parent', target: 'bb-epic' },
|
||||
{ type: 'relates_to', target: 'bb-2' },
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
87
tests/lib/project-scope.test.ts
Normal file
87
tests/lib/project-scope.test.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { resolveProjectScope, type ProjectScopeRegistryEntry } from '../../src/lib/project-scope';
|
||||
|
||||
const REGISTRY: ProjectScopeRegistryEntry[] = [
|
||||
{ path: 'D:/Repos/Alpha' },
|
||||
{ path: 'D:/Repos/Beta' },
|
||||
];
|
||||
|
||||
test('resolveProjectScope defaults to local when query key is missing', () => {
|
||||
const scope = resolveProjectScope({
|
||||
currentProjectRoot: 'C:/Users/Zenchant/codex/beadboard',
|
||||
registryProjects: REGISTRY,
|
||||
});
|
||||
|
||||
assert.equal(scope.mode, 'single');
|
||||
assert.equal(scope.selected.source, 'local');
|
||||
assert.equal(scope.selected.root, 'C:\\Users\\Zenchant\\codex\\beadboard');
|
||||
assert.equal(scope.selected.key, 'local');
|
||||
assert.deepEqual(scope.readRoots, ['C:\\Users\\Zenchant\\codex\\beadboard']);
|
||||
assert.equal(scope.options[0].key, 'local');
|
||||
assert.equal(scope.options.length, 3);
|
||||
});
|
||||
|
||||
test('resolveProjectScope selects registry project when key matches', () => {
|
||||
const scope = resolveProjectScope({
|
||||
currentProjectRoot: 'C:/Users/Zenchant/codex/beadboard',
|
||||
registryProjects: REGISTRY,
|
||||
requestedProjectKey: 'd:\\repos\\beta',
|
||||
});
|
||||
|
||||
assert.equal(scope.selected.source, 'registry');
|
||||
assert.equal(scope.selected.root, 'D:\\Repos\\Beta');
|
||||
assert.equal(scope.selected.key, 'd:\\repos\\beta');
|
||||
assert.deepEqual(scope.readRoots, ['D:\\Repos\\Beta']);
|
||||
});
|
||||
|
||||
test('resolveProjectScope falls back to local when query key is unknown', () => {
|
||||
const scope = resolveProjectScope({
|
||||
currentProjectRoot: 'C:/Users/Zenchant/codex/beadboard',
|
||||
registryProjects: REGISTRY,
|
||||
requestedProjectKey: 'd:\\repos\\missing',
|
||||
});
|
||||
|
||||
assert.equal(scope.selected.source, 'local');
|
||||
assert.equal(scope.selected.key, 'local');
|
||||
assert.deepEqual(scope.readRoots, ['C:\\Users\\Zenchant\\codex\\beadboard']);
|
||||
});
|
||||
|
||||
test('resolveProjectScope deduplicates registry entries by normalized key', () => {
|
||||
const scope = resolveProjectScope({
|
||||
currentProjectRoot: 'C:/Users/Zenchant/codex/beadboard',
|
||||
registryProjects: [{ path: 'D:/Repos/Alpha/' }, { path: 'd:\\repos\\alpha' }],
|
||||
});
|
||||
|
||||
assert.equal(scope.options.length, 2);
|
||||
assert.equal(scope.options.filter((option) => option.source === 'registry').length, 1);
|
||||
});
|
||||
|
||||
test('resolveProjectScope supports aggregate mode and reads all roots', () => {
|
||||
const scope = resolveProjectScope({
|
||||
currentProjectRoot: 'C:/Users/Zenchant/codex/beadboard',
|
||||
registryProjects: REGISTRY,
|
||||
requestedProjectKey: 'd:\\repos\\alpha',
|
||||
requestedMode: 'aggregate',
|
||||
});
|
||||
|
||||
assert.equal(scope.mode, 'aggregate');
|
||||
assert.equal(scope.selected.key, 'd:\\repos\\alpha');
|
||||
assert.deepEqual(scope.readRoots, [
|
||||
'C:\\Users\\Zenchant\\codex\\beadboard',
|
||||
'D:\\Repos\\Alpha',
|
||||
'D:\\Repos\\Beta',
|
||||
]);
|
||||
});
|
||||
|
||||
test('resolveProjectScope falls back to single mode for unknown mode values', () => {
|
||||
const scope = resolveProjectScope({
|
||||
currentProjectRoot: 'C:/Users/Zenchant/codex/beadboard',
|
||||
registryProjects: REGISTRY,
|
||||
requestedMode: 'invalid-mode',
|
||||
});
|
||||
|
||||
assert.equal(scope.mode, 'single');
|
||||
assert.deepEqual(scope.readRoots, ['C:\\Users\\Zenchant\\codex\\beadboard']);
|
||||
});
|
||||
27
tests/lib/read-text-retry.test.ts
Normal file
27
tests/lib/read-text-retry.test.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { readTextFileWithRetry } from '../../src/lib/read-text-retry';
|
||||
|
||||
test('readTextFileWithRetry reads file content', async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-retry-read-'));
|
||||
const target = path.join(root, 'sample.txt');
|
||||
await fs.writeFile(target, 'ok', 'utf8');
|
||||
|
||||
const content = await readTextFileWithRetry(target);
|
||||
assert.equal(content, 'ok');
|
||||
});
|
||||
|
||||
test('readTextFileWithRetry does not retry non-retryable errors', async () => {
|
||||
await assert.rejects(
|
||||
() => readTextFileWithRetry('C:/definitely/missing/file.txt', { retries: 3, delayMs: 1 }),
|
||||
(error: unknown) => {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
assert.equal(code, 'ENOENT');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
46
tests/lib/realtime.test.ts
Normal file
46
tests/lib/realtime.test.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { IssuesEventBus, toSseFrame } from '../../src/lib/realtime';
|
||||
|
||||
test('IssuesEventBus emits monotonically increasing IDs', () => {
|
||||
const bus = new IssuesEventBus();
|
||||
const seen: number[] = [];
|
||||
const unsubscribe = bus.subscribe((event) => seen.push(event.id));
|
||||
|
||||
bus.emit('C:/Repo/One');
|
||||
bus.emit('C:/Repo/One');
|
||||
unsubscribe();
|
||||
|
||||
assert.deepEqual(seen, [1, 2]);
|
||||
});
|
||||
|
||||
test('IssuesEventBus filters by project root', () => {
|
||||
const bus = new IssuesEventBus();
|
||||
const one: number[] = [];
|
||||
const two: number[] = [];
|
||||
const stopOne = bus.subscribe((event) => one.push(event.id), { projectRoot: 'C:/Repo/One' });
|
||||
const stopTwo = bus.subscribe((event) => two.push(event.id), { projectRoot: 'D:/Repo/Two' });
|
||||
|
||||
bus.emit('c:\\repo\\one');
|
||||
bus.emit('D:/Repo/Two');
|
||||
|
||||
stopOne();
|
||||
stopTwo();
|
||||
|
||||
assert.deepEqual(one, [1]);
|
||||
assert.deepEqual(two, [2]);
|
||||
});
|
||||
|
||||
test('toSseFrame includes id, event name, and data payload', () => {
|
||||
const frame = toSseFrame({
|
||||
id: 9,
|
||||
projectRoot: 'C:\\Repo\\One',
|
||||
kind: 'changed',
|
||||
at: '2026-02-12T01:00:00.000Z',
|
||||
});
|
||||
|
||||
assert.equal(frame.includes('id: 9'), true);
|
||||
assert.equal(frame.includes('event: issues'), true);
|
||||
assert.equal(frame.includes('"projectRoot":"C:\\\\Repo\\\\One"'), true);
|
||||
});
|
||||
86
tests/lib/registry.test.ts
Normal file
86
tests/lib/registry.test.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
addProject,
|
||||
listProjects,
|
||||
removeProject,
|
||||
registryFilePath,
|
||||
type RegistryProject,
|
||||
} from '../../src/lib/registry';
|
||||
|
||||
async function withTempUserProfile(run: (userProfile: string) => Promise<void>): Promise<void> {
|
||||
const previous = process.env.USERPROFILE;
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-registry-'));
|
||||
process.env.USERPROFILE = tempDir;
|
||||
|
||||
try {
|
||||
await run(tempDir);
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.USERPROFILE;
|
||||
} else {
|
||||
process.env.USERPROFILE = previous;
|
||||
}
|
||||
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test('registryFilePath resolves under %USERPROFILE%/.beadboard/projects.json', async () => {
|
||||
await withTempUserProfile(async (userProfile) => {
|
||||
const result = registryFilePath();
|
||||
assert.equal(result, path.join(userProfile, '.beadboard', 'projects.json'));
|
||||
});
|
||||
});
|
||||
|
||||
test('listProjects returns empty when registry does not exist', async () => {
|
||||
await withTempUserProfile(async () => {
|
||||
const result = await listProjects();
|
||||
assert.deepEqual(result, []);
|
||||
});
|
||||
});
|
||||
|
||||
test('addProject persists normalized path and deduplicates case/separators', async () => {
|
||||
await withTempUserProfile(async () => {
|
||||
const first = await addProject('c:/Work/Alpha/');
|
||||
assert.equal(first.added, true);
|
||||
|
||||
const second = await addProject('C:\\work\\alpha');
|
||||
assert.equal(second.added, false);
|
||||
|
||||
const listed = await listProjects();
|
||||
assert.equal(listed.length, 1);
|
||||
assert.equal(listed[0].path, 'C:/Work/Alpha');
|
||||
|
||||
const file = await fs.readFile(registryFilePath(), 'utf8');
|
||||
const parsed = JSON.parse(file) as { projects: RegistryProject[] };
|
||||
assert.equal(parsed.projects.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test('removeProject removes matching normalized path', async () => {
|
||||
await withTempUserProfile(async () => {
|
||||
await addProject('D:/Repos/One');
|
||||
await addProject('D:/Repos/Two');
|
||||
|
||||
const removed = await removeProject('d:\\repos\\one\\');
|
||||
assert.equal(removed.removed, true);
|
||||
|
||||
const listed = await listProjects();
|
||||
assert.deepEqual(
|
||||
listed.map((project) => project.path),
|
||||
['D:/Repos/Two'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('addProject rejects non-Windows absolute paths', async () => {
|
||||
await withTempUserProfile(async () => {
|
||||
await assert.rejects(() => addProject('/tmp/project'), /Windows absolute path/i);
|
||||
await assert.rejects(() => addProject('relative/path'), /Windows absolute path/i);
|
||||
});
|
||||
});
|
||||
139
tests/lib/scanner.test.ts
Normal file
139
tests/lib/scanner.test.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { addProject } from '../../src/lib/registry';
|
||||
import { scanForProjects, resolveScanRoots } from '../../src/lib/scanner';
|
||||
import { canonicalizeWindowsPath, sameWindowsPath, windowsPathKey } from '../../src/lib/pathing';
|
||||
|
||||
async function withTempUserProfile(run: (userProfile: string) => Promise<void>): Promise<void> {
|
||||
const previous = process.env.USERPROFILE;
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-scan-'));
|
||||
process.env.USERPROFILE = tempDir;
|
||||
|
||||
try {
|
||||
await run(tempDir);
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.USERPROFILE;
|
||||
} else {
|
||||
process.env.USERPROFILE = previous;
|
||||
}
|
||||
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test('resolveScanRoots includes profile and registry roots by default', async () => {
|
||||
await withTempUserProfile(async (userProfile) => {
|
||||
const registryRoot = path.join(userProfile, 'Registered');
|
||||
await fs.mkdir(registryRoot, { recursive: true });
|
||||
await addProject(registryRoot);
|
||||
|
||||
const roots = await resolveScanRoots();
|
||||
|
||||
assert.equal(roots.some((root) => sameWindowsPath(root, userProfile)), true);
|
||||
assert.equal(roots.some((root) => sameWindowsPath(root, registryRoot)), true);
|
||||
assert.equal(roots.some((root) => windowsPathKey(root) === windowsPathKey('C:\\')), false);
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveScanRoots includes full-drive roots only when requested', async () => {
|
||||
await withTempUserProfile(async () => {
|
||||
const roots = await resolveScanRoots({ mode: 'full-drive' });
|
||||
assert.equal(roots.some((root) => windowsPathKey(root) === windowsPathKey('C:\\')), true);
|
||||
});
|
||||
});
|
||||
|
||||
test('scanForProjects respects depth limits and ignore list', async () => {
|
||||
await withTempUserProfile(async (userProfile) => {
|
||||
const projectRoot = path.join(userProfile, 'ProjectA');
|
||||
await fs.mkdir(path.join(projectRoot, '.beads'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(projectRoot, '.beads', 'issues.jsonl'),
|
||||
JSON.stringify({ id: 'bb-a', title: 'A', issue_type: 'task', status: 'open', priority: 1 }),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const ignoredRoot = path.join(userProfile, 'node_modules', 'Ignored');
|
||||
await fs.mkdir(path.join(ignoredRoot, '.beads'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(ignoredRoot, '.beads', 'issues.jsonl'),
|
||||
JSON.stringify({ id: 'bb-ignored', title: 'Ignored', issue_type: 'task', status: 'open', priority: 1 }),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const deepRoot = path.join(userProfile, 'Deep', 'Level1', 'Level2', 'ProjectDeep');
|
||||
await fs.mkdir(path.join(deepRoot, '.beads'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(deepRoot, '.beads', 'issues.jsonl'),
|
||||
JSON.stringify({ id: 'bb-deep', title: 'Deep', issue_type: 'task', status: 'open', priority: 1 }),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const result = await scanForProjects({ maxDepth: 1 });
|
||||
const keys = result.projects.map((project) => project.key);
|
||||
|
||||
assert.equal(keys.includes(windowsPathKey(canonicalizeWindowsPath(projectRoot))), true);
|
||||
assert.equal(keys.includes(windowsPathKey(canonicalizeWindowsPath(ignoredRoot))), false);
|
||||
assert.equal(keys.includes(windowsPathKey(canonicalizeWindowsPath(deepRoot))), false);
|
||||
});
|
||||
});
|
||||
|
||||
test('scanForProjects ignores directories that have .beads but no issues JSONL files', async () => {
|
||||
await withTempUserProfile(async (userProfile) => {
|
||||
const falsePositiveRoot = path.join(userProfile, 'LooksLikeBeadsProject');
|
||||
await fs.mkdir(path.join(falsePositiveRoot, '.beads'), { recursive: true });
|
||||
|
||||
const validRoot = path.join(userProfile, 'ValidProject');
|
||||
await fs.mkdir(path.join(validRoot, '.beads'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(validRoot, '.beads', 'issues.jsonl'),
|
||||
JSON.stringify({ id: 'bb-1', title: 'valid', issue_type: 'task', status: 'open', priority: 1 }),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const result = await scanForProjects();
|
||||
const keys = result.projects.map((project) => project.key);
|
||||
|
||||
assert.equal(keys.includes(windowsPathKey(canonicalizeWindowsPath(falsePositiveRoot))), false);
|
||||
assert.equal(keys.includes(windowsPathKey(canonicalizeWindowsPath(validRoot))), true);
|
||||
});
|
||||
});
|
||||
|
||||
test('scanForProjects ignores tool/cache paths even if they contain issues JSONL', async () => {
|
||||
await withTempUserProfile(async (userProfile) => {
|
||||
const tempBeads = path.join(userProfile, 'AppData', 'Local', 'Temp', 'beadboard-read-X');
|
||||
await fs.mkdir(path.join(tempBeads, '.beads'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(tempBeads, '.beads', 'issues.jsonl'),
|
||||
JSON.stringify({ id: 'bb-temp', title: 'temp', issue_type: 'task', status: 'open', priority: 1 }),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const skillsBeads = path.join(userProfile, '.agents', 'skills', 'create-beads-orchestration');
|
||||
await fs.mkdir(path.join(skillsBeads, '.beads'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(skillsBeads, '.beads', 'issues.jsonl'),
|
||||
JSON.stringify({ id: 'bb-skill', title: 'skill', issue_type: 'task', status: 'open', priority: 1 }),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const realProject = path.join(userProfile, 'RealProject');
|
||||
await fs.mkdir(path.join(realProject, '.beads'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(realProject, '.beads', 'issues.jsonl'),
|
||||
JSON.stringify({ id: 'bb-real', title: 'real', issue_type: 'task', status: 'open', priority: 1 }),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const result = await scanForProjects();
|
||||
const keys = result.projects.map((project) => project.key);
|
||||
|
||||
assert.equal(keys.includes(windowsPathKey(canonicalizeWindowsPath(tempBeads))), false);
|
||||
assert.equal(keys.includes(windowsPathKey(canonicalizeWindowsPath(skillsBeads))), false);
|
||||
assert.equal(keys.includes(windowsPathKey(canonicalizeWindowsPath(realProject))), true);
|
||||
});
|
||||
});
|
||||
45
tests/lib/watcher.test.ts
Normal file
45
tests/lib/watcher.test.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { IssuesEventBus } from '../../src/lib/realtime';
|
||||
import { IssuesWatchManager } from '../../src/lib/watcher';
|
||||
|
||||
test('IssuesWatchManager startWatch is idempotent per project', async () => {
|
||||
const bus = new IssuesEventBus();
|
||||
const manager = new IssuesWatchManager({ eventBus: bus, debounceMs: 20 });
|
||||
|
||||
manager.startWatch('C:/Repo/One');
|
||||
manager.startWatch('c:\\repo\\one');
|
||||
|
||||
assert.equal(manager.getWatchedProjectCount(), 1);
|
||||
await manager.stopAll();
|
||||
});
|
||||
|
||||
test('IssuesWatchManager emits event after file change in watched .beads path', async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-watch-'));
|
||||
const beadsDir = path.join(root, '.beads');
|
||||
const issuesPath = path.join(beadsDir, 'issues.jsonl');
|
||||
await fs.mkdir(beadsDir, { recursive: true });
|
||||
await fs.writeFile(issuesPath, '', 'utf8');
|
||||
|
||||
const bus = new IssuesEventBus();
|
||||
const manager = new IssuesWatchManager({ eventBus: bus, debounceMs: 40 });
|
||||
|
||||
const events: string[] = [];
|
||||
const stop = bus.subscribe((event) => {
|
||||
events.push(event.projectRoot);
|
||||
});
|
||||
|
||||
manager.startWatch(root);
|
||||
|
||||
await fs.writeFile(issuesPath, `${JSON.stringify({ id: 'bb-1', title: 'watch' })}\n`, 'utf8');
|
||||
await new Promise((resolve) => setTimeout(resolve, 220));
|
||||
|
||||
stop();
|
||||
await manager.stopAll();
|
||||
|
||||
assert.equal(events.length >= 1, true);
|
||||
});
|
||||
55
tests/lib/writeback.test.ts
Normal file
55
tests/lib/writeback.test.ts
Normal 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');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue