Complete implementation of 7 slices addressing E2E testing gaps: Slice 0+1: Wire Actions + ReasonPrompt - FocusView now uses useActions hook instead of direct act() calls - Added pendingAction state pattern for skip/defer/complete actions - ReasonPrompt integration with proper confirm/cancel flow - Tags support in DecisionEntry interface Slice 2: Drag Reorder UI - Installed @dnd-kit (core, sortable, utilities) - QueueView with DndContext, SortableContext, verticalListSortingStrategy - SortableQueueItem wrapper component using useSortable hook - pendingReorder state with ReasonPrompt for reorder reasons - Cmd+Up/Down keyboard shortcuts for accessibility - Fixed: Store item ID in PendingReorder to avoid stale queue reference Slice 3: System Tray Integration - tray.rs with TrayState, setup_tray, toggle_window_visibility - Menu with Show/Quit items - Left-click toggles window visibility - update_tray_badge command updates tooltip with item count - Frontend wiring in AppShell Slice 4: E2E Test Updates - Fixed test selectors for InboxView, Queue badge - Exposed inbox store for test seeding Slice 5: Staleness Visualization - Already implemented in computeStaleness() with tests Slice 6: Quick Wiring - onStartBatch callback wired to QueueView - SyncStatus rendered in nav area - SettingsView renders Settings component Slice 7: State Persistence - settings-store with hydrate/update methods - Tauri backend integration via read_settings/write_settings - AppShell hydrates settings on mount Bug fixes from code review: - close_bead now has error isolation (try/catch) so decision logging and queue advancement continue even if bead close fails - PendingReorder stores item ID to avoid stale queue reference E2E tests for all ACs (tests/e2e/followup-acs.spec.ts): - AC-F1: Drag reorder (4 tests) - AC-F2: ReasonPrompt integration (7 tests) - AC-F5: Staleness visualization (3 tests) - AC-F6: Batch mode (2 tests) - AC-F7: SyncStatus (2 tests) - ReasonPrompt behavior (3 tests) Tests: 388 frontend + 119 Rust + 32 E2E all passing Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1661 lines
52 KiB
Markdown
1661 lines
52 KiB
Markdown
# Mission Control — Followup Implementation Plan
|
|
|
|
> **Version:** 2.0
|
|
> **Created:** 2026-02-26
|
|
> **Status:** Planning Complete, Ready for Implementation
|
|
> **Parent:** PLAN.md (original implementation plan)
|
|
|
|
---
|
|
|
|
## Executive Summary
|
|
|
|
This plan addresses nine gaps discovered during E2E testing and codebase audit against PLAN.md Acceptance Criteria. Several components were built but never wired up.
|
|
|
|
**Critical insight from cross-model review:** Before wiring individual features, implement a canonical action pipeline (Slice 0) that de-risks persistence, idempotency, and state coordination across all subsequent slices.
|
|
|
|
| Gap | Priority | Effort | Dependencies |
|
|
|-----|----------|--------|--------------|
|
|
| **Action Wiring + ReasonPrompt** | P0 | 0.5 day | None |
|
|
| Drag Reorder UI | P1 | 1 day | Slice 0+1 |
|
|
| System Tray Integration | P2 | 1 day | None |
|
|
| E2E Test Updates | P3 | 0.5 day | Slice 0+1 |
|
|
| Staleness Visualization | P1 | 0.5 day | None |
|
|
| Batch Mode Wiring | P1 | 15 min | None |
|
|
| SyncStatus Wiring | P2 | 30 min | None |
|
|
| Settings Wiring | P2 | 1 hour | None |
|
|
|
|
**Total estimated effort:** 4.5 days
|
|
|
|
> **Note (from codebase audit):** Original plan proposed 5.5 days with new infrastructure. After discovering existing `useActions` hook and Tauri persistence already work correctly, effort reduced by 1 day. The fix is wiring, not architecture.
|
|
|
|
---
|
|
|
|
## Table of Contents
|
|
|
|
1. [Gap Analysis](#gap-analysis)
|
|
2. [User Workflows](#user-workflows)
|
|
3. [Acceptance Criteria](#acceptance-criteria)
|
|
4. [Architecture](#architecture)
|
|
5. [Implementation Specs](#implementation-specs)
|
|
6. [Rollout Slices](#rollout-slices)
|
|
7. [Testing Strategy](#testing-strategy)
|
|
|
|
---
|
|
|
|
## Gap Analysis
|
|
|
|
### Gap 0: Action Wiring Gap (REVISED after codebase audit)
|
|
|
|
**Actual problem (discovered via codebase audit):**
|
|
- `useActions` hook EXISTS with full backend integration (`log_decision`, `update_item`, `close_bead`)
|
|
- BUT `FocusView` calls `act()` directly on the store, bypassing `useActions` entirely
|
|
- The store's `act()` only advances the queue locally — NO backend calls
|
|
- Result: decisions not logged, beads not closed, items not updated
|
|
|
|
**What works already:**
|
|
- Persistence is UNIFIED — Zustand uses `tauri-storage.ts` which calls `readState/writeState`
|
|
- Individual Tauri commands work: `log_decision`, `update_item`, `close_bead`
|
|
- `useActions` hook integrates these commands correctly
|
|
|
|
**Gap:** FocusView needs to use `useActions` hook + ReasonPrompt, not call `act()` directly.
|
|
|
|
---
|
|
|
|
### Gap 1: Drag Reorder UI (AC-004)
|
|
|
|
**Original AC-004:**
|
|
> Given user is in Queue View, when user drags an item to new position, then order is persisted AND decision is logged with context AND user is prompted for optional reason.
|
|
|
|
**Current State:**
|
|
- `reorderQueue(fromIndex, toIndex)` implemented in `focus-store.ts`
|
|
- Store tests pass (4 tests covering reorder logic)
|
|
- QueueView displays items but lacks drag-and-drop interaction
|
|
- No DnD library installed
|
|
|
|
**Gap:** Frontend drag-and-drop UI not implemented.
|
|
|
|
---
|
|
|
|
### Gap 2: ReasonPrompt Integration
|
|
|
|
**Original AC-005:**
|
|
> Given user performs any significant action (set_focus, reorder, defer, skip, complete), when action is executed, then decision_log.jsonl is appended with: timestamp, action, bead_id, reason (if provided), tags, full context snapshot.
|
|
|
|
**Current State:**
|
|
- `ReasonPrompt` component fully implemented with quick tags
|
|
- `log_decision` Tauri command accepts reason and tags
|
|
- `DecisionLog::append` writes to `~/.local/share/mc/decision_log.jsonl`
|
|
- Actions call `log_decision` but **without prompting user for reason**
|
|
|
|
**Gap:** ReasonPrompt not wired into action flows.
|
|
|
|
---
|
|
|
|
### Gap 3: System Tray Integration (AC-007)
|
|
|
|
**Original AC-007:**
|
|
> Given MC is running, when there are pending items, then menu bar icon shows badge with count AND clicking icon opens popover AND popover shows THE ONE THING and queue summary.
|
|
|
|
**Current State:**
|
|
- `TrayPopover` component fully implemented
|
|
- Shows THE ONE THING, queue/inbox counts, quick actions
|
|
- Navigation bar badges work within the app
|
|
- **No system tray integration** — app only shows in dock/taskbar
|
|
|
|
**Gap:** Tauri system tray plugin not configured.
|
|
|
|
---
|
|
|
|
### Gap 4: E2E Test Updates
|
|
|
|
**Current State:**
|
|
- 2 of 11 Playwright tests failing
|
|
- "shows Inbox placeholder" expects old text — InboxView is now fully implemented
|
|
- "Queue tab shows item count badge" selector too broad — matches multiple "3" elements
|
|
|
|
**Gap:** Tests outdated, not code bugs.
|
|
|
|
---
|
|
|
|
### Gap 5: Staleness Visualization (AC-010)
|
|
|
|
**Original AC-010:**
|
|
> Given an item has been in queue for N days, when displayed, then visual staleness indicator shows: fresh (<1d), normal (1-2d), amber (3-6d), red pulsing (7+d).
|
|
|
|
**Current State:**
|
|
- Items display title, type, project, age text
|
|
- No color-coded staleness indicators
|
|
- Age shown as text only ("2 days ago")
|
|
|
|
**Gap:** No visual staleness colors in FocusCard, QueueItem, or SuggestionCard.
|
|
|
|
---
|
|
|
|
### Gap 6: Batch Mode Activation (AC-008)
|
|
|
|
**Original AC-008:**
|
|
> Given user is in Queue View, when user clicks "Batch [type]", then batch mode activates for that type.
|
|
|
|
**Current State:**
|
|
- `BatchMode.tsx` fully implemented with progress, keyboard shortcuts, celebration
|
|
- `QueueView.tsx` has "Batch" buttons in section headers
|
|
- `batch-store.ts` has `startBatch()` ready
|
|
|
|
**Gap:** AppShell doesn't pass `onStartBatch` callback to QueueView.
|
|
|
|
---
|
|
|
|
### Gap 7: Sync Status Visibility (AC-009)
|
|
|
|
**Original AC-009:**
|
|
> Given MC is running, when sync occurs, then status bar shows: syncing indicator, last sync time, error state with retry.
|
|
|
|
**Current State:**
|
|
- `SyncStatus.tsx` fully implemented (health, last sync, retry button)
|
|
- Component never rendered in any view
|
|
|
|
**Gap:** SyncStatus not displayed anywhere.
|
|
|
|
---
|
|
|
|
### Gap 8: Settings Persistence
|
|
|
|
**Current State:**
|
|
- `Settings.tsx` has full UI (toggles, inputs, sections)
|
|
- `SettingsView.tsx` is a placeholder div
|
|
- No settings store or persistence layer
|
|
|
|
**Gap:** Settings component built but never instantiated; no persistence.
|
|
|
|
---
|
|
|
|
### Gap 9: State & Data Integration
|
|
|
|
**Current State:**
|
|
- `closeBead()` Tauri command exists but never called
|
|
- `getTriage()` / `getNextPick()` commands exist but never called
|
|
- `readState()` / `writeState()` commands exist but never called
|
|
- Focus/queue state lost on app restart
|
|
|
|
**Gap:** Built Tauri commands not wired to frontend actions.
|
|
|
|
---
|
|
|
|
## User Workflows
|
|
|
|
### Workflow 1: Reorder Queue via Drag
|
|
|
|
```
|
|
User is in Queue View
|
|
│
|
|
├── User sees items grouped by type (Reviews, Issues, etc.)
|
|
│
|
|
├── User clicks and holds on an item
|
|
│ └── Item lifts with visual feedback (shadow, scale)
|
|
│
|
|
├── User drags item to new position
|
|
│ └── Drop indicators show valid positions
|
|
│ └── Other items animate to make space
|
|
│
|
|
├── User releases item at new position
|
|
│ └── ReasonPrompt appears (optional)
|
|
│ ├── User can type free-form reason
|
|
│ ├── User can click quick tags (Blocking, Urgent, etc.)
|
|
│ ├── User can "Skip reason" to proceed without
|
|
│ └── User clicks "Confirm" to complete
|
|
│
|
|
└── System persists new order and logs decision
|
|
└── Queue re-renders with new order
|
|
```
|
|
|
|
### Workflow 2: System Tray Quick Action
|
|
|
|
```
|
|
User has MC running (minimized or in background)
|
|
│
|
|
├── User clicks MC icon in menu bar (macOS) / system tray (Windows/Linux)
|
|
│ └── Popover appears near tray icon
|
|
│
|
|
├── Popover shows:
|
|
│ ├── THE ONE THING (title, type, project, age)
|
|
│ ├── Action buttons: Start, Defer, Skip
|
|
│ └── Queue/Inbox counts + "Full window" link
|
|
│
|
|
├── User clicks "Start"
|
|
│ └── Opens GitLab URL in browser
|
|
│ └── Popover dismisses
|
|
│
|
|
├── User clicks "Defer"
|
|
│ └── Item snoozed for 1 hour
|
|
│ └── Popover updates to show next item
|
|
│
|
|
└── User clicks "Full window"
|
|
└── Main MC window opens/focuses
|
|
└── Popover dismisses
|
|
```
|
|
|
|
### Workflow 3: Decision Capture on Action
|
|
|
|
```
|
|
User performs significant action (skip, defer, set_focus, complete)
|
|
│
|
|
├── ReasonPrompt modal appears
|
|
│ ├── Header: "Skipping: [Item Title]"
|
|
│ ├── Text area: "Why? (optional, helps learn your patterns)"
|
|
│ ├── Quick tags: [Blocking] [Urgent] [Context switch] [Energy] [Flow]
|
|
│ └── Buttons: [Confirm] [Skip reason]
|
|
│
|
|
├── User interaction:
|
|
│ ├── Types reason: "Waiting for Sarah's feedback"
|
|
│ ├── Clicks "Urgent" tag
|
|
│ └── Clicks "Confirm"
|
|
│
|
|
└── System logs decision with full context:
|
|
├── timestamp: "2026-02-26T16:30:00Z"
|
|
├── action: "skip"
|
|
├── bead_id: "mr_review:platform/core:847"
|
|
├── reason: "Waiting for Sarah's feedback"
|
|
├── tags: ["urgent"]
|
|
└── context: {time_of_day: "afternoon", queue_size: 5, ...}
|
|
```
|
|
|
|
### Workflow 4: Batch Mode from Queue
|
|
|
|
```
|
|
User is in Queue View with multiple items of same type
|
|
│
|
|
├── User sees section header: "Reviews (4)" with [Batch] button
|
|
│
|
|
├── User clicks [Batch] button
|
|
│ └── Batch Mode activates
|
|
│ └── First item displayed prominently
|
|
│ └── Progress bar shows "1 of 4"
|
|
│
|
|
├── User processes each item:
|
|
│ ├── [Done] - marks complete, advances to next
|
|
│ ├── [Skip] - skips item, advances to next
|
|
│ └── [Open] - opens in browser, stays on item
|
|
│
|
|
├── After last item:
|
|
│ └── Celebration screen shows completion stats
|
|
│ └── "Back to Queue" button
|
|
│
|
|
└── User presses ESC at any time:
|
|
└── Batch Mode exits
|
|
└── Returns to Queue View
|
|
```
|
|
|
|
### Workflow 5: Settings Configuration
|
|
|
|
```
|
|
User clicks Settings (gear icon or Cmd+,)
|
|
│
|
|
├── Settings view opens with sections:
|
|
│ ├── Sync
|
|
│ │ └── Interval dropdown: [5 min] [15 min] [30 min]
|
|
│ │
|
|
│ ├── Notifications
|
|
│ │ └── Toggle: Enable desktop notifications
|
|
│ │
|
|
│ └── Shortcuts
|
|
│ ├── Quick Capture: [Cmd+Shift+C] [Change...]
|
|
│ ├── Navigation: Cmd+1/2/3/4 (read-only)
|
|
│ └── Settings: Cmd+, (read-only)
|
|
│
|
|
├── User changes sync interval to 5 min
|
|
│ └── Change applies immediately
|
|
│ └── No save button needed
|
|
│
|
|
└── User navigates away
|
|
└── Settings persist automatically
|
|
```
|
|
|
|
---
|
|
|
|
## Acceptance Criteria
|
|
|
|
### AC-F1: Drag Reorder Interaction
|
|
|
|
**AC-F1.1:** Given user is in Queue View with 2+ items in a section, when user clicks and holds an item for 150ms, then the item visually lifts (shadow, slight scale increase) to indicate drag mode.
|
|
|
|
**AC-F1.2:** Given user is dragging an item, when user moves over valid drop positions, then a visual indicator (line or gap) shows where the item will be inserted.
|
|
|
|
**AC-F1.3:** Given user is dragging an item, when user moves outside the droppable area, then the item snaps back to original position on release.
|
|
|
|
**AC-F1.4:** Given user drops an item at a new position, when the drop completes, then the queue order is updated AND the queue re-renders with new order.
|
|
|
|
**AC-F1.5:** Given user completes a reorder, when the order changes, then ReasonPrompt appears.
|
|
|
|
**AC-F1.6:** Given user submits ReasonPrompt after reorder, when confirm is clicked, then the reorder decision is logged with action "reorder", reason (if provided), and selected tags.
|
|
|
|
**AC-F1.7:** Given user focuses an item with keyboard, when user presses Cmd+Up or Cmd+Down, then item moves up/down in the queue (accessibility fallback for drag).
|
|
|
|
---
|
|
|
|
### AC-F2: ReasonPrompt Integration
|
|
|
|
**AC-F2.1:** Given user clicks "Skip" on FocusCard, when action is triggered, then ReasonPrompt appears with header "Skipping: [item title]".
|
|
|
|
**AC-F2.2:** Given user clicks any defer option (1h/Tomorrow) on FocusCard, when action is triggered, then ReasonPrompt appears with header "Deferring: [item title]".
|
|
|
|
**AC-F2.3:** Given user clicks "Start" on FocusCard, when action is triggered, then ReasonPrompt appears with header "Starting: [item title]".
|
|
|
|
**AC-F2.4:** Given user completes an item, when completion is triggered, then ReasonPrompt appears with header "Completing: [item title]".
|
|
|
|
**AC-F2.5:** Given ReasonPrompt is open, when user clicks "Skip reason", then action proceeds without reason AND decision is logged with reason=null.
|
|
|
|
**AC-F2.6:** Given ReasonPrompt is open, when user clicks quick tag, then tag toggles selected state (visual change) AND is included in decision log.
|
|
|
|
**AC-F2.7:** Given ReasonPrompt is open, when user presses Escape, then prompt closes AND action is cancelled (item not skipped/deferred).
|
|
|
|
**AC-F2.8:** Given ReasonPrompt is open with text and tags, when user clicks "Confirm", then decision is logged with provided reason and selected tags AND action proceeds.
|
|
|
|
---
|
|
|
|
### AC-F3: System Tray
|
|
|
|
**AC-F3.1:** Given MC is running, when app starts, then a system tray icon appears in the menu bar (macOS) or system tray (Windows/Linux).
|
|
|
|
**AC-F3.2:** Given MC has items in focus or queue, when the total item count changes, then the tray icon badge updates to show the new total.
|
|
|
|
**AC-F3.3:** Given user clicks the tray icon on macOS, when there is a focused item, then TrayPopover appears anchored to the menu bar showing item title, type, project, and age.
|
|
|
|
**AC-F3.4:** Given user clicks the tray icon on macOS, when there is no focused item, then TrayPopover appears showing "All clear" empty state.
|
|
|
|
**AC-F3.5:** Given user clicks the tray icon on Windows/Linux, when clicked, then the main MC window toggles visibility (show if hidden, hide if visible).
|
|
|
|
**AC-F3.6:** Given TrayPopover is open, when user clicks "Start", then the item's GitLab URL opens in default browser AND popover dismisses.
|
|
|
|
**AC-F3.7:** Given TrayPopover is open, when user clicks "Defer", then item is snoozed for 1 hour AND popover updates to show next item.
|
|
|
|
**AC-F3.8:** Given TrayPopover is open and user defers the last item, when defer completes, then popover shows "All clear" empty state.
|
|
|
|
**AC-F3.9:** Given TrayPopover is open, when user clicks "Full window", then main MC window opens/focuses AND popover dismisses.
|
|
|
|
**AC-F3.10:** Given TrayPopover is open, when user clicks outside the popover, then popover dismisses.
|
|
|
|
**AC-F3.11:** Given tray API fails on startup, when error occurs, then app continues without tray icon AND logs warning (no user-visible error unless main window fails).
|
|
|
|
---
|
|
|
|
### AC-F5: Staleness Visualization
|
|
|
|
**AC-F5.1:** Given an item is less than 1 day old, when displayed, then a green freshness indicator is visible.
|
|
|
|
**AC-F5.2:** Given an item is 1-2 days old, when displayed, then no staleness indicator is shown (default styling).
|
|
|
|
**AC-F5.3:** Given an item is 3-6 days old, when displayed, then an amber staleness indicator is visible.
|
|
|
|
**AC-F5.4:** Given an item is 7+ days old, when displayed, then a red pulsing staleness indicator is visible.
|
|
|
|
---
|
|
|
|
### AC-F6: Batch Mode Activation
|
|
|
|
**AC-F6.1:** Given user is in Queue View, when user clicks "Batch" on a section header, then Batch Mode activates showing that section's items.
|
|
|
|
---
|
|
|
|
### AC-F7: Sync Status Visibility
|
|
|
|
**AC-F7.1:** Given MC is running, when user views the app, then sync status (last sync time, health) is visible.
|
|
|
|
**AC-F7.2:** Given sync status shows stale or error, when user clicks retry, then a sync is triggered.
|
|
|
|
---
|
|
|
|
### AC-F8: Settings Persistence
|
|
|
|
**AC-F8.1:** Given user opens Settings view, when user changes sync interval (5/15/30 min), then the new interval takes effect immediately.
|
|
|
|
**AC-F8.2:** Given user opens Settings view, when user toggles notifications, then notification behavior changes immediately.
|
|
|
|
**AC-F8.3:** Given user opens Settings view, when user changes a keyboard shortcut, then the new shortcut is active immediately.
|
|
|
|
**AC-F8.4:** Given user changed settings previously, when app restarts, then all settings are preserved.
|
|
|
|
---
|
|
|
|
### AC-F9: State & Data Integration
|
|
|
|
**AC-F9.1:** Given user marks an item complete, when confirmed, then the corresponding bead is closed.
|
|
|
|
**AC-F9.2:** Given user views Focus with no current item, when the view loads, then the top triage pick is shown with its recommendation reason (e.g., "Highest priority", "Blocking others").
|
|
|
|
**AC-F9.3:** Given user sets focus or reorders queue, when app restarts, then focus and queue state are preserved.
|
|
|
|
---
|
|
|
|
## Architecture
|
|
|
|
### A1: Drag-and-Drop Library Selection
|
|
|
|
**Decision:** Use `@dnd-kit/core` + `@dnd-kit/sortable`
|
|
|
|
**Rationale:**
|
|
| Factor | @dnd-kit | react-beautiful-dnd | react-dnd |
|
|
|--------|----------|---------------------|-----------|
|
|
| React 19 support | Yes | No (deprecated) | Partial |
|
|
| Bundle size | 15KB | 32KB | 45KB |
|
|
| TypeScript | Native | @types needed | Native |
|
|
| Touch support | Built-in | Limited | Plugin |
|
|
| Accessibility | WCAG 2.1 | WCAG 2.0 | Manual |
|
|
| Active maintenance | Yes | No (Atlassian EOL) | Yes |
|
|
|
|
**Fulfills:** AC-F1.1, AC-F1.2, AC-F1.3, AC-F1.4
|
|
|
|
---
|
|
|
|
### A2: ReasonPrompt State Management
|
|
|
|
**Decision:** Use local React state + callback pattern (no new store)
|
|
|
|
**Rationale:**
|
|
- ReasonPrompt is ephemeral UI (not persisted)
|
|
- Parent component controls visibility
|
|
- Callback returns `{ reason: string | null, tags: string[] }`
|
|
- Avoids store complexity for modal state
|
|
|
|
**Pattern:**
|
|
```
|
|
FocusView
|
|
└── state: pendingAction: { type: 'skip' | 'defer' | null, item: FocusItem }
|
|
└── FocusCard (triggers pendingAction)
|
|
└── ReasonPrompt (shown when pendingAction !== null)
|
|
└── onSubmit → calls action handler → clears pendingAction
|
|
└── onCancel → clears pendingAction (no action taken)
|
|
```
|
|
|
|
**Fulfills:** AC-F2.1 through AC-F2.6
|
|
|
|
---
|
|
|
|
### A3: System Tray Architecture
|
|
|
|
**Decision:** Use Tauri's native system tray API with separate webview for popover
|
|
|
|
**Components:**
|
|
1. **Rust tray setup** (`src-tauri/src/tray.rs`)
|
|
- Creates tray icon with badge
|
|
- Handles click events
|
|
- Emits events to frontend
|
|
|
|
2. **Popover window** (separate Tauri window)
|
|
- Small, frameless, always-on-top
|
|
- Positioned near tray icon
|
|
- Receives focus data via Tauri events
|
|
|
|
3. **Badge updates** (frontend → Rust)
|
|
- Frontend calls `update_tray_badge(count)` command
|
|
- Rust updates native badge
|
|
|
|
**Fulfills:** AC-F3.1 through AC-F3.7
|
|
|
|
---
|
|
|
|
### A4: Decision Flow Interruption (REVISED after codebase audit)
|
|
|
|
**Decision:** Actions are two-phase: trigger → prompt → execute
|
|
|
|
**Current flow (BROKEN — discovered in audit):**
|
|
```
|
|
User clicks "Skip" → FocusView.handleSkip() → act('skip') → queue advances
|
|
↳ NO backend calls! useActions hook is never invoked
|
|
```
|
|
|
|
**Correct flow (using existing `useActions` hook):**
|
|
```
|
|
User clicks "Skip" → setPendingAction({ type: 'skip', item }) → ReasonPrompt shown
|
|
User confirms → useActions().skip(item, reason) → update_item + log_decision + act()
|
|
User cancels → clearPendingAction → nothing happens
|
|
```
|
|
|
|
**Key insight:** Infrastructure already exists — it's just not wired up:
|
|
- `useActions` hook calls `update_item`, `close_bead`, `log_decision` commands
|
|
- These commands work correctly in the backend
|
|
- FocusView simply needs to USE the hook instead of bypassing it
|
|
|
|
**What needs to change:**
|
|
1. FocusView: Replace direct `act()` calls with `useActions()` hook methods
|
|
2. FocusView: Add `pendingAction` state + ReasonPrompt modal
|
|
3. `useActions`: Extend `DecisionEntry` to include `tags` array
|
|
4. Backend `log_decision`: Already accepts tags (verified in commands/mod.rs)
|
|
|
|
**No new Tauri commands needed.** No atomic transaction wrapper needed.
|
|
The existing individual commands are sufficient for this use case.
|
|
|
|
**Fulfills:** AC-F2.5, AC-F2.6
|
|
|
|
---
|
|
|
|
### A5: Persistence Strategy (ALREADY IMPLEMENTED — NO CHANGES NEEDED)
|
|
|
|
**Status:** This is already correctly implemented in the codebase.
|
|
|
|
**Current implementation:**
|
|
- `src/lib/tauri-storage.ts` provides a Zustand `StateStorage` adapter
|
|
- All stores use `createJSONStorage(() => getStorage())` which calls Tauri's `readState/writeState`
|
|
- State persists to `~/.local/share/mc/state.json` via the Tauri backend
|
|
- Falls back to localStorage only in browser dev context (non-Tauri)
|
|
|
|
**No "dual persistence" problem exists.** The prior diagnosis was incorrect.
|
|
|
|
**What's already working:**
|
|
- `focus-store.ts`: Persists `current` and `queue` via Tauri
|
|
- `nav-store.ts`: Persists `activeView` via Tauri
|
|
- `inbox-store.ts`: Persists `items` via Tauri
|
|
|
|
**No changes needed to persistence architecture.**
|
|
|
|
**Pattern:**
|
|
```
|
|
App startup:
|
|
1. invoke("read_state") → get persisted state
|
|
2. zustand.setState(persisted) → hydrate cache
|
|
|
|
On action:
|
|
1. pendingAction set → ReasonPrompt shown
|
|
2. User confirms → invoke("execute_action", {...})
|
|
3. Backend: validate, dedupe, execute, persist, refresh triage
|
|
4. Frontend: update zustand cache from response
|
|
5. Clear pendingAction
|
|
|
|
On sync/triage refresh:
|
|
1. invoke("get_triage") → latest recommendations
|
|
2. Merge into zustand (don't overwrite user's queue order)
|
|
```
|
|
|
|
**Conflict resolution:**
|
|
- User's explicit queue order takes priority over triage suggestions
|
|
- Triage provides recommendations, user decides final order
|
|
- Backend logs conflicts for pattern learning
|
|
|
|
**Fulfills:** AC-F9.3
|
|
|
|
---
|
|
|
|
### A6: System Tray Fallback (NEW)
|
|
|
|
**Decision:** Graceful degradation when tray APIs fail.
|
|
|
|
**Behavior:**
|
|
- If tray icon creation fails, log warning and continue (app works without tray)
|
|
- If badge update fails, silently ignore (tooltip still works)
|
|
- If popover window fails on macOS, fall back to window toggle behavior
|
|
- Show user-visible error only if core functionality (main window) is affected
|
|
|
|
**Fulfills:** AC-F3.1 (graceful handling of platform differences)
|
|
|
|
---
|
|
|
|
### A7-A9: REMOVED (based on codebase audit)
|
|
|
|
**Why removed:** A7 (pending action persistence), A8 (idempotency log lifecycle), and A9 (multi-surface coordination) were proposed solutions to problems that don't exist in the current architecture.
|
|
|
|
**Reality check:**
|
|
- Individual Tauri commands (`log_decision`, `update_item`, `close_bead`) are naturally idempotent — calling them twice with the same data produces the same result
|
|
- Zustand's `act()` is synchronous and atomic within the JS thread
|
|
- The tray popover doesn't exist as a separate window — `TrayPopover.tsx` is just a component, not a separate Tauri webview
|
|
- Decision logging is append-only to `decision_log.jsonl` — no compaction needed for a personal productivity app
|
|
|
|
**What we DO need (simpler):**
|
|
- TrayPopover needs actual system tray integration (Gap 3, separate concern)
|
|
- Actions from any surface all go through `useActions` hook → same commands
|
|
- No new complexity required
|
|
|
|
---
|
|
|
|
## Implementation Specs
|
|
|
|
### IMP-0: Wire FocusView to useActions + ReasonPrompt (REVISED)
|
|
|
|
**Fulfills:** AC-F2.5, AC-F2.7, AC-F9.1, AC-F9.3
|
|
|
|
**The actual gap:** FocusView calls `act()` directly, bypassing `useActions` hook which has all the backend integration. This is a wiring fix, not new infrastructure.
|
|
|
|
**Files to modify (no new files):**
|
|
- `src/components/FocusView.tsx` — wire to `useActions`, add ReasonPrompt
|
|
- `src/hooks/useActions.ts` — add tags support to DecisionEntry
|
|
|
|
**Step 1: Update useActions to support tags**
|
|
|
|
```typescript
|
|
// In src/hooks/useActions.ts - extend DecisionEntry
|
|
interface DecisionEntry {
|
|
action: string;
|
|
bead_id: string;
|
|
reason?: string | null;
|
|
tags?: string[]; // ADD THIS
|
|
}
|
|
|
|
// Update each action method signature to accept tags:
|
|
skip: (item: ActionItem, reason: string | null, tags?: string[]) => Promise<void>;
|
|
defer: (item: ActionItem, duration: DeferDuration, reason: string | null, tags?: string[]) => Promise<void>;
|
|
complete: (item: ActionItem, reason: string | null, tags?: string[]) => Promise<void>;
|
|
|
|
// Update logDecision calls to include tags:
|
|
await logDecision({
|
|
action: "skip",
|
|
bead_id: item.id,
|
|
reason,
|
|
tags: tags ?? [],
|
|
});
|
|
```
|
|
|
|
**Step 2: Wire FocusView to useActions + ReasonPrompt**
|
|
|
|
```typescript
|
|
// src/components/FocusView.tsx
|
|
import { useState } from "react";
|
|
import { useActions, type ActionItem } from "@/hooks/useActions";
|
|
import { ReasonPrompt } from "./ReasonPrompt";
|
|
import type { FocusAction, DeferDuration } from "@/lib/types";
|
|
|
|
interface PendingAction {
|
|
type: FocusAction;
|
|
item: ActionItem;
|
|
duration?: DeferDuration; // for defer actions
|
|
}
|
|
|
|
export function FocusView(): React.ReactElement {
|
|
const { current, queue, act, setFocus } = useFocusStore((s) => s);
|
|
const actions = useActions();
|
|
const [pendingAction, setPendingAction] = useState<PendingAction | null>(null);
|
|
|
|
// Action handlers now set pending state instead of executing immediately
|
|
const handleStart = useCallback(() => {
|
|
if (current) {
|
|
setPendingAction({ type: "start", item: current });
|
|
}
|
|
}, [current]);
|
|
|
|
const handleSkip = useCallback(() => {
|
|
if (current) {
|
|
setPendingAction({ type: "skip", item: current });
|
|
}
|
|
}, [current]);
|
|
|
|
const handleDefer1h = useCallback(() => {
|
|
if (current) {
|
|
setPendingAction({ type: "defer_1h", item: current, duration: "1h" });
|
|
}
|
|
}, [current]);
|
|
|
|
const handleDeferTomorrow = useCallback(() => {
|
|
if (current) {
|
|
setPendingAction({ type: "defer_tomorrow", item: current, duration: "tomorrow" });
|
|
}
|
|
}, [current]);
|
|
|
|
// Execute action after ReasonPrompt confirmation
|
|
const handleConfirm = useCallback(
|
|
async ({ reason, tags }: { reason: string | null; tags: string[] }) => {
|
|
if (!pendingAction) return;
|
|
|
|
const { type, item, duration } = pendingAction;
|
|
|
|
switch (type) {
|
|
case "start":
|
|
await actions.start(item);
|
|
break;
|
|
case "skip":
|
|
await actions.skip(item, reason, tags);
|
|
break;
|
|
case "defer_1h":
|
|
case "defer_tomorrow":
|
|
await actions.defer(item, duration!, reason, tags);
|
|
break;
|
|
case "complete":
|
|
await actions.complete(item, reason, tags);
|
|
break;
|
|
}
|
|
|
|
setPendingAction(null);
|
|
},
|
|
[pendingAction, actions]
|
|
);
|
|
|
|
const handleCancel = useCallback(() => {
|
|
setPendingAction(null);
|
|
}, []);
|
|
|
|
return (
|
|
<div className="flex min-h-screen flex-col">
|
|
{/* ... existing JSX ... */}
|
|
|
|
{/* ReasonPrompt modal */}
|
|
{pendingAction && (
|
|
<ReasonPrompt
|
|
action={pendingAction.type}
|
|
itemTitle={pendingAction.item.title}
|
|
onSubmit={handleConfirm}
|
|
onCancel={handleCancel}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Step 3: Verify backend already supports tags**
|
|
|
|
The `log_decision` command in `src-tauri/src/commands/mod.rs` already has:
|
|
```rust
|
|
pub struct DecisionEntry {
|
|
pub action: String,
|
|
pub bead_id: String,
|
|
pub reason: Option<String>,
|
|
#[serde(default)]
|
|
pub tags: Vec<String>, // ALREADY EXISTS!
|
|
}
|
|
```
|
|
|
|
**No new Tauri commands needed.**
|
|
|
|
---
|
|
|
|
### IMP-1: Install and Configure @dnd-kit
|
|
|
|
**Fulfills:** AC-F1.1, AC-F1.2, AC-F1.3, AC-F1.4, AC-F1.7
|
|
|
|
**Files:**
|
|
- `package.json` — add dependencies
|
|
- `src/components/QueueView.tsx` — wrap with DndContext
|
|
- `src/components/QueueItem.tsx` — make sortable
|
|
- `src/components/SortableQueueItem.tsx` — new wrapper component
|
|
|
|
**Step 1: Install dependencies**
|
|
```bash
|
|
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
|
|
```
|
|
|
|
**Step 2: Create SortableQueueItem wrapper**
|
|
|
|
```typescript
|
|
// src/components/SortableQueueItem.tsx
|
|
import { useSortable } from "@dnd-kit/sortable";
|
|
import { CSS } from "@dnd-kit/utilities";
|
|
import { QueueItem, type QueueItemProps } from "./QueueItem";
|
|
|
|
interface SortableQueueItemProps extends QueueItemProps {
|
|
id: string;
|
|
}
|
|
|
|
export function SortableQueueItem({ id, ...props }: SortableQueueItemProps) {
|
|
const {
|
|
attributes,
|
|
listeners,
|
|
setNodeRef,
|
|
transform,
|
|
transition,
|
|
isDragging,
|
|
} = useSortable({ id });
|
|
|
|
const style = {
|
|
transform: CSS.Transform.toString(transform),
|
|
transition,
|
|
opacity: isDragging ? 0.5 : 1,
|
|
cursor: isDragging ? "grabbing" : "grab",
|
|
};
|
|
|
|
return (
|
|
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
|
|
<QueueItem {...props} isDragging={isDragging} />
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Step 3: Update QueueView with DndContext**
|
|
|
|
```typescript
|
|
// In QueueView.tsx, add imports:
|
|
import {
|
|
DndContext,
|
|
closestCenter,
|
|
KeyboardSensor,
|
|
PointerSensor,
|
|
useSensor,
|
|
useSensors,
|
|
type DragEndEvent,
|
|
} from "@dnd-kit/core";
|
|
import {
|
|
SortableContext,
|
|
sortableKeyboardCoordinates,
|
|
verticalListSortingStrategy,
|
|
} from "@dnd-kit/sortable";
|
|
import { SortableQueueItem } from "./SortableQueueItem";
|
|
|
|
// In component:
|
|
const sensors = useSensors(
|
|
useSensor(PointerSensor, {
|
|
activationConstraint: { delay: 150, tolerance: 5 },
|
|
}),
|
|
useSensor(KeyboardSensor, {
|
|
coordinateGetter: sortableKeyboardCoordinates,
|
|
})
|
|
);
|
|
|
|
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
|
const { active, over } = event;
|
|
if (!over || active.id === over.id) return;
|
|
|
|
const allItems = current ? [current, ...queue] : [...queue];
|
|
const oldIndex = allItems.findIndex((i) => i.id === active.id);
|
|
const newIndex = allItems.findIndex((i) => i.id === over.id);
|
|
|
|
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
|
|
// Trigger pending reorder (shows ReasonPrompt)
|
|
setPendingReorder({ fromIndex: oldIndex, toIndex: newIndex });
|
|
}
|
|
}, [current, queue]);
|
|
|
|
// In JSX:
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={closestCenter}
|
|
onDragEnd={handleDragEnd}
|
|
>
|
|
<SortableContext
|
|
items={section.items.map((i) => i.id)}
|
|
strategy={verticalListSortingStrategy}
|
|
>
|
|
{section.items.map((item) => (
|
|
<SortableQueueItem
|
|
key={item.id}
|
|
id={item.id}
|
|
item={item}
|
|
onClick={...}
|
|
isFocused={...}
|
|
/>
|
|
))}
|
|
</SortableContext>
|
|
</DndContext>
|
|
```
|
|
|
|
**Step 4: Add keyboard reorder (accessibility fallback)**
|
|
|
|
```typescript
|
|
// In QueueItem.tsx or SortableQueueItem.tsx
|
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
if (e.metaKey || e.ctrlKey) {
|
|
if (e.key === "ArrowUp") {
|
|
e.preventDefault();
|
|
onMoveUp?.(item.id);
|
|
} else if (e.key === "ArrowDown") {
|
|
e.preventDefault();
|
|
onMoveDown?.(item.id);
|
|
}
|
|
}
|
|
}, [item.id, onMoveUp, onMoveDown]);
|
|
|
|
// In QueueView, pass handlers:
|
|
onMoveUp={(id) => {
|
|
const index = items.findIndex(i => i.id === id);
|
|
if (index > 0) {
|
|
setPendingReorder({ fromIndex: index, toIndex: index - 1 });
|
|
}
|
|
}}
|
|
onMoveDown={(id) => {
|
|
const index = items.findIndex(i => i.id === id);
|
|
if (index < items.length - 1) {
|
|
setPendingReorder({ fromIndex: index, toIndex: index + 1 });
|
|
}
|
|
}}
|
|
```
|
|
|
|
---
|
|
|
|
### IMP-2: Wire ReasonPrompt to Actions
|
|
|
|
**Fulfills:** AC-F2.1 through AC-F2.8
|
|
|
|
**Files:**
|
|
- `src/components/FocusView.tsx` — add pending action state
|
|
- `src/components/ReasonPrompt.tsx` — already implemented, no changes
|
|
- `src/lib/tauri.ts` — update log_decision signature
|
|
|
|
**Step 1: Add pending action state to FocusView**
|
|
|
|
```typescript
|
|
// In FocusView.tsx
|
|
interface PendingAction {
|
|
type: FocusAction;
|
|
item: FocusItem;
|
|
}
|
|
|
|
const [pendingAction, setPendingAction] = useState<PendingAction | null>(null);
|
|
|
|
// Replace direct action handlers with pending setters:
|
|
const handleSkip = useCallback(() => {
|
|
if (current) {
|
|
setPendingAction({ type: "skip", item: current });
|
|
}
|
|
}, [current]);
|
|
|
|
const handleDefer1h = useCallback(() => {
|
|
if (current) {
|
|
setPendingAction({ type: "defer_1h", item: current });
|
|
}
|
|
}, [current]);
|
|
|
|
// Add confirmation handler:
|
|
const handleConfirmAction = useCallback(
|
|
async (data: { reason: string | null; tags: string[] }) => {
|
|
if (!pendingAction) return;
|
|
|
|
// Log decision with reason and tags
|
|
await logDecision({
|
|
action: pendingAction.type,
|
|
bead_id: pendingAction.item.id,
|
|
reason: data.reason,
|
|
tags: data.tags,
|
|
});
|
|
|
|
// Execute the action
|
|
act(pendingAction.type);
|
|
setPendingAction(null);
|
|
},
|
|
[pendingAction, act]
|
|
);
|
|
|
|
const handleCancelAction = useCallback(() => {
|
|
setPendingAction(null);
|
|
}, []);
|
|
|
|
// In JSX, add ReasonPrompt:
|
|
{pendingAction && (
|
|
<ReasonPrompt
|
|
action={pendingAction.type}
|
|
itemTitle={pendingAction.item.title}
|
|
onSubmit={handleConfirmAction}
|
|
onCancel={handleCancelAction}
|
|
/>
|
|
)}
|
|
```
|
|
|
|
**Step 2: Update log_decision to accept tags**
|
|
|
|
```typescript
|
|
// In src/lib/tauri.ts
|
|
export interface DecisionEntry {
|
|
action: string;
|
|
bead_id: string;
|
|
reason: string | null;
|
|
tags?: string[];
|
|
}
|
|
|
|
export async function logDecision(entry: DecisionEntry): Promise<void> {
|
|
return invoke("log_decision", { entry });
|
|
}
|
|
```
|
|
|
|
**Step 3: Update Rust command to handle tags**
|
|
|
|
```rust
|
|
// In src-tauri/src/commands/mod.rs
|
|
#[derive(Debug, Clone, Deserialize, Type)]
|
|
pub struct DecisionEntry {
|
|
pub action: String,
|
|
pub bead_id: String,
|
|
pub reason: Option<String>,
|
|
#[serde(default)]
|
|
pub tags: Vec<String>,
|
|
}
|
|
|
|
fn log_decision_inner(entry: &DecisionEntry) -> Result<(), McError> {
|
|
// ... existing code ...
|
|
|
|
let decision = Decision {
|
|
timestamp: now.to_rfc3339(),
|
|
action,
|
|
bead_id: entry.bead_id.clone(),
|
|
reason: entry.reason.clone(),
|
|
tags: entry.tags.clone(), // Now passed from frontend
|
|
context,
|
|
};
|
|
|
|
// ... rest unchanged ...
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### IMP-3: System Tray Setup
|
|
|
|
**Fulfills:** AC-F3.1 through AC-F3.10
|
|
|
|
**Files:**
|
|
- `src-tauri/Cargo.toml` — add tray plugin
|
|
- `src-tauri/src/tray.rs` — new file for tray logic
|
|
- `src-tauri/src/lib.rs` — register tray
|
|
- `src-tauri/src/commands/mod.rs` — add badge update command
|
|
- `src-tauri/capabilities/default.json` — add tray permissions
|
|
|
|
**Step 1: Add tray plugin to Cargo.toml**
|
|
|
|
```toml
|
|
[dependencies]
|
|
tauri-plugin-shell = "2"
|
|
# Add:
|
|
tauri-plugin-notification = "2" # For badge support
|
|
```
|
|
|
|
**Step 2: Create tray module**
|
|
|
|
```rust
|
|
// src-tauri/src/tray.rs
|
|
use tauri::{
|
|
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
|
Manager, Runtime,
|
|
};
|
|
|
|
pub fn setup_tray<R: Runtime>(app: &tauri::AppHandle<R>) -> Result<(), Box<dyn std::error::Error>> {
|
|
let tray = TrayIconBuilder::new()
|
|
.icon(app.default_window_icon().unwrap().clone())
|
|
.tooltip("Mission Control")
|
|
.on_tray_icon_event(|tray, event| {
|
|
if let TrayIconEvent::Click {
|
|
button: MouseButton::Left,
|
|
button_state: MouseButtonState::Up,
|
|
..
|
|
} = event
|
|
{
|
|
let app = tray.app_handle();
|
|
// Emit event to frontend to show popover
|
|
app.emit("tray-clicked", ()).ok();
|
|
|
|
// Or toggle main window visibility:
|
|
if let Some(window) = app.get_webview_window("main") {
|
|
if window.is_visible().unwrap_or(false) {
|
|
window.hide().ok();
|
|
} else {
|
|
window.show().ok();
|
|
window.set_focus().ok();
|
|
}
|
|
}
|
|
}
|
|
})
|
|
.build(app)?;
|
|
|
|
// Store tray handle for badge updates
|
|
app.manage(TrayState { tray });
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub struct TrayState {
|
|
pub tray: tauri::tray::TrayIcon,
|
|
}
|
|
```
|
|
|
|
**Step 3: Register tray in lib.rs**
|
|
|
|
```rust
|
|
// In src-tauri/src/lib.rs
|
|
mod tray;
|
|
|
|
pub fn run() {
|
|
tauri::Builder::default()
|
|
.plugin(tauri_plugin_shell::init())
|
|
.setup(|app| {
|
|
tray::setup_tray(app.handle())?;
|
|
Ok(())
|
|
})
|
|
// ... rest of setup
|
|
.run(tauri::generate_context!())
|
|
.expect("error while running tauri application");
|
|
}
|
|
```
|
|
|
|
**Step 4: Add badge update command**
|
|
|
|
```rust
|
|
// In src-tauri/src/commands/mod.rs
|
|
use crate::tray::TrayState;
|
|
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn update_tray_badge(
|
|
count: u32,
|
|
state: tauri::State<'_, TrayState>,
|
|
) -> Result<(), McError> {
|
|
// Update tooltip with count
|
|
let tooltip = if count == 0 {
|
|
"Mission Control - All clear".to_string()
|
|
} else {
|
|
format!("Mission Control - {} items", count)
|
|
};
|
|
|
|
state
|
|
.tray
|
|
.set_tooltip(Some(&tooltip))
|
|
.map_err(|e| McError::internal(e.to_string()))?;
|
|
|
|
// Note: macOS dock badge requires different API
|
|
// This handles tray tooltip; dock badge is separate
|
|
|
|
Ok(())
|
|
}
|
|
```
|
|
|
|
**Step 5: Frontend integration**
|
|
|
|
```typescript
|
|
// In src/components/AppShell.tsx
|
|
import { listen } from "@tauri-apps/api/event";
|
|
import { invoke } from "@tauri-apps/api/core";
|
|
|
|
// Update badge when counts change
|
|
useEffect(() => {
|
|
invoke("update_tray_badge", { count: totalItems }).catch(console.error);
|
|
}, [totalItems]);
|
|
|
|
// Listen for tray click to show popover
|
|
useEffect(() => {
|
|
const unlisten = listen("tray-clicked", () => {
|
|
// Could show TrayPopover in a separate window
|
|
// For now, just focus main window
|
|
});
|
|
|
|
return () => {
|
|
unlisten.then((fn) => fn());
|
|
};
|
|
}, []);
|
|
```
|
|
|
|
---
|
|
|
|
### IMP-4: Update E2E Tests
|
|
|
|
**Fulfills:** E2E Test Coverage Requirements (see Testing Strategy)
|
|
|
|
**Files:**
|
|
- `tests/e2e/app.spec.ts`
|
|
|
|
**Step 1: Fix Inbox placeholder test**
|
|
|
|
```typescript
|
|
// Replace:
|
|
test("shows Inbox placeholder", async ({ page }) => {
|
|
await page.getByRole("button", { name: "Inbox" }).click();
|
|
await expect(page.getByText("Inbox view coming in Phase 4b")).toBeVisible();
|
|
});
|
|
|
|
// With:
|
|
test("shows Inbox view with zero state", async ({ page }) => {
|
|
await page.getByRole("button", { name: "Inbox" }).click();
|
|
// InboxView shows "Inbox Zero" when no untriaged items
|
|
await expect(page.getByText("Inbox Zero")).toBeVisible();
|
|
await expect(page.getByText("All caught up!")).toBeVisible();
|
|
});
|
|
|
|
test("shows Inbox with items when store has data", async ({ page }) => {
|
|
try {
|
|
await exposeStores(page);
|
|
// Seed inbox store with test data
|
|
await page.evaluate(() => {
|
|
const w = window as Record<string, unknown>;
|
|
const inboxStore = w.__MC_INBOX_STORE__ as {
|
|
setState: (state: Record<string, unknown>) => void;
|
|
};
|
|
if (inboxStore) {
|
|
inboxStore.setState({
|
|
items: [
|
|
{
|
|
id: "test-1",
|
|
title: "Test inbox item",
|
|
type: "issue",
|
|
project: "test/project",
|
|
triaged: false,
|
|
},
|
|
],
|
|
});
|
|
}
|
|
});
|
|
} catch {
|
|
test.skip();
|
|
return;
|
|
}
|
|
|
|
await page.getByRole("button", { name: "Inbox" }).click();
|
|
await expect(page.getByText("Inbox (1)")).toBeVisible();
|
|
});
|
|
```
|
|
|
|
**Step 2: Fix badge selector test**
|
|
|
|
```typescript
|
|
// Replace:
|
|
test("Queue tab shows item count badge when store has data", async ({ page }) => {
|
|
// ...seeding code...
|
|
// 1 current + 2 queue = 3
|
|
await expect(page.getByText("3")).toBeVisible();
|
|
});
|
|
|
|
// With:
|
|
test("Queue tab shows item count badge when store has data", async ({ page }) => {
|
|
try {
|
|
await exposeStores(page);
|
|
await seedFocusStore(page);
|
|
} catch {
|
|
test.skip();
|
|
return;
|
|
}
|
|
|
|
// Use specific test ID selector
|
|
const badge = page.getByTestId("queue-badge");
|
|
await expect(badge).toBeVisible();
|
|
await expect(badge).toHaveText("3");
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
### IMP-5: Staleness Visualization
|
|
|
|
**Fulfills:** AC-F5.1 through AC-F5.4
|
|
|
|
**Files:**
|
|
- `src/components/FocusCard.tsx`
|
|
- `src/components/QueueItem.tsx`
|
|
- `src/components/SuggestionCard.tsx`
|
|
- `src/lib/staleness.ts` (new utility)
|
|
|
|
**Step 1: Create staleness utility**
|
|
|
|
```typescript
|
|
// src/lib/staleness.ts
|
|
export type StalenessLevel = "fresh" | "normal" | "stale" | "critical";
|
|
|
|
export function getStalenessLevel(createdAt: string): StalenessLevel {
|
|
const now = Date.now();
|
|
const created = new Date(createdAt).getTime();
|
|
const daysOld = (now - created) / (1000 * 60 * 60 * 24);
|
|
|
|
if (daysOld < 1) return "fresh"; // Green indicator
|
|
if (daysOld < 3) return "normal"; // No indicator (default styling)
|
|
if (daysOld < 7) return "stale"; // Amber indicator
|
|
return "critical"; // Red pulsing indicator
|
|
}
|
|
|
|
export const stalenessStyles: Record<StalenessLevel, string> = {
|
|
fresh: "border-l-2 border-l-green-500",
|
|
normal: "", // No special styling for 1-2 day items
|
|
stale: "border-l-2 border-l-amber-500",
|
|
critical: "border-l-2 border-l-red-500 animate-pulse",
|
|
};
|
|
```
|
|
|
|
**Step 2: Apply to item components**
|
|
|
|
Add `stalenessStyles[getStalenessLevel(item.createdAt)]` to className in each component.
|
|
|
|
---
|
|
|
|
### IMP-6: Batch Mode Wiring
|
|
|
|
**Fulfills:** AC-F6.1
|
|
|
|
**Files:**
|
|
- `src/components/AppShell.tsx`
|
|
- `src/components/QueueView.tsx`
|
|
|
|
**Change:** Pass `onStartBatch` callback from AppShell to QueueView.
|
|
|
|
```typescript
|
|
// In AppShell.tsx, add to QueueView props:
|
|
<QueueView
|
|
onSetFocus={(id) => { setFocus(id); }}
|
|
onSwitchToFocus={() => setView("focus")}
|
|
onStartBatch={(items) => {
|
|
useBatchStore.getState().startBatch(items);
|
|
}}
|
|
/>
|
|
```
|
|
|
|
---
|
|
|
|
### IMP-7: SyncStatus Wiring
|
|
|
|
**Fulfills:** AC-F7.1, AC-F7.2
|
|
|
|
**Files:**
|
|
- `src/components/AppShell.tsx`
|
|
- `src/components/SyncStatus.tsx`
|
|
|
|
**Change:** Render SyncStatus in navigation bar.
|
|
|
|
```typescript
|
|
// In AppShell.tsx nav section, add:
|
|
<SyncStatus />
|
|
```
|
|
|
|
---
|
|
|
|
### IMP-8: Settings Wiring
|
|
|
|
**Fulfills:** AC-F8.1 through AC-F8.4
|
|
|
|
**Files:**
|
|
- `src/components/SettingsView.tsx`
|
|
- `src/stores/settings-store.ts` (new)
|
|
|
|
**Step 1: Create settings store with Tauri persistence**
|
|
|
|
Per A5 (Persistence Strategy), settings use Tauri backend, not zustand persist:
|
|
|
|
```typescript
|
|
// src/stores/settings-store.ts
|
|
import { create } from "zustand";
|
|
import { invoke } from "@tauri-apps/api/core";
|
|
|
|
interface Settings {
|
|
syncInterval: 5 | 15 | 30;
|
|
notificationsEnabled: boolean;
|
|
quickCaptureShortcut: string;
|
|
}
|
|
|
|
interface SettingsStore extends Settings {
|
|
hydrate: () => Promise<void>;
|
|
update: (partial: Partial<Settings>) => Promise<void>;
|
|
}
|
|
|
|
export const useSettingsStore = create<SettingsStore>()((set, get) => ({
|
|
syncInterval: 15,
|
|
notificationsEnabled: true,
|
|
quickCaptureShortcut: "CommandOrControl+Shift+C",
|
|
|
|
hydrate: async () => {
|
|
const state = await invoke<{ settings: Settings }>("read_state");
|
|
if (state.settings) {
|
|
set(state.settings);
|
|
}
|
|
},
|
|
|
|
update: async (partial) => {
|
|
const newSettings = { ...get(), ...partial };
|
|
await invoke("write_state", { settings: newSettings });
|
|
set(partial);
|
|
},
|
|
}));
|
|
```
|
|
|
|
**Step 2: Replace SettingsView placeholder with Settings component**
|
|
|
|
**Step 3: Wire shortcut registration to settings store**
|
|
|
|
When `quickCaptureShortcut` changes, re-register the global shortcut via Tauri.
|
|
|
|
---
|
|
|
|
### IMP-9: State & Data Integration
|
|
|
|
**Fulfills:** AC-F9.1, AC-F9.2, AC-F9.3
|
|
|
|
**Files:**
|
|
- `src/components/FocusView.tsx`
|
|
- `src/components/SuggestionCard.tsx`
|
|
- `src/stores/focus-store.ts`
|
|
- `src/lib/tauri.ts`
|
|
- `src/hooks/useActionPipeline.ts` (from Slice 0)
|
|
|
|
**Step 1: Call closeBead via action pipeline**
|
|
|
|
```typescript
|
|
// In FocusView, use the action pipeline:
|
|
const { requestAction } = useActionPipeline();
|
|
|
|
const handleComplete = () => {
|
|
if (current) {
|
|
requestAction({ type: "complete", item: current });
|
|
}
|
|
};
|
|
|
|
// The pipeline handles: prompt → closeBead → persist → refresh triage
|
|
```
|
|
|
|
**Step 2: Fetch triage for suggestions**
|
|
|
|
```typescript
|
|
// In SuggestionCard or FocusView:
|
|
const { data: triage } = useQuery({
|
|
queryKey: ["triage"],
|
|
queryFn: () => invoke("get_triage"),
|
|
});
|
|
```
|
|
|
|
**Step 3: Persist focus store via Tauri**
|
|
|
|
Per A5 (Persistence Strategy), remove zustand `persist` middleware. Use Tauri `readState/writeState` as source of truth:
|
|
|
|
```typescript
|
|
// In focus-store.ts, add hydration:
|
|
hydrate: async () => {
|
|
const state = await invoke<FocusState>("read_state");
|
|
if (state.focus) set({ current: state.focus.current, queue: state.focus.queue });
|
|
},
|
|
|
|
// Actions persist via useActionPipeline, not store middleware
|
|
```
|
|
|
|
---
|
|
|
|
## Rollout Slices
|
|
|
|
### Slice 0+1 COMBINED: Wire Actions + ReasonPrompt (0.5 day) — REVISED
|
|
|
|
**Scope:** Wire FocusView to use existing `useActions` hook + add ReasonPrompt modal.
|
|
|
|
**Rationale:** The infrastructure already exists. This is a wiring task, not new architecture.
|
|
|
|
**What already works:**
|
|
- `useActions` hook with `start`, `defer`, `skip`, `complete` methods
|
|
- Backend commands: `log_decision`, `update_item`, `close_bead`
|
|
- Zustand persistence via Tauri storage
|
|
- `ReasonPrompt` component with quick tags
|
|
|
|
**Tasks:**
|
|
1. Update `useActions` hook to accept `tags` parameter (4 lines changed)
|
|
2. Add `pendingAction` state to FocusView
|
|
3. Change action handlers to set `pendingAction` instead of executing
|
|
4. Import and render `ReasonPrompt` when `pendingAction !== null`
|
|
5. Wire ReasonPrompt's `onSubmit` to call `useActions()` methods
|
|
6. Wire ReasonPrompt's `onCancel` to clear `pendingAction`
|
|
7. Write test: action triggers prompt, confirm executes, cancel aborts
|
|
|
|
**Validation:**
|
|
- Clicking Skip shows ReasonPrompt
|
|
- Clicking "Skip reason" proceeds with `reason: null, tags: []`
|
|
- Clicking "Confirm" with reason/tags logs them via existing `log_decision`
|
|
- Pressing Escape cancels (no backend call)
|
|
- Backend commands already work — no changes needed
|
|
|
|
**Files:**
|
|
- `src/hooks/useActions.ts` — add optional `tags` param (MODIFY, not create)
|
|
- `src/components/FocusView.tsx` — add pending state + ReasonPrompt (MODIFY)
|
|
|
|
---
|
|
|
|
### Slice 2: Drag Reorder UI (1 day)
|
|
|
|
**Scope:** Add drag-and-drop to QueueView with keyboard fallback
|
|
|
|
**Tasks:**
|
|
1. Install @dnd-kit dependencies
|
|
2. Create SortableQueueItem wrapper
|
|
3. Wrap QueueView sections with DndContext
|
|
4. Implement handleDragEnd with pending reorder (uses action pipeline from Slice 0)
|
|
5. Add Cmd+Up/Down keyboard reorder (accessibility)
|
|
6. Show ReasonPrompt on reorder
|
|
7. Write tests for drag and keyboard interactions
|
|
|
|
**Validation:**
|
|
- Items can be dragged within sections
|
|
- Visual feedback during drag
|
|
- Cmd+Up/Down moves focused item
|
|
- Order persists after drop (via action pipeline)
|
|
- Decision logged with reason
|
|
|
|
---
|
|
|
|
### Slice 3: System Tray (1 day)
|
|
|
|
**Scope:** Add menu bar / system tray icon with badge
|
|
|
|
**Tasks:**
|
|
1. Add tray plugin to Cargo.toml
|
|
2. Create tray.rs with setup logic
|
|
3. Register tray in lib.rs
|
|
4. Add update_tray_badge command
|
|
5. Wire frontend to update badge on count changes
|
|
6. Handle tray click to toggle window
|
|
|
|
**Validation:**
|
|
- Tray icon appears on app start
|
|
- Badge updates when items change
|
|
- Clicking tray toggles main window
|
|
|
|
---
|
|
|
|
### Slice 4: E2E Test Updates (0.5 day)
|
|
|
|
**Scope:** Fix failing Playwright tests
|
|
|
|
**Tasks:**
|
|
1. Update Inbox placeholder test for new InboxView
|
|
2. Fix badge selector to use data-testid
|
|
3. Expose inbox store for test seeding
|
|
4. Run full E2E suite to verify
|
|
|
|
**Validation:**
|
|
- All 11 E2E tests pass
|
|
- No new test failures
|
|
|
|
---
|
|
|
|
### Slice 5: Staleness Visualization (0.5 day)
|
|
|
|
**Scope:** Add age-based color indicators to items
|
|
|
|
**Tasks:**
|
|
1. Create staleness utility with level calculation
|
|
2. Add staleness styles to FocusCard
|
|
3. Add staleness styles to QueueItem
|
|
4. Add staleness styles to SuggestionCard
|
|
5. Write unit tests for staleness calculation
|
|
|
|
**Validation:**
|
|
- Fresh items (<1 day) show green indicator
|
|
- Stale items (3-6 days) show amber indicator
|
|
- Critical items (7+ days) show red pulsing indicator
|
|
|
|
---
|
|
|
|
### Slice 6: Quick Wiring (1 hour)
|
|
|
|
**Scope:** Connect built-but-unhooked components
|
|
|
|
**Tasks:**
|
|
1. Pass `onStartBatch` from AppShell to QueueView
|
|
2. Render SyncStatus in navigation bar
|
|
3. Replace SettingsView placeholder with Settings component
|
|
|
|
**Validation:**
|
|
- Clicking "Batch" in Queue enters batch mode
|
|
- Sync status visible in nav bar
|
|
- Settings view shows actual settings UI
|
|
|
|
---
|
|
|
|
### Slice 7: State Persistence (2 hours)
|
|
|
|
**Scope:** Persist state across app restarts using Tauri backend
|
|
|
|
**Tasks:**
|
|
1. Add `hydrate()` method to focus-store (calls `readState`)
|
|
2. Add `hydrate()` method to settings-store (calls `readState`)
|
|
3. Call hydration on app startup in AppShell
|
|
4. Wire closeBead through action pipeline (Slice 0)
|
|
5. Wire getTriage to SuggestionCard with TanStack Query
|
|
|
|
**Validation:**
|
|
- Focus and queue preserved after restart (via Tauri state file)
|
|
- Settings preserved after restart (via Tauri state file)
|
|
- Completing item closes corresponding bead
|
|
- Suggestions reflect triage recommendations
|
|
|
|
**Note:** Zustand persist middleware NOT used. Tauri `readState/writeState` is the single persistence backend per A5.
|
|
|
|
---
|
|
|
|
## Testing Strategy
|
|
|
|
### E2E Test Coverage Requirements
|
|
|
|
The following test coverage must be maintained:
|
|
|
|
1. **InboxView with items** — E2E test can verify items are displayed with triage actions when inbox has untriaged items
|
|
2. **InboxView empty state** — E2E test can verify "Inbox Zero" celebration UI when no untriaged items
|
|
3. **Queue badge** — E2E test can locate badge with `data-testid="queue-badge"` containing the correct count
|
|
|
|
### Unit Tests (Vitest)
|
|
|
|
| Component | Test Cases |
|
|
|-----------|------------|
|
|
| SortableQueueItem | Renders, passes props through, applies drag styles |
|
|
| FocusView with pending | Shows prompt, handles confirm/cancel, clears pending |
|
|
| log_decision with tags | Tags included in entry |
|
|
|
|
### Integration Tests (Playwright)
|
|
|
|
| Flow | Steps |
|
|
|------|-------|
|
|
| **Prompt gating (Slice 0)** | Seed data → Click Skip → Verify action NOT executed → Confirm → Verify action executed |
|
|
| Reorder with reason | Seed data → Drag item → Enter reason → Verify log |
|
|
| Skip with quick tags | Seed data → Skip → Select tags → Confirm → Verify log |
|
|
| Inbox zero state | Navigate to inbox → Verify celebration UI |
|
|
| Keyboard reorder | Seed data → Focus item → Cmd+Down → Verify order changed |
|
|
|
|
### Rust Tests
|
|
|
|
| Command | Test Cases |
|
|
|---------|------------|
|
|
| log_decision | With tags, without tags, invalid action |
|
|
| update_tray_badge | Zero count, positive count |
|
|
|
|
---
|
|
|
|
## Resolved Decisions
|
|
|
|
1. **Reorder across sections?** — No, sections are type-based. Users reorder within sections only.
|
|
|
|
2. **Prompt behavior** — Always prompt. No settings toggle. Users can click "Skip reason" to proceed quickly.
|
|
|
|
3. **Which actions prompt?** — All significant actions: Skip, Defer (1h/Tomorrow), Start, Complete.
|
|
|
|
4. **Tray behavior** — macOS gets full TrayPopover anchored to menu bar. Windows/Linux toggle main window visibility.
|
|
|
|
5. **Persistence authority?** — Tauri `readState/writeState` is the single source of truth. Zustand stores are hydration caches only. (From GPT 5.3 review)
|
|
|
|
6. **Idempotency?** — Actions include client-generated `actionId`. Backend deduplicates before executing. (From GPT 5.3 review)
|
|
|
|
7. **Keyboard reorder?** — Yes, Cmd+Up/Down as accessibility fallback for drag-and-drop. (From GPT 5.3 review)
|
|
|
|
8. **Tray fallback?** — Graceful degradation. If tray APIs fail, app continues without tray. No user-visible error unless main window fails. (From GPT 5.3 review)
|
|
|
|
---
|
|
|
|
## Changelog
|
|
|
|
| Date | Change |
|
|
|------|--------|
|
|
| 2026-02-26 | Initial followup plan created from E2E testing gaps |
|
|
| 2026-02-26 | Resolved: always prompt, all actions trigger prompt, macOS popover + Win/Linux toggle |
|
|
| 2026-02-26 | AC quality pass: removed implementation detail from F1.4, sequential numbering F2.x, defined "pending items" in F3.2, added empty state AC F3.4/F3.8, split F3.5 "or" ambiguity, moved F4 meta-ACs to Testing Strategy |
|
|
| 2026-02-26 | Codebase audit: added 5 new gaps (staleness, batch wiring, sync status, settings, state persistence) with 10 new ACs (F5.1-F5.3, F6.1, F7.1-F7.2, F8.1-F8.2, F9.1-F9.3) |
|
|
| 2026-02-26 | AC quality review: removed code refs from F1.6/F3.2, added F5.2 (1-2 day normal state), expanded F8 to 4 ACs (sync/notifications/shortcuts/persistence), clarified F9.2 (top triage pick with reason), added workflows 4-5 (batch mode, settings) |
|
|
| 2026-02-26 | GPT 5.3 cross-model review: added Slice 0 (Action Pipeline Foundation), A5 (Persistence Strategy), A6 (Tray Fallback), AC-F1.7 (keyboard reorder), AC-F3.11 (tray graceful degradation), idempotency rules in A4, prompt gating E2E test. Updated effort to 5 days. |
|
|
| 2026-02-26 | GPT 5.3 second review applied: A4 updated with atomic transaction ordering (record pending first, complete after persist); A7 added (pending action persistence for crash recovery); A8 added (idempotency log lifecycle: 1000 entries/30 days retention); A9 added (multi-surface mutex coordination); IMP-0 TypeScript hook rewritten with error/isExecuting/retryAction states; IMP-0 Rust command rewritten with mutex lock, all 6 action types, ActionResult with canonical state+version; Slice 0 expanded to 11 tasks, effort increased to 1 day. Total effort now 5.5 days. |
|
|
| 2026-02-26 | **MAJOR REVISION (v2.0) after codebase audit:** Discovered existing infrastructure that plan didn't account for. (1) `useActions` hook already exists with full backend integration — plan's proposed `useActionPipeline` was duplicate work. (2) Zustand persistence ALREADY uses Tauri via `tauri-storage.ts` — there was never a "dual persistence" problem. (3) The real gap: FocusView calls `act()` directly instead of using `useActions`. CHANGES: Gap 0 rewritten to describe actual wiring issue; A4 revised to show correct vs broken flow; A5 marked "ALREADY IMPLEMENTED"; A7-A9 removed as unnecessary; IMP-0 rewritten to modify existing files not create new ones; Slices 0+1 combined; effort reduced from 5.5 to 4.5 days. |
|