diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index b1cce1e..5b25a5b 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -29,7 +29,7 @@ {"id":"bd-2sj","title":"Implement br CLI wrapper for bead operations","description":"# Beads CLI Wrapper (RED → GREEN)\n\n**Parent epic:** Phase 2: Bridge + Data Layer\n\n**Why CLI wrapper:**\nSame pattern as lore — shell out to br CLI instead of importing beads as library.\nThis provides clean boundaries and guaranteed compatibility.\n\n**TDD: br CLI tests (RED first):**\n\n```rust\n#[test]\nfn parse_br_create_success() {\n let fixture = include_str!(\"fixtures/br/create_success.json\");\n let result: BrCreateResponse = serde_json::from_str(fixture).unwrap();\n \n assert!(result.id.starts_with(\"br-\"));\n}\n\n#[test]\nfn parse_br_create_error() {\n let fixture = include_str!(\"fixtures/br/create_error.json\");\n let result: Result = serde_json::from_str(fixture);\n \n // Error fixture should have error field\n assert!(result.is_err() || has_error_field(fixture));\n}\n\n#[test]\nfn mock_beads_records_calls() {\n let mut mock = MockBeadsCli::new();\n mock.create(\"Test\", \"gitlab\")?;\n \n assert_eq!(mock.calls().len(), 1);\n assert_eq!(mock.calls()[0], BeadsCall::Create { title: \"Test\", bead_type: \"gitlab\" });\n}\n```\n\n**Implementation (GREEN):**\n\n```rust\npub struct RealBeadsCli;\n\nimpl BeadsCli for RealBeadsCli {\n fn create(&self, title: &str, bead_type: &str) -> Result {\n let output = Command::new(\"br\")\n .args([\"create\", title, \"-t\", bead_type, \"--json\"])\n .output()?;\n \n if !output.status.success() {\n return Err(anyhow!(\"br create failed: {}\", \n String::from_utf8_lossy(&output.stderr)));\n }\n \n let response: BrCreateResponse = serde_json::from_slice(&output.stdout)?;\n Ok(response.id)\n }\n \n fn close(&self, id: &str, reason: &str) -> Result<()> {\n let output = Command::new(\"br\")\n .args([\"close\", id, \"--reason\", reason])\n .output()?;\n \n if !output.status.success() {\n return Err(anyhow!(\"br close failed\"));\n }\n \n Ok(())\n }\n \n fn exists(&self, id: &str) -> Result {\n let output = Command::new(\"br\")\n .args([\"show\", id, \"--json\"])\n .output()?;\n \n Ok(output.status.success())\n }\n}\n```\n\n**Mock implementation for tests:**\n\n```rust\npub struct MockBeadsCli {\n responses: HashMap,\n calls: Vec,\n next_id: u32,\n}\n\nimpl BeadsCli for MockBeadsCli {\n fn create(&mut self, title: &str, bead_type: &str) -> Result {\n self.calls.push(BeadsCall::Create { title: title.into(), bead_type: bead_type.into() });\n let id = format!(\"br-mock{}\", self.next_id);\n self.next_id += 1;\n Ok(id)\n }\n}\n```\n\n**Acceptance criteria:**\n- All fixture files parse successfully\n- RealBeadsCli executes actual br commands\n- MockBeadsCli records calls for verification\n- Error responses handled gracefully","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-25T20:28:13.919393Z","created_by":"tayloreernisse","updated_at":"2026-02-26T14:05:42.538735Z","closed_at":"2026-02-26T14:05:42.538689Z","close_reason":"Completed: BeadsCli trait and RealBeadsCli implementation exist in beads.rs","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2sj","depends_on_id":"bd-2us","type":"blocks","created_at":"2026-02-25T20:30:20.664547Z","created_by":"tayloreernisse"}]} {"id":"bd-2us","title":"Phase 2: Bridge + Data Layer","description":"# Bridge + Data Layer — GitLab → Beads Integration\n\n**Context:** This phase implements the heart of Mission Control — the bridge that converts GitLab events into beads tasks, manages the mapping file, handles crash recovery, and maintains data integrity.\n\n**Why this matters:**\nThe bridge ensures no GitLab activity is lost and no duplicate tasks are created. It's the reliability layer that lets users trust MC with their work.\n\n**Duration estimate:** 2-3 days\n\n**Core components:**\n1. **State Machine** — Lifecycle: (new) → active → suspect_orphan → closed\n2. **Mapping File** — Persistent {gitlab_key} → {bead_id} mapping\n3. **Two-Strike Rule** — Items must be missing 2 consecutive reconciliations to auto-close\n4. **Crash Recovery** — Write-ahead pattern with pending flag\n5. **Reconciliation** — Periodic full sync to heal missed events\n6. **Decision Logging** — Append-only log of all user decisions\n\n**Invariants (must ALWAYS hold):**\n- INV-1: No duplicate beads (each key maps to exactly one bead)\n- INV-2: No orphan beads (every bead_id exists in beads)\n- INV-3: No false closes (two-strike rule enforced)\n- INV-4: Cursor monotonicity (never moves backward)\n\n**Dependencies:**\n- Requires Phase 1 (Foundation) complete\n- Blocks all view phases (Focus, Queue, etc.)\n\n**Acceptance criteria:**\n- Bridge creates beads from GitLab events\n- Two-strike auto-close works correctly\n- Crash recovery handles all failure scenarios\n- Reconciliation heals missed events\n- Single-instance lock prevents race conditions\n- Decision log captures all actions","status":"closed","priority":2,"issue_type":"epic","created_at":"2026-02-25T20:27:22.704633Z","created_by":"tayloreernisse","updated_at":"2026-02-26T14:05:10.184246Z","closed_at":"2026-02-26T14:05:10.184189Z","close_reason":"Completed: Full bridge implementation with incremental sync, reconciliation, two-strike close, flock lock, write-ahead pattern, and Tauri IPC commands. 45 tests.","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2us","depends_on_id":"bd-hee","type":"blocks","created_at":"2026-02-25T20:27:26.006003Z","created_by":"tayloreernisse"}]} {"id":"bd-2vw","title":"Display raw lore data in UI","description":"# Basic UI Showing Lore Data (Visual Verification)\n\n**Parent epic:** Phase 1: Foundation\n\n**Why this task:**\nBefore building real views, we need to prove the full data pipeline works:\nFrontend → Tauri IPC → Rust backend → lore CLI → parsed data → back to UI\n\nThis is visual verification (no automated tests) — we're proving plumbing works.\n\n**Implementation:**\n\n1. **Tauri command to fetch lore data:**\n ```rust\n // src-tauri/src/commands/work_items.rs\n #[tauri::command]\n pub async fn get_lore_data(\n lore: State<'_, Box>,\n ) -> Result {\n lore.me().map_err(|e| e.to_string())\n }\n ```\n\n2. **Frontend hook for data fetching:**\n ```typescript\n // src/hooks/useLoreData.ts\n import { invoke } from '@tauri-apps/api/core';\n import { useQuery } from '@tanstack/react-query';\n \n export function useLoreData() {\n return useQuery({\n queryKey: ['lore-data'],\n queryFn: () => invoke('get_lore_data'),\n refetchInterval: false, // Manual refetch on lore-db-changed\n });\n }\n ```\n\n3. **Listen for file watcher events:**\n ```typescript\n // src/hooks/useTauriEvents.ts\n import { listen } from '@tauri-apps/api/event';\n \n export function useLoreRefresh(queryClient: QueryClient) {\n useEffect(() => {\n const unlisten = listen('lore-db-changed', () => {\n queryClient.invalidateQueries({ queryKey: ['lore-data'] });\n });\n \n return () => { unlisten.then(fn => fn()); };\n }, [queryClient]);\n }\n ```\n\n4. **Basic debug UI component:**\n ```tsx\n // src/components/DebugView.tsx\n export function DebugView() {\n const { data, isLoading, error } = useLoreData();\n \n if (isLoading) return
Loading...
;\n if (error) return
Error: {error.message}
;\n \n return (\n
\n

Reviews ({data.mrs.reviewing.length})

\n
{JSON.stringify(data.mrs.reviewing, null, 2)}
\n \n

Issues ({data.issues.length})

\n
{JSON.stringify(data.issues, null, 2)}
\n \n

Since Last Check ({data.since_last_check.length})

\n
{JSON.stringify(data.since_last_check, null, 2)}
\n
\n );\n }\n ```\n\n**Verification checklist:**\n- [ ] App starts without errors\n- [ ] Data appears in UI after ~1 second\n- [ ] Running `lore sync` causes UI to refresh\n- [ ] Error states show meaningful messages\n- [ ] Console has no TypeScript errors\n\n**Acceptance criteria:**\n- Full data pipeline works end-to-end\n- UI shows raw JSON data from lore\n- File watcher triggers refresh\n- Ready to build real views on top","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T20:27:03.716160Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:27:09.230315Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2vw","depends_on_id":"bd-1ev","type":"blocks","created_at":"2026-02-25T20:27:09.210985Z","created_by":"tayloreernisse"},{"issue_id":"bd-2vw","depends_on_id":"bd-hee","type":"blocks","created_at":"2026-02-25T20:27:07.988696Z","created_by":"tayloreernisse"},{"issue_id":"bd-2vw","depends_on_id":"bd-xvy","type":"blocks","created_at":"2026-02-25T20:27:09.230298Z","created_by":"tayloreernisse"}]} -{"id":"bd-2x6","title":"Implement Zustand store with persistence","description":"# Zustand Store with Persistence\n\n**Parent epic:** Phase 1: Foundation\n\n**Purpose:** Central state management for MC. Zustand provides simple, scalable state with persistence support.\n\n**Store slices:**\n\n```typescript\ninterface MCStore {\n // Focus state\n focusId: string | null;\n setFocus: (id: string) => void;\n clearFocus: () => void;\n \n // Items (cached from lore + manual)\n items: WorkItem[];\n setItems: (items: WorkItem[]) => void;\n updateItem: (id: string, updates: Partial) => void;\n \n // Queue order (manual priority)\n queueOrder: string[];\n reorderQueue: (fromIndex: number, toIndex: number) => void;\n \n // UI state\n activeView: 'focus' | 'queue' | 'inbox' | 'settings';\n setActiveView: (view: string) => void;\n \n // Sync state\n syncStatus: SyncState;\n lastSync: Date | null;\n syncError: string | null;\n setSyncStatus: (status: SyncState, error?: string) => void;\n}\n```\n\n**TDD: Store tests (RED first):**\n\n```typescript\n// tests/unit/store.test.ts\ndescribe('MCStore', () => {\n beforeEach(() => {\n useStore.setState(initialState);\n });\n \n describe('focus', () => {\n it('sets focus id', () => {\n const { setFocus } = useStore.getState();\n setFocus('br-123');\n \n expect(useStore.getState().focusId).toBe('br-123');\n });\n \n it('clears focus', () => {\n useStore.setState({ focusId: 'br-123' });\n const { clearFocus } = useStore.getState();\n \n clearFocus();\n \n expect(useStore.getState().focusId).toBeNull();\n });\n });\n \n describe('queue order', () => {\n it('reorders items', () => {\n useStore.setState({ queueOrder: ['a', 'b', 'c', 'd'] });\n const { reorderQueue } = useStore.getState();\n \n reorderQueue(2, 0); // Move 'c' to front\n \n expect(useStore.getState().queueOrder).toEqual(['c', 'a', 'b', 'd']);\n });\n \n it('persists order to state.json', async () => {\n const { reorderQueue } = useStore.getState();\n reorderQueue(0, 1);\n \n // Wait for persistence\n await new Promise(r => setTimeout(r, 100));\n \n const saved = await invoke('read_state');\n expect(saved.queueOrder).toEqual(useStore.getState().queueOrder);\n });\n });\n \n describe('items', () => {\n it('updates single item', () => {\n useStore.setState({ items: [{ id: '1', title: 'Test' }] });\n const { updateItem } = useStore.getState();\n \n updateItem('1', { snoozedUntil: new Date() });\n \n expect(useStore.getState().items[0].snoozedUntil).toBeDefined();\n });\n });\n \n describe('persistence', () => {\n it('loads state from state.json on init', async () => {\n const savedState = { focusId: 'br-saved', queueOrder: ['a', 'b'] };\n vi.mocked(invoke).mockResolvedValue(savedState);\n \n await initStore();\n \n expect(useStore.getState().focusId).toBe('br-saved');\n });\n });\n});\n```\n\n**Implementation:**\n\n```typescript\n// src/store/index.ts\nimport { create } from 'zustand';\nimport { persist, createJSONStorage } from 'zustand/middleware';\nimport { invoke } from '@tauri-apps/api/core';\n\n// Custom storage that uses Tauri backend\nconst tauriStorage = createJSONStorage(() => ({\n getItem: async (name) => {\n const state = await invoke('read_state');\n return JSON.stringify(state);\n },\n setItem: async (name, value) => {\n await invoke('write_state', { state: JSON.parse(value) });\n },\n removeItem: async (name) => {\n await invoke('clear_state');\n },\n}));\n\nexport const useStore = create()(\n persist(\n (set, get) => ({\n // Focus\n focusId: null,\n setFocus: (id) => set({ focusId: id }),\n clearFocus: () => set({ focusId: null }),\n \n // Items\n items: [],\n setItems: (items) => set({ items }),\n updateItem: (id, updates) => set(state => ({\n items: state.items.map(i => i.id === id ? { ...i, ...updates } : i)\n })),\n \n // Queue order\n queueOrder: [],\n reorderQueue: (from, to) => set(state => {\n const newOrder = [...state.queueOrder];\n const [removed] = newOrder.splice(from, 1);\n newOrder.splice(to, 0, removed);\n return { queueOrder: newOrder };\n }),\n \n // UI\n activeView: 'focus',\n setActiveView: (view) => set({ activeView: view }),\n \n // Sync\n syncStatus: 'synced',\n lastSync: null,\n syncError: null,\n setSyncStatus: (status, error) => set({ \n syncStatus: status, \n syncError: error,\n lastSync: status === 'synced' ? new Date() : get().lastSync\n }),\n }),\n {\n name: 'mc-state',\n storage: tauriStorage,\n partialize: (state) => ({\n focusId: state.focusId,\n queueOrder: state.queueOrder,\n activeView: state.activeView,\n }),\n }\n )\n);\n```\n\n**Acceptance criteria:**\n- All store actions work correctly\n- State persists to ~/.local/share/mc/state.json\n- Loads persisted state on startup\n- Queue order persists across sessions","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T20:51:57.414784Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:53:42.026033Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2x6","depends_on_id":"bd-3fd","type":"blocks","created_at":"2026-02-25T20:53:42.026016Z","created_by":"tayloreernisse"},{"issue_id":"bd-2x6","depends_on_id":"bd-hee","type":"blocks","created_at":"2026-02-25T20:53:41.996038Z","created_by":"tayloreernisse"}]} +{"id":"bd-2x6","title":"Implement Zustand store with persistence","description":"# Zustand Store with Persistence\n\n**Parent epic:** Phase 1: Foundation\n\n**Purpose:** Central state management for MC. Zustand provides simple, scalable state with persistence support.\n\n**Store slices:**\n\n```typescript\ninterface MCStore {\n // Focus state\n focusId: string | null;\n setFocus: (id: string) => void;\n clearFocus: () => void;\n \n // Items (cached from lore + manual)\n items: WorkItem[];\n setItems: (items: WorkItem[]) => void;\n updateItem: (id: string, updates: Partial) => void;\n \n // Queue order (manual priority)\n queueOrder: string[];\n reorderQueue: (fromIndex: number, toIndex: number) => void;\n \n // UI state\n activeView: 'focus' | 'queue' | 'inbox' | 'settings';\n setActiveView: (view: string) => void;\n \n // Sync state\n syncStatus: SyncState;\n lastSync: Date | null;\n syncError: string | null;\n setSyncStatus: (status: SyncState, error?: string) => void;\n}\n```\n\n**TDD: Store tests (RED first):**\n\n```typescript\n// tests/unit/store.test.ts\ndescribe('MCStore', () => {\n beforeEach(() => {\n useStore.setState(initialState);\n });\n \n describe('focus', () => {\n it('sets focus id', () => {\n const { setFocus } = useStore.getState();\n setFocus('br-123');\n \n expect(useStore.getState().focusId).toBe('br-123');\n });\n \n it('clears focus', () => {\n useStore.setState({ focusId: 'br-123' });\n const { clearFocus } = useStore.getState();\n \n clearFocus();\n \n expect(useStore.getState().focusId).toBeNull();\n });\n });\n \n describe('queue order', () => {\n it('reorders items', () => {\n useStore.setState({ queueOrder: ['a', 'b', 'c', 'd'] });\n const { reorderQueue } = useStore.getState();\n \n reorderQueue(2, 0); // Move 'c' to front\n \n expect(useStore.getState().queueOrder).toEqual(['c', 'a', 'b', 'd']);\n });\n \n it('persists order to state.json', async () => {\n const { reorderQueue } = useStore.getState();\n reorderQueue(0, 1);\n \n // Wait for persistence\n await new Promise(r => setTimeout(r, 100));\n \n const saved = await invoke('read_state');\n expect(saved.queueOrder).toEqual(useStore.getState().queueOrder);\n });\n });\n \n describe('items', () => {\n it('updates single item', () => {\n useStore.setState({ items: [{ id: '1', title: 'Test' }] });\n const { updateItem } = useStore.getState();\n \n updateItem('1', { snoozedUntil: new Date() });\n \n expect(useStore.getState().items[0].snoozedUntil).toBeDefined();\n });\n });\n \n describe('persistence', () => {\n it('loads state from state.json on init', async () => {\n const savedState = { focusId: 'br-saved', queueOrder: ['a', 'b'] };\n vi.mocked(invoke).mockResolvedValue(savedState);\n \n await initStore();\n \n expect(useStore.getState().focusId).toBe('br-saved');\n });\n });\n});\n```\n\n**Implementation:**\n\n```typescript\n// src/store/index.ts\nimport { create } from 'zustand';\nimport { persist, createJSONStorage } from 'zustand/middleware';\nimport { invoke } from '@tauri-apps/api/core';\n\n// Custom storage that uses Tauri backend\nconst tauriStorage = createJSONStorage(() => ({\n getItem: async (name) => {\n const state = await invoke('read_state');\n return JSON.stringify(state);\n },\n setItem: async (name, value) => {\n await invoke('write_state', { state: JSON.parse(value) });\n },\n removeItem: async (name) => {\n await invoke('clear_state');\n },\n}));\n\nexport const useStore = create()(\n persist(\n (set, get) => ({\n // Focus\n focusId: null,\n setFocus: (id) => set({ focusId: id }),\n clearFocus: () => set({ focusId: null }),\n \n // Items\n items: [],\n setItems: (items) => set({ items }),\n updateItem: (id, updates) => set(state => ({\n items: state.items.map(i => i.id === id ? { ...i, ...updates } : i)\n })),\n \n // Queue order\n queueOrder: [],\n reorderQueue: (from, to) => set(state => {\n const newOrder = [...state.queueOrder];\n const [removed] = newOrder.splice(from, 1);\n newOrder.splice(to, 0, removed);\n return { queueOrder: newOrder };\n }),\n \n // UI\n activeView: 'focus',\n setActiveView: (view) => set({ activeView: view }),\n \n // Sync\n syncStatus: 'synced',\n lastSync: null,\n syncError: null,\n setSyncStatus: (status, error) => set({ \n syncStatus: status, \n syncError: error,\n lastSync: status === 'synced' ? new Date() : get().lastSync\n }),\n }),\n {\n name: 'mc-state',\n storage: tauriStorage,\n partialize: (state) => ({\n focusId: state.focusId,\n queueOrder: state.queueOrder,\n activeView: state.activeView,\n }),\n }\n )\n);\n```\n\n**Acceptance criteria:**\n- All store actions work correctly\n- State persists to ~/.local/share/mc/state.json\n- Loads persisted state on startup\n- Queue order persists across sessions","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-25T20:51:57.414784Z","created_by":"tayloreernisse","updated_at":"2026-02-26T15:01:58.832498Z","closed_at":"2026-02-26T15:01:58.832439Z","close_reason":"Completed: Implemented Tauri-backed persistence for Zustand stores. Created tauri-storage.ts adapter, added read_state/write_state/clear_state Tauri commands, and wired up focus-store and nav-store to persist to ~/.local/share/mc/state.json","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2x6","depends_on_id":"bd-3fd","type":"blocks","created_at":"2026-02-25T20:53:42.026016Z","created_by":"tayloreernisse"},{"issue_id":"bd-2x6","depends_on_id":"bd-hee","type":"blocks","created_at":"2026-02-25T20:53:41.996038Z","created_by":"tayloreernisse"}]} {"id":"bd-2zu","title":"Implement decision log infrastructure","description":"# Decision Logging Infrastructure (RED → GREEN)\n\n**Parent epic:** Phase 2: Bridge + Data Layer\n\n**Philosophy:** Manual-first, learn from data. We don't know the right prioritization algorithm yet. Instead:\n1. User manually sets THE ONE THING\n2. MC logs every decision with context and reasoning\n3. Post-process logs to extract patterns (future)\n4. Eventually codify patterns into suggestions (future)\n\n**Log file:** `~/.local/share/mc/decision_log.jsonl`\n\n**Actions to log:**\n| Action | What to Capture |\n|--------|-----------------|\n| `set_focus` | Which bead, why, what else was available |\n| `reorder` | Old order, new order, why |\n| `defer` | Which bead, duration, why |\n| `snooze` | Which bead, until when, why |\n| `skip` | Which bead, why (explicitly chose not to do it) |\n| `complete` | Which bead, duration if tracked, notes |\n| `create_manual` | New bead from quick capture |\n| `change_priority` | Old priority, new priority, why |\n\n**TDD: Decision log tests (RED first):**\n\n```rust\n#[test]\nfn log_entry_appended() {\n let temp = tempfile::tempdir().unwrap();\n let log_path = temp.path().join(\"decisions.jsonl\");\n let logger = DecisionLogger::new(&log_path)?;\n \n logger.log(DecisionEntry {\n action: Action::SetFocus,\n bead_id: \"br-x7f\".into(),\n reason: Some(\"Sarah is blocked\".into()),\n tags: vec![\"blocking\".into(), \"urgent\".into()],\n context: context_snapshot(),\n })?;\n \n let content = std::fs::read_to_string(&log_path)?;\n let entries: Vec = content.lines()\n .map(|l| serde_json::from_str(l).unwrap())\n .collect();\n \n assert_eq!(entries.len(), 1);\n assert_eq!(entries[0].action, Action::SetFocus);\n}\n\n#[test]\nfn multiple_logs_append_not_overwrite() {\n let temp = tempfile::tempdir().unwrap();\n let log_path = temp.path().join(\"decisions.jsonl\");\n let logger = DecisionLogger::new(&log_path)?;\n \n logger.log(entry1)?;\n logger.log(entry2)?;\n logger.log(entry3)?;\n \n let content = std::fs::read_to_string(&log_path)?;\n assert_eq!(content.lines().count(), 3);\n}\n\n#[test]\nfn context_snapshot_captured() {\n let temp = tempfile::tempdir().unwrap();\n let log_path = temp.path().join(\"decisions.jsonl\");\n let logger = DecisionLogger::new(&log_path)?;\n \n let context = DecisionContext {\n queue_snapshot: vec![item1, item2, item3],\n time_of_day: TimeOfDay::Morning,\n day_of_week: Weekday::Tuesday,\n items_completed_today: 3,\n focus_session_duration: Some(Duration::from_secs(2700)), // 45 min\n };\n \n logger.log(DecisionEntry {\n context,\n ..Default::default()\n })?;\n \n let content = std::fs::read_to_string(&log_path)?;\n let entry: DecisionEntry = serde_json::from_str(content.lines().next().unwrap())?;\n \n assert_eq!(entry.context.items_completed_today, 3);\n}\n```\n\n**Implementation (GREEN):**\n\n```rust\n#[derive(Serialize, Deserialize, Debug)]\npub struct DecisionEntry {\n pub timestamp: DateTime,\n pub action: Action,\n pub bead_id: String,\n pub reason: Option,\n pub tags: Vec,\n pub context: DecisionContext,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct DecisionContext {\n pub queue_snapshot: Vec,\n pub time_of_day: TimeOfDay,\n pub day_of_week: Weekday,\n pub focus_session_duration: Option,\n pub items_completed_today: u32,\n pub previous_focus: Option,\n}\n\npub struct DecisionLogger {\n file: std::fs::File,\n}\n\nimpl DecisionLogger {\n pub fn new(path: &Path) -> Result {\n let file = OpenOptions::new()\n .create(true)\n .append(true)\n .open(path)?;\n Ok(Self { file })\n }\n \n pub fn log(&self, entry: DecisionEntry) -> Result<()> {\n let mut entry = entry;\n entry.timestamp = Utc::now();\n \n let json = serde_json::to_string(&entry)?;\n writeln!(&self.file, \"{}\", json)?;\n \n Ok(())\n }\n}\n```\n\n**Example log entry:**\n```json\n{\n \"timestamp\": \"2026-02-25T10:30:00Z\",\n \"action\": \"set_focus\",\n \"bead_id\": \"br-x7f\",\n \"reason\": \"Sarah pinged me on Slack, she's blocked\",\n \"tags\": [\"blocking\", \"urgent\"],\n \"context\": {\n \"previous_focus\": \"br-a3b\",\n \"queue_size\": 12,\n \"time_of_day\": \"morning\",\n \"day_of_week\": \"Tuesday\",\n \"items_completed_today\": 3\n }\n}\n```\n\n**Log retention (v1):**\n- Accept unbounded growth (18 MB/year at 100 decisions/day)\n- Future: rotate at 50 MB, time-based pruning\n\n**Acceptance criteria:**\n- Append-only JSONL format\n- All action types logged with context\n- No data loss on crash (flush after each write)\n- Queryable for pattern extraction (future)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-25T20:29:59.639352Z","created_by":"tayloreernisse","updated_at":"2026-02-26T14:05:43.498026Z","closed_at":"2026-02-26T14:05:43.497903Z","close_reason":"Completed: DecisionLog with append/read_all exists in state.rs","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2zu","depends_on_id":"bd-2us","type":"blocks","created_at":"2026-02-25T20:30:20.789955Z","created_by":"tayloreernisse"}]} {"id":"bd-318","title":"Implement QueueView container","description":"# QueueView Container\n\n**Parent epic:** Phase 4: Queue + Inbox Views\n\n**Purpose:** Container component that assembles QueueList with filtering, batch trigger, and navigation.\n\n**TDD: QueueView tests (RED first):**\n\n```typescript\n// tests/views/QueueView.test.tsx\ndescribe('QueueView', () => {\n it('renders QueueList with items grouped by type', () => {\n const store = createStore({ items: mockItems });\n render(, { wrapper: StoreProvider(store) });\n \n expect(screen.getByText('REVIEWS (2)')).toBeInTheDocument();\n expect(screen.getByText('ASSIGNED ISSUES (1)')).toBeInTheDocument();\n });\n \n it('filters items when filter applied', async () => {\n const store = createStore({ items: mockItems });\n render(, { wrapper: StoreProvider(store) });\n \n await userEvent.keyboard('{Meta>}k{/Meta}');\n await userEvent.type(screen.getByRole('textbox'), 'type:review{Enter}');\n \n expect(screen.getByText('REVIEWS (2)')).toBeInTheDocument();\n expect(screen.queryByText('ASSIGNED ISSUES')).not.toBeInTheDocument();\n });\n \n it('hides snoozed items by default', () => {\n const items = [\n ...mockItems,\n { id: '99', snoozedUntil: new Date(Date.now() + 3600000) }\n ];\n const store = createStore({ items });\n render(, { wrapper: StoreProvider(store) });\n \n expect(screen.queryByText(items[2].title)).not.toBeInTheDocument();\n });\n \n it('shows empty state when no items', () => {\n const store = createStore({ items: [] });\n render(, { wrapper: StoreProvider(store) });\n \n expect(screen.getByText(/Queue is empty/)).toBeInTheDocument();\n });\n \n it('batch button enters batch mode', async () => {\n const setActiveView = vi.fn();\n const store = createStore({ items: mockReviewItems, setActiveView });\n render(, { wrapper: StoreProvider(store) });\n \n await userEvent.click(screen.getByRole('button', { name: /batch all/i }));\n \n // Should show batch mode (or navigate to it)\n expect(screen.getByText('BATCH:')).toBeInTheDocument();\n });\n});\n```\n\n**Implementation:**\n\n```tsx\n// src/views/QueueView.tsx\nexport function QueueView() {\n const { items, queueOrder, setFocus, setActiveView } = useStore();\n const [filter, setFilter] = useState(null);\n const [showCommandPalette, setShowCommandPalette] = useState(false);\n const [batchType, setBatchType] = useState(null);\n \n useKeyboardShortcuts({\n 'mod+k': () => setShowCommandPalette(true),\n });\n \n // Filter and sort items\n const visibleItems = useMemo(() => {\n let filtered = items\n .filter(i => !i.snoozedUntil || new Date(i.snoozedUntil) < new Date())\n .filter(i => !i.skippedToday);\n \n if (filter?.type) {\n filtered = filtered.filter(i => i.type === filter.type);\n }\n \n // Sort by queue order, then by age\n return filtered.sort((a, b) => {\n const aIdx = queueOrder.indexOf(a.id);\n const bIdx = queueOrder.indexOf(b.id);\n if (aIdx !== -1 && bIdx !== -1) return aIdx - bIdx;\n if (aIdx !== -1) return -1;\n if (bIdx !== -1) return 1;\n return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();\n });\n }, [items, queueOrder, filter]);\n \n const handleSetFocus = (id: string) => {\n setFocus(id);\n setActiveView('focus');\n };\n \n const handleBatchStart = (type: ItemType) => {\n setBatchType(type);\n };\n \n if (batchType) {\n const batchItems = visibleItems.filter(i => i.type === batchType);\n return (\n setBatchType(null)}\n />\n );\n }\n \n if (visibleItems.length === 0) {\n return (\n
\n \n

Queue is empty

\n

All caught up!

\n
\n );\n }\n \n return (\n
\n
\n

Queue

\n \n
\n \n {filter && (\n
\n {formatFilter(filter)}\n \n
\n )}\n \n \n \n {showCommandPalette && (\n setShowCommandPalette(false)}\n />\n )}\n
\n );\n}\n```\n\n**Acceptance criteria:**\n- Items display grouped by type\n- Filter works via command palette\n- Snoozed items hidden\n- Empty state shows\n- Batch mode triggers correctly\n- Click sets focus and navigates","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T20:54:23.048976Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:54:48.312782Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-318","depends_on_id":"bd-2cl","type":"blocks","created_at":"2026-02-25T20:54:48.235273Z","created_by":"tayloreernisse"},{"issue_id":"bd-318","depends_on_id":"bd-3ua","type":"blocks","created_at":"2026-02-25T20:54:48.271546Z","created_by":"tayloreernisse"},{"issue_id":"bd-318","depends_on_id":"bd-716","type":"blocks","created_at":"2026-02-25T20:54:48.201245Z","created_by":"tayloreernisse"},{"issue_id":"bd-318","depends_on_id":"bd-j76","type":"blocks","created_at":"2026-02-25T20:54:48.312764Z","created_by":"tayloreernisse"}]} {"id":"bd-35u","title":"Implement two-strike auto-close for GitLab state changes","description":"# Two-Strike Auto-Close Rule (RED → GREEN)\n\n**Parent epic:** Phase 2: Bridge + Data Layer\n\n**Problem being solved:**\nWhat if GitLab's API hiccups and temporarily says \"you have no reviews\"? Without protection, we'd delete all your tasks.\n\n**Solution:**\nItems must be missing for TWO consecutive reconciliations before auto-close.\n\n| Check #1 | Check #2 | Result |\n|----------|----------|--------|\n| Missing | Missing | Close the task (confirmed gone) |\n| Missing | Found | Keep it (was just a glitch) |\n| Found | — | Keep it (still active) |\n\n**TDD: Two-strike tests (RED first):**\n\n```rust\n#[test]\nfn first_miss_sets_suspect_orphan() {\n let mut bridge = setup_with_mapping(\"mr:gitlab:123:847\", \"br-x\");\n let lore_items = vec![]; // Item missing\n \n bridge.reconcile(&lore_items)?;\n \n let entry = bridge.mapping.get(\"mr:gitlab:123:847\").unwrap();\n assert!(entry.suspect_orphan, \"First miss should set suspect_orphan\");\n assert!(bridge.beads.exists(\"br-x\")?, \"Bead should NOT be closed yet\");\n}\n\n#[test]\nfn second_miss_closes_bead() {\n let mut bridge = setup_with_suspect_orphan(\"mr:gitlab:123:847\", \"br-x\");\n let lore_items = vec![]; // Still missing\n \n bridge.reconcile(&lore_items)?;\n \n assert!(!bridge.mapping.contains_key(\"mr:gitlab:123:847\"), \"Entry should be removed\");\n assert!(!bridge.beads.exists(\"br-x\")?, \"Bead should be closed\");\n}\n\n#[test]\nfn reappearance_clears_suspect() {\n let mut bridge = setup_with_suspect_orphan(\"mr:gitlab:123:847\", \"br-x\");\n let lore_items = vec![mr_item(\"mr:gitlab:123:847\")]; // Item reappears\n \n bridge.reconcile(&lore_items)?;\n \n let entry = bridge.mapping.get(\"mr:gitlab:123:847\").unwrap();\n assert!(!entry.suspect_orphan, \"Reappearance should clear suspect flag\");\n}\n\n#[test]\nfn auto_close_includes_reason() {\n let mut beads = MockBeadsCli::new();\n let mut bridge = setup_with_suspect_orphan_and(&mut beads, \"mr:gitlab:123:847\", \"br-x\");\n \n bridge.reconcile(&[])?;\n \n let close_call = beads.calls().iter().find(|c| matches!(c, BeadsCall::Close { .. }));\n assert!(close_call.is_some());\n if let Some(BeadsCall::Close { reason, .. }) = close_call {\n assert!(reason.contains(\"MR\") || reason.contains(\"GitLab\"));\n }\n}\n\n#[test]\nfn invariant_inv3_no_false_closes() {\n // Fuzz test: random sequences of present/missing should never violate INV-3\n let mut bridge = setup_with_mapping(\"mr:gitlab:123:847\", \"br-x\");\n \n // Single miss should never close\n bridge.reconcile(&[])?;\n assert!(bridge.beads.exists(\"br-x\")?);\n \n // Reappearance after single miss should preserve bead\n bridge.reconcile(&[mr_item(\"mr:gitlab:123:847\")])?;\n assert!(bridge.beads.exists(\"br-x\")?);\n \n // Only double-miss should close\n bridge.reconcile(&[])?; // Miss 1\n bridge.reconcile(&[])?; // Miss 2 → close\n assert!(!bridge.beads.exists(\"br-x\")?);\n}\n```\n\n**Implementation (GREEN):**\n\n```rust\nimpl BridgeState {\n pub fn reconcile(&mut self, current_items: &[LoreItem]) -> Result<()> {\n let expected_keys: HashSet<_> = current_items\n .iter()\n .map(|i| i.to_mapping_key())\n .collect();\n \n let mut to_close = vec![];\n \n for (key, entry) in self.mapping.iter_mut() {\n if expected_keys.contains(key) {\n // Item still exists — clear any suspect flag\n entry.suspect_orphan = false;\n } else if entry.suspect_orphan {\n // Second miss — schedule for closure\n to_close.push((key.clone(), entry.bead_id.clone()));\n } else {\n // First miss — set suspect flag\n entry.suspect_orphan = true;\n }\n }\n \n // Close confirmed orphans\n for (key, bead_id) in to_close {\n if let Some(id) = bead_id {\n self.beads.close(&id, \"Item no longer in GitLab\")?;\n }\n self.mapping.remove(&key);\n }\n \n // Add new items not in mapping\n for item in current_items {\n let key = item.to_mapping_key();\n if !self.mapping.contains_key(&key) {\n // ... create bead\n }\n }\n \n Ok(())\n }\n}\n```\n\n**Acceptance criteria:**\n- First miss only sets flag, never closes\n- Second miss closes bead with descriptive reason\n- Reappearance clears suspect flag\n- Invariant INV-3 never violated","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-25T20:28:34.240322Z","created_by":"tayloreernisse","updated_at":"2026-02-26T14:55:39.630071Z","closed_at":"2026-02-26T14:55:39.630020Z","close_reason":"Done in bridge.rs - suspect_orphan two-strike","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-35u","depends_on_id":"bd-1q7","type":"blocks","created_at":"2026-02-25T20:30:22.698016Z","created_by":"tayloreernisse"},{"issue_id":"bd-35u","depends_on_id":"bd-2us","type":"blocks","created_at":"2026-02-25T20:30:20.690779Z","created_by":"tayloreernisse"}]} @@ -42,7 +42,7 @@ {"id":"bd-3k4","title":"Implement focus actions with decision logging","description":"# Focus Actions with Decision Logging (RED → GREEN)\n\n**Parent epic:** Phase 3: Focus View\n\n**Actions:**\n| Action | What happens |\n|--------|--------------|\n| Start | Opens GitLab URL in browser, logs decision |\n| Defer 1h | Sets snooze until now+1h, logs with reason |\n| Defer Tomorrow | Sets snooze until 9am tomorrow, logs with reason |\n| Skip | Removes from queue for today, logs with reason |\n\n**TDD: Action tests (RED first):**\n\n```typescript\n// tests/hooks/useActions.test.ts\ndescribe('useActions', () => {\n it('start opens URL in browser', async () => {\n const mockOpen = vi.fn();\n vi.mock('@tauri-apps/plugin-shell', () => ({ open: mockOpen }));\n \n const { result } = renderHook(() => useActions());\n \n await result.current.start({\n id: 'br-x7f',\n url: 'https://gitlab.com/platform/core/-/merge_requests/847'\n });\n \n expect(mockOpen).toHaveBeenCalledWith('https://gitlab.com/platform/core/-/merge_requests/847');\n });\n \n it('start logs decision', async () => {\n const mockLogDecision = vi.fn();\n vi.mock('../store', () => ({ useStore: () => ({ logDecision: mockLogDecision }) }));\n \n const { result } = renderHook(() => useActions());\n \n await result.current.start({ id: 'br-x7f' });\n \n expect(mockLogDecision).toHaveBeenCalledWith(expect.objectContaining({\n action: 'start',\n bead_id: 'br-x7f',\n }));\n });\n \n it('defer calculates correct snooze time', async () => {\n vi.useFakeTimers();\n vi.setSystemTime(new Date('2026-02-25T10:00:00Z'));\n \n const mockUpdateBead = vi.fn();\n const { result } = renderHook(() => useActions());\n \n await result.current.defer({ id: 'br-x7f' }, '1h');\n \n expect(mockUpdateBead).toHaveBeenCalledWith('br-x7f', {\n snoozedUntil: new Date('2026-02-25T11:00:00Z')\n });\n \n vi.useRealTimers();\n });\n \n it('defer tomorrow uses 9am', async () => {\n vi.useFakeTimers();\n vi.setSystemTime(new Date('2026-02-25T22:00:00Z')); // 10pm\n \n const mockUpdateBead = vi.fn();\n const { result } = renderHook(() => useActions());\n \n await result.current.defer({ id: 'br-x7f' }, 'tomorrow');\n \n expect(mockUpdateBead).toHaveBeenCalledWith('br-x7f', {\n snoozedUntil: new Date('2026-02-26T09:00:00') // 9am next day\n });\n \n vi.useRealTimers();\n });\n \n it('skip marks item as skipped for today', async () => {\n const mockUpdateBead = vi.fn();\n const { result } = renderHook(() => useActions());\n \n await result.current.skip({ id: 'br-x7f' }, 'Not urgent');\n \n expect(mockUpdateBead).toHaveBeenCalledWith('br-x7f', {\n skippedToday: true\n });\n });\n});\n```\n\n**Implementation (GREEN):**\n\n```typescript\n// src/hooks/useActions.ts\nimport { open } from '@tauri-apps/plugin-shell';\nimport { invoke } from '@tauri-apps/api/core';\n\nexport function useActions() {\n const { logDecision, updateItem } = useStore();\n \n const start = async (item: WorkItem) => {\n // Open in browser\n if (item.url) {\n await open(item.url);\n }\n \n // Log decision\n await invoke('log_decision', {\n entry: {\n action: 'start',\n bead_id: item.id,\n context: captureContext(),\n }\n });\n };\n \n const defer = async (item: WorkItem, duration: '1h' | 'tomorrow' | string) => {\n const snoozedUntil = calculateSnoozeTime(duration);\n \n await invoke('update_item', { \n id: item.id, \n updates: { snoozedUntil } \n });\n \n // Prompt for reason then log\n const reason = await promptForReason('defer');\n await invoke('log_decision', {\n entry: {\n action: 'defer',\n bead_id: item.id,\n reason,\n context: captureContext(),\n }\n });\n };\n \n const skip = async (item: WorkItem) => {\n await invoke('update_item', { \n id: item.id, \n updates: { skippedToday: true } \n });\n \n const reason = await promptForReason('skip');\n await invoke('log_decision', {\n entry: {\n action: 'skip',\n bead_id: item.id,\n reason,\n context: captureContext(),\n }\n });\n };\n \n return { start, defer, skip };\n}\n```\n\n**Acceptance criteria:**\n- Start opens correct URL in default browser\n- Defer calculates correct snooze times\n- Skip hides item for rest of day\n- All actions logged with context\n- Reason prompt appears and captures input","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T20:31:32.700423Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:32:00.962929Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3k4","depends_on_id":"bd-1ft","type":"blocks","created_at":"2026-02-25T20:32:00.837728Z","created_by":"tayloreernisse"},{"issue_id":"bd-3k4","depends_on_id":"bd-1kr","type":"blocks","created_at":"2026-02-25T20:32:00.912094Z","created_by":"tayloreernisse"},{"issue_id":"bd-3k4","depends_on_id":"bd-2p0","type":"blocks","created_at":"2026-02-25T20:32:00.962907Z","created_by":"tayloreernisse"},{"issue_id":"bd-3k4","depends_on_id":"bd-2zu","type":"blocks","created_at":"2026-02-25T20:32:00.938124Z","created_by":"tayloreernisse"}]} {"id":"bd-3ke","title":"Implement event-to-bead title and key formatting","description":"# Event-to-Bead Title and Key Formatting\n\n**Parent epic:** Phase 2: Bridge + Data Layer\n\n**Purpose:** Convert lore events into standardized bead titles and stable mapping keys.\n\n**Mapping rules from plan:**\n\n| GitLab Event | Bead Title | Mapping Key Format |\n|--------------|------------|-------------------|\n| MR review requested | `Review MR !{iid}: {title}` | `mr_review:{host}:{project_id}:{iid}` |\n| Issue assigned | `Issue #{iid}: {title}` | `issue:{host}:{project_id}:{iid}` |\n| MR you authored | `Your MR !{iid}: {title}` | `mr_authored:{host}:{project_id}:{iid}` |\n| Mention in discussion | `Mentioned in {type} #{iid}: {snippet}` | `mention:{host}:{project_id}:{iid}:{note_id}` |\n| Comment on your MR | `Respond to @{actor} on MR !{iid}` | `feedback:{host}:{project_id}:{iid}:{note_id}` |\n\n**Key format rationale:** We use numeric `project_id` instead of project path because paths can change (renames, transfers). Numeric IDs are immutable.\n\n**TDD: Formatting tests (RED first):**\n\n```rust\n// src-tauri/tests/event_formatting_test.rs\n\n#[test]\nfn mr_review_title_format() {\n let event = LoreEvent::MrReview {\n iid: 847,\n title: \"Fix authentication token refresh logic\".into(),\n project: \"platform/core\".into(),\n author: \"sarah\".into(),\n };\n \n let title = format_bead_title(&event);\n \n assert_eq!(title, \"Review MR !847: Fix authentication token refresh logic\");\n}\n\n#[test]\nfn mr_review_key_format() {\n let event = LoreEvent::MrReview {\n host: \"gitlab.com\".into(),\n project_id: 12345,\n iid: 847,\n ..\n };\n \n let key = format_mapping_key(&event);\n \n assert_eq!(key, \"mr_review:gitlab.com:12345:847\");\n}\n\n#[test]\nfn issue_assigned_title_format() {\n let event = LoreEvent::IssueAssigned {\n iid: 312,\n title: \"Implement user profile page\".into(),\n };\n \n let title = format_bead_title(&event);\n \n assert_eq!(title, \"Issue #312: Implement user profile page\");\n}\n\n#[test]\nfn mention_title_truncates_snippet() {\n let event = LoreEvent::Mention {\n entity_type: \"issue\".into(),\n iid: 312,\n snippet: \"Hey @taylor can you take a look at this really long comment that goes on and on\".into(),\n };\n \n let title = format_bead_title(&event);\n \n assert!(title.len() <= 80);\n assert!(title.ends_with(\"...\"));\n}\n\n#[test]\nfn feedback_title_format() {\n let event = LoreEvent::Feedback {\n iid: 847,\n actor: \"mike\".into(),\n };\n \n let title = format_bead_title(&event);\n \n assert_eq!(title, \"Respond to @mike on MR !847\");\n}\n\n#[test]\nfn key_uses_project_id_not_path() {\n let event = LoreEvent::MrReview {\n project: \"old-name/repo\".into(), // Path can change\n project_id: 12345, // ID is stable\n ..\n };\n \n let key = format_mapping_key(&event);\n \n assert!(key.contains(\"12345\"));\n assert!(!key.contains(\"old-name\"));\n}\n\n#[test]\nfn key_handles_special_characters() {\n let event = LoreEvent::Mention {\n host: \"gitlab.example.com\".into(),\n project_id: 123,\n iid: 45,\n note_id: 67890,\n };\n \n let key = format_mapping_key(&event);\n \n // Should be safe for JSON keys and filenames\n assert!(!key.contains(' '));\n assert!(!key.contains('/'));\n}\n```\n\n**Implementation:**\n\n```rust\n// src-tauri/src/bridge/formatting.rs\n\npub fn format_bead_title(event: &LoreEvent) -> String {\n match event {\n LoreEvent::MrReview { iid, title, .. } => {\n format!(\"Review MR !{}: {}\", iid, truncate(title, 60))\n }\n \n LoreEvent::IssueAssigned { iid, title, .. } => {\n format!(\"Issue #{}: {}\", iid, truncate(title, 60))\n }\n \n LoreEvent::MrAuthored { iid, title, .. } => {\n format!(\"Your MR !{}: {}\", iid, truncate(title, 60))\n }\n \n LoreEvent::Mention { entity_type, iid, snippet, .. } => {\n format!(\"Mentioned in {} #{}: {}\", entity_type, iid, truncate(snippet, 40))\n }\n \n LoreEvent::Feedback { iid, actor, .. } => {\n format!(\"Respond to @{} on MR !{}\", actor, iid)\n }\n }\n}\n\npub fn format_mapping_key(event: &LoreEvent) -> String {\n match event {\n LoreEvent::MrReview { host, project_id, iid, .. } => {\n format!(\"mr_review:{}:{}:{}\", host, project_id, iid)\n }\n \n LoreEvent::IssueAssigned { host, project_id, iid, .. } => {\n format!(\"issue:{}:{}:{}\", host, project_id, iid)\n }\n \n LoreEvent::MrAuthored { host, project_id, iid, .. } => {\n format!(\"mr_authored:{}:{}:{}\", host, project_id, iid)\n }\n \n LoreEvent::Mention { host, project_id, iid, note_id, .. } => {\n format!(\"mention:{}:{}:{}:{}\", host, project_id, iid, note_id)\n }\n \n LoreEvent::Feedback { host, project_id, iid, note_id, .. } => {\n format!(\"feedback:{}:{}:{}:{}\", host, project_id, iid, note_id)\n }\n }\n}\n\nfn truncate(s: &str, max_len: usize) -> String {\n if s.len() <= max_len {\n s.to_string()\n } else {\n format!(\"{}...\", &s[..max_len - 3])\n }\n}\n```\n\n**Acceptance criteria:**\n- All event types produce correct titles\n- Keys use project_id (stable) not path\n- Long titles truncated with ellipsis\n- Keys are safe for JSON/filesystem\n- Roundtrip: key → parse → same components","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T21:07:19.732579Z","created_by":"tayloreernisse","updated_at":"2026-02-25T21:08:14.773249Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3ke","depends_on_id":"bd-1ev","type":"blocks","created_at":"2026-02-25T21:08:14.773232Z","created_by":"tayloreernisse"},{"issue_id":"bd-3ke","depends_on_id":"bd-2us","type":"blocks","created_at":"2026-02-25T21:08:14.740140Z","created_by":"tayloreernisse"}]} {"id":"bd-3pc","title":"Implement error boundary and error handling UI","description":"# Error Boundary and Error Handling UI\n\n**Parent epic:** Phase 7: Polish + E2E Tests\n\n**Purpose:** Graceful error handling throughout the app. Crashes should be caught and show recovery options.\n\n**Error types:**\n\n| Error Type | Handling | User Message |\n|------------|----------|--------------|\n| CLI not found | Show setup guide | \"lore not found. Install with...\" |\n| CLI failure | Retry option | \"Sync failed. [Retry]\" |\n| Network error | Retry + offline mode | \"GitLab unreachable. Working offline.\" |\n| State corruption | Auto-recover + notify | \"Recovered from error. Some data may need refresh.\" |\n| React crash | Error boundary | \"Something went wrong. [Reload]\" |\n\n**TDD: Error handling tests (RED first):**\n\n```typescript\n// tests/components/ErrorBoundary.test.tsx\ndescribe('ErrorBoundary', () => {\n it('catches errors and shows fallback', () => {\n const ThrowingComponent = () => {\n throw new Error('Test error');\n };\n \n render(\n \n \n \n );\n \n expect(screen.getByText(/Something went wrong/)).toBeInTheDocument();\n expect(screen.getByRole('button', { name: /reload/i })).toBeInTheDocument();\n });\n \n it('logs error details', () => {\n const consoleSpy = vi.spyOn(console, 'error');\n const ThrowingComponent = () => { throw new Error('Test'); };\n \n render(\n \n \n \n );\n \n expect(consoleSpy).toHaveBeenCalled();\n });\n \n it('shows recovery actions', () => {\n const onRecover = vi.fn();\n \n render(\n \n \n \n );\n \n userEvent.click(screen.getByRole('button', { name: /try again/i }));\n \n expect(onRecover).toHaveBeenCalled();\n });\n});\n\n// tests/components/ErrorDisplay.test.tsx\ndescribe('ErrorDisplay', () => {\n it('shows CLI not found error with setup guide', () => {\n render();\n \n expect(screen.getByText(/lore not found/)).toBeInTheDocument();\n expect(screen.getByText(/Install with/)).toBeInTheDocument();\n });\n \n it('shows retry button for transient errors', () => {\n const onRetry = vi.fn();\n render();\n \n expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument();\n });\n});\n```\n\n**Implementation:**\n\n```tsx\n// src/components/ErrorBoundary.tsx\ninterface ErrorBoundaryState {\n hasError: boolean;\n error: Error | null;\n}\n\nexport class ErrorBoundary extends React.Component<\n { children: React.ReactNode; onRecover?: () => void },\n ErrorBoundaryState\n> {\n state: ErrorBoundaryState = { hasError: false, error: null };\n \n static getDerivedStateFromError(error: Error) {\n return { hasError: true, error };\n }\n \n componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {\n console.error('React error boundary caught:', error, errorInfo);\n \n // Could send to error tracking service\n }\n \n handleReload = () => {\n window.location.reload();\n };\n \n handleRecover = () => {\n this.setState({ hasError: false, error: null });\n this.props.onRecover?.();\n };\n \n render() {\n if (this.state.hasError) {\n return (\n
\n \n

