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>
52 KiB
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
useActionshook and Tauri persistence already work correctly, effort reduced by 1 day. The fix is wiring, not architecture.
Table of Contents
- Gap Analysis
- User Workflows
- Acceptance Criteria
- Architecture
- Implementation Specs
- Rollout Slices
- Testing Strategy
Gap Analysis
Gap 0: Action Wiring Gap (REVISED after codebase audit)
Actual problem (discovered via codebase audit):
useActionshook EXISTS with full backend integration (log_decision,update_item,close_bead)- BUT
FocusViewcallsact()directly on the store, bypassinguseActionsentirely - 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.tswhich callsreadState/writeState - Individual Tauri commands work:
log_decision,update_item,close_bead useActionshook 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 infocus-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:
ReasonPromptcomponent fully implemented with quick tagslog_decisionTauri command accepts reason and tagsDecisionLog::appendwrites to~/.local/share/mc/decision_log.jsonl- Actions call
log_decisionbut 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:
TrayPopovercomponent 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.tsxfully implemented with progress, keyboard shortcuts, celebrationQueueView.tsxhas "Batch" buttons in section headersbatch-store.tshasstartBatch()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.tsxfully implemented (health, last sync, retry button)- Component never rendered in any view
Gap: SyncStatus not displayed anywhere.
Gap 8: Settings Persistence
Current State:
Settings.tsxhas full UI (toggles, inputs, sections)SettingsView.tsxis 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 calledgetTriage()/getNextPick()commands exist but never calledreadState()/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:
-
Rust tray setup (
src-tauri/src/tray.rs)- Creates tray icon with badge
- Handles click events
- Emits events to frontend
-
Popover window (separate Tauri window)
- Small, frameless, always-on-top
- Positioned near tray icon
- Receives focus data via Tauri events
-
Badge updates (frontend → Rust)
- Frontend calls
update_tray_badge(count)command - Rust updates native badge
- Frontend calls
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:
useActionshook callsupdate_item,close_bead,log_decisioncommands- These commands work correctly in the backend
- FocusView simply needs to USE the hook instead of bypassing it
What needs to change:
- FocusView: Replace direct
act()calls withuseActions()hook methods - FocusView: Add
pendingActionstate + ReasonPrompt modal useActions: ExtendDecisionEntryto includetagsarray- 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.tsprovides a ZustandStateStorageadapter- All stores use
createJSONStorage(() => getStorage())which calls Tauri'sreadState/writeState - State persists to
~/.local/share/mc/state.jsonvia 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: Persistscurrentandqueuevia Taurinav-store.ts: PersistsactiveViewvia Tauriinbox-store.ts: Persistsitemsvia 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.tsxis 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
useActionshook → 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 touseActions, add ReasonPromptsrc/hooks/useActions.ts— add tags support to DecisionEntry
Step 1: Update useActions to support tags
// 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
// 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:
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 dependenciessrc/components/QueueView.tsx— wrap with DndContextsrc/components/QueueItem.tsx— make sortablesrc/components/SortableQueueItem.tsx— new wrapper component
Step 1: Install dependencies
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
Step 2: Create SortableQueueItem wrapper
// 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
// 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)
// 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 statesrc/components/ReasonPrompt.tsx— already implemented, no changessrc/lib/tauri.ts— update log_decision signature
Step 1: Add pending action state to FocusView
// 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
// 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
// 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 pluginsrc-tauri/src/tray.rs— new file for tray logicsrc-tauri/src/lib.rs— register traysrc-tauri/src/commands/mod.rs— add badge update commandsrc-tauri/capabilities/default.json— add tray permissions
Step 1: Add tray plugin to Cargo.toml
[dependencies]
tauri-plugin-shell = "2"
# Add:
tauri-plugin-notification = "2" # For badge support
Step 2: Create tray module
// 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
// 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
// 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
// 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
// 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
// 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.tsxsrc/components/QueueItem.tsxsrc/components/SuggestionCard.tsxsrc/lib/staleness.ts(new utility)
Step 1: Create staleness utility
// 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.tsxsrc/components/QueueView.tsx
Change: Pass onStartBatch callback from AppShell to QueueView.
// 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.tsxsrc/components/SyncStatus.tsx
Change: Render SyncStatus in navigation bar.
// In AppShell.tsx nav section, add:
<SyncStatus />
IMP-8: Settings Wiring
Fulfills: AC-F8.1 through AC-F8.4
Files:
src/components/SettingsView.tsxsrc/stores/settings-store.ts(new)
Step 1: Create settings store with Tauri persistence
Per A5 (Persistence Strategy), settings use Tauri backend, not zustand persist:
// 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.tsxsrc/components/SuggestionCard.tsxsrc/stores/focus-store.tssrc/lib/tauri.tssrc/hooks/useActionPipeline.ts(from Slice 0)
Step 1: Call closeBead via action pipeline
// 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
// 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:
// 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:
useActionshook withstart,defer,skip,completemethods- Backend commands:
log_decision,update_item,close_bead - Zustand persistence via Tauri storage
ReasonPromptcomponent with quick tags
Tasks:
- Update
useActionshook to accepttagsparameter (4 lines changed) - Add
pendingActionstate to FocusView - Change action handlers to set
pendingActioninstead of executing - Import and render
ReasonPromptwhenpendingAction !== null - Wire ReasonPrompt's
onSubmitto calluseActions()methods - Wire ReasonPrompt's
onCancelto clearpendingAction - 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 optionaltagsparam (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:
- Install @dnd-kit dependencies
- Create SortableQueueItem wrapper
- Wrap QueueView sections with DndContext
- Implement handleDragEnd with pending reorder (uses action pipeline from Slice 0)
- Add Cmd+Up/Down keyboard reorder (accessibility)
- Show ReasonPrompt on reorder
- 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:
- Add tray plugin to Cargo.toml
- Create tray.rs with setup logic
- Register tray in lib.rs
- Add update_tray_badge command
- Wire frontend to update badge on count changes
- 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:
- Update Inbox placeholder test for new InboxView
- Fix badge selector to use data-testid
- Expose inbox store for test seeding
- 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:
- Create staleness utility with level calculation
- Add staleness styles to FocusCard
- Add staleness styles to QueueItem
- Add staleness styles to SuggestionCard
- 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:
- Pass
onStartBatchfrom AppShell to QueueView - Render SyncStatus in navigation bar
- 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:
- Add
hydrate()method to focus-store (callsreadState) - Add
hydrate()method to settings-store (callsreadState) - Call hydration on app startup in AppShell
- Wire closeBead through action pipeline (Slice 0)
- 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:
- InboxView with items — E2E test can verify items are displayed with triage actions when inbox has untriaged items
- InboxView empty state — E2E test can verify "Inbox Zero" celebration UI when no untriaged items
- 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
-
Reorder across sections? — No, sections are type-based. Users reorder within sections only.
-
Prompt behavior — Always prompt. No settings toggle. Users can click "Skip reason" to proceed quickly.
-
Which actions prompt? — All significant actions: Skip, Defer (1h/Tomorrow), Start, Complete.
-
Tray behavior — macOS gets full TrayPopover anchored to menu bar. Windows/Linux toggle main window visibility.
-
Persistence authority? — Tauri
readState/writeStateis the single source of truth. Zustand stores are hydration caches only. (From GPT 5.3 review) -
Idempotency? — Actions include client-generated
actionId. Backend deduplicates before executing. (From GPT 5.3 review) -
Keyboard reorder? — Yes, Cmd+Up/Down as accessibility fallback for drag-and-drop. (From GPT 5.3 review)
-
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. |