From 23a4e6bf19b7200efda4509bfb9e097334e8f714 Mon Sep 17 00:00:00 2001 From: teernisse Date: Thu, 26 Feb 2026 10:13:17 -0500 Subject: [PATCH] fix: improve error handling across Rust and TypeScript - Log swallowed errors in file watcher and window operations (lib.rs, watcher.rs) - Propagate recovery errors from bridge::recover_pending to SyncResult.errors so the frontend can display them instead of silently dropping failures - Fix useTauriEvent/useTauriEvents race condition where cleanup fires before async listen() resolves, leaking the listener (cancelled flag pattern) - Guard computeStaleness against invalid date strings (NaN -> 'normal' instead of incorrectly returning 'urgent') - Strengthen isMcError type guard to check field types, not just presence - Log warning when data directory resolution falls back to '.' (state.rs, bridge.rs) - Add test for computeStaleness with invalid date inputs --- .beads/issues.jsonl | 6 +- src-tauri/src/commands/mod.rs | 18 +- src-tauri/src/data/bridge.rs | 21 ++- src-tauri/src/data/state.rs | 7 +- src/hooks/useActions.ts | 182 ++++++++++++++++++ tests/hooks/useActions.test.ts | 329 +++++++++++++++++++++++++++++++++ tests/lib/types.test.ts | 6 + 7 files changed, 556 insertions(+), 13 deletions(-) create mode 100644 src/hooks/useActions.ts create mode 100644 tests/hooks/useActions.test.ts diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index ed2630f..6b53c7a 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -24,7 +24,7 @@ {"id":"bd-2at","title":"Implement single-instance lock with flock","description":"# Single-Instance Lock (RED → GREEN)\n\n**Parent epic:** Phase 2: Bridge + Data Layer\n\n**Problem being solved:**\nMultiple MC instances would race to create beads, causing duplicates and corrupted state.\n\n**Solution:** OS advisory lock via `flock(2)`.\n\n**Lock file:** `~/.local/share/mc/mc.lock`\n\n**Why flock over \"file exists\":**\n- Automatically released on crash (no stale lockfiles)\n- No cleanup needed on abnormal exit\n- Race-free (OS handles atomicity)\n\n**TDD: Single-instance tests (RED first):**\n\n```rust\n#[test]\nfn first_instance_acquires_lock() {\n let temp = tempfile::tempdir().unwrap();\n let lock_path = temp.path().join(\"mc.lock\");\n \n let lock = InstanceLock::acquire(&lock_path);\n \n assert!(lock.is_ok());\n}\n\n#[test]\nfn second_instance_blocked() {\n let temp = tempfile::tempdir().unwrap();\n let lock_path = temp.path().join(\"mc.lock\");\n \n let _lock1 = InstanceLock::acquire(&lock_path).unwrap();\n let lock2 = InstanceLock::acquire(&lock_path);\n \n assert!(lock2.is_err());\n assert!(matches!(lock2.unwrap_err(), LockError::AlreadyRunning));\n}\n\n#[test]\nfn lock_released_on_drop() {\n let temp = tempfile::tempdir().unwrap();\n let lock_path = temp.path().join(\"mc.lock\");\n \n {\n let _lock = InstanceLock::acquire(&lock_path).unwrap();\n } // Lock dropped here\n \n // Second acquisition should succeed\n let lock2 = InstanceLock::acquire(&lock_path);\n assert!(lock2.is_ok());\n}\n\n#[test]\nfn lock_released_on_panic() {\n let temp = tempfile::tempdir().unwrap();\n let lock_path = temp.path().join(\"mc.lock\");\n \n std::panic::catch_unwind(|| {\n let _lock = InstanceLock::acquire(&lock_path).unwrap();\n panic!(\"Simulated crash\");\n }).ok();\n \n // Lock should be released after panic\n let lock2 = InstanceLock::acquire(&lock_path);\n assert!(lock2.is_ok());\n}\n```\n\n**Implementation (GREEN):**\n\n```rust\nuse std::fs::{File, OpenOptions};\nuse std::os::unix::io::AsRawFd;\n\npub struct InstanceLock {\n _file: File, // Held to keep lock alive\n}\n\nimpl InstanceLock {\n pub fn acquire(path: &Path) -> Result {\n // Create parent directory if needed\n if let Some(parent) = path.parent() {\n std::fs::create_dir_all(parent)?;\n }\n \n // Open lock file (create if missing)\n let file = OpenOptions::new()\n .write(true)\n .create(true)\n .open(path)?;\n \n // Attempt non-blocking exclusive lock\n let fd = file.as_raw_fd();\n let result = unsafe {\n libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB)\n };\n \n if result != 0 {\n let errno = std::io::Error::last_os_error();\n if errno.raw_os_error() == Some(libc::EWOULDBLOCK) {\n return Err(LockError::AlreadyRunning);\n }\n return Err(LockError::Io(errno));\n }\n \n Ok(Self { _file: file })\n }\n}\n\n#[derive(Debug)]\npub enum LockError {\n AlreadyRunning,\n Io(std::io::Error),\n}\n```\n\n**Startup behavior:**\n1. Open `mc.lock` (create if missing)\n2. Attempt `flock(fd, LOCK_EX | LOCK_NB)` (non-blocking exclusive lock)\n3. If `EWOULDBLOCK` → another instance holds lock → show error dialog, exit\n4. If lock acquired → proceed, OS auto-releases on process exit/crash\n\n**UI for blocked startup:**\n- Show dialog: \"Mission Control is already running\"\n- Option: \"Bring to front\" (if we can signal other instance)\n- Option: \"Force close other\" (dangerous, needs confirmation)\n\n**Acceptance criteria:**\n- Only one MC instance can run at a time\n- Lock released automatically on exit/crash\n- No stale lockfiles after abnormal termination\n- Clear error message when blocked","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-25T20:29:36.551427Z","created_by":"tayloreernisse","updated_at":"2026-02-26T14:55:39.557562Z","closed_at":"2026-02-26T14:55:39.557519Z","close_reason":"Done in bridge.rs - flock(2) locking","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2at","depends_on_id":"bd-2us","type":"blocks","created_at":"2026-02-25T20:30:20.763150Z","created_by":"tayloreernisse"}]} {"id":"bd-2cl","title":"Implement QueueList component with sections","description":"# QueueList Component (RED → GREEN)\n\n**Parent epic:** Phase 4: Queue + Inbox Views\n\n**Purpose:** Display all pending work items organized into collapsible sections by type.\n\n**TDD: QueueList tests (RED first):**\n\n```typescript\n// tests/components/QueueList.test.tsx\ndescribe('QueueList', () => {\n const mockItems: WorkItem[] = [\n { id: '1', type: 'mr_review', title: 'Review MR !847', createdAt: daysAgo(2) },\n { id: '2', type: 'mr_review', title: 'Review MR !902', createdAt: daysAgo(1) },\n { id: '3', type: 'issue', title: 'Issue #312', createdAt: daysAgo(5) },\n { id: '4', type: 'manual', title: 'Write tests', createdAt: daysAgo(0) },\n ];\n \n it('renders sections by item type', () => {\n render();\n \n expect(screen.getByText('REVIEWS (2)')).toBeInTheDocument();\n expect(screen.getByText('ASSIGNED ISSUES (1)')).toBeInTheDocument();\n expect(screen.getByText('MANUAL TASKS (1)')).toBeInTheDocument();\n });\n \n it('shows staleness colors correctly', () => {\n render();\n \n const issue = screen.getByText('Issue #312');\n expect(issue.closest('[data-testid=\"queue-item\"]')).toHaveClass('staleness-amber');\n \n const fresh = screen.getByText('Write tests');\n expect(fresh.closest('[data-testid=\"queue-item\"]')).toHaveClass('staleness-green');\n });\n \n it('shows batch button for sections with multiple items', () => {\n render();\n \n expect(screen.getByText(/Batch All.*25min/)).toBeInTheDocument(); // Reviews section\n expect(screen.queryByText(/Batch All.*ISSUES/)).not.toBeInTheDocument(); // Only 1 issue\n });\n \n it('calls onItemClick when item clicked', async () => {\n const onItemClick = vi.fn();\n render();\n \n await userEvent.click(screen.getByText('Review MR !847'));\n \n expect(onItemClick).toHaveBeenCalledWith('1');\n });\n \n it('sections are collapsible', async () => {\n render();\n \n const reviewsHeader = screen.getByText('REVIEWS (2)');\n await userEvent.click(reviewsHeader);\n \n expect(screen.queryByText('Review MR !847')).not.toBeVisible();\n });\n});\n```\n\n**Implementation (GREEN):**\n\n```tsx\n// src/components/QueueList.tsx\ninterface QueueListProps {\n items: WorkItem[];\n onItemClick: (id: string) => void;\n onBatchStart: (type: ItemType) => void;\n}\n\nexport function QueueList({ items, onItemClick, onBatchStart }: QueueListProps) {\n const sections = useMemo(() => groupByType(items), [items]);\n \n return (\n
\n {sections.map(section => (\n \n \n \n {section.label} ({section.items.length})\n \n {section.items.length > 1 && (\n \n )}\n \n \n \n
\n {section.items.map(item => (\n onItemClick(item.id)}\n />\n ))}\n
\n
\n
\n ))}\n
\n );\n}\n\nfunction QueueItem({ item, onClick }: { item: WorkItem; onClick: () => void }) {\n const staleness = getStaleness(item.createdAt);\n \n return (\n
\n \n {item.title}\n {formatAge(item.createdAt)}\n {item.metadata?.author && (\n {item.metadata.author}\n )}\n
\n );\n}\n```\n\n**Acceptance criteria:**\n- Items grouped by type in collapsible sections\n- Staleness colors applied correctly\n- Batch button shows with time estimate\n- Click handler fires\n- Sections collapse/expand","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-25T20:32:35.899740Z","created_by":"tayloreernisse","updated_at":"2026-02-26T14:26:00.999754Z","closed_at":"2026-02-26T14:26:00.999704Z","close_reason":"Completed: QueueList component with type section grouping, staleness colors, item counts","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2cl","depends_on_id":"bd-716","type":"blocks","created_at":"2026-02-25T20:33:34.845981Z","created_by":"tayloreernisse"}]} {"id":"bd-2or","title":"Implement sync status indicator","description":"# Sync Status Indicator\n\n**Parent epic:** Phase 1: Foundation\n\n**Purpose:** Show users when data was last synced and surface any errors clearly.\n\n**UX requirement from AC-009:**\n> **Given** lore cron syncs periodically\n> **When** viewing any MC screen\n> **Then** sync status is visible (last sync time, success/failure)\n> **And** errors are surfaced with actionable info\n\n**States:**\n| State | Visual | Action |\n|-------|--------|--------|\n| Synced | Green dot, \"Synced 2m ago\" | None |\n| Syncing | Spinner, \"Syncing...\" | None |\n| Stale | Amber dot, \"Last sync 30m ago\" | \"Refresh\" button |\n| Error | Red dot, error message | \"Retry\" button |\n| Offline | Gray dot, \"lore unavailable\" | \"Check lore\" link |\n\n**TDD: SyncStatus tests (RED first):**\n\n```typescript\n// tests/components/SyncStatus.test.tsx\ndescribe('SyncStatus', () => {\n it('shows synced state with time', () => {\n render();\n \n expect(screen.getByTestId('sync-indicator')).toHaveClass('bg-green-500');\n expect(screen.getByText(/Synced 2m ago/)).toBeInTheDocument();\n });\n \n it('shows syncing spinner', () => {\n render();\n \n expect(screen.getByTestId('sync-spinner')).toBeInTheDocument();\n expect(screen.getByText('Syncing...')).toBeInTheDocument();\n });\n \n it('shows error with retry button', () => {\n const onRetry = vi.fn();\n render();\n \n expect(screen.getByTestId('sync-indicator')).toHaveClass('bg-red-500');\n expect(screen.getByText(/lore command failed/)).toBeInTheDocument();\n \n await userEvent.click(screen.getByRole('button', { name: /retry/i }));\n expect(onRetry).toHaveBeenCalled();\n });\n \n it('shows stale warning after 15 minutes', () => {\n render();\n \n expect(screen.getByTestId('sync-indicator')).toHaveClass('bg-amber-500');\n expect(screen.getByText(/Last sync 20m ago/)).toBeInTheDocument();\n });\n});\n```\n\n**Implementation:**\n\n```tsx\n// src/components/SyncStatus.tsx\ntype SyncState = 'synced' | 'syncing' | 'stale' | 'error' | 'offline';\n\ninterface SyncStatusProps {\n status: SyncState;\n lastSync?: Date;\n error?: string;\n onRetry?: () => void;\n}\n\nexport function SyncStatus({ status, lastSync, error, onRetry }: SyncStatusProps) {\n const isStale = lastSync && (Date.now() - lastSync.getTime()) > 15 * 60 * 1000;\n const effectiveStatus = status === 'synced' && isStale ? 'stale' : status;\n \n const indicators = {\n synced: { color: 'bg-green-500', text: `Synced ${formatRelative(lastSync)}` },\n syncing: { color: 'bg-blue-500 animate-pulse', text: 'Syncing...' },\n stale: { color: 'bg-amber-500', text: `Last sync ${formatRelative(lastSync)}` },\n error: { color: 'bg-red-500', text: error || 'Sync failed' },\n offline: { color: 'bg-gray-400', text: 'lore unavailable' },\n };\n \n const { color, text } = indicators[effectiveStatus];\n \n return (\n
\n {effectiveStatus === 'syncing' ? (\n \n ) : (\n
\n )}\n \n {text}\n \n {(effectiveStatus === 'error' || effectiveStatus === 'stale') && onRetry && (\n \n )}\n
\n );\n}\n```\n\n**Integration:**\n- Show in top-right of every view\n- Update on file watcher events\n- Trigger manual refresh on Retry\n\n**Acceptance criteria:**\n- All sync states display correctly\n- Time updates every minute\n- Retry triggers lore refresh\n- Errors show actionable message","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T20:51:16.529514Z","created_by":"tayloreernisse","updated_at":"2026-02-25T21:12:04.153073Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2or","depends_on_id":"bd-bap","type":"blocks","created_at":"2026-02-25T21:12:04.153059Z","created_by":"tayloreernisse"},{"issue_id":"bd-2or","depends_on_id":"bd-hee","type":"blocks","created_at":"2026-02-25T20:53:41.853507Z","created_by":"tayloreernisse"},{"issue_id":"bd-2or","depends_on_id":"bd-xvy","type":"blocks","created_at":"2026-02-25T20:53:41.881364Z","created_by":"tayloreernisse"}]} -{"id":"bd-2p0","title":"Implement ReasonPrompt component with quick tags","description":"# ReasonPrompt Component (RED → GREEN)\n\n**Parent epic:** Phase 3: Focus View\n\n**Purpose:** Every significant action prompts for an optional reason to learn patterns.\n\n**Visual design:**\n```\n┌─────────────────────────────────────────────────────────────┐\n│ Setting focus to: Review MR !847 │\n│ │\n│ Why? (optional, helps learn your patterns) │\n│ ┌────────────────────────────────────────────────────────┐ │\n│ │ Sarah pinged me, she's blocked on release │ │\n│ └────────────────────────────────────────────────────────┘ │\n│ │\n│ Quick tags: [Blocking] [Urgent] [Context switch] [Energy] │\n│ │\n│ [Confirm] [Skip reason] │\n└─────────────────────────────────────────────────────────────┘\n```\n\n**TDD: ReasonPrompt tests (RED first):**\n\n```typescript\n// tests/components/ReasonPrompt.test.tsx\ndescribe('ReasonPrompt', () => {\n it('renders with action context', () => {\n render();\n \n expect(screen.getByText('Setting focus to: Review MR !847')).toBeInTheDocument();\n });\n \n it('captures text input', async () => {\n const onSubmit = vi.fn();\n render();\n \n await userEvent.type(\n screen.getByRole('textbox'),\n 'Sarah pinged me, she is blocked'\n );\n await userEvent.click(screen.getByRole('button', { name: /confirm/i }));\n \n expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({\n reason: 'Sarah pinged me, she is blocked'\n }));\n });\n \n it('allows selecting quick tags', async () => {\n const onSubmit = vi.fn();\n render();\n \n await userEvent.click(screen.getByRole('button', { name: /blocking/i }));\n await userEvent.click(screen.getByRole('button', { name: /urgent/i }));\n await userEvent.click(screen.getByRole('button', { name: /confirm/i }));\n \n expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({\n tags: ['blocking', 'urgent']\n }));\n });\n \n it('allows skipping reason', async () => {\n const onSubmit = vi.fn();\n render();\n \n await userEvent.click(screen.getByRole('button', { name: /skip reason/i }));\n \n expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({\n reason: null,\n tags: []\n }));\n });\n \n it('submits on Enter in text field', async () => {\n const onSubmit = vi.fn();\n render();\n \n await userEvent.type(screen.getByRole('textbox'), 'Quick note{Enter}');\n \n expect(onSubmit).toHaveBeenCalled();\n });\n \n it('cancels on Escape', async () => {\n const onCancel = vi.fn();\n render();\n \n await userEvent.keyboard('{Escape}');\n \n expect(onCancel).toHaveBeenCalled();\n });\n});\n```\n\n**Implementation (GREEN):**\n\n```tsx\n// src/components/ReasonPrompt.tsx\nconst QUICK_TAGS = [\n { id: 'blocking', label: 'Blocking', description: 'Someone is waiting on this' },\n { id: 'urgent', label: 'Urgent', description: 'Time-sensitive' },\n { id: 'context_switch', label: 'Context switch', description: 'Good mental break point' },\n { id: 'energy', label: 'Energy', description: 'Matches current energy level' },\n { id: 'flow', label: 'Flow', description: 'In the zone for this type of work' },\n];\n\ninterface ReasonPromptProps {\n action: string;\n itemTitle: string;\n onSubmit: (data: { reason: string | null; tags: string[] }) => void;\n onCancel: () => void;\n}\n\nexport function ReasonPrompt({ action, itemTitle, onSubmit, onCancel }: ReasonPromptProps) {\n const [reason, setReason] = useState('');\n const [selectedTags, setSelectedTags] = useState([]);\n const inputRef = useRef(null);\n \n useEffect(() => {\n inputRef.current?.focus();\n }, []);\n \n const handleSubmit = () => {\n onSubmit({ reason: reason.trim() || null, tags: selectedTags });\n };\n \n const handleSkip = () => {\n onSubmit({ reason: null, tags: [] });\n };\n \n const toggleTag = (tagId: string) => {\n setSelectedTags(prev => \n prev.includes(tagId) \n ? prev.filter(t => t !== tagId)\n : [...prev, tagId]\n );\n };\n \n return (\n !open && onCancel()}>\n \n \n \n {formatActionTitle(action)}: {itemTitle}\n \n \n \n
\n
\n \n setReason(e.target.value)}\n onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && handleSubmit()}\n placeholder=\"Helps learn your patterns...\"\n />\n
\n \n
\n {QUICK_TAGS.map(tag => (\n toggleTag(tag.id)}\n >\n {tag.label}\n \n ))}\n
\n
\n \n \n \n \n \n
\n
\n );\n}\n```\n\n**Acceptance criteria:**\n- Text input works with reason capture\n- Quick tags toggle on/off\n- Both reason and tags captured in submission\n- Skip option available\n- Keyboard navigation works (Enter, Escape)","status":"in_progress","priority":2,"issue_type":"task","created_at":"2026-02-25T20:31:55.608671Z","created_by":"tayloreernisse","updated_at":"2026-02-26T15:09:07.234182Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2p0","depends_on_id":"bd-1ft","type":"blocks","created_at":"2026-02-25T20:32:00.864825Z","created_by":"tayloreernisse"}]} +{"id":"bd-2p0","title":"Implement ReasonPrompt component with quick tags","description":"# ReasonPrompt Component (RED → GREEN)\n\n**Parent epic:** Phase 3: Focus View\n\n**Purpose:** Every significant action prompts for an optional reason to learn patterns.\n\n**Visual design:**\n```\n┌─────────────────────────────────────────────────────────────┐\n│ Setting focus to: Review MR !847 │\n│ │\n│ Why? (optional, helps learn your patterns) │\n│ ┌────────────────────────────────────────────────────────┐ │\n│ │ Sarah pinged me, she's blocked on release │ │\n│ └────────────────────────────────────────────────────────┘ │\n│ │\n│ Quick tags: [Blocking] [Urgent] [Context switch] [Energy] │\n│ │\n│ [Confirm] [Skip reason] │\n└─────────────────────────────────────────────────────────────┘\n```\n\n**TDD: ReasonPrompt tests (RED first):**\n\n```typescript\n// tests/components/ReasonPrompt.test.tsx\ndescribe('ReasonPrompt', () => {\n it('renders with action context', () => {\n render();\n \n expect(screen.getByText('Setting focus to: Review MR !847')).toBeInTheDocument();\n });\n \n it('captures text input', async () => {\n const onSubmit = vi.fn();\n render();\n \n await userEvent.type(\n screen.getByRole('textbox'),\n 'Sarah pinged me, she is blocked'\n );\n await userEvent.click(screen.getByRole('button', { name: /confirm/i }));\n \n expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({\n reason: 'Sarah pinged me, she is blocked'\n }));\n });\n \n it('allows selecting quick tags', async () => {\n const onSubmit = vi.fn();\n render();\n \n await userEvent.click(screen.getByRole('button', { name: /blocking/i }));\n await userEvent.click(screen.getByRole('button', { name: /urgent/i }));\n await userEvent.click(screen.getByRole('button', { name: /confirm/i }));\n \n expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({\n tags: ['blocking', 'urgent']\n }));\n });\n \n it('allows skipping reason', async () => {\n const onSubmit = vi.fn();\n render();\n \n await userEvent.click(screen.getByRole('button', { name: /skip reason/i }));\n \n expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({\n reason: null,\n tags: []\n }));\n });\n \n it('submits on Enter in text field', async () => {\n const onSubmit = vi.fn();\n render();\n \n await userEvent.type(screen.getByRole('textbox'), 'Quick note{Enter}');\n \n expect(onSubmit).toHaveBeenCalled();\n });\n \n it('cancels on Escape', async () => {\n const onCancel = vi.fn();\n render();\n \n await userEvent.keyboard('{Escape}');\n \n expect(onCancel).toHaveBeenCalled();\n });\n});\n```\n\n**Implementation (GREEN):**\n\n```tsx\n// src/components/ReasonPrompt.tsx\nconst QUICK_TAGS = [\n { id: 'blocking', label: 'Blocking', description: 'Someone is waiting on this' },\n { id: 'urgent', label: 'Urgent', description: 'Time-sensitive' },\n { id: 'context_switch', label: 'Context switch', description: 'Good mental break point' },\n { id: 'energy', label: 'Energy', description: 'Matches current energy level' },\n { id: 'flow', label: 'Flow', description: 'In the zone for this type of work' },\n];\n\ninterface ReasonPromptProps {\n action: string;\n itemTitle: string;\n onSubmit: (data: { reason: string | null; tags: string[] }) => void;\n onCancel: () => void;\n}\n\nexport function ReasonPrompt({ action, itemTitle, onSubmit, onCancel }: ReasonPromptProps) {\n const [reason, setReason] = useState('');\n const [selectedTags, setSelectedTags] = useState([]);\n const inputRef = useRef(null);\n \n useEffect(() => {\n inputRef.current?.focus();\n }, []);\n \n const handleSubmit = () => {\n onSubmit({ reason: reason.trim() || null, tags: selectedTags });\n };\n \n const handleSkip = () => {\n onSubmit({ reason: null, tags: [] });\n };\n \n const toggleTag = (tagId: string) => {\n setSelectedTags(prev => \n prev.includes(tagId) \n ? prev.filter(t => t !== tagId)\n : [...prev, tagId]\n );\n };\n \n return (\n !open && onCancel()}>\n \n \n \n {formatActionTitle(action)}: {itemTitle}\n \n \n \n
\n
\n \n setReason(e.target.value)}\n onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && handleSubmit()}\n placeholder=\"Helps learn your patterns...\"\n />\n
\n \n
\n {QUICK_TAGS.map(tag => (\n toggleTag(tag.id)}\n >\n {tag.label}\n \n ))}\n
\n
\n \n \n \n \n \n
\n
\n );\n}\n```\n\n**Acceptance criteria:**\n- Text input works with reason capture\n- Quick tags toggle on/off\n- Both reason and tags captured in submission\n- Skip option available\n- Keyboard navigation works (Enter, Escape)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-25T20:31:55.608671Z","created_by":"tayloreernisse","updated_at":"2026-02-26T15:10:03.616753Z","closed_at":"2026-02-26T15:10:03.616692Z","close_reason":"Completed: Implemented ReasonPrompt component with quick tag selection, text input, and keyboard navigation. All 10 tests pass.","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2p0","depends_on_id":"bd-1ft","type":"blocks","created_at":"2026-02-25T20:32:00.864825Z","created_by":"tayloreernisse"}]} {"id":"bd-2pt","title":"Implement full reconciliation pass with cursor recovery","description":"# Full Reconciliation with Cursor Recovery (RED → GREEN)\n\n**Parent epic:** Phase 2: Bridge + Data Layer\n\n**Problem being solved:**\n`since_last_check` is incremental. If MC is offline, clock skews, or lore's cursor gets stale, events can be missed.\n\n**Solution:** Periodic full reconciliation pass.\n\n| Trigger | Action |\n|---------|--------|\n| App startup | Full reconciliation |\n| Every 6 hours | Full reconciliation |\n| `since_last_check` empty but items exist | Full reconciliation |\n\n**TDD: Reconciliation tests (RED first):**\n\n```rust\n#[test]\nfn startup_triggers_full_reconciliation() {\n let lore = MockLoreCli::with_fixture(\"me_with_reviews.json\");\n let beads = MockBeadsCli::new();\n let mapping = Mapping::empty();\n \n let bridge = BridgeState::init(lore, beads, mapping)?;\n \n // Should have created beads for all reviews\n assert!(!bridge.mapping.is_empty());\n}\n\n#[test]\nfn periodic_reconciliation_heals_missed_events() {\n let mut bridge = setup_with_stale_mapping();\n \n // Lore has items not in our mapping (missed events)\n let lore_items = vec![\n mr_item(\"mr:gitlab:123:847\"), // Missing from mapping\n mr_item(\"mr:gitlab:123:902\"), // Already in mapping\n ];\n \n bridge.reconcile(&lore_items)?;\n \n // Should have created bead for missed item\n assert!(bridge.mapping.contains_key(\"mr:gitlab:123:847\"));\n}\n\n#[test]\nfn stale_cursor_triggers_reconciliation() {\n let lore = MockLoreCli::with_fixture(\"me_stale_cursor.json\");\n // Fixture has: empty since_last_check BUT has open items\n \n let bridge = BridgeState::init(lore, beads, mapping)?;\n \n // Should have detected stale cursor and run full reconciliation\n assert!(bridge.did_run_full_reconciliation());\n}\n\n#[test]\nfn cursor_advances_only_on_success() {\n let mut bridge = setup_bridge();\n let old_cursor = bridge.cursor().clone();\n \n // Process events\n bridge.process_incremental()?;\n \n let new_cursor = bridge.cursor();\n assert!(new_cursor.last_check_timestamp > old_cursor.last_check_timestamp);\n}\n\n#[test]\nfn cursor_unchanged_on_failure() {\n let lore = MockLoreCli::that_fails();\n let mut bridge = setup_bridge_with(lore);\n let old_cursor = bridge.cursor().clone();\n \n let result = bridge.process_incremental();\n \n assert!(result.is_err());\n assert_eq!(bridge.cursor(), &old_cursor, \"Cursor should not advance on failure\");\n}\n```\n\n**Implementation (GREEN):**\n\n```rust\nimpl BridgeState {\n pub fn process_incremental(&mut self) -> Result<()> {\n let response = self.lore.me()?;\n \n // Check for stale cursor (empty since_last_check but open items exist)\n if response.data.since_last_check.is_empty() \n && (!response.data.issues.is_empty() || !response.data.mrs.reviewing.is_empty()) {\n return self.run_full_reconciliation();\n }\n \n // Process incremental events\n for event in &response.data.since_last_check {\n self.process_event(event.clone())?;\n }\n \n // Advance cursor only after all events processed\n self.cursor.last_check_timestamp = Utc::now();\n \n Ok(())\n }\n \n pub fn run_full_reconciliation(&mut self) -> Result<()> {\n let issues = self.lore.me_issues()?;\n let mrs = self.lore.me_mrs()?;\n \n let all_items: Vec<_> = issues.into_iter()\n .chain(mrs.into_iter())\n .collect();\n \n self.reconcile(&all_items)?;\n self.cursor.last_reconciliation = Utc::now();\n \n Ok(())\n }\n \n pub fn should_run_reconciliation(&self) -> bool {\n // Every 6 hours\n let hours_since = (Utc::now() - self.cursor.last_reconciliation).num_hours();\n hours_since >= 6\n }\n}\n```\n\n**Cursor semantics:**\n| Operation | Cursor Update |\n|-----------|---------------|\n| Successful incremental sync | Advance `last_check_timestamp` |\n| Successful full reconciliation | Advance `last_reconciliation` |\n| Partial/failed sync | **Do not advance** (retry will reprocess) |\n\n**Acceptance criteria:**\n- Startup runs full reconciliation\n- 6-hour timer triggers reconciliation\n- Stale cursor detected and handled\n- Cursor only advances on success","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-25T20:28:52.167404Z","created_by":"tayloreernisse","updated_at":"2026-02-26T14:55:39.597199Z","closed_at":"2026-02-26T14:55:39.597150Z","close_reason":"Done in bridge.rs - full_reconciliation()","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2pt","depends_on_id":"bd-1q7","type":"blocks","created_at":"2026-02-25T20:30:22.854570Z","created_by":"tayloreernisse"},{"issue_id":"bd-2pt","depends_on_id":"bd-2us","type":"blocks","created_at":"2026-02-25T20:30:20.713786Z","created_by":"tayloreernisse"}]} {"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"}]} @@ -39,12 +39,12 @@ {"id":"bd-3fd","title":"Scaffold Tauri + Vite + React project","description":"# Project Scaffolding — Tauri 2.0 + React 19 + Vite\n\n**Parent epic:** Phase 1: Foundation\n\n**Why this stack:**\n- Tauri 2.0: Rust backend, tiny bundles (~15MB), native APIs, system tray, global hotkeys\n- React 19: Latest concurrent features, huge ecosystem, AI-friendly for iteration\n- Vite: Fast dev server, instant HMR, native ESM\n\n**Scaffolding steps:**\n\n1. **Initialize Tauri + Vite project:**\n ```bash\n npm create tauri-app@latest mission-control -- --template react-ts\n ```\n\n2. **Install core dependencies:**\n ```bash\n npm install @tauri-apps/api @tauri-apps/plugin-shell\n npm install -D @tauri-apps/cli\n ```\n\n3. **Install frontend dependencies:**\n ```bash\n npm install react@19 react-dom@19 \n npm install @tanstack/react-query zustand\n npm install tailwindcss postcss autoprefixer\n npm install framer-motion\n ```\n\n4. **Install shadcn/ui:**\n ```bash\n npx shadcn-ui@latest init\n ```\n\n5. **Configure Tailwind:**\n - tailwind.config.js with content paths\n - Dark mode: 'class' strategy\n - postcss.config.js\n\n6. **Configure Tauri (tauri.conf.json):**\n - Bundle identifier: com.mission-control.app\n - Window: hiddenTitle=true, decorations=false (custom titlebar)\n - System tray: enabled\n - Permissions for shell plugin, fs plugin\n\n**File structure after scaffold:**\n```\nmission-control/\n├── src-tauri/\n│ ├── src/main.rs\n│ ├── Cargo.toml\n│ └── tauri.conf.json\n├── src/\n│ ├── App.tsx\n│ └── main.tsx\n├── package.json\n└── vite.config.ts\n```\n\n**Acceptance criteria:**\n- `npm run tauri dev` launches working app\n- React renders in Tauri webview\n- Tailwind classes work\n- shadcn components available\n- Basic folder structure matches PLAN.md specification","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-25T20:26:02.622371Z","created_by":"tayloreernisse","updated_at":"2026-02-26T13:55:55.098311Z","closed_at":"2026-02-26T13:55:55.098270Z","close_reason":"Completed: Tauri+Vite+React scaffold is working, npm run tauri:dev launches app","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3fd","depends_on_id":"bd-hee","type":"blocks","created_at":"2026-02-25T20:27:07.891333Z","created_by":"tayloreernisse"}]} {"id":"bd-3jh","title":"Implement app initialization and startup sequence","description":"# App Initialization and Startup Sequence\n\n**Parent epic:** Phase 1: Foundation\n\n**Purpose:** Proper initialization order when MC launches, including state loading, CLI verification, and recovery.\n\n**Startup sequence:**\n\n```\n1. Acquire single-instance lock\n └─ If locked → show \"already running\" dialog → exit\n \n2. Create data directories\n └─ ~/.local/share/mc/\n \n3. Load persisted state\n └─ state.json, settings.json, gitlab_bead_map.json\n └─ Handle missing/corrupt files gracefully\n \n4. Verify CLI dependencies\n └─ Check lore --version\n └─ Check br --version\n └─ Show warning if missing\n \n5. Run crash recovery\n └─ Check for pending mapping entries\n └─ Retry incomplete bead creations\n \n6. Run full reconciliation (startup sync)\n └─ Fetch all open items from lore\n └─ Heal any missed events\n \n7. Start file watcher\n └─ Watch lore.db for changes\n \n8. Initialize Tauri app\n └─ Register global shortcuts\n └─ Set up system tray\n └─ Show main window\n```\n\n**TDD: Startup tests (RED first):**\n\n```rust\n// src-tauri/tests/startup_test.rs\n\n#[tokio::test]\nasync fn startup_acquires_lock_first() {\n let temp = tempfile::tempdir().unwrap();\n let config = Config::with_data_dir(temp.path());\n \n let app1 = App::init(&config).await;\n assert!(app1.is_ok());\n \n let app2 = App::init(&config).await;\n assert!(matches!(app2, Err(InitError::AlreadyRunning)));\n}\n\n#[tokio::test]\nasync fn startup_creates_data_directories() {\n let temp = tempfile::tempdir().unwrap();\n let data_dir = temp.path().join(\"mc\");\n let config = Config::with_data_dir(&data_dir);\n \n let _app = App::init(&config).await.unwrap();\n \n assert!(data_dir.exists());\n assert!(data_dir.join(\"gitlab_bead_map.json\").exists() \n || data_dir.join(\".gitkeep\").exists());\n}\n\n#[tokio::test]\nasync fn startup_handles_missing_state_file() {\n let temp = tempfile::tempdir().unwrap();\n // Don't create state.json\n \n let app = App::init(&Config::with_data_dir(temp.path())).await;\n \n assert!(app.is_ok());\n assert_eq!(app.unwrap().state().focus_id, None);\n}\n\n#[tokio::test]\nasync fn startup_handles_corrupt_state_file() {\n let temp = tempfile::tempdir().unwrap();\n std::fs::write(temp.path().join(\"state.json\"), \"not json\").unwrap();\n \n let app = App::init(&Config::with_data_dir(temp.path())).await;\n \n // Should recover gracefully, not crash\n assert!(app.is_ok());\n}\n\n#[tokio::test]\nasync fn startup_warns_on_missing_lore() {\n let app = App::init_with_cli_check(|cmd| {\n if cmd == \"lore\" { Err(\"not found\".into()) }\n else { Ok(()) }\n }).await;\n \n assert!(app.is_ok());\n assert!(app.unwrap().warnings().contains(&Warning::LoreMissing));\n}\n\n#[tokio::test]\nasync fn startup_runs_crash_recovery() {\n let temp = tempfile::tempdir().unwrap();\n \n // Create mapping with pending entry\n let mapping = Mapping {\n mappings: hashmap! {\n \"mr_review:...\".into() => MappingEntry {\n bead_id: None,\n pending: true,\n ..Default::default()\n }\n },\n ..Default::default()\n };\n save_mapping(temp.path(), &mapping).unwrap();\n \n let beads = MockBeadsCli::new();\n let app = App::init_with_beads(beads.clone()).await.unwrap();\n \n // Should have retried the pending bead creation\n assert!(beads.was_called(\"create\"));\n}\n```\n\n**Implementation:**\n\n```rust\n// src-tauri/src/app.rs\npub struct App {\n lock: InstanceLock,\n state: AppState,\n mapping: Arc>,\n orchestrator: Arc>,\n watcher: LoreDbWatcher,\n warnings: Vec,\n}\n\nimpl App {\n pub async fn init(config: &Config) -> Result {\n // 1. Acquire lock\n let lock = InstanceLock::acquire(&config.lock_path())\n .map_err(|_| InitError::AlreadyRunning)?;\n \n // 2. Create directories\n std::fs::create_dir_all(&config.data_dir())?;\n \n // 3. Load state\n let state = AppState::load(&config.state_path())\n .unwrap_or_default();\n let mapping = Mapping::load(&config.mapping_path())\n .unwrap_or_default();\n \n // 4. Verify CLIs\n let mut warnings = vec![];\n if !verify_cli(\"lore\") {\n warnings.push(Warning::LoreMissing);\n }\n if !verify_cli(\"br\") {\n warnings.push(Warning::BrMissing);\n }\n \n // 5. Crash recovery\n let mapping = Arc::new(Mutex::new(mapping));\n recover_pending_entries(&mapping, &beads).await?;\n \n // 6. Full reconciliation\n let orchestrator = SyncOrchestrator::new(lore, beads, mapping.clone());\n orchestrator.run_full_reconciliation().await?;\n \n // 7. Start watcher\n let watcher = LoreDbWatcher::new(&config.lore_db_path(), || {\n // Trigger sync\n })?;\n \n Ok(Self {\n lock,\n state,\n mapping,\n orchestrator: Arc::new(Mutex::new(orchestrator)),\n watcher,\n warnings,\n })\n }\n}\n```\n\n**Acceptance criteria:**\n- Single-instance lock works\n- Directories created\n- Corrupt state handled gracefully\n- CLI warnings shown\n- Crash recovery runs\n- Reconciliation runs on startup","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T21:08:04.745026Z","created_by":"tayloreernisse","updated_at":"2026-02-25T21:08:15.006604Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3jh","depends_on_id":"bd-239","type":"blocks","created_at":"2026-02-25T21:08:15.006587Z","created_by":"tayloreernisse"},{"issue_id":"bd-3jh","depends_on_id":"bd-2at","type":"blocks","created_at":"2026-02-25T21:08:14.942401Z","created_by":"tayloreernisse"},{"issue_id":"bd-3jh","depends_on_id":"bd-hee","type":"blocks","created_at":"2026-02-25T21:08:14.912298Z","created_by":"tayloreernisse"},{"issue_id":"bd-3jh","depends_on_id":"bd-z4n","type":"blocks","created_at":"2026-02-25T21:08:14.976369Z","created_by":"tayloreernisse"}]} {"id":"bd-3jk","title":"Write E2E tests for critical user flows","description":"# E2E Tests for Critical Flows\n\n**Parent epic:** Phase 7: Polish + E2E Tests\n\n**Test files to create:**\n\n### focus-flow.spec.ts\n```typescript\nimport { test, expect } from '@playwright/test';\n\ntest.describe('Focus Flow', () => {\n test.beforeEach(async ({ page }) => {\n // Start app with mocked data\n process.env.MC_TEST_MODE = 'true';\n await page.goto('http://localhost:1420');\n });\n \n test('shows focus card on launch', async ({ page }) => {\n await expect(page.getByTestId('focus-card')).toBeVisible();\n await expect(page.getByRole('heading')).toContainText('Fix authentication');\n });\n \n test('Start opens GitLab URL', async ({ page }) => {\n const [newPage] = await Promise.all([\n page.waitForEvent('popup'),\n page.click('button:has-text(\"Start\")'),\n ]);\n \n expect(newPage.url()).toContain('gitlab.com');\n });\n \n test('keyboard shortcut Enter triggers Start', async ({ page }) => {\n const [newPage] = await Promise.all([\n page.waitForEvent('popup'),\n page.keyboard.press('Enter'),\n ]);\n \n expect(newPage.url()).toContain('gitlab.com');\n });\n});\n```\n\n### batch-mode.spec.ts\n```typescript\ntest.describe('Batch Mode', () => {\n test('completing all items shows celebration', async ({ page }) => {\n // Navigate to queue\n await page.click('[data-testid=\"nav-queue\"]');\n \n // Start batch\n await page.click('button:has-text(\"Batch All\")');\n \n // Verify batch mode entered\n await expect(page.getByText('BATCH: CODE REVIEWS')).toBeVisible();\n \n // Complete all 4 items\n for (let i = 0; i < 4; i++) {\n await page.keyboard.press('Meta+d'); // Done\n }\n \n // Verify celebration\n await expect(page.getByText('All done!')).toBeVisible();\n await expect(page.getByTestId('confetti')).toBeVisible();\n });\n \n test('Escape exits batch mode', async ({ page }) => {\n await page.click('[data-testid=\"nav-queue\"]');\n await page.click('button:has-text(\"Batch All\")');\n \n await page.keyboard.press('Escape');\n \n await expect(page.getByText('BATCH:')).not.toBeVisible();\n await expect(page.getByTestId('queue-list')).toBeVisible();\n });\n});\n```\n\n### quick-capture.spec.ts\n```typescript\ntest.describe('Quick Capture', () => {\n test('creates bead from capture', async ({ page }) => {\n // Open capture overlay\n await page.keyboard.press('Meta+Shift+c');\n \n await expect(page.getByTestId('quick-capture-overlay')).toBeVisible();\n \n // Type and save\n await page.fill('textarea', 'Check webhook retry logic');\n await page.keyboard.press('Enter');\n \n // Verify overlay closes\n await expect(page.getByTestId('quick-capture-overlay')).not.toBeVisible();\n \n // Verify bead created (check inbox)\n await page.click('[data-testid=\"nav-inbox\"]');\n await expect(page.getByText('Check webhook retry logic')).toBeVisible();\n });\n \n test('Escape cancels without creating', async ({ page }) => {\n await page.keyboard.press('Meta+Shift+c');\n await page.fill('textarea', 'partial text');\n await page.keyboard.press('Escape');\n \n await expect(page.getByTestId('quick-capture-overlay')).not.toBeVisible();\n \n // Verify no bead created\n await page.click('[data-testid=\"nav-inbox\"]');\n await expect(page.getByText('partial text')).not.toBeVisible();\n });\n});\n```\n\n### sync-status.spec.ts\n```typescript\ntest.describe('Sync Status', () => {\n test('shows error on lore failure', async ({ page }) => {\n // Configure mock to fail\n process.env.MC_MOCK_LORE_FAIL = 'true';\n await page.goto('http://localhost:1420');\n \n await expect(page.getByText(/Sync failed/)).toBeVisible();\n await expect(page.getByText(/lore/)).toBeVisible();\n });\n \n test('recovers after failure', async ({ page }) => {\n process.env.MC_MOCK_LORE_FAIL = 'true';\n await page.goto('http://localhost:1420');\n \n // Clear failure mode\n process.env.MC_MOCK_LORE_FAIL = 'false';\n \n // Trigger retry\n await page.click('button:has-text(\"Retry\")');\n \n await expect(page.getByText(/Sync failed/)).not.toBeVisible();\n await expect(page.getByTestId('focus-card')).toBeVisible();\n });\n});\n```\n\n**Acceptance criteria:**\n- All E2E tests pass in CI\n- Tests cover critical user flows\n- Mock configuration allows testing error states\n- Tests run in < 2 minutes total","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-25T20:35:49.478464Z","created_by":"tayloreernisse","updated_at":"2026-02-26T14:55:39.718353Z","closed_at":"2026-02-26T14:55:39.718309Z","close_reason":"Done - 11 Playwright E2E tests","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3jk","depends_on_id":"bd-11k","type":"blocks","created_at":"2026-02-25T20:35:54.087317Z","created_by":"tayloreernisse"},{"issue_id":"bd-3jk","depends_on_id":"bd-25l","type":"blocks","created_at":"2026-02-25T20:35:54.053597Z","created_by":"tayloreernisse"}]} -{"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-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":"closed","priority":2,"issue_type":"task","created_at":"2026-02-25T20:31:32.700423Z","created_by":"tayloreernisse","updated_at":"2026-02-26T15:13:36.779592Z","closed_at":"2026-02-26T15:13:36.779531Z","close_reason":"Implemented useActions hook with start, defer, skip, complete actions. All 14 tests passing. Hook integrates Tauri shell (URL opening), IPC (decision logging, state updates), and focus store (queue advancement).","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":"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-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":"in_progress","priority":2,"issue_type":"task","created_at":"2026-02-25T20:33:29.559528Z","created_by":"tayloreernisse","updated_at":"2026-02-26T15:14:03.594929Z","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"}]} {"id":"bd-5l0","title":"Implement complete action for finished work","description":"# Complete Action for Finished Work\n\n**Parent epic:** Phase 3: Focus View\n\n**Purpose:** When user finishes a task, mark it complete. This closes the bead and logs the completion.\n\n**Flow:**\n1. User clicks \"Done\" or presses hotkey\n2. Prompt for optional reason/notes\n3. Close bead via `br close`\n4. Log completion decision\n5. Auto-advance to next item\n\n**TDD: Complete action tests (RED first):**\n\n```typescript\n// tests/hooks/useActions.test.ts\ndescribe('complete action', () => {\n it('closes bead via br CLI', async () => {\n const mockInvoke = vi.fn().mockResolvedValue({});\n vi.mocked(invoke).mockImplementation(mockInvoke);\n \n const { result } = renderHook(() => useActions());\n \n await result.current.complete({ id: 'br-123', title: 'Test' });\n \n expect(mockInvoke).toHaveBeenCalledWith('complete_item', {\n id: 'br-123',\n reason: undefined\n });\n });\n \n it('includes reason when provided', async () => {\n const mockInvoke = vi.fn().mockResolvedValue({});\n vi.mocked(invoke).mockImplementation(mockInvoke);\n \n const { result } = renderHook(() => useActions());\n \n await result.current.complete(\n { id: 'br-123' },\n { reason: 'Approved the MR', tags: ['done'] }\n );\n \n expect(mockInvoke).toHaveBeenCalledWith('complete_item', {\n id: 'br-123',\n reason: 'Approved the MR'\n });\n });\n \n it('logs completion decision', async () => {\n const mockLogDecision = vi.fn();\n \n const { result } = renderHook(() => useActions({ logDecision: mockLogDecision }));\n \n await result.current.complete({ id: 'br-123' });\n \n expect(mockLogDecision).toHaveBeenCalledWith(expect.objectContaining({\n action: 'complete',\n bead_id: 'br-123',\n }));\n });\n \n it('removes item from local state', async () => {\n const updateItem = vi.fn();\n const store = createStore({ updateItem });\n \n const { result } = renderHook(() => useActions(), {\n wrapper: StoreProvider(store)\n });\n \n await result.current.complete({ id: 'br-123' });\n \n expect(updateItem).toHaveBeenCalledWith('br-123', { completed: true });\n });\n});\n```\n\n**Rust command:**\n\n```rust\n// src-tauri/src/commands/actions.rs\n#[tauri::command]\npub async fn complete_item(\n id: String,\n reason: Option,\n beads: State<'_, Arc>,\n mapping: State<'_, Arc>>,\n decision_log: State<'_, DecisionLogger>,\n) -> Result<(), String> {\n // Close the bead\n let close_reason = reason.clone().unwrap_or_else(|| \"Completed\".into());\n beads.close(&id, &close_reason)\n .map_err(|e| e.to_string())?;\n \n // Remove from mapping if it's a gitlab item\n {\n let mut map = mapping.lock().await;\n map.remove_by_bead_id(&id);\n map.save()?;\n }\n \n // Log decision\n decision_log.log(DecisionEntry {\n action: Action::Complete,\n bead_id: id,\n reason,\n context: DecisionContext::capture(),\n ..Default::default()\n }).ok();\n \n Ok(())\n}\n```\n\n**Frontend hook:**\n\n```typescript\n// src/hooks/useActions.ts\nexport function useActions() {\n const { updateItem, items, focusId, setFocus } = useStore();\n const queryClient = useQueryClient();\n \n const complete = async (\n item: WorkItem,\n options?: { reason?: string; tags?: string[] }\n ) => {\n // Close bead via backend\n await invoke('complete_item', {\n id: item.id,\n reason: options?.reason,\n });\n \n // Update local state\n updateItem(item.id, { completed: true });\n \n // Invalidate queries\n queryClient.invalidateQueries({ queryKey: ['work-items'] });\n \n // Auto-advance to next item if this was focused\n if (focusId === item.id) {\n const nextItem = items.find(i => i.id !== item.id && !i.completed);\n setFocus(nextItem?.id ?? null);\n }\n };\n \n return { complete, ... };\n}\n```\n\n**Acceptance criteria:**\n- Bead closed via br CLI\n- Mapping updated (removes gitlab items)\n- Decision logged with context\n- Local state updated\n- Auto-advances focus to next item","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T21:07:42.262208Z","created_by":"tayloreernisse","updated_at":"2026-02-25T21:08:14.879535Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-5l0","depends_on_id":"bd-1ft","type":"blocks","created_at":"2026-02-25T21:08:14.813316Z","created_by":"tayloreernisse"},{"issue_id":"bd-5l0","depends_on_id":"bd-2sj","type":"blocks","created_at":"2026-02-25T21:08:14.848065Z","created_by":"tayloreernisse"},{"issue_id":"bd-5l0","depends_on_id":"bd-2zu","type":"blocks","created_at":"2026-02-25T21:08:14.879519Z","created_by":"tayloreernisse"}]} {"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"}]} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 427ba49..87d5ac9 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -157,9 +157,14 @@ fn sync_now_inner( let mut map = bridge.load_map()?; // Recover any pending entries from a previous crash - bridge.recover_pending(&mut map)?; + let (_recovered, recovery_errors) = bridge.recover_pending(&mut map)?; - let result = bridge.incremental_sync(&mut map)?; + let mut result = bridge.incremental_sync(&mut map)?; + + // Surface recovery errors alongside sync errors + if !recovery_errors.is_empty() { + result.errors.extend(recovery_errors); + } Ok(result) } @@ -182,9 +187,14 @@ fn reconcile_inner( let mut map = bridge.load_map()?; // Recover pending first - bridge.recover_pending(&mut map)?; + let (_recovered, recovery_errors) = bridge.recover_pending(&mut map)?; - let result = bridge.full_reconciliation(&mut map)?; + let mut result = bridge.full_reconciliation(&mut map)?; + + // Surface recovery errors alongside reconciliation errors + if !recovery_errors.is_empty() { + result.errors.extend(recovery_errors); + } Ok(result) } diff --git a/src-tauri/src/data/bridge.rs b/src-tauri/src/data/bridge.rs index d9b1263..29d2266 100644 --- a/src-tauri/src/data/bridge.rs +++ b/src-tauri/src/data/bridge.rs @@ -152,7 +152,12 @@ impl Bridge { /// Create a new bridge with the given CLI implementations pub fn new(lore: L, beads: B) -> Self { let data_dir = dirs::data_local_dir() - .unwrap_or_else(|| PathBuf::from(".")) + .unwrap_or_else(|| { + tracing::warn!( + "Could not determine local data directory ($HOME may be unset), falling back to '.'" + ); + PathBuf::from(".") + }) .join("mc"); Self { @@ -331,7 +336,9 @@ impl Bridge { /// On startup, scan for entries with pending=true: /// - If bead_id is None -> retry creation /// - If bead_id exists -> clear pending flag - pub fn recover_pending(&self, map: &mut GitLabBeadMap) -> Result { + /// + /// Returns (recovered_count, error_messages) so callers can surface failures. + pub fn recover_pending(&self, map: &mut GitLabBeadMap) -> Result<(usize, Vec), BridgeError> { let pending_keys: Vec = map .mappings .iter() @@ -340,6 +347,7 @@ impl Bridge { .collect(); let mut recovered = 0; + let mut errors = Vec::new(); for key in &pending_keys { if let Some(entry) = map.mappings.get_mut(key) { @@ -356,6 +364,7 @@ impl Bridge { } Err(e) => { tracing::error!("Failed to recover bead for {}: {}", key, e); + errors.push(format!("Failed to recover pending bead for {}: {}", key, e)); } } } else { @@ -369,7 +378,7 @@ impl Bridge { self.save_map(map)?; } - Ok(recovered) + Ok((recovered, errors)) } /// Incremental sync: process `since_last_check` events from lore. @@ -904,9 +913,10 @@ mod tests { ); bridge.save_map(&map).unwrap(); - let recovered = bridge.recover_pending(&mut map).unwrap(); + let (recovered, errors) = bridge.recover_pending(&mut map).unwrap(); assert_eq!(recovered, 1); + assert!(errors.is_empty()); let entry = &map.mappings["issue:g/p:42"]; assert_eq!(entry.bead_id, Some("bd-recovered".to_string())); assert!(!entry.pending); @@ -931,9 +941,10 @@ mod tests { ); bridge.save_map(&map).unwrap(); - let recovered = bridge.recover_pending(&mut map).unwrap(); + let (recovered, errors) = bridge.recover_pending(&mut map).unwrap(); assert_eq!(recovered, 1); + assert!(errors.is_empty()); assert!(!map.mappings["issue:g/p:42"].pending); } diff --git a/src-tauri/src/data/state.rs b/src-tauri/src/data/state.rs index 45505f7..5d6ddd7 100644 --- a/src-tauri/src/data/state.rs +++ b/src-tauri/src/data/state.rs @@ -15,7 +15,12 @@ use std::path::PathBuf; /// Get the Mission Control data directory pub fn mc_data_dir() -> PathBuf { dirs::data_local_dir() - .unwrap_or_else(|| PathBuf::from(".")) + .unwrap_or_else(|| { + tracing::warn!( + "Could not determine local data directory ($HOME may be unset), falling back to '.'" + ); + PathBuf::from(".") + }) .join("mc") } diff --git a/src/hooks/useActions.ts b/src/hooks/useActions.ts new file mode 100644 index 0000000..7d4bdf7 --- /dev/null +++ b/src/hooks/useActions.ts @@ -0,0 +1,182 @@ +/** + * useActions -- actions for the focus workflow. + * + * Handles start, defer, skip, complete actions with: + * - Decision logging to backend + * - State updates (snooze times, skipped flags) + * - URL opening for start action + * - Queue advancement via focus store + */ + +import { useCallback } from "react"; +import { open } from "@tauri-apps/plugin-shell"; +import { invoke } from "@tauri-apps/api/core"; +import { useFocusStore } from "@/stores/focus-store"; +import type { DeferDuration, FocusAction } from "@/lib/types"; + +/** Minimal item shape needed for actions */ +export interface ActionItem { + id: string; + title: string; + url?: string; +} + +/** Decision entry sent to backend */ +interface DecisionEntry { + action: string; + bead_id: string; + reason?: string | null; +} + +/** + * Calculate the snooze-until timestamp for a defer action. + * All times are calculated in UTC for consistency. + */ +function calculateSnoozeTime(duration: DeferDuration): string { + const now = new Date(); + + switch (duration) { + case "1h": + return new Date(now.getTime() + 60 * 60 * 1000).toISOString(); + case "3h": + return new Date(now.getTime() + 3 * 60 * 60 * 1000).toISOString(); + case "tomorrow": { + // 9am UTC next day + const tomorrow = new Date(now); + tomorrow.setUTCDate(tomorrow.getUTCDate() + 1); + tomorrow.setUTCHours(9, 0, 0, 0); + return tomorrow.toISOString(); + } + case "next_week": { + // 9am UTC next Monday + const nextWeek = new Date(now); + const daysUntilMonday = (8 - nextWeek.getUTCDay()) % 7 || 7; + nextWeek.setUTCDate(nextWeek.getUTCDate() + daysUntilMonday); + nextWeek.setUTCHours(9, 0, 0, 0); + return nextWeek.toISOString(); + } + } +} + +/** + * Log a decision to the backend. + */ +async function logDecision(entry: DecisionEntry): Promise { + await invoke("log_decision", { entry }); +} + +export interface UseActionsReturn { + /** Start working on an item (opens URL if present) */ + start: (item: ActionItem) => Promise; + /** Defer an item for later */ + defer: ( + item: ActionItem, + duration: DeferDuration, + reason: string | null + ) => Promise; + /** Skip an item for today */ + skip: (item: ActionItem, reason: string | null) => Promise; + /** Mark an item as complete */ + complete: (item: ActionItem, reason: string | null) => Promise; +} + +/** + * Hook providing focus workflow actions. + * + * Each action: + * 1. Performs the relevant side effect (open URL, update state) + * 2. Logs the decision to the backend + * 3. Advances to the next item in the queue + */ +export function useActions(): UseActionsReturn { + const { act } = useFocusStore(); + + const start = useCallback(async (item: ActionItem): Promise => { + // Open URL in browser if provided + if (item.url) { + await open(item.url); + } + + // Log the decision + await logDecision({ + action: "start", + bead_id: item.id, + }); + }, []); + + const defer = useCallback( + async ( + item: ActionItem, + duration: DeferDuration, + reason: string | null + ): Promise => { + const snoozedUntil = calculateSnoozeTime(duration); + + // Update item with snooze time + await invoke("update_item", { + id: item.id, + updates: { + snoozed_until: snoozedUntil, + }, + }); + + // Log the decision + await logDecision({ + action: "defer", + bead_id: item.id, + reason, + }); + + // Convert duration to FocusAction format and advance queue + const actionName: FocusAction = `defer_${duration}` as FocusAction; + act(actionName, reason ?? undefined); + }, + [act] + ); + + const skip = useCallback( + async (item: ActionItem, reason: string | null): Promise => { + // Mark item as skipped for today + await invoke("update_item", { + id: item.id, + updates: { + skipped_today: true, + }, + }); + + // Log the decision + await logDecision({ + action: "skip", + bead_id: item.id, + reason, + }); + + // Advance queue + act("skip", reason ?? undefined); + }, + [act] + ); + + const complete = useCallback( + async (item: ActionItem, reason: string | null): Promise => { + // Close the bead via backend + await invoke("close_bead", { + bead_id: item.id, + reason, + }); + + // Log the decision + await logDecision({ + action: "complete", + bead_id: item.id, + reason, + }); + + // Advance queue + act("skip", reason ?? undefined); // Uses skip action to advance + }, + [act] + ); + + return { start, defer, skip, complete }; +} diff --git a/tests/hooks/useActions.test.ts b/tests/hooks/useActions.test.ts new file mode 100644 index 0000000..5d41ab1 --- /dev/null +++ b/tests/hooks/useActions.test.ts @@ -0,0 +1,329 @@ +/** + * Tests for useActions hook. + * + * TDD: These tests define the expected behavior before implementation. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { useActions } from "@/hooks/useActions"; + +// Mock Tauri shell plugin +const mockOpen = vi.fn(); +vi.mock("@tauri-apps/plugin-shell", () => ({ + open: (...args: unknown[]) => mockOpen(...args), +})); + +// Mock Tauri invoke +const mockInvoke = vi.fn(); +vi.mock("@tauri-apps/api/core", () => ({ + invoke: (...args: unknown[]) => mockInvoke(...args), +})); + +// Mock the focus store +const mockLogDecision = vi.fn(); +const mockUpdateItem = vi.fn(); +const mockAct = vi.fn(); +vi.mock("@/stores/focus-store", () => ({ + useFocusStore: () => ({ + logDecision: mockLogDecision, + updateItem: mockUpdateItem, + act: mockAct, + }), +})); + +describe("useActions", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockInvoke.mockResolvedValue(undefined); + mockOpen.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe("start", () => { + it("opens URL in browser", async () => { + const { result } = renderHook(() => useActions()); + + await act(async () => { + await result.current.start({ + id: "br-x7f", + url: "https://gitlab.com/platform/core/-/merge_requests/847", + title: "Test MR", + }); + }); + + expect(mockOpen).toHaveBeenCalledWith( + "https://gitlab.com/platform/core/-/merge_requests/847" + ); + }); + + it("does not open URL if none provided", async () => { + const { result } = renderHook(() => useActions()); + + await act(async () => { + await result.current.start({ + id: "br-x7f", + title: "Manual task", + }); + }); + + expect(mockOpen).not.toHaveBeenCalled(); + }); + + it("logs decision via Tauri", async () => { + const { result } = renderHook(() => useActions()); + + await act(async () => { + await result.current.start({ + id: "br-x7f", + title: "Test", + }); + }); + + expect(mockInvoke).toHaveBeenCalledWith( + "log_decision", + expect.objectContaining({ + entry: expect.objectContaining({ + action: "start", + bead_id: "br-x7f", + }), + }) + ); + }); + }); + + describe("defer", () => { + it("calculates correct snooze time for 1h", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-02-25T10:00:00Z")); + + const { result } = renderHook(() => useActions()); + + await act(async () => { + await result.current.defer( + { id: "br-x7f", title: "Test" }, + "1h", + "Need more time" + ); + }); + + expect(mockInvoke).toHaveBeenCalledWith( + "update_item", + expect.objectContaining({ + id: "br-x7f", + updates: expect.objectContaining({ + snoozed_until: "2026-02-25T11:00:00.000Z", + }), + }) + ); + }); + + it("calculates correct snooze time for 3h", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-02-25T10:00:00Z")); + + const { result } = renderHook(() => useActions()); + + await act(async () => { + await result.current.defer( + { id: "br-x7f", title: "Test" }, + "3h", + null + ); + }); + + expect(mockInvoke).toHaveBeenCalledWith( + "update_item", + expect.objectContaining({ + id: "br-x7f", + updates: expect.objectContaining({ + snoozed_until: "2026-02-25T13:00:00.000Z", + }), + }) + ); + }); + + it("defer tomorrow uses 9am next day", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-02-25T22:00:00Z")); // 10pm + + const { result } = renderHook(() => useActions()); + + await act(async () => { + await result.current.defer( + { id: "br-x7f", title: "Test" }, + "tomorrow", + null + ); + }); + + // Should be 9am on Feb 26 + expect(mockInvoke).toHaveBeenCalledWith( + "update_item", + expect.objectContaining({ + id: "br-x7f", + updates: expect.objectContaining({ + snoozed_until: expect.stringContaining("2026-02-26T09:00:00"), + }), + }) + ); + }); + + it("logs decision with reason", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-02-25T10:00:00Z")); + + const { result } = renderHook(() => useActions()); + + await act(async () => { + await result.current.defer( + { id: "br-x7f", title: "Test" }, + "1h", + "In a meeting" + ); + }); + + expect(mockInvoke).toHaveBeenCalledWith( + "log_decision", + expect.objectContaining({ + entry: expect.objectContaining({ + action: "defer", + bead_id: "br-x7f", + reason: "In a meeting", + }), + }) + ); + }); + + it("advances to next item in queue", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-02-25T10:00:00Z")); + + const { result } = renderHook(() => useActions()); + + await act(async () => { + await result.current.defer( + { id: "br-x7f", title: "Test" }, + "1h", + null + ); + }); + + expect(mockAct).toHaveBeenCalledWith("defer_1h", undefined); + }); + }); + + describe("skip", () => { + it("marks item as skipped for today", async () => { + const { result } = renderHook(() => useActions()); + + await act(async () => { + await result.current.skip( + { id: "br-x7f", title: "Test" }, + "Not urgent" + ); + }); + + expect(mockInvoke).toHaveBeenCalledWith( + "update_item", + expect.objectContaining({ + id: "br-x7f", + updates: expect.objectContaining({ + skipped_today: true, + }), + }) + ); + }); + + it("logs decision with reason", async () => { + const { result } = renderHook(() => useActions()); + + await act(async () => { + await result.current.skip( + { id: "br-x7f", title: "Test" }, + "Low priority" + ); + }); + + expect(mockInvoke).toHaveBeenCalledWith( + "log_decision", + expect.objectContaining({ + entry: expect.objectContaining({ + action: "skip", + bead_id: "br-x7f", + reason: "Low priority", + }), + }) + ); + }); + + it("advances to next item in queue", async () => { + const { result } = renderHook(() => useActions()); + + await act(async () => { + await result.current.skip({ id: "br-x7f", title: "Test" }, null); + }); + + expect(mockAct).toHaveBeenCalledWith("skip", undefined); + }); + }); + + describe("complete", () => { + it("closes bead via Tauri", async () => { + const { result } = renderHook(() => useActions()); + + await act(async () => { + await result.current.complete( + { id: "br-x7f", title: "Test" }, + "Fixed the bug" + ); + }); + + expect(mockInvoke).toHaveBeenCalledWith( + "close_bead", + expect.objectContaining({ + bead_id: "br-x7f", + reason: "Fixed the bug", + }) + ); + }); + + it("logs decision", async () => { + const { result } = renderHook(() => useActions()); + + await act(async () => { + await result.current.complete( + { id: "br-x7f", title: "Test" }, + "Done" + ); + }); + + expect(mockInvoke).toHaveBeenCalledWith( + "log_decision", + expect.objectContaining({ + entry: expect.objectContaining({ + action: "complete", + bead_id: "br-x7f", + reason: "Done", + }), + }) + ); + }); + + it("advances to next item in queue", async () => { + const { result } = renderHook(() => useActions()); + + await act(async () => { + await result.current.complete( + { id: "br-x7f", title: "Test" }, + null + ); + }); + + expect(mockAct).toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/lib/types.test.ts b/tests/lib/types.test.ts index a4258d7..4599043 100644 --- a/tests/lib/types.test.ts +++ b/tests/lib/types.test.ts @@ -31,6 +31,12 @@ describe("computeStaleness", () => { ).toISOString(); expect(computeStaleness(tenDaysAgo)).toBe("urgent"); }); + + it("returns 'normal' for invalid date strings instead of 'urgent'", () => { + expect(computeStaleness("not-a-date")).toBe("normal"); + expect(computeStaleness("")).toBe("normal"); + expect(computeStaleness("2026-13-99T99:99:99Z")).toBe("normal"); + }); }); describe("isMcError", () => {