Something went wrong

\n

\n Mission Control encountered an unexpected error.\n

\n \n {process.env.NODE_ENV === 'development' && (\n
\n              {this.state.error?.stack}\n            
\n )}\n \n
\n \n \n
\n
\n );\n }\n \n return this.props.children;\n }\n}\n\n// src/components/ErrorDisplay.tsx\ninterface ErrorDisplayProps {\n error: AppError;\n onRetry?: () => void;\n onDismiss?: () => void;\n}\n\nexport function ErrorDisplay({ error, onRetry, onDismiss }: ErrorDisplayProps) {\n const messages: Record = {\n cli_not_found: {\n title: `${error.cli} not found`,\n desc: `Install with: cargo install ${error.cli}`,\n },\n sync_failed: {\n title: 'Sync failed',\n desc: 'Could not fetch latest data from GitLab.',\n action: 'retry',\n },\n network_error: {\n title: 'Network error',\n desc: 'GitLab is unreachable. Working offline.',\n },\n };\n \n const msg = messages[error.type] || { title: 'Error', desc: error.message };\n \n return (\n \n {msg.title}\n {msg.desc}\n \n {msg.action === 'retry' && onRetry && (\n \n )}\n \n );\n}\n```\n\n**Acceptance criteria:**\n- React errors caught by boundary\n- Helpful error messages shown\n- Recovery/retry actions available\n- No raw error stacks in production\n- Errors logged for debugging","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T21:09:06.871401Z","created_by":"tayloreernisse","updated_at":"2026-02-25T21:09:06.871401Z","compaction_level":0,"original_size":0} -{"id":"bd-3px","title":"Implement tmp file cleanup on startup","description":"Implement startup cleanup of orphaned .tmp files from interrupted atomic writes.\n\nBACKGROUND:\nPLAN.md states: 'Crash Recovery: On startup, check for .json.tmp files — if found, previous write was interrupted. Delete tmp and use existing .json (last known good state).'\n\nWHY THIS MATTERS:\n- Atomic write pattern: write to .tmp, then rename to target\n- If crash occurs between write and rename, .tmp file lingers\n- Lingering .tmp files indicate incomplete operation\n- Must clean up before proceeding to avoid confusion\n\nIMPLEMENTATION:\n- On app startup, scan ~/.local/share/mc/ for *.tmp files\n- For each .tmp file found:\n 1. Log warning: 'Found orphaned tmp file, previous write interrupted'\n 2. Delete the .tmp file\n 3. Continue with existing .json file (if any)\n- Run this BEFORE loading state files\n- Run this AFTER acquiring single-instance lock (bd-2at)\n\nTESTING (TDD):\n- Create .tmp file, start app, verify .tmp deleted\n- Verify main .json file unchanged\n- Verify app continues normally after cleanup\n- Test with multiple .tmp files\n- Test with no .tmp files (normal case)\n\nFILE LOCATION:\nPart of app initialization (bd-3jh) or data/state.rs\n\nORDERING:\n1. Acquire lock (bd-2at)\n2. Clean up tmp files (THIS BEAD)\n3. Run pending recovery (bd-z4n)\n4. Load state files","status":"in_progress","priority":1,"issue_type":"feature","created_at":"2026-02-25T21:11:56.718214Z","created_by":"tayloreernisse","updated_at":"2026-02-26T14:55:54.736227Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3px","depends_on_id":"bd-2at","type":"blocks","created_at":"2026-02-25T21:12:03.102707Z","created_by":"tayloreernisse"}]} +{"id":"bd-3px","title":"Implement tmp file cleanup on startup","description":"Implement startup cleanup of orphaned .tmp files from interrupted atomic writes.\n\nBACKGROUND:\nPLAN.md states: 'Crash Recovery: On startup, check for .json.tmp files — if found, previous write was interrupted. Delete tmp and use existing .json (last known good state).'\n\nWHY THIS MATTERS:\n- Atomic write pattern: write to .tmp, then rename to target\n- If crash occurs between write and rename, .tmp file lingers\n- Lingering .tmp files indicate incomplete operation\n- Must clean up before proceeding to avoid confusion\n\nIMPLEMENTATION:\n- On app startup, scan ~/.local/share/mc/ for *.tmp files\n- For each .tmp file found:\n 1. Log warning: 'Found orphaned tmp file, previous write interrupted'\n 2. Delete the .tmp file\n 3. Continue with existing .json file (if any)\n- Run this BEFORE loading state files\n- Run this AFTER acquiring single-instance lock (bd-2at)\n\nTESTING (TDD):\n- Create .tmp file, start app, verify .tmp deleted\n- Verify main .json file unchanged\n- Verify app continues normally after cleanup\n- Test with multiple .tmp files\n- Test with no .tmp files (normal case)\n\nFILE LOCATION:\nPart of app initialization (bd-3jh) or data/state.rs\n\nORDERING:\n1. Acquire lock (bd-2at)\n2. Clean up tmp files (THIS BEAD)\n3. Run pending recovery (bd-z4n)\n4. Load state files","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-02-25T21:11:56.718214Z","created_by":"tayloreernisse","updated_at":"2026-02-26T14:58:19.267235Z","closed_at":"2026-02-26T14:58:19.267183Z","close_reason":"Completed: Added cleanup_tmp_files() method to Bridge that removes orphaned .tmp files on startup. Called from lib.rs setup. Added 3 tests covering: removal of tmp files, ignoring non-tmp files, and handling missing directories.","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3px","depends_on_id":"bd-2at","type":"blocks","created_at":"2026-02-25T21:12:03.102707Z","created_by":"tayloreernisse"}]} {"id":"bd-3ta","title":"Add test commands to package.json and Cargo.toml","description":"# Test Command Configuration\n\n**Parent epic:** Phase 0: Test Infrastructure\n\n**package.json scripts to add:**\n\n```json\n{\n \"scripts\": {\n \"test\": \"vitest run\",\n \"test:watch\": \"vitest\",\n \"test:coverage\": \"vitest run --coverage\",\n \"test:e2e\": \"playwright test\",\n \"test:e2e:headed\": \"playwright test --headed\",\n \"test:e2e:debug\": \"playwright test --debug\",\n \"test:all\": \"npm run test && npm run test:e2e\",\n \"test:ci\": \"npm run test:coverage && npm run test:e2e\"\n }\n}\n```\n\n**Cargo.toml test configuration:**\n\n```toml\n[dev-dependencies]\ntempfile = \"3\" # For temp directory fixtures\ntokio-test = \"0.4\" # Async test utilities\nrstest = \"0.18\" # Parameterized tests\n\n[[test]]\nname = \"bridge\"\npath = \"tests/bridge_test.rs\"\n\n[[test]]\nname = \"crash_recovery\"\npath = \"tests/crash_recovery_test.rs\"\n\n[[test]]\nname = \"mapping\"\npath = \"tests/mapping_test.rs\"\n```\n\n**CI workflow integration (.github/workflows/test.yml):**\n\n```yaml\njobs:\n rust-tests:\n runs-on: ubuntu-latest\n steps:\n - run: cargo test --all-features\n \n frontend-tests:\n runs-on: ubuntu-latest\n steps:\n - run: npm run test:coverage\n \n e2e-tests:\n runs-on: macos-latest\n steps:\n - run: npm run test:e2e\n```\n\n**Acceptance criteria:**\n- All test commands work from project root\n- cargo test runs all Rust tests\n- npm run test:all runs frontend + E2E tests\n- CI config ready for GitHub Actions","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-25T20:25:31.832976Z","created_by":"tayloreernisse","updated_at":"2026-02-26T13:47:47.498343Z","closed_at":"2026-02-26T13:47:47.498295Z","close_reason":"Completed: Test scripts in package.json","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3ta","depends_on_id":"bd-11k","type":"blocks","created_at":"2026-02-25T20:25:37.245644Z","created_by":"tayloreernisse"},{"issue_id":"bd-3ta","depends_on_id":"bd-1ps","type":"blocks","created_at":"2026-02-25T20:25:37.270286Z","created_by":"tayloreernisse"},{"issue_id":"bd-3ta","depends_on_id":"bd-3em","type":"blocks","created_at":"2026-02-25T20:25:37.225223Z","created_by":"tayloreernisse"},{"issue_id":"bd-3ta","depends_on_id":"bd-jyz","type":"blocks","created_at":"2026-02-25T20:25:36.269511Z","created_by":"tayloreernisse"}]} {"id":"bd-3ua","title":"Implement filter/search with command palette","description":"# Filter/Search with Command Palette (RED → GREEN)\n\n**Parent epic:** Phase 4: Queue + Inbox Views\n\n**Purpose:** ⌘K opens a command palette for quick filtering and searching across all items.\n\n**TDD: Filter tests (RED first):**\n\n```typescript\n// tests/components/CommandPalette.test.tsx\ndescribe('CommandPalette', () => {\n it('opens on ⌘K', async () => {\n render();\n \n await userEvent.keyboard('{Meta>}k{/Meta}');\n \n expect(screen.getByRole('dialog')).toBeInTheDocument();\n expect(screen.getByPlaceholderText(/Search or filter/)).toBeInTheDocument();\n });\n \n it('closes on Escape', async () => {\n render();\n await userEvent.keyboard('{Meta>}k{/Meta}');\n \n await userEvent.keyboard('{Escape}');\n \n expect(screen.queryByRole('dialog')).not.toBeInTheDocument();\n });\n \n it('filters items by text', async () => {\n render();\n await userEvent.keyboard('{Meta>}k{/Meta}');\n \n await userEvent.type(screen.getByRole('textbox'), 'auth');\n \n expect(screen.getByText('Fix auth token refresh')).toBeInTheDocument();\n expect(screen.queryByText('Update README')).not.toBeInTheDocument();\n });\n \n it('shows filter commands', async () => {\n render();\n await userEvent.keyboard('{Meta>}k{/Meta}');\n \n await userEvent.type(screen.getByRole('textbox'), 'type:');\n \n expect(screen.getByText('type:review')).toBeInTheDocument();\n expect(screen.getByText('type:issue')).toBeInTheDocument();\n expect(screen.getByText('type:manual')).toBeInTheDocument();\n });\n \n it('filters by type when type: command used', async () => {\n const onFilter = vi.fn();\n render();\n \n await userEvent.type(screen.getByRole('textbox'), 'type:review');\n await userEvent.keyboard('{Enter}');\n \n expect(onFilter).toHaveBeenCalledWith({ type: 'review' });\n });\n \n it('filters by staleness', async () => {\n const onFilter = vi.fn();\n render();\n \n await userEvent.type(screen.getByRole('textbox'), 'stale:7d');\n await userEvent.keyboard('{Enter}');\n \n expect(onFilter).toHaveBeenCalledWith({ minAge: 7 });\n });\n});\n```\n\n**Implementation (GREEN):**\n\n```tsx\n// src/components/CommandPalette.tsx\nimport { Command, CommandInput, CommandList, CommandItem, CommandGroup } from '@/components/ui/command';\n\nconst FILTER_COMMANDS = [\n { prefix: 'type:', options: ['review', 'issue', 'manual', 'mention'] },\n { prefix: 'from:', options: ['gitlab', 'manual'] },\n { prefix: 'stale:', options: ['1d', '3d', '7d'] },\n { prefix: 'status:', options: ['active', 'snoozed', 'all'] },\n];\n\nexport function CommandPalette({ items, onFilter, onSelect, onClose }: CommandPaletteProps) {\n const [search, setSearch] = useState('');\n \n const { filteredItems, activeCommand } = useMemo(() => {\n // Check if search matches a command prefix\n for (const cmd of FILTER_COMMANDS) {\n if (search.startsWith(cmd.prefix)) {\n return {\n filteredItems: [],\n activeCommand: cmd,\n };\n }\n }\n \n // Text search\n return {\n filteredItems: items.filter(item => \n item.title.toLowerCase().includes(search.toLowerCase())\n ),\n activeCommand: null,\n };\n }, [search, items]);\n \n useKeyboardShortcuts({\n 'mod+k': () => {}, // Already open\n 'escape': onClose,\n });\n \n return (\n \n \n \n \n {activeCommand ? (\n \n {activeCommand.options.map(opt => (\n {\n onFilter(parseFilter(activeCommand.prefix + opt));\n onClose();\n }}\n >\n {activeCommand.prefix}{opt}\n \n ))}\n \n ) : (\n <>\n {filteredItems.length > 0 && (\n \n {filteredItems.slice(0, 10).map(item => (\n {\n onSelect(item.id);\n onClose();\n }}\n >\n \n {item.title}\n \n ))}\n \n )}\n \n \n {FILTER_COMMANDS.map(cmd => (\n setSearch(cmd.prefix)}>\n {cmd.prefix}...\n \n ))}\n \n \n )}\n \n \n );\n}\n```\n\n**Acceptance criteria:**\n- ⌘K opens palette\n- Text search filters items\n- Command prefixes work (type:, from:, stale:)\n- Selection navigates to item\n- Filter applied to queue view","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T20:33:29.559528Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:33:34.913664Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3ua","depends_on_id":"bd-716","type":"blocks","created_at":"2026-02-25T20:33:34.913648Z","created_by":"tayloreernisse"}]} {"id":"bd-4s6","title":"Implement bv integration for triage recommendations","description":"# BV Integration for Triage Recommendations\n\n**Parent epic:** Phase 4: Queue + Inbox Views\n\n**Purpose:** Show bv's triage recommendations as hints to help prioritize work. This is OPTIONAL advisory data, not mandatory.\n\n**From plan:** \"bv recommendations — Show as separate 'suggestions' section or inline hints?\"\n\n**Design decision:** Show as subtle hints, not separate section. User is always in control.\n\n**TDD: BV integration tests (RED first):**\n\n```rust\n// src-tauri/tests/bv_integration_test.rs\n\n#[test]\nfn parse_bv_triage_output() {\n let fixture = include_str!(\"fixtures/bv/triage.json\");\n let result: BvTriageResponse = serde_json::from_str(fixture).unwrap();\n \n assert!(result.recommendations.is_some());\n}\n\n#[tokio::test]\nasync fn get_recommendations_returns_top_picks() {\n let app = setup_test_app();\n \n let result: Vec = app.invoke(\"get_recommendations\", ()).await;\n \n assert!(result.len() <= 5); // Top 5 max\n}\n\n#[tokio::test]\nasync fn recommendations_gracefully_handle_bv_failure() {\n let bv = MockBvCli::that_fails();\n let app = setup_test_app_with(bv);\n \n let result: Vec = app.invoke(\"get_recommendations\", ()).await;\n \n // Should return empty, not error\n assert!(result.is_empty());\n}\n```\n\n**Frontend tests:**\n\n```typescript\n// tests/hooks/useRecommendations.test.ts\ndescribe('useRecommendations', () => {\n it('fetches recommendations from bv', async () => {\n vi.mocked(invoke).mockResolvedValue([\n { id: 'br-123', score: 0.95, reason: 'High PageRank' }\n ]);\n \n const { result } = renderHook(() => useRecommendations());\n \n await waitFor(() => {\n expect(result.current.data).toHaveLength(1);\n });\n });\n \n it('shows loading state', () => {\n const { result } = renderHook(() => useRecommendations());\n \n expect(result.current.isLoading).toBe(true);\n });\n \n it('returns empty on error (no crash)', async () => {\n vi.mocked(invoke).mockRejectedValue(new Error('bv failed'));\n \n const { result } = renderHook(() => useRecommendations());\n \n await waitFor(() => {\n expect(result.current.data).toEqual([]);\n expect(result.current.error).toBeNull(); // Swallowed\n });\n });\n});\n```\n\n**Implementation:**\n\n```rust\n// src-tauri/src/data/bv.rs\npub trait BvCli: Send + Sync {\n fn triage(&self) -> Result;\n}\n\npub struct RealBvCli;\n\nimpl BvCli for RealBvCli {\n fn triage(&self) -> Result {\n let output = Command::new(\"bv\")\n .args([\"--robot-triage\"])\n .output()?;\n \n if !output.status.success() {\n return Ok(BvTriageResponse::default()); // Graceful degradation\n }\n \n serde_json::from_slice(&output.stdout)\n .context(\"Failed to parse bv output\")\n }\n}\n\n// src-tauri/src/commands/recommendations.rs\n#[tauri::command]\npub async fn get_recommendations(\n bv: State<'_, Arc>,\n) -> Vec {\n bv.triage()\n .map(|r| r.recommendations.unwrap_or_default())\n .map(|recs| recs.into_iter().take(5).collect())\n .unwrap_or_default()\n}\n```\n\n**Frontend hook:**\n\n```typescript\n// src/hooks/useRecommendations.ts\nexport function useRecommendations() {\n return useQuery({\n queryKey: ['recommendations'],\n queryFn: async () => {\n try {\n return await invoke('get_recommendations');\n } catch {\n return []; // Graceful degradation\n }\n },\n staleTime: 5 * 60 * 1000, // Cache 5 minutes\n });\n}\n```\n\n**UI integration:**\n\n```tsx\n// In QueueList, show hint for recommended items\nfunction QueueItem({ item, recommendation }) {\n return (\n
\n {/* ... item content ... */}\n \n {recommendation && (\n \n \n \n \n \n Suggested: {recommendation.reason}\n \n \n )}\n
\n );\n}\n```\n\n**Acceptance criteria:**\n- BV output parses correctly\n- Recommendations show as subtle hints\n- Graceful degradation on bv failure\n- User never forced to follow recommendations","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T21:08:46.435837Z","created_by":"tayloreernisse","updated_at":"2026-02-25T21:12:02.387394Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-4s6","depends_on_id":"bd-gil","type":"blocks","created_at":"2026-02-25T21:12:02.387379Z","created_by":"tayloreernisse"}]} @@ -50,7 +50,7 @@ {"id":"bd-716","title":"Phase 4: Queue + Inbox Views","description":"# Queue + Inbox Views\n\n**Context:** Queue View shows all pending work organized by type. Inbox View shows new items requiring triage. Both support the \"achievable inbox zero\" principle.\n\n**Duration estimate:** 2-3 days\n\n**Queue View visual:**\n```\n┌─────────────────────────────────────────────────────────────┐\n│ Queue ⌘K filter │\n├─────────────────────────────────────────────────────────────┤\n│ │\n│ REVIEWS (4) [Batch All · 25min] │\n│ ┌─────────────────────────────────────────────────────┐ │\n│ │ 🔴 !847 Fix auth token refresh 2d @sarah │ │\n│ │ 🟡 !902 Add rate limiting middleware 1d @mike │ │\n│ │ 🟢 !915 Update README badges 4h @alex │ │\n│ │ 🟢 !918 Typo fix in error messages 2h @bot │ │\n│ └─────────────────────────────────────────────────────┘ │\n│ │\n│ ASSIGNED ISSUES (2) │\n│ BEADS (3) │\n│ MANUAL TASKS (1) │\n│ │\n└─────────────────────────────────────────────────────────────┘\n```\n\n**Scope includes:**\n1. QueueList component with sections by type\n2. Staleness color coding (fresh=green, aging=amber, stale=red)\n3. Drag to reorder (manual priority)\n4. Click to set as focus\n5. Inbox with triage actions\n6. Filter/search (⌘K)\n\n**Behavior:**\n- Items colored by staleness\n- Click to make it THE ONE THING\n- Drag to reorder (manual priority, logged)\n- \"Batch All\" enters batch mode\n\n**Dependencies:**\n- Requires Phase 3 (Focus View) for focus-setting integration\n- Blocks Phase 5 (Batch Mode) which is triggered from here\n\n**Acceptance criteria:**\n- Queue shows all items grouped by type\n- Staleness colors visible\n- Drag reorder persists and logs\n- Click sets focus\n- Inbox has triage actions\n- Filter/search works","status":"closed","priority":2,"issue_type":"epic","created_at":"2026-02-25T20:32:16.422391Z","created_by":"tayloreernisse","updated_at":"2026-02-26T14:25:55.308232Z","closed_at":"2026-02-26T14:25:55.308181Z","close_reason":"Completed: QueueView with sections, QueueItem with staleness, AppShell with nav, click-to-focus, reorder support, 73 tests. Inbox and command palette are placeholdered for Phase 4b.","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-716","depends_on_id":"bd-1ft","type":"blocks","created_at":"2026-02-25T20:32:19.948812Z","created_by":"tayloreernisse"}]} {"id":"bd-ah2","title":"Implement InboxView container","description":"# InboxView Container\n\n**Parent epic:** Phase 4: Queue + Inbox Views\n\n**Purpose:** Container for Inbox component with triage flow and keyboard navigation.\n\n**TDD: InboxView tests (RED first):**\n\n```typescript\n// tests/views/InboxView.test.tsx\ndescribe('InboxView', () => {\n const mockNewItems = [\n { id: '1', triaged: false, title: 'Mention in #312' },\n { id: '2', triaged: false, title: 'Comment on MR !847' },\n ];\n \n it('shows only untriaged items', () => {\n const items = [...mockNewItems, { id: '3', triaged: true, title: 'Already done' }];\n const store = createStore({ items });\n render(, { wrapper: StoreProvider(store) });\n \n expect(screen.getAllByTestId('inbox-item')).toHaveLength(2);\n expect(screen.queryByText('Already done')).not.toBeInTheDocument();\n });\n \n it('shows inbox zero celebration when empty', () => {\n const store = createStore({ items: [] });\n render(, { wrapper: StoreProvider(store) });\n \n expect(screen.getByText('Inbox Zero')).toBeInTheDocument();\n expect(screen.getByText(/All caught up/)).toBeInTheDocument();\n });\n \n it('keyboard navigation between items', async () => {\n const store = createStore({ items: mockNewItems });\n render(, { wrapper: StoreProvider(store) });\n \n await userEvent.keyboard('{ArrowDown}');\n \n expect(screen.getAllByTestId('inbox-item')[1]).toHaveFocus();\n });\n \n it('accept triages item', async () => {\n const updateItem = vi.fn();\n const store = createStore({ items: mockNewItems, updateItem });\n render(, { wrapper: StoreProvider(store) });\n \n await userEvent.click(screen.getAllByRole('button', { name: /accept/i })[0]);\n \n expect(updateItem).toHaveBeenCalledWith('1', { triaged: true });\n });\n \n it('logs triage decision', async () => {\n const logDecision = vi.fn();\n const store = createStore({ items: mockNewItems, logDecision });\n render(, { wrapper: StoreProvider(store) });\n \n await userEvent.click(screen.getAllByRole('button', { name: /accept/i })[0]);\n \n expect(logDecision).toHaveBeenCalledWith(expect.objectContaining({\n action: 'triage',\n bead_id: '1',\n }));\n });\n \n it('updates count in real-time after triage', async () => {\n const store = createStore({ items: mockNewItems });\n render(, { wrapper: StoreProvider(store) });\n \n expect(screen.getByText('Inbox (2)')).toBeInTheDocument();\n \n await userEvent.click(screen.getAllByRole('button', { name: /accept/i })[0]);\n \n expect(screen.getByText('Inbox (1)')).toBeInTheDocument();\n });\n});\n```\n\n**Implementation:**\n\n```tsx\n// src/views/InboxView.tsx\nexport function InboxView() {\n const { items, updateItem } = useStore();\n const { logDecision } = useDecisionLog();\n const [focusIndex, setFocusIndex] = useState(0);\n \n const untriagedItems = useMemo(() => \n items.filter(i => !i.triaged),\n [items]\n );\n \n useKeyboardShortcuts({\n 'arrowdown': () => setFocusIndex(i => Math.min(i + 1, untriagedItems.length - 1)),\n 'arrowup': () => setFocusIndex(i => Math.max(i - 1, 0)),\n 'a': () => handleTriage(untriagedItems[focusIndex]?.id, 'accept'),\n 'd': () => handleTriage(untriagedItems[focusIndex]?.id, 'defer'),\n 'x': () => handleTriage(untriagedItems[focusIndex]?.id, 'archive'),\n }, { enabled: untriagedItems.length > 0 });\n \n const handleTriage = async (id: string, action: 'accept' | 'defer' | 'archive', duration?: string) => {\n if (!id) return;\n \n // Update item state\n if (action === 'accept') {\n updateItem(id, { triaged: true });\n } else if (action === 'defer') {\n updateItem(id, { snoozedUntil: calculateSnoozeTime(duration || '1h') });\n } else if (action === 'archive') {\n updateItem(id, { triaged: true, archived: true });\n }\n \n // Log decision\n logDecision({\n action: 'triage',\n bead_id: id,\n context: {\n triage_action: action,\n inbox_size: untriagedItems.length,\n }\n });\n };\n \n if (untriagedItems.length === 0) {\n return (\n
\n \n \n

