Files
mission-control/PLAN-FOLLOWUP.md
teernisse f5ce8a9091 feat(followup): implement PLAN-FOLLOWUP.md gap fixes
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>
2026-02-26 17:28:28 -05:00

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 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
  2. User Workflows
  3. Acceptance Criteria
  4. Architecture
  5. Implementation Specs
  6. Rollout Slices
  7. 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

// 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 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

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 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

// 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 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

[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.tsx
  • src/components/QueueItem.tsx
  • src/components/SuggestionCard.tsx
  • src/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.tsx
  • src/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.tsx
  • src/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.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:

// 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

// 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:

  • 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.