Inbox Zero

\n

All caught up!

\n \n
\n );\n }\n \n return (\n
\n

Inbox ({untriagedItems.length})

\n \n \n \n

\n Keyboard: ↑↓ navigate · A accept · D defer · X archive\n

\n
\n );\n}\n```\n\n**Acceptance criteria:**\n- Only untriaged items shown\n- Inbox zero state with animation\n- Keyboard navigation works\n- Triage actions update state\n- Decisions logged\n- Count updates in real-time","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T20:54:42.382429Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:54:48.411862Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-ah2","depends_on_id":"bd-2zu","type":"blocks","created_at":"2026-02-25T20:54:48.411841Z","created_by":"tayloreernisse"},{"issue_id":"bd-ah2","depends_on_id":"bd-716","type":"blocks","created_at":"2026-02-25T20:54:48.346260Z","created_by":"tayloreernisse"},{"issue_id":"bd-ah2","depends_on_id":"bd-qvc","type":"blocks","created_at":"2026-02-25T20:54:48.382139Z","created_by":"tayloreernisse"}]} {"id":"bd-bap","title":"Implement useTauriEvents React hook for event communication","description":"Create the useTauriEvents React hook that handles Tauri→React event communication. This hook is critical for the reactive UI - it receives events from the Rust backend (file watcher triggers, sync status changes, error notifications) and propagates them to React state.\n\nBACKGROUND:\n- Tauri supports bidirectional IPC: invoke (React→Rust) AND events (Rust→React)\n- File watcher in Rust emits events when lore.db changes\n- Sync orchestrator emits status updates\n- These events need to trigger React re-renders\n\nIMPLEMENTATION:\n- Use @tauri-apps/api/event for listening\n- Subscribe on mount, cleanup on unmount\n- Type-safe event payloads matching Rust structs\n- Events: 'lore-db-changed', 'sync-started', 'sync-completed', 'sync-error'\n\nTESTING (TDD):\n- Mock @tauri-apps/api/event\n- Test subscription on mount\n- Test cleanup on unmount\n- Test event handler invocation\n- Test payload parsing\n\nFILE LOCATION:\nsrc/hooks/useTauriEvents.ts\n\nDEPENDENCIES:\n- Needs TypeScript type definitions (bd-20b)\n- Used by TanStack Query layer (bd-1fy) for cache invalidation\n- Used by sync status indicator (bd-2or)","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-02-25T21:11:36.089240Z","created_by":"tayloreernisse","updated_at":"2026-02-26T14:55:08.411188Z","closed_at":"2026-02-26T14:55:08.411125Z","close_reason":"Implemented useTauriEvent and useTauriEvents hooks in src/hooks/useTauriEvents.ts with 5 tests. Total: 134 TS tests.","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-bap","depends_on_id":"bd-20b","type":"blocks","created_at":"2026-02-25T21:12:01.006532Z","created_by":"tayloreernisse"},{"issue_id":"bd-bap","depends_on_id":"bd-247","type":"blocks","created_at":"2026-02-25T21:12:01.044329Z","created_by":"tayloreernisse"}]} -{"id":"bd-gil","title":"Implement BvCli trait for triage recommendation mocking","description":"Add BvCli trait to the Rust trait-based mocking infrastructure for bv (beads graph triage) CLI operations.\n\nBACKGROUND:\n- bd-28q sets up LoreCli and BeadsCli traits for mocking\n- BV integration (bd-4s6) needs BvCli trait for testability\n- bv --robot-* commands return triage recommendations, insights, etc.\n\nIMPLEMENTATION:\n- Define BvCli trait with methods matching bv robot commands\n- RealBvCli: shells out to actual bv CLI\n- MockBvCli: returns fixture data for testing\n\nTRAIT METHODS:\n- fn robot_triage(&self) -> Result\n- fn robot_next(&self) -> Result\n- fn robot_insights(&self) -> Result\n\nTESTING:\n- Test MockBvCli returns fixture data\n- Test RealBvCli parses actual bv output\n- Test error handling when bv unavailable\n\nFILE LOCATION:\nsrc-tauri/src/data/bv.rs\n\nNOTE: This trait completes the CLI wrapper trio (LoreCli, BeadsCli, BvCli) enabling full backend testability.","status":"open","priority":2,"issue_type":"feature","created_at":"2026-02-25T21:11:49.400225Z","created_by":"tayloreernisse","updated_at":"2026-02-25T21:12:02.355183Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-gil","depends_on_id":"bd-28q","type":"blocks","created_at":"2026-02-25T21:12:02.355167Z","created_by":"tayloreernisse"}]} +{"id":"bd-gil","title":"Implement BvCli trait for triage recommendation mocking","description":"Add BvCli trait to the Rust trait-based mocking infrastructure for bv (beads graph triage) CLI operations.\n\nBACKGROUND:\n- bd-28q sets up LoreCli and BeadsCli traits for mocking\n- BV integration (bd-4s6) needs BvCli trait for testability\n- bv --robot-* commands return triage recommendations, insights, etc.\n\nIMPLEMENTATION:\n- Define BvCli trait with methods matching bv robot commands\n- RealBvCli: shells out to actual bv CLI\n- MockBvCli: returns fixture data for testing\n\nTRAIT METHODS:\n- fn robot_triage(&self) -> Result\n- fn robot_next(&self) -> Result\n- fn robot_insights(&self) -> Result\n\nTESTING:\n- Test MockBvCli returns fixture data\n- Test RealBvCli parses actual bv output\n- Test error handling when bv unavailable\n\nFILE LOCATION:\nsrc-tauri/src/data/bv.rs\n\nNOTE: This trait completes the CLI wrapper trio (LoreCli, BeadsCli, BvCli) enabling full backend testability.","status":"in_progress","priority":2,"issue_type":"feature","created_at":"2026-02-25T21:11:49.400225Z","created_by":"tayloreernisse","updated_at":"2026-02-26T15:02:04.738045Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-gil","depends_on_id":"bd-28q","type":"blocks","created_at":"2026-02-25T21:12:02.355167Z","created_by":"tayloreernisse"}]} {"id":"bd-grs","title":"Implement app navigation and routing","description":"# App Navigation and Routing\n\n**Parent epic:** Phase 1: Foundation\n\n**Purpose:** Navigate between views (Focus, Queue, Inbox, Settings) with keyboard shortcuts and visual nav.\n\n**Navigation structure:**\n```\n┌─────────────────────────────────────────────────────────┐\n│ [Focus ⌘1] [Queue ⌘2] [Inbox ⌘3] [···] [⚙️ ⌘,] │\n├─────────────────────────────────────────────────────────┤\n│ │\n│ < Active View > │\n│ │\n└─────────────────────────────────────────────────────────┘\n```\n\n**Keyboard shortcuts:**\n| Key | Action |\n|-----|--------|\n| ⌘1 | Focus View |\n| ⌘2 | Queue View |\n| ⌘3 | Inbox View |\n| ⌘, | Settings |\n\n**TDD: Navigation tests (RED first):**\n\n```typescript\n// tests/components/Navigation.test.tsx\ndescribe('Navigation', () => {\n it('renders nav items', () => {\n render();\n \n expect(screen.getByRole('button', { name: /focus/i })).toBeInTheDocument();\n expect(screen.getByRole('button', { name: /queue/i })).toBeInTheDocument();\n expect(screen.getByRole('button', { name: /inbox/i })).toBeInTheDocument();\n });\n \n it('highlights active view', () => {\n const store = createStore({ activeView: 'queue' });\n render(, { wrapper: StoreProvider(store) });\n \n expect(screen.getByRole('button', { name: /queue/i })).toHaveAttribute('data-active', 'true');\n });\n \n it('shows inbox badge count', () => {\n const store = createStore({ items: mockItems.filter(i => !i.triaged) });\n render(, { wrapper: StoreProvider(store) });\n \n expect(screen.getByTestId('inbox-badge')).toHaveTextContent('3');\n });\n \n it('navigates on click', async () => {\n const setActiveView = vi.fn();\n const store = createStore({ setActiveView });\n render(, { wrapper: StoreProvider(store) });\n \n await userEvent.click(screen.getByRole('button', { name: /queue/i }));\n \n expect(setActiveView).toHaveBeenCalledWith('queue');\n });\n \n it('navigates on keyboard shortcut', async () => {\n const setActiveView = vi.fn();\n const store = createStore({ setActiveView });\n render(, { wrapper: StoreProvider(store) });\n \n await userEvent.keyboard('{Meta>}2{/Meta}');\n \n expect(setActiveView).toHaveBeenCalledWith('queue');\n });\n});\n\n// tests/App.test.tsx\ndescribe('App routing', () => {\n it('renders FocusView when activeView is focus', () => {\n const store = createStore({ activeView: 'focus' });\n render(, { wrapper: StoreProvider(store) });\n \n expect(screen.getByTestId('focus-view')).toBeInTheDocument();\n });\n \n it('renders QueueView when activeView is queue', () => {\n const store = createStore({ activeView: 'queue' });\n render(, { wrapper: StoreProvider(store) });\n \n expect(screen.getByTestId('queue-view')).toBeInTheDocument();\n });\n});\n```\n\n**Implementation:**\n\n```tsx\n// src/components/Navigation.tsx\nconst NAV_ITEMS = [\n { id: 'focus', label: 'Focus', shortcut: '⌘1' },\n { id: 'queue', label: 'Queue', shortcut: '⌘2' },\n { id: 'inbox', label: 'Inbox', shortcut: '⌘3', badge: true },\n];\n\nexport function Navigation() {\n const { activeView, setActiveView, items } = useStore();\n const inboxCount = items.filter(i => !i.triaged).length;\n \n useKeyboardShortcuts({\n 'mod+1': () => setActiveView('focus'),\n 'mod+2': () => setActiveView('queue'),\n 'mod+3': () => setActiveView('inbox'),\n 'mod+,': () => setActiveView('settings'),\n });\n \n return (\n \n );\n}\n\n// src/App.tsx\nexport function App() {\n const { activeView } = useStore();\n \n const views = {\n focus: ,\n queue: ,\n inbox: ,\n settings: ,\n };\n \n return (\n
\n \n
\n {views[activeView]}\n
\n
\n );\n}\n```\n\n**Acceptance criteria:**\n- All nav items render\n- Active view highlighted\n- Inbox badge shows count\n- Click navigates\n- Keyboard shortcuts work\n- Correct view renders for each state","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T20:52:31.852445Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:53:42.180510Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-grs","depends_on_id":"bd-2x6","type":"blocks","created_at":"2026-02-25T20:53:42.180494Z","created_by":"tayloreernisse"},{"issue_id":"bd-grs","depends_on_id":"bd-hee","type":"blocks","created_at":"2026-02-25T20:53:42.149875Z","created_by":"tayloreernisse"}]} {"id":"bd-hee","title":"Phase 1: Foundation","description":"# Foundation — Tauri + React + Lore Integration\n\n**Context:** This phase creates the basic shell of Mission Control — a working Tauri app with system tray, global hotkey, and the ability to read data from lore CLI.\n\n**Why this matters:**\n- Foundation must be solid before building features on top\n- System tray + hotkey is core to the \"ambient awareness\" principle\n- Lore integration proves the CLI-shelling architecture works\n\n**Duration estimate:** 2-3 days\n\n**Scope includes:**\n1. Scaffold Tauri 2.0 + Vite + React project\n2. Basic window with system tray icon\n3. Global hotkey to toggle window (⌘⇧M)\n4. Lore CLI wrapper with JSON parsing (RED → GREEN)\n5. File watcher on lore.db for change detection (RED → GREEN)\n6. Display raw lore data in UI (visual verification)\n\n**Architecture decisions embedded:**\n- Tauri 2.0 for Rust backend + tiny bundle (~15MB vs Electron 150MB)\n- React 19 + Vite for fast iteration\n- CLI shelling (not library imports) for clean API boundaries\n\n**Dependencies:**\n- Requires Phase 0 (Test Infrastructure) complete\n- Blocks Phase 2 (Bridge) and all view phases\n\n**Acceptance criteria:**\n- Tauri app launches and shows in system tray\n- ⌘⇧M toggles window visibility\n- App can call `lore --robot me` and parse response\n- lore.db changes trigger refresh callback\n- Basic UI shows parsed lore data","status":"closed","priority":2,"issue_type":"epic","created_at":"2026-02-25T20:25:49.298937Z","created_by":"tayloreernisse","updated_at":"2026-02-26T13:52:44.511318Z","closed_at":"2026-02-26T13:52:44.511272Z","close_reason":"Completed: System tray icon, Cmd+Shift+M global hotkey, and wired get_lore_status to RealLoreCli with 5 new tests","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-hee","depends_on_id":"bd-jyz","type":"blocks","created_at":"2026-02-25T20:25:52.591859Z","created_by":"tayloreernisse"}]} {"id":"bd-j76","title":"Implement BatchMode component","description":"# BatchMode Component (RED → GREEN)\n\n**Parent epic:** Phase 5: Batch Mode\n\n**TDD: BatchMode tests (RED first):**\n\n```typescript\n// tests/components/BatchMode.test.tsx\ndescribe('BatchMode', () => {\n const mockItems = [\n { id: '1', title: 'Review MR !847' },\n { id: '2', title: 'Review MR !902' },\n { id: '3', title: 'Review MR !915' },\n { id: '4', title: 'Review MR !918' },\n ];\n \n it('shows current item and progress', () => {\n render();\n \n expect(screen.getByText('BATCH: CODE REVIEWS')).toBeInTheDocument();\n expect(screen.getByText('1 of 4')).toBeInTheDocument();\n expect(screen.getByText('Review MR !847')).toBeInTheDocument();\n });\n \n it('shows progress bar', () => {\n render();\n \n const progress = screen.getByRole('progressbar');\n expect(progress).toHaveAttribute('aria-valuenow', '0');\n expect(progress).toHaveAttribute('aria-valuemax', '4');\n });\n \n it('Done advances to next item', async () => {\n render();\n \n await userEvent.click(screen.getByRole('button', { name: /done/i }));\n \n expect(screen.getByText('2 of 4')).toBeInTheDocument();\n expect(screen.getByText('Review MR !902')).toBeInTheDocument();\n expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '1');\n });\n \n it('Skip advances without marking complete', async () => {\n const onComplete = vi.fn();\n render();\n \n await userEvent.click(screen.getByRole('button', { name: /skip/i }));\n \n expect(screen.getByText('2 of 4')).toBeInTheDocument();\n expect(onComplete).not.toHaveBeenCalled();\n });\n \n it('Escape exits batch mode', async () => {\n const onExit = vi.fn();\n render();\n \n await userEvent.keyboard('{Escape}');\n \n expect(onExit).toHaveBeenCalled();\n });\n \n it('shows celebration on completion', async () => {\n render();\n \n // Complete all 4\n for (let i = 0; i < 4; i++) {\n await userEvent.click(screen.getByRole('button', { name: /done/i }));\n }\n \n expect(screen.getByText(/All done/)).toBeInTheDocument();\n expect(screen.getByTestId('confetti')).toBeInTheDocument();\n });\n \n it('keyboard shortcuts work', async () => {\n const onComplete = vi.fn();\n render();\n \n await userEvent.keyboard('{Meta>}d{/Meta}'); // ⌘D for Done\n \n expect(onComplete).toHaveBeenCalledWith('1');\n });\n});\n```\n\n**Implementation (GREEN):**\n\n```tsx\n// src/components/BatchMode.tsx\ninterface BatchModeProps {\n items: WorkItem[];\n type: ItemType;\n onComplete: (id: string) => void;\n onSkip: (id: string) => void;\n onExit: () => void;\n}\n\nexport function BatchMode({ items, type, onComplete, onSkip, onExit }: BatchModeProps) {\n const [currentIndex, setCurrentIndex] = useState(0);\n const [completedIds, setCompletedIds] = useState>(new Set());\n const [showCelebration, setShowCelebration] = useState(false);\n \n const currentItem = items[currentIndex];\n const progress = completedIds.size;\n \n useKeyboardShortcuts({\n 'mod+o': () => currentItem && open(currentItem.url),\n 'mod+d': handleDone,\n 'mod+s': handleSkip,\n 'escape': onExit,\n });\n \n const handleDone = () => {\n onComplete(currentItem.id);\n setCompletedIds(prev => new Set(prev).add(currentItem.id));\n advanceOrComplete();\n };\n \n const handleSkip = () => {\n onSkip(currentItem.id);\n advanceOrComplete();\n };\n \n const advanceOrComplete = () => {\n if (currentIndex === items.length - 1) {\n setShowCelebration(true);\n } else {\n setCurrentIndex(prev => prev + 1);\n }\n };\n \n if (showCelebration) {\n return ;\n }\n \n return (\n
\n
\n

BATCH: {getTypeLabel(type)}

\n

{currentIndex + 1} of {items.length}

\n \n
\n \n
\n \n
\n \n
\n \n \n \n
\n \n

ESC to exit batch

\n
\n );\n}\n```\n\n**Acceptance criteria:**\n- All tests pass\n- Progress tracking works\n- Done/Skip advance correctly\n- Keyboard shortcuts work\n- Celebration shown on completion","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-25T20:34:07.281651Z","created_by":"tayloreernisse","updated_at":"2026-02-26T14:31:02.746177Z","closed_at":"2026-02-26T14:31:02.746132Z","close_reason":"Completed: BatchMode component with progress bar, action buttons, keyboard shortcuts, item transitions","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-j76","depends_on_id":"bd-3c2","type":"blocks","created_at":"2026-02-25T20:34:12.889428Z","created_by":"tayloreernisse"}]} diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 03d1345..3bca7cf 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "Inflector" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3" + [[package]] name = "adler2" version = "2.0.1" @@ -1940,10 +1946,13 @@ dependencies = [ "notify", "serde", "serde_json", + "specta", + "specta-typescript", "tauri", "tauri-build", "tauri-plugin-global-shortcut", "tauri-plugin-shell", + "tauri-specta", "tempfile", "thiserror 2.0.18", "tokio", @@ -2408,6 +2417,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pathdiff" version = "0.2.3" @@ -3402,6 +3417,50 @@ dependencies = [ "system-deps", ] +[[package]] +name = "specta" +version = "2.0.0-rc.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab7f01e9310a820edd31c80fde3cae445295adde21a3f9416517d7d65015b971" +dependencies = [ + "paste", + "specta-macros", + "thiserror 1.0.69", +] + +[[package]] +name = "specta-macros" +version = "2.0.0-rc.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0074b9e30ed84c6924eb63ad8d2fe71cdc82628525d84b1fcb1f2fd40676517" +dependencies = [ + "Inflector", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "specta-serde" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77216504061374659e7245eac53d30c7b3e5fe64b88da97c753e7184b0781e63" +dependencies = [ + "specta", + "thiserror 1.0.69", +] + +[[package]] +name = "specta-typescript" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3220a0c365e51e248ac98eab5a6a32f544ff6f961906f09d3ee10903a4f52b2d" +dependencies = [ + "specta", + "specta-serde", + "thiserror 1.0.69", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -3597,6 +3656,7 @@ dependencies = [ "serde_json", "serde_repr", "serialize-to-javascript", + "specta", "swift-rs", "tauri-build", "tauri-macros", @@ -3781,6 +3841,34 @@ dependencies = [ "wry", ] +[[package]] +name = "tauri-specta" +version = "2.0.0-rc.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b23c0132dd3cf6064e5cd919b82b3f47780e9280e7b5910babfe139829b76655" +dependencies = [ + "heck 0.5.0", + "serde", + "serde_json", + "specta", + "specta-typescript", + "tauri", + "tauri-specta-macros", + "thiserror 2.0.18", +] + +[[package]] +name = "tauri-specta-macros" +version = "2.0.0-rc.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a4aa93823e07859546aa796b8a5d608190cd8037a3a5dce3eb63d491c34bda8" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "tauri-utils" version = "2.8.2" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 43ba33f..1c338f9 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -30,6 +30,9 @@ dirs = "5" notify = "7" tauri-plugin-global-shortcut = "2" libc = "0.2" +specta = { version = "=2.0.0-rc.22", features = ["derive"] } +tauri-specta = { version = "=2.0.0-rc.21", features = ["derive", "typescript"] } +specta-typescript = "0.0.9" [dev-dependencies] tempfile = "3" diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 2963b82..2499fbc 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -5,8 +5,10 @@ use crate::data::beads::{BeadsCli, RealBeadsCli}; use crate::data::bridge::{Bridge, SyncResult}; use crate::data::lore::{LoreCli, LoreError, RealLoreCli}; +use crate::data::state::{clear_frontend_state, read_frontend_state, write_frontend_state, FrontendState}; use crate::error::McError; use serde::Serialize; +use specta::Type; /// Simple greeting command for testing IPC #[tauri::command] @@ -15,7 +17,7 @@ pub fn greet(name: &str) -> String { } /// Lore sync status -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Type)] pub struct LoreStatus { pub last_sync: Option, pub is_healthy: bool, @@ -24,7 +26,7 @@ pub struct LoreStatus { } /// Summary counts from lore for the status response -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Type)] pub struct LoreSummaryStatus { pub open_issues: usize, pub authored_mrs: usize, @@ -88,7 +90,7 @@ fn get_lore_status_with(cli: &dyn LoreCli) -> Result { // -- Bridge commands -- /// Bridge status for the frontend -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Type)] pub struct BridgeStatus { /// Total mapped items pub mapping_count: usize, @@ -185,7 +187,7 @@ fn reconcile_inner( // -- Quick capture command -- /// Response from quick_capture: the bead ID created -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Type)] pub struct CaptureResult { pub bead_id: String, } @@ -203,6 +205,39 @@ fn quick_capture_inner(cli: &dyn BeadsCli, title: &str) -> Result Result, McError> { + tokio::task::spawn_blocking(read_frontend_state) + .await + .map_err(|e| McError::internal(format!("Task join error: {}", e)))? + .map_err(|e| McError::io_error(format!("Failed to read state: {}", e))) +} + +/// Write frontend state to ~/.local/share/mc/state.json. +/// +/// Uses atomic rename pattern to prevent corruption. +#[tauri::command] +pub async fn write_state(state: FrontendState) -> Result<(), McError> { + tokio::task::spawn_blocking(move || write_frontend_state(&state)) + .await + .map_err(|e| McError::internal(format!("Task join error: {}", e)))? + .map_err(|e| McError::io_error(format!("Failed to write state: {}", e))) +} + +/// Clear persisted frontend state. +#[tauri::command] +pub async fn clear_state() -> Result<(), McError> { + tokio::task::spawn_blocking(clear_frontend_state) + .await + .map_err(|e| McError::internal(format!("Task join error: {}", e)))? + .map_err(|e| McError::io_error(format!("Failed to clear state: {}", e))) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/data/bridge.rs b/src-tauri/src/data/bridge.rs index 8f8b674..207cd36 100644 --- a/src-tauri/src/data/bridge.rs +++ b/src-tauri/src/data/bridge.rs @@ -7,6 +7,7 @@ //! - Single-instance locking via flock(2) use serde::{Deserialize, Serialize}; +use specta::Type; use std::collections::HashMap; use std::fs::{self, File}; use std::io::{self, Write}; @@ -109,7 +110,7 @@ impl MappingKey { } /// Result of a sync operation -#[derive(Debug, Default, Serialize)] +#[derive(Debug, Default, Serialize, Type)] pub struct SyncResult { /// Number of new beads created pub created: usize, @@ -1323,4 +1324,48 @@ mod tests { assert_eq!(r4.closed, 1); assert!(!map.mappings.contains_key("issue:g/p:42")); } + + // -- cleanup_tmp_files tests -- + + #[test] + fn test_cleanup_tmp_files_removes_orphaned_tmp() { + let dir = TempDir::new().unwrap(); + let bridge = test_bridge(MockLoreCli::new(), MockBeadsCli::new(), &dir); + + // Create an orphaned .tmp file (simulating a crash during save_map) + let tmp_file = dir.path().join("gitlab_bead_map.json.tmp"); + std::fs::write(&tmp_file, "orphaned data").unwrap(); + assert!(tmp_file.exists()); + + // Cleanup should remove it + let cleaned = bridge.cleanup_tmp_files().unwrap(); + assert_eq!(cleaned, 1); + assert!(!tmp_file.exists()); + } + + #[test] + fn test_cleanup_tmp_files_ignores_non_tmp_files() { + let dir = TempDir::new().unwrap(); + let bridge = test_bridge(MockLoreCli::new(), MockBeadsCli::new(), &dir); + + // Create a regular file (should not be removed) + let json_file = dir.path().join("gitlab_bead_map.json"); + std::fs::write(&json_file, "{}").unwrap(); + + let cleaned = bridge.cleanup_tmp_files().unwrap(); + assert_eq!(cleaned, 0); + assert!(json_file.exists()); + } + + #[test] + fn test_cleanup_tmp_files_handles_missing_dir() { + let dir = TempDir::new().unwrap(); + let nonexistent = dir.path().join("nonexistent"); + let bridge: Bridge = + Bridge::with_data_dir(MockLoreCli::new(), MockBeadsCli::new(), nonexistent); + + // Should return 0, not error, when dir doesn't exist + let cleaned = bridge.cleanup_tmp_files().unwrap(); + assert_eq!(cleaned, 0); + } } diff --git a/src-tauri/src/data/bv.rs b/src-tauri/src/data/bv.rs new file mode 100644 index 0000000..85fca14 --- /dev/null +++ b/src-tauri/src/data/bv.rs @@ -0,0 +1,325 @@ +//! bv CLI integration (beads graph triage) +//! +//! Provides trait-based abstraction over bv CLI for testability. +//! bv is the graph-aware triage engine for beads projects. + +use serde::{Deserialize, Serialize}; +use std::process::Command; + +#[cfg(test)] +use mockall::automock; + +/// Trait for interacting with bv CLI +/// +/// This abstraction allows us to mock bv in tests. +#[cfg_attr(test, automock)] +pub trait BvCli: Send + Sync { + /// Get triage recommendations via `bv --robot-triage` + fn robot_triage(&self) -> Result; + + /// Get the single top recommendation via `bv --robot-next` + fn robot_next(&self) -> Result; +} + +/// Real implementation that shells out to bv CLI +#[derive(Debug, Default)] +pub struct RealBvCli; + +impl BvCli for RealBvCli { + fn robot_triage(&self) -> Result { + let output = Command::new("bv") + .args(["--robot-triage"]) + .output() + .map_err(|e| BvError::ExecutionFailed(e.to_string()))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(BvError::CommandFailed(stderr.to_string())); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + serde_json::from_str(&stdout).map_err(|e| BvError::ParseFailed(e.to_string())) + } + + fn robot_next(&self) -> Result { + let output = Command::new("bv") + .args(["--robot-next"]) + .output() + .map_err(|e| BvError::ExecutionFailed(e.to_string()))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(BvError::CommandFailed(stderr.to_string())); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + serde_json::from_str(&stdout).map_err(|e| BvError::ParseFailed(e.to_string())) + } +} + +/// Errors that can occur when interacting with bv CLI +#[derive(Debug, Clone, thiserror::Error)] +pub enum BvError { + #[error("Failed to execute bv: {0}")] + ExecutionFailed(String), + + #[error("bv command failed: {0}")] + CommandFailed(String), + + #[error("Failed to parse bv response: {0}")] + ParseFailed(String), +} + +// -- Response types -- + +/// Response from `bv --robot-triage` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BvTriageResponse { + pub generated_at: String, + pub data_hash: String, + pub triage: TriageData, + #[serde(default)] + pub usage_hints: Option>, +} + +/// Triage data containing recommendations +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TriageData { + pub quick_ref: QuickRef, + #[serde(default)] + pub recommendations: Vec, + #[serde(default)] + pub quick_wins: Vec, + #[serde(default)] + pub blockers_to_clear: Vec, + #[serde(default)] + pub project_health: Option, + #[serde(default)] + pub commands: Option, + #[serde(default)] + pub meta: Option, +} + +/// Quick reference counts +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QuickRef { + pub open_count: i64, + pub actionable_count: i64, + pub blocked_count: i64, + pub in_progress_count: i64, + pub top_picks: Vec, +} + +/// Top pick from triage +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TopPick { + pub id: String, + pub title: String, + pub score: f64, + pub reasons: Vec, + pub unblocks: i64, +} + +/// Full recommendation with breakdown +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Recommendation { + pub id: String, + pub title: String, + #[serde(rename = "type")] + pub item_type: Option, + pub status: Option, + pub priority: Option, + pub labels: Option>, + pub score: f64, + #[serde(default)] + pub breakdown: Option, + pub action: Option, + pub reasons: Vec, + #[serde(default)] + pub unblocks_ids: Vec, + #[serde(default)] + pub blocked_by: Vec, +} + +/// Quick win item +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QuickWin { + pub id: String, + pub title: String, + pub score: f64, + pub reason: String, + #[serde(default)] + pub unblocks_ids: Vec, +} + +/// Blocker to clear +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BlockerToClear { + pub id: String, + pub title: String, + pub unblocks_count: i64, + #[serde(default)] + pub unblocks_ids: Vec, + pub actionable: bool, +} + +/// Response from `bv --robot-next` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BvNextResponse { + pub generated_at: String, + pub data_hash: String, + #[serde(default)] + pub output_format: Option, + pub id: String, + pub title: String, + pub score: f64, + pub reasons: Vec, + pub unblocks: i64, + pub claim_command: String, + pub show_command: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_bv_next_response_deserialize() { + let json = r#"{ + "generated_at": "2026-02-26T15:00:00Z", + "data_hash": "abc123", + "output_format": "json", + "id": "bd-qvc", + "title": "Implement Inbox view", + "score": 0.116, + "reasons": ["Unblocks 1 item"], + "unblocks": 1, + "claim_command": "bd update bd-qvc --status=in_progress", + "show_command": "bd show bd-qvc" + }"#; + + let response: BvNextResponse = serde_json::from_str(json).unwrap(); + assert_eq!(response.id, "bd-qvc"); + assert_eq!(response.title, "Implement Inbox view"); + assert_eq!(response.unblocks, 1); + } + + #[test] + fn test_quick_ref_deserialize() { + let json = r#"{ + "open_count": 23, + "actionable_count": 17, + "blocked_count": 6, + "in_progress_count": 1, + "top_picks": [ + { + "id": "bd-qvc", + "title": "Inbox view", + "score": 0.116, + "reasons": ["Unblocks 1"], + "unblocks": 1 + } + ] + }"#; + + let quick_ref: QuickRef = serde_json::from_str(json).unwrap(); + assert_eq!(quick_ref.open_count, 23); + assert_eq!(quick_ref.top_picks.len(), 1); + assert_eq!(quick_ref.top_picks[0].id, "bd-qvc"); + } + + #[test] + fn test_triage_response_deserialize() { + let json = r#"{ + "generated_at": "2026-02-26T15:00:00Z", + "data_hash": "abc123", + "triage": { + "quick_ref": { + "open_count": 23, + "actionable_count": 17, + "blocked_count": 6, + "in_progress_count": 0, + "top_picks": [] + }, + "recommendations": [], + "quick_wins": [], + "blockers_to_clear": [] + } + }"#; + + let response: BvTriageResponse = serde_json::from_str(json).unwrap(); + assert_eq!(response.triage.quick_ref.open_count, 23); + assert!(response.triage.recommendations.is_empty()); + } + + #[test] + fn test_mock_bv_cli_robot_next() { + let mut mock = MockBvCli::new(); + mock.expect_robot_next().returning(|| { + Ok(BvNextResponse { + generated_at: "2026-02-26T15:00:00Z".to_string(), + data_hash: "test".to_string(), + output_format: Some("json".to_string()), + id: "bd-test".to_string(), + title: "Test bead".to_string(), + score: 0.5, + reasons: vec!["Test reason".to_string()], + unblocks: 2, + claim_command: "br update bd-test --status in_progress".to_string(), + show_command: "br show bd-test".to_string(), + }) + }); + + let result = mock.robot_next().unwrap(); + assert_eq!(result.id, "bd-test"); + assert_eq!(result.unblocks, 2); + } + + #[test] + fn test_mock_bv_cli_robot_triage() { + let mut mock = MockBvCli::new(); + mock.expect_robot_triage().returning(|| { + Ok(BvTriageResponse { + generated_at: "2026-02-26T15:00:00Z".to_string(), + data_hash: "test".to_string(), + triage: TriageData { + quick_ref: QuickRef { + open_count: 10, + actionable_count: 5, + blocked_count: 3, + in_progress_count: 2, + top_picks: vec![TopPick { + id: "bd-top".to_string(), + title: "Top pick".to_string(), + score: 0.8, + reasons: vec!["High priority".to_string()], + unblocks: 3, + }], + }, + recommendations: vec![], + quick_wins: vec![], + blockers_to_clear: vec![], + project_health: None, + commands: None, + meta: None, + }, + usage_hints: None, + }) + }); + + let result = mock.robot_triage().unwrap(); + assert_eq!(result.triage.quick_ref.open_count, 10); + assert_eq!(result.triage.quick_ref.top_picks[0].id, "bd-top"); + } + + #[test] + fn test_mock_bv_cli_can_return_error() { + let mut mock = MockBvCli::new(); + mock.expect_robot_next() + .returning(|| Err(BvError::ExecutionFailed("bv not found".to_string()))); + + let result = mock.robot_next(); + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), BvError::ExecutionFailed(_))); + } +} diff --git a/src-tauri/src/data/mod.rs b/src-tauri/src/data/mod.rs index 8e1080d..67953cf 100644 --- a/src-tauri/src/data/mod.rs +++ b/src-tauri/src/data/mod.rs @@ -7,5 +7,6 @@ pub mod beads; pub mod bridge; +pub mod bv; pub mod lore; pub mod state; diff --git a/src-tauri/src/data/state.rs b/src-tauri/src/data/state.rs index 61460f4..5982bec 100644 --- a/src-tauri/src/data/state.rs +++ b/src-tauri/src/data/state.rs @@ -56,6 +56,60 @@ pub struct DecisionContext { /// Decision log - append-only for learning pub struct DecisionLog; +/// Frontend state stored by Zustand. +/// +/// We store this as a JSON blob rather than parsing individual fields, +/// allowing the frontend to evolve its schema freely. +pub type FrontendState = serde_json::Value; + +/// Read the persisted frontend state. +/// +/// Returns `None` if the file doesn't exist (first run). +pub fn read_frontend_state() -> io::Result> { + let path = mc_data_dir().join("state.json"); + + if !path.exists() { + return Ok(None); + } + + let content = fs::read_to_string(&path)?; + let state: FrontendState = serde_json::from_str(&content) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + + Ok(Some(state)) +} + +/// Write frontend state with atomic rename pattern. +/// +/// Writes to `state.json.tmp` first, then renames to `state.json`. +/// This prevents corruption from crashes during write. +pub fn write_frontend_state(state: &FrontendState) -> io::Result<()> { + let dir = mc_data_dir(); + fs::create_dir_all(&dir)?; + + let path = dir.join("state.json"); + let tmp_path = dir.join("state.json.tmp"); + + let content = serde_json::to_string_pretty(state) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + + fs::write(&tmp_path, &content)?; + fs::rename(&tmp_path, &path)?; + + Ok(()) +} + +/// Clear the persisted frontend state (delete the file). +pub fn clear_frontend_state() -> io::Result<()> { + let path = mc_data_dir().join("state.json"); + + if path.exists() { + fs::remove_file(&path)?; + } + + Ok(()) +} + impl DecisionLog { /// Append a decision to the log pub fn append(decision: &Decision) -> io::Result<()> { diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index 477ce08..d20f766 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -4,12 +4,13 @@ //! to handle errors programmatically rather than parsing strings. use serde::Serialize; +use specta::Type; /// Structured error type for Tauri IPC commands. /// /// This replaces string-based errors (`Result`) with typed errors /// that the frontend can handle programmatically. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Type)] pub struct McError { /// Machine-readable error code (e.g., "LORE_UNAVAILABLE", "BRIDGE_LOCKED") pub code: McErrorCode, @@ -20,7 +21,7 @@ pub struct McError { } /// Error codes for frontend handling -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, PartialEq, Eq, Type)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub enum McErrorCode { // Lore errors @@ -38,6 +39,10 @@ pub enum McErrorCode { BeadsCreateFailed, BeadsCloseFailed, + // Bv errors + BvUnavailable, + BvTriageFailed, + // General errors IoError, InternalError, @@ -50,6 +55,7 @@ impl McError { code, McErrorCode::LoreUnavailable | McErrorCode::BeadsUnavailable + | McErrorCode::BvUnavailable | McErrorCode::BridgeLocked | McErrorCode::IoError ); @@ -97,6 +103,19 @@ impl McError { "br CLI not found -- is beads installed?", ) } + + /// Create an IO error with context + pub fn io_error(context: impl Into) -> Self { + Self::new(McErrorCode::IoError, context) + } + + /// Create a bv unavailable error + pub fn bv_unavailable() -> Self { + Self::new( + McErrorCode::BvUnavailable, + "bv CLI not found -- is beads installed?", + ) + } } impl std::fmt::Display for McError { @@ -169,6 +188,25 @@ impl From for McError { } } +// Conversion from bv errors +impl From for McError { + fn from(err: crate::data::bv::BvError) -> Self { + use crate::data::bv::BvError; + + match err { + BvError::ExecutionFailed(_) => Self::bv_unavailable(), + BvError::CommandFailed(msg) => Self::new( + McErrorCode::BvTriageFailed, + format!("bv command failed: {}", msg), + ), + BvError::ParseFailed(msg) => Self::new( + McErrorCode::BvTriageFailed, + format!("Failed to parse bv response: {}", msg), + ), + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -261,4 +299,24 @@ mod tests { assert_eq!(err.code, McErrorCode::BridgeMapCorrupted); assert!(!err.recoverable); } + + #[test] + fn test_bv_error_conversion() { + use crate::data::bv::BvError; + + // ExecutionFailed -> BvUnavailable (recoverable) + let err: McError = BvError::ExecutionFailed("not found".to_string()).into(); + assert_eq!(err.code, McErrorCode::BvUnavailable); + assert!(err.recoverable); + + // CommandFailed -> BvTriageFailed (not recoverable) + let err: McError = BvError::CommandFailed("failed".to_string()).into(); + assert_eq!(err.code, McErrorCode::BvTriageFailed); + assert!(!err.recoverable); + + // ParseFailed -> BvTriageFailed (not recoverable) + let err: McError = BvError::ParseFailed("bad json".to_string()).into(); + assert_eq!(err.code, McErrorCode::BvTriageFailed); + assert!(!err.recoverable); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 544504d..b267306 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -15,6 +15,7 @@ use tauri::menu::{MenuBuilder, MenuItemBuilder}; use tauri::tray::TrayIconBuilder; use tauri::{Emitter, Manager}; use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState}; +use tauri_specta::{collect_commands, Builder}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; /// Toggle the main window's visibility. @@ -101,6 +102,28 @@ pub fn run() { tracing::info!("Starting Mission Control"); + // Build tauri-specta builder for type-safe IPC + let builder = Builder::::new().commands(collect_commands![ + commands::greet, + commands::get_lore_status, + commands::get_bridge_status, + commands::sync_now, + commands::reconcile, + commands::quick_capture, + commands::read_state, + commands::write_state, + commands::clear_state, + ]); + + // Export TypeScript bindings in debug builds + #[cfg(debug_assertions)] + builder + .export( + specta_typescript::Typescript::default(), + "../src/lib/bindings.ts", + ) + .expect("Failed to export TypeScript bindings"); + tauri::Builder::default() .plugin(tauri_plugin_shell::init()) .plugin( @@ -165,14 +188,7 @@ pub fn run() { } Ok(()) }) - .invoke_handler(tauri::generate_handler![ - commands::greet, - commands::get_lore_status, - commands::get_bridge_status, - commands::sync_now, - commands::reconcile, - commands::quick_capture, - ]) + .invoke_handler(builder.invoke_handler()) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/src/lib/tauri-storage.ts b/src/lib/tauri-storage.ts new file mode 100644 index 0000000..b9fac34 --- /dev/null +++ b/src/lib/tauri-storage.ts @@ -0,0 +1,109 @@ +/** + * Tauri storage adapter for Zustand persist middleware. + * + * Stores Zustand state in `~/.local/share/mc/state.json` via Tauri backend + * instead of browser localStorage. Falls back to localStorage in browser context. + */ + +import { invoke } from "@tauri-apps/api/core"; +import type { StateStorage } from "zustand/middleware"; + +/** + * Create a storage adapter that persists to Tauri backend. + * + * Uses the `read_state`, `write_state`, and `clear_state` Tauri commands. + */ +export function createTauriStorage(): StateStorage { + return { + getItem: async (_name: string): Promise => { + try { + const state = await invoke | null>("read_state"); + if (state === null) { + return null; + } + return JSON.stringify(state); + } catch (error) { + console.warn("[tauri-storage] Failed to read state:", error); + return null; + } + }, + + setItem: async (_name: string, value: string): Promise => { + try { + const state = JSON.parse(value) as Record; + await invoke("write_state", { state }); + } catch (error) { + console.warn("[tauri-storage] Failed to write state:", error); + } + }, + + removeItem: async (_name: string): Promise => { + try { + await invoke("clear_state"); + } catch (error) { + console.warn("[tauri-storage] Failed to clear state:", error); + } + }, + }; +} + +/** + * Check if running in Tauri context. + */ +function isTauriContext(): boolean { + return typeof window !== "undefined" && "__TAURI__" in window; +} + +/** + * Create a localStorage-based storage adapter for browser context. + */ +function createLocalStorageAdapter(): StateStorage { + return { + getItem: (name: string): string | null => { + if (typeof window === "undefined") return null; + return localStorage.getItem(name); + }, + + setItem: (name: string, value: string): void => { + if (typeof window === "undefined") return; + localStorage.setItem(name, value); + }, + + removeItem: (name: string): void => { + if (typeof window === "undefined") return; + localStorage.removeItem(name); + }, + }; +} + +/** + * Get the appropriate storage adapter for the current context. + * + * - In Tauri: Uses backend persistence to ~/.local/share/mc/state.json + * - In browser: Falls back to localStorage + */ +export async function initializeStorage(): Promise { + if (isTauriContext()) { + return createTauriStorage(); + } + return createLocalStorageAdapter(); +} + +/** + * Singleton storage instance. + * Use this in store definitions to avoid recreating the adapter. + */ +let _storage: StateStorage | null = null; + +/** + * Get the storage adapter (lazy initialization). + * + * In Tauri context, returns Tauri storage. + * In browser context, returns localStorage wrapper. + */ +export function getStorage(): StateStorage { + if (_storage === null) { + _storage = isTauriContext() ? createTauriStorage() : createLocalStorageAdapter(); + } + return _storage; +} diff --git a/src/stores/focus-store.ts b/src/stores/focus-store.ts index 4e1e0ed..4e1d787 100644 --- a/src/stores/focus-store.ts +++ b/src/stores/focus-store.ts @@ -6,7 +6,8 @@ */ import { create } from "zustand"; -import { persist } from "zustand/middleware"; +import { persist, createJSONStorage } from "zustand/middleware"; +import { getStorage } from "@/lib/tauri-storage"; import type { FocusAction, FocusItem } from "@/lib/types"; export interface FocusState { @@ -108,6 +109,7 @@ export const useFocusStore = create()( }), { name: "mc-focus-store", + storage: createJSONStorage(() => getStorage()), partialize: (state) => ({ current: state.current, queue: state.queue, diff --git a/src/stores/nav-store.ts b/src/stores/nav-store.ts index 419034f..8dc72db 100644 --- a/src/stores/nav-store.ts +++ b/src/stores/nav-store.ts @@ -6,7 +6,8 @@ */ import { create } from "zustand"; -import { persist } from "zustand/middleware"; +import { persist, createJSONStorage } from "zustand/middleware"; +import { getStorage } from "@/lib/tauri-storage"; export type ViewId = "focus" | "queue" | "inbox"; @@ -23,6 +24,7 @@ export const useNavStore = create()( }), { name: "mc-nav-store", + storage: createJSONStorage(() => getStorage()), } ) ); diff --git a/tests/stores/tauri-storage.test.ts b/tests/stores/tauri-storage.test.ts new file mode 100644 index 0000000..8ee8c75 --- /dev/null +++ b/tests/stores/tauri-storage.test.ts @@ -0,0 +1,119 @@ +/** + * Tests for Tauri storage adapter for Zustand. + * + * Verifies that the store persists state to Tauri backend + * instead of browser localStorage. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mock Tauri's invoke +const mockInvoke = vi.fn(); +vi.mock("@tauri-apps/api/core", () => ({ + invoke: (...args: unknown[]) => mockInvoke(...args), +})); + +// Import after mocking +import { createTauriStorage, initializeStorage } from "@/lib/tauri-storage"; + +describe("Tauri Storage Adapter", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("getItem", () => { + it("calls read_state Tauri command", async () => { + const savedState = { focusId: "br-123", queueOrder: ["a", "b"] }; + mockInvoke.mockResolvedValue(savedState); + + const storage = createTauriStorage(); + const result = await storage.getItem("mc-state"); + + expect(mockInvoke).toHaveBeenCalledWith("read_state"); + expect(result).toBe(JSON.stringify(savedState)); + }); + + it("returns null when no state exists", async () => { + mockInvoke.mockResolvedValue(null); + + const storage = createTauriStorage(); + const result = await storage.getItem("mc-state"); + + expect(result).toBeNull(); + }); + + it("returns null on Tauri error (graceful fallback)", async () => { + mockInvoke.mockRejectedValue(new Error("Tauri not available")); + + const storage = createTauriStorage(); + const result = await storage.getItem("mc-state"); + + expect(result).toBeNull(); + }); + }); + + describe("setItem", () => { + it("calls write_state Tauri command with parsed JSON", async () => { + mockInvoke.mockResolvedValue(undefined); + + const storage = createTauriStorage(); + const state = { focusId: "br-456", activeView: "queue" }; + await storage.setItem("mc-state", JSON.stringify(state)); + + expect(mockInvoke).toHaveBeenCalledWith("write_state", { state }); + }); + + it("handles Tauri error gracefully (does not throw)", async () => { + mockInvoke.mockRejectedValue(new Error("Write failed")); + + const storage = createTauriStorage(); + + // Should not throw + await expect( + storage.setItem("mc-state", JSON.stringify({ focusId: null })) + ).resolves.not.toThrow(); + }); + }); + + describe("removeItem", () => { + it("calls clear_state Tauri command", async () => { + mockInvoke.mockResolvedValue(undefined); + + const storage = createTauriStorage(); + await storage.removeItem("mc-state"); + + expect(mockInvoke).toHaveBeenCalledWith("clear_state"); + }); + }); +}); + +describe("initializeStorage", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns Tauri storage when in Tauri context", async () => { + // Tauri context detection: window.__TAURI__ exists + vi.stubGlobal("__TAURI__", {}); + + const storage = await initializeStorage(); + + // Should be our custom storage, not localStorage + expect(storage.getItem).toBeDefined(); + expect(storage.setItem).toBeDefined(); + + vi.unstubAllGlobals(); + }); + + it("falls back to localStorage in browser context", async () => { + // Not in Tauri + vi.stubGlobal("__TAURI__", undefined); + + const storage = await initializeStorage(); + + // Should fall back to localStorage wrapper + expect(storage).toBeDefined(); + + vi.unstubAllGlobals(); + }); +});