diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 5b25a5b..ed2630f 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":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T20:31:55.608671Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:32:00.864842Z","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":"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-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"}]} @@ -50,14 +50,14 @@ {"id":"bd-716","title":"Phase 4: Queue + Inbox Views","description":"# Queue + Inbox Views\n\n**Context:** Queue View shows all pending work organized by type. Inbox View shows new items requiring triage. Both support the \"achievable inbox zero\" principle.\n\n**Duration estimate:** 2-3 days\n\n**Queue View visual:**\n```\n┌─────────────────────────────────────────────────────────────┐\n│ Queue ⌘K filter │\n├─────────────────────────────────────────────────────────────┤\n│ │\n│ REVIEWS (4) [Batch All · 25min] │\n│ ┌─────────────────────────────────────────────────────┐ │\n│ │ 🔴 !847 Fix auth token refresh 2d @sarah │ │\n│ │ 🟡 !902 Add rate limiting middleware 1d @mike │ │\n│ │ 🟢 !915 Update README badges 4h @alex │ │\n│ │ 🟢 !918 Typo fix in error messages 2h @bot │ │\n│ └─────────────────────────────────────────────────────┘ │\n│ │\n│ ASSIGNED ISSUES (2) │\n│ BEADS (3) │\n│ MANUAL TASKS (1) │\n│ │\n└─────────────────────────────────────────────────────────────┘\n```\n\n**Scope includes:**\n1. QueueList component with sections by type\n2. Staleness color coding (fresh=green, aging=amber, stale=red)\n3. Drag to reorder (manual priority)\n4. Click to set as focus\n5. Inbox with triage actions\n6. Filter/search (⌘K)\n\n**Behavior:**\n- Items colored by staleness\n- Click to make it THE ONE THING\n- Drag to reorder (manual priority, logged)\n- \"Batch All\" enters batch mode\n\n**Dependencies:**\n- Requires Phase 3 (Focus View) for focus-setting integration\n- Blocks Phase 5 (Batch Mode) which is triggered from here\n\n**Acceptance criteria:**\n- Queue shows all items grouped by type\n- Staleness colors visible\n- Drag reorder persists and logs\n- Click sets focus\n- Inbox has triage actions\n- Filter/search works","status":"closed","priority":2,"issue_type":"epic","created_at":"2026-02-25T20:32:16.422391Z","created_by":"tayloreernisse","updated_at":"2026-02-26T14:25:55.308232Z","closed_at":"2026-02-26T14:25:55.308181Z","close_reason":"Completed: QueueView with sections, QueueItem with staleness, AppShell with nav, click-to-focus, reorder support, 73 tests. Inbox and command palette are placeholdered for Phase 4b.","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-716","depends_on_id":"bd-1ft","type":"blocks","created_at":"2026-02-25T20:32:19.948812Z","created_by":"tayloreernisse"}]} {"id":"bd-ah2","title":"Implement InboxView container","description":"# InboxView Container\n\n**Parent epic:** Phase 4: Queue + Inbox Views\n\n**Purpose:** Container for Inbox component with triage flow and keyboard navigation.\n\n**TDD: InboxView tests (RED first):**\n\n```typescript\n// tests/views/InboxView.test.tsx\ndescribe('InboxView', () => {\n const mockNewItems = [\n { id: '1', triaged: false, title: 'Mention in #312' },\n { id: '2', triaged: false, title: 'Comment on MR !847' },\n ];\n \n it('shows only untriaged items', () => {\n const items = [...mockNewItems, { id: '3', triaged: true, title: 'Already done' }];\n const store = createStore({ items });\n render(, { wrapper: StoreProvider(store) });\n \n expect(screen.getAllByTestId('inbox-item')).toHaveLength(2);\n expect(screen.queryByText('Already done')).not.toBeInTheDocument();\n });\n \n it('shows inbox zero celebration when empty', () => {\n const store = createStore({ items: [] });\n render(, { wrapper: StoreProvider(store) });\n \n expect(screen.getByText('Inbox Zero')).toBeInTheDocument();\n expect(screen.getByText(/All caught up/)).toBeInTheDocument();\n });\n \n it('keyboard navigation between items', async () => {\n const store = createStore({ items: mockNewItems });\n render(, { wrapper: StoreProvider(store) });\n \n await userEvent.keyboard('{ArrowDown}');\n \n expect(screen.getAllByTestId('inbox-item')[1]).toHaveFocus();\n });\n \n it('accept triages item', async () => {\n const updateItem = vi.fn();\n const store = createStore({ items: mockNewItems, updateItem });\n render(, { wrapper: StoreProvider(store) });\n \n await userEvent.click(screen.getAllByRole('button', { name: /accept/i })[0]);\n \n expect(updateItem).toHaveBeenCalledWith('1', { triaged: true });\n });\n \n it('logs triage decision', async () => {\n const logDecision = vi.fn();\n const store = createStore({ items: mockNewItems, logDecision });\n render(, { wrapper: StoreProvider(store) });\n \n await userEvent.click(screen.getAllByRole('button', { name: /accept/i })[0]);\n \n expect(logDecision).toHaveBeenCalledWith(expect.objectContaining({\n action: 'triage',\n bead_id: '1',\n }));\n });\n \n it('updates count in real-time after triage', async () => {\n const store = createStore({ items: mockNewItems });\n render(, { wrapper: StoreProvider(store) });\n \n expect(screen.getByText('Inbox (2)')).toBeInTheDocument();\n \n await userEvent.click(screen.getAllByRole('button', { name: /accept/i })[0]);\n \n expect(screen.getByText('Inbox (1)')).toBeInTheDocument();\n });\n});\n```\n\n**Implementation:**\n\n```tsx\n// src/views/InboxView.tsx\nexport function InboxView() {\n const { items, updateItem } = useStore();\n const { logDecision } = useDecisionLog();\n const [focusIndex, setFocusIndex] = useState(0);\n \n const untriagedItems = useMemo(() => \n items.filter(i => !i.triaged),\n [items]\n );\n \n useKeyboardShortcuts({\n 'arrowdown': () => setFocusIndex(i => Math.min(i + 1, untriagedItems.length - 1)),\n 'arrowup': () => setFocusIndex(i => Math.max(i - 1, 0)),\n 'a': () => handleTriage(untriagedItems[focusIndex]?.id, 'accept'),\n 'd': () => handleTriage(untriagedItems[focusIndex]?.id, 'defer'),\n 'x': () => handleTriage(untriagedItems[focusIndex]?.id, 'archive'),\n }, { enabled: untriagedItems.length > 0 });\n \n const handleTriage = async (id: string, action: 'accept' | 'defer' | 'archive', duration?: string) => {\n if (!id) return;\n \n // Update item state\n if (action === 'accept') {\n updateItem(id, { triaged: true });\n } else if (action === 'defer') {\n updateItem(id, { snoozedUntil: calculateSnoozeTime(duration || '1h') });\n } else if (action === 'archive') {\n updateItem(id, { triaged: true, archived: true });\n }\n \n // Log decision\n logDecision({\n action: 'triage',\n bead_id: id,\n context: {\n triage_action: action,\n inbox_size: untriagedItems.length,\n }\n });\n };\n \n if (untriagedItems.length === 0) {\n return (\n
\n \n \n

Inbox Zero

\n

All caught up!

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

Inbox ({untriagedItems.length})

\n \n \n \n

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

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

BATCH: {getTypeLabel(type)}

\n

{currentIndex + 1} of {items.length}

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

ESC to exit batch

\n
\n );\n}\n```\n\n**Acceptance criteria:**\n- All tests pass\n- Progress tracking works\n- Done/Skip advance correctly\n- Keyboard shortcuts work\n- Celebration shown on completion","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-25T20:34:07.281651Z","created_by":"tayloreernisse","updated_at":"2026-02-26T14:31:02.746177Z","closed_at":"2026-02-26T14:31:02.746132Z","close_reason":"Completed: BatchMode component with progress bar, action buttons, keyboard shortcuts, item transitions","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-j76","depends_on_id":"bd-3c2","type":"blocks","created_at":"2026-02-25T20:34:12.889428Z","created_by":"tayloreernisse"}]} {"id":"bd-jri","title":"Implement contract tests for CLI outputs","description":"# Contract Tests for CLI Outputs\n\n**Parent epic:** Phase 0: Test Infrastructure\n\n**Purpose:** Detect schema drift in lore/br/bv CLI outputs before it breaks MC at runtime.\n\n**Strategy:**\n1. Fixtures contain real CLI outputs\n2. Rust types with `#[serde(deny_unknown_fields)]` fail on unexpected fields\n3. CI job regenerates fixtures and diffs against committed versions\n\n**Contract test structure:**\n\n```rust\n// src-tauri/tests/contract_test.rs\n\n#[test]\nfn lore_me_response_matches_fixture() {\n let fixture = include_str!(\"fixtures/lore/me_with_reviews.json\");\n \n // This will fail if:\n // 1. Required field is missing\n // 2. Field type changed\n // 3. Unknown field added (deny_unknown_fields)\n let result: Result = serde_json::from_str(fixture);\n \n assert!(result.is_ok(), \"Fixture parse failed: {:?}\", result.err());\n}\n\n#[test]\nfn lore_me_empty_response_matches_fixture() {\n let fixture = include_str!(\"fixtures/lore/me_empty.json\");\n let result: LoreMeResponse = serde_json::from_str(fixture)\n .expect(\"Empty response fixture should parse\");\n \n assert!(result.data.since_last_check.is_empty());\n}\n\n#[test]\nfn br_create_response_matches_fixture() {\n let fixture = include_str!(\"fixtures/br/create_success.json\");\n let result: BrCreateResponse = serde_json::from_str(fixture)\n .expect(\"BR create fixture should parse\");\n \n assert!(result.id.starts_with(\"bd-\") || result.id.starts_with(\"br-\"));\n}\n\n#[test]\nfn br_list_response_matches_fixture() {\n let fixture = include_str!(\"fixtures/br/list.json\");\n let result: Vec = serde_json::from_str(fixture)\n .expect(\"BR list fixture should parse\");\n \n assert!(!result.is_empty());\n}\n\n#[test]\nfn bv_triage_response_matches_fixture() {\n let fixture = include_str!(\"fixtures/bv/triage.json\");\n let result: BvTriageResponse = serde_json::from_str(fixture)\n .expect(\"BV triage fixture should parse\");\n \n // Validate expected structure\n assert!(result.recommendations.is_some() || result.quick_ref.is_some());\n}\n```\n\n**Type definitions with strict parsing:**\n\n```rust\n// src-tauri/src/data/types.rs\n\n#[derive(Deserialize, Debug)]\n#[serde(deny_unknown_fields)] // CRITICAL: Fails on new fields\npub struct LoreMeResponse {\n pub ok: bool,\n pub data: LoreMeData,\n #[serde(default)]\n pub meta: Option,\n}\n\n#[derive(Deserialize, Debug)]\n#[serde(deny_unknown_fields)]\npub struct LoreMeData {\n pub issues: Vec,\n pub mrs: LoreMrData,\n pub since_last_check: Vec,\n}\n```\n\n**Fixture regeneration script:**\n\n```bash\n#!/bin/bash\n# scripts/regenerate-fixtures.sh\nset -e\n\nFIXTURE_DIR=\"src-tauri/tests/fixtures\"\n\necho \"Regenerating CLI fixtures...\"\n\n# Lore fixtures\nlore --robot me > \"$FIXTURE_DIR/lore/me_current.json\"\nlore --robot me --issues > \"$FIXTURE_DIR/lore/issues.json\"\nlore --robot me --mrs > \"$FIXTURE_DIR/lore/mrs.json\"\n\n# BR fixtures\nbr list --json > \"$FIXTURE_DIR/br/list.json\"\n\n# BV fixtures\nbv --robot-triage > \"$FIXTURE_DIR/bv/triage.json\" 2>/dev/null || echo '{}' > \"$FIXTURE_DIR/bv/triage.json\"\n\necho \"Fixtures regenerated. Run 'git diff' to see changes.\"\n```\n\n**CI job:**\n\n```yaml\ncontract-tests:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n \n - name: Install CLIs\n run: |\n cargo install lore br bv\n \n - name: Regenerate fixtures\n run: ./scripts/regenerate-fixtures.sh\n \n - name: Check for drift\n run: |\n if ! git diff --exit-code src-tauri/tests/fixtures/; then\n echo \"::error::CLI output schema has changed! Update types and fixtures.\"\n exit 1\n fi\n \n - name: Run contract tests\n run: cargo test contract\n```\n\n**Acceptance criteria:**\n- All fixture files parse with deny_unknown_fields\n- Regeneration script works\n- CI fails on schema drift\n- Clear error messages for parsing failures","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-25T20:53:29.013746Z","created_by":"tayloreernisse","updated_at":"2026-02-26T13:47:47.525684Z","closed_at":"2026-02-26T13:47:47.525630Z","close_reason":"Completed: Contract tests in lore.rs/beads.rs","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-jri","depends_on_id":"bd-1ps","type":"blocks","created_at":"2026-02-25T20:53:42.360610Z","created_by":"tayloreernisse"},{"issue_id":"bd-jri","depends_on_id":"bd-jyz","type":"blocks","created_at":"2026-02-25T20:53:42.329014Z","created_by":"tayloreernisse"}]} {"id":"bd-jsi","title":"Implement keyboard shortcuts for Focus View","description":"# Keyboard Shortcuts for Focus View (RED → GREEN)\n\n**Parent epic:** Phase 3: Focus View\n\n**Shortcuts:**\n| Key | Action |\n|-----|--------|\n| Enter | Start (open in browser) |\n| ⌘1 | Defer 1 hour |\n| ⌘2 | Defer tomorrow |\n| ⌘S | Skip |\n| ⌘⇧C | Quick capture (global, but also here) |\n\n**TDD: Keyboard tests (RED first):**\n\n```typescript\n// tests/components/FocusCard.test.tsx\ndescribe('FocusCard keyboard shortcuts', () => {\n it('calls onStart when Enter pressed', async () => {\n const onStart = vi.fn();\n render();\n \n await userEvent.keyboard('{Enter}');\n \n expect(onStart).toHaveBeenCalledWith(mockItem.id);\n });\n \n it('calls onDefer with 1h when ⌘1 pressed', async () => {\n const onDefer = vi.fn();\n render();\n \n await userEvent.keyboard('{Meta>}1{/Meta}');\n \n expect(onDefer).toHaveBeenCalledWith(mockItem.id, '1h');\n });\n \n it('calls onDefer with tomorrow when ⌘2 pressed', async () => {\n const onDefer = vi.fn();\n render();\n \n await userEvent.keyboard('{Meta>}2{/Meta}');\n \n expect(onDefer).toHaveBeenCalledWith(mockItem.id, 'tomorrow');\n });\n \n it('calls onSkip when ⌘S pressed', async () => {\n const onSkip = vi.fn();\n render();\n \n await userEvent.keyboard('{Meta>}s{/Meta}');\n \n expect(onSkip).toHaveBeenCalledWith(mockItem.id);\n });\n \n it('does not trigger shortcuts when input focused', async () => {\n const onStart = vi.fn();\n render(\n <>\n \n \n \n );\n \n screen.getByTestId('other-input').focus();\n await userEvent.keyboard('{Enter}');\n \n expect(onStart).not.toHaveBeenCalled();\n });\n});\n```\n\n**Implementation (GREEN):**\n\n```typescript\n// src/hooks/useKeyboard.ts\nexport function useKeyboardShortcuts(\n shortcuts: Record void>,\n options: { enabled?: boolean } = {}\n) {\n const { enabled = true } = options;\n \n useEffect(() => {\n if (!enabled) return;\n \n const handler = (e: KeyboardEvent) => {\n // Don't trigger in input/textarea\n if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {\n return;\n }\n \n const key = buildKeyString(e);\n const action = shortcuts[key];\n if (action) {\n e.preventDefault();\n action();\n }\n };\n \n window.addEventListener('keydown', handler);\n return () => window.removeEventListener('keydown', handler);\n }, [shortcuts, enabled]);\n}\n\nfunction buildKeyString(e: KeyboardEvent): string {\n const parts = [];\n if (e.metaKey || e.ctrlKey) parts.push('mod');\n if (e.shiftKey) parts.push('shift');\n if (e.altKey) parts.push('alt');\n parts.push(e.key.toLowerCase());\n return parts.join('+');\n}\n```\n\n**Usage in FocusCard:**\n\n```tsx\nexport function FocusCard({ item, onStart, onDefer, onSkip }) {\n useKeyboardShortcuts({\n 'enter': () => onStart(item.id),\n 'mod+1': () => onDefer(item.id, '1h'),\n 'mod+2': () => onDefer(item.id, 'tomorrow'),\n 'mod+s': () => onSkip(item.id),\n });\n \n // ... render\n}\n```\n\n**Acceptance criteria:**\n- All shortcut tests pass\n- Shortcuts don't fire when typing in inputs\n- Visual feedback shows shortcut hints on buttons\n- Works on both Mac (⌘) and potential future Windows (Ctrl)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-25T20:31:17.260266Z","created_by":"tayloreernisse","updated_at":"2026-02-26T14:55:39.529415Z","closed_at":"2026-02-26T14:55:39.529365Z","close_reason":"Done in FocusCard.tsx - Enter/Cmd+S shortcuts","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-jsi","depends_on_id":"bd-1ft","type":"blocks","created_at":"2026-02-25T20:32:00.812957Z","created_by":"tayloreernisse"},{"issue_id":"bd-jsi","depends_on_id":"bd-1kr","type":"blocks","created_at":"2026-02-25T20:32:00.886999Z","created_by":"tayloreernisse"}]} {"id":"bd-jyz","title":"Phase 0: Test Infrastructure","description":"# Test Infrastructure Foundation\n\n**Context:** Mission Control follows TDD (RED → GREEN → REFACTOR). Before any feature work, we need a complete testing foundation that supports both Rust backend tests and React frontend tests.\n\n**Why this matters:**\n- Every feature will be implemented RED → GREEN (failing test first, then implementation)\n- CLI integration (lore, br, bv) requires fixture-based contract testing\n- Crash recovery and state machine tests need trait-based mocking in Rust\n- E2E tests need Playwright + Tauri integration\n\n**Scope includes:**\n1. Configure Vitest for frontend unit/component tests\n2. Configure Playwright for E2E tests \n3. Set up Rust test harness with trait-based mocking\n4. Create fixture directory structure (tests/fixtures/lore/, tests/fixtures/br/)\n5. Capture initial CLI fixtures from real lore/br outputs\n6. Add test commands to package.json and Cargo.toml\n\n**Duration estimate:** 0.5 day\n\n**Success criteria:**\n- `npm run test` runs Vitest with React Testing Library\n- `npm run test:e2e` runs Playwright\n- `cargo test` runs Rust tests with mocking infrastructure\n- Fixture files exist with real CLI output samples\n- Scripts exist to regenerate fixtures\n\n**Architecture notes:**\n- Rust uses trait-based mocking (LoreCli, BeadsCli traits)\n- Frontend uses @tauri-apps/api/mocks for IPC mocking\n- Fixtures capture real CLI outputs for contract testing\n- CI will compare fresh fixture regeneration against committed fixtures","status":"closed","priority":2,"issue_type":"epic","created_at":"2026-02-25T20:24:48.843252Z","created_by":"tayloreernisse","updated_at":"2026-02-26T13:47:34.869495Z","closed_at":"2026-02-26T13:47:34.869453Z","close_reason":"Completed: Vitest + Playwright + Rust mocks + contract tests all configured","compaction_level":0,"original_size":0} -{"id":"bd-qvc","title":"Implement Inbox view with triage actions","description":"# Inbox View with Triage Actions (RED → GREEN)\n\n**Parent epic:** Phase 4: Queue + Inbox Views\n\n**Purpose:** New items land in Inbox first for triage. Achievable inbox zero is the goal.\n\n**Triage actions:**\n| Action | Result |\n|--------|--------|\n| Accept | Move to Queue |\n| Defer | Snooze for later |\n| Archive | Mark as \"not actionable for me\" |\n\n**TDD: Inbox tests (RED first):**\n\n```typescript\n// tests/components/Inbox.test.tsx\ndescribe('Inbox', () => {\n const mockNewItems: WorkItem[] = [\n { id: '1', type: 'mention', title: 'You were mentioned in #312', triaged: false },\n { id: '2', type: 'mr_feedback', title: 'Comment on MR !847', triaged: false },\n ];\n \n it('shows only untriaged items', () => {\n const items = [...mockNewItems, { id: '3', triaged: true, ... }];\n render();\n \n expect(screen.getAllByTestId('inbox-item')).toHaveLength(2);\n });\n \n it('shows inbox zero state when empty', () => {\n render();\n \n expect(screen.getByText(/Inbox Zero/)).toBeInTheDocument();\n expect(screen.getByText(/All caught up/)).toBeInTheDocument();\n });\n \n it('accept moves item to queue', async () => {\n const onTriage = vi.fn();\n render();\n \n await userEvent.click(screen.getAllByRole('button', { name: /accept/i })[0]);\n \n expect(onTriage).toHaveBeenCalledWith('1', 'accept');\n });\n \n it('defer shows duration picker', async () => {\n render();\n \n await userEvent.click(screen.getAllByRole('button', { name: /defer/i })[0]);\n \n expect(screen.getByRole('dialog')).toBeInTheDocument();\n expect(screen.getByText('1 hour')).toBeInTheDocument();\n expect(screen.getByText('Tomorrow')).toBeInTheDocument();\n });\n \n it('archive removes item from queue', async () => {\n const onTriage = vi.fn();\n render();\n \n await userEvent.click(screen.getAllByRole('button', { name: /archive/i })[0]);\n \n expect(onTriage).toHaveBeenCalledWith('1', 'archive');\n });\n \n it('keyboard shortcuts work for triage', async () => {\n const onTriage = vi.fn();\n render();\n \n // Focus first item\n await userEvent.tab();\n // Press A for Accept\n await userEvent.keyboard('a');\n \n expect(onTriage).toHaveBeenCalledWith('1', 'accept');\n });\n});\n```\n\n**Implementation (GREEN):**\n\n```tsx\n// src/components/Inbox.tsx\nexport function Inbox({ items, onTriage }: InboxProps) {\n const untriagedItems = items.filter(i => !i.triaged);\n \n if (untriagedItems.length === 0) {\n return (\n
\n \n

Inbox Zero

\n

All caught up!

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

Inbox ({untriagedItems.length})

\n \n {untriagedItems.map((item, index) => (\n onTriage(item.id, 'accept')}\n onDefer={(duration) => onTriage(item.id, 'defer', duration)}\n onArchive={() => onTriage(item.id, 'archive')}\n shortcuts={index === 0 ? { accept: 'a', defer: 'd', archive: 'x' } : undefined}\n />\n ))}\n
\n );\n}\n\nfunction InboxItem({ item, onAccept, onDefer, onArchive, shortcuts }: InboxItemProps) {\n return (\n \n
\n \n \n
\n

{item.title}

\n

{item.metadata?.snippet}

\n
\n \n
\n \n \n \n
\n
\n
\n );\n}\n```\n\n**Acceptance criteria:**\n- Only untriaged items shown\n- Inbox zero celebration state\n- Accept/Defer/Archive actions work\n- Keyboard shortcuts for fast triage\n- Triage decisions logged","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T20:33:12.523788Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:33:34.890847Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-qvc","depends_on_id":"bd-716","type":"blocks","created_at":"2026-02-25T20:33:34.890832Z","created_by":"tayloreernisse"}]} +{"id":"bd-qvc","title":"Implement Inbox view with triage actions","description":"# Inbox View with Triage Actions (RED → GREEN)\n\n**Parent epic:** Phase 4: Queue + Inbox Views\n\n**Purpose:** New items land in Inbox first for triage. Achievable inbox zero is the goal.\n\n**Triage actions:**\n| Action | Result |\n|--------|--------|\n| Accept | Move to Queue |\n| Defer | Snooze for later |\n| Archive | Mark as \"not actionable for me\" |\n\n**TDD: Inbox tests (RED first):**\n\n```typescript\n// tests/components/Inbox.test.tsx\ndescribe('Inbox', () => {\n const mockNewItems: WorkItem[] = [\n { id: '1', type: 'mention', title: 'You were mentioned in #312', triaged: false },\n { id: '2', type: 'mr_feedback', title: 'Comment on MR !847', triaged: false },\n ];\n \n it('shows only untriaged items', () => {\n const items = [...mockNewItems, { id: '3', triaged: true, ... }];\n render();\n \n expect(screen.getAllByTestId('inbox-item')).toHaveLength(2);\n });\n \n it('shows inbox zero state when empty', () => {\n render();\n \n expect(screen.getByText(/Inbox Zero/)).toBeInTheDocument();\n expect(screen.getByText(/All caught up/)).toBeInTheDocument();\n });\n \n it('accept moves item to queue', async () => {\n const onTriage = vi.fn();\n render();\n \n await userEvent.click(screen.getAllByRole('button', { name: /accept/i })[0]);\n \n expect(onTriage).toHaveBeenCalledWith('1', 'accept');\n });\n \n it('defer shows duration picker', async () => {\n render();\n \n await userEvent.click(screen.getAllByRole('button', { name: /defer/i })[0]);\n \n expect(screen.getByRole('dialog')).toBeInTheDocument();\n expect(screen.getByText('1 hour')).toBeInTheDocument();\n expect(screen.getByText('Tomorrow')).toBeInTheDocument();\n });\n \n it('archive removes item from queue', async () => {\n const onTriage = vi.fn();\n render();\n \n await userEvent.click(screen.getAllByRole('button', { name: /archive/i })[0]);\n \n expect(onTriage).toHaveBeenCalledWith('1', 'archive');\n });\n \n it('keyboard shortcuts work for triage', async () => {\n const onTriage = vi.fn();\n render();\n \n // Focus first item\n await userEvent.tab();\n // Press A for Accept\n await userEvent.keyboard('a');\n \n expect(onTriage).toHaveBeenCalledWith('1', 'accept');\n });\n});\n```\n\n**Implementation (GREEN):**\n\n```tsx\n// src/components/Inbox.tsx\nexport function Inbox({ items, onTriage }: InboxProps) {\n const untriagedItems = items.filter(i => !i.triaged);\n \n if (untriagedItems.length === 0) {\n return (\n
\n \n

Inbox Zero

\n

All caught up!

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

Inbox ({untriagedItems.length})

\n \n {untriagedItems.map((item, index) => (\n onTriage(item.id, 'accept')}\n onDefer={(duration) => onTriage(item.id, 'defer', duration)}\n onArchive={() => onTriage(item.id, 'archive')}\n shortcuts={index === 0 ? { accept: 'a', defer: 'd', archive: 'x' } : undefined}\n />\n ))}\n
\n );\n}\n\nfunction InboxItem({ item, onAccept, onDefer, onArchive, shortcuts }: InboxItemProps) {\n return (\n \n
\n \n \n
\n

{item.title}

\n

{item.metadata?.snippet}

\n
\n \n
\n \n \n \n
\n
\n
\n );\n}\n```\n\n**Acceptance criteria:**\n- Only untriaged items shown\n- Inbox zero celebration state\n- Accept/Defer/Archive actions work\n- Keyboard shortcuts for fast triage\n- Triage decisions logged","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-25T20:33:12.523788Z","created_by":"tayloreernisse","updated_at":"2026-02-26T15:08:56.001557Z","closed_at":"2026-02-26T15:08:56.001382Z","close_reason":"Completed: Implemented Inbox view with Accept/Defer/Archive triage actions, keyboard shortcuts, defer duration picker, and inbox zero state. All 12 tests pass.","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-qvc","depends_on_id":"bd-716","type":"blocks","created_at":"2026-02-25T20:33:34.890832Z","created_by":"tayloreernisse"}]} {"id":"bd-sec","title":"Implement Settings UI","description":"# Settings UI\n\n**Parent epic:** Phase 7: Polish + E2E Tests\n\n**Purpose:** Allow users to customize Mission Control behavior.\n\n**Settings to include:**\n\n| Setting | Type | Default | Description |\n|---------|------|---------|-------------|\n| Toggle hotkey | Keybinding | ⌘⇧M | Customize window toggle |\n| Capture hotkey | Keybinding | ⌘⇧C | Customize quick capture |\n| Lore DB path | File path | ~/.local/share/lore/lore.db | Custom lore location |\n| Reconciliation interval | Number | 6 | Hours between full reconciliation |\n| Show floating widget | Boolean | false | Enable persistent mini-view |\n| Default defer duration | Select | 1h | Default snooze time |\n| Sound effects | Boolean | true | Celebration sounds |\n\n**File location:** `~/.local/share/mc/settings.json`\n\n**Schema:**\n```json\n{\n \"schema_version\": 1,\n \"hotkeys\": {\n \"toggle\": \"Meta+Shift+M\",\n \"capture\": \"Meta+Shift+C\"\n },\n \"lore_db_path\": null,\n \"reconciliation_hours\": 6,\n \"floating_widget\": false,\n \"default_defer\": \"1h\",\n \"sounds\": true\n}\n```\n\n**TDD: Settings tests (RED first):**\n\n```typescript\n// tests/components/Settings.test.tsx\ndescribe('Settings', () => {\n it('loads current settings on mount', async () => {\n render();\n \n await waitFor(() => {\n expect(screen.getByDisplayValue('⌘⇧M')).toBeInTheDocument();\n });\n });\n \n it('saves settings on change', async () => {\n const mockSave = vi.fn();\n render();\n \n await userEvent.click(screen.getByLabelText('Sound effects'));\n \n expect(mockSave).toHaveBeenCalledWith(expect.objectContaining({\n sounds: false\n }));\n });\n \n it('validates hotkey format', async () => {\n render();\n \n await userEvent.clear(screen.getByLabelText('Toggle hotkey'));\n await userEvent.type(screen.getByLabelText('Toggle hotkey'), 'invalid');\n \n expect(screen.getByText(/Invalid hotkey format/)).toBeInTheDocument();\n });\n});\n```\n\n**Implementation:**\n\n```tsx\n// src/views/SettingsView.tsx\nexport function SettingsView() {\n const { data: settings, mutate } = useSettings();\n \n return (\n
\n

Settings

\n \n
\n
\n

Hotkeys

\n mutate({ ...settings, hotkeys: { ...settings.hotkeys, toggle: v }})}\n />\n mutate({ ...settings, hotkeys: { ...settings.hotkeys, capture: v }})}\n />\n
\n \n
\n

Behavior

\n mutate({ ...settings, sounds: v })}\n />\n mutate({ ...settings, floating_widget: v })}\n />\n
\n
\n
\n );\n}\n```\n\n**Acceptance criteria:**\n- All settings load and save correctly\n- Hotkey changes take effect immediately\n- Invalid inputs show validation errors\n- Settings persist across app restarts","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T20:50:39.809809Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:53:41.765926Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-sec","depends_on_id":"bd-25l","type":"blocks","created_at":"2026-02-25T20:53:41.731118Z","created_by":"tayloreernisse"},{"issue_id":"bd-sec","depends_on_id":"bd-2x6","type":"blocks","created_at":"2026-02-25T20:53:41.765906Z","created_by":"tayloreernisse"}]} {"id":"bd-uh6","title":"System tray icon with badge counts","description":"# System Tray Icon Implementation\n\n**Parent epic:** Phase 1: Foundation\n\n**UX principle:** Ambient awareness, not interruption. The tray icon shows at-a-glance status without being modal.\n\n**Implementation:**\n\n1. **Tauri tray setup (src-tauri/src/tray.rs):**\n ```rust\n use tauri::{\n tray::{TrayIconBuilder, TrayIconEvent},\n Manager, Runtime,\n };\n\n pub fn setup_tray(app: &tauri::App) -> Result<()> {\n let tray = TrayIconBuilder::new()\n .icon(app.default_window_icon().unwrap().clone())\n .tooltip(\"Mission Control\")\n .on_tray_icon_event(|tray, event| {\n match event {\n TrayIconEvent::Click { button: LeftButton, .. } => {\n // Toggle popover\n }\n _ => {}\n }\n })\n .build(app)?;\n Ok(())\n }\n ```\n\n2. **Badge count updates:**\n - Tray icon can show numeric badge (macOS native)\n - Update badge when work item count changes\n - Badge reflects: pending items + inbox items\n\n3. **Icon assets:**\n - Create icons at multiple sizes (16x16, 32x32, 64x64)\n - Template image for macOS (works with light/dark menu bar)\n - Colored variants for badge states\n\n4. **Menu on right-click:**\n - \"Show Mission Control\" (opens full window)\n - \"Quick Capture\" (opens capture overlay)\n - Separator\n - \"Quit\"\n\n**Integration with frontend:**\n- Tauri event to update badge count\n- Frontend sends count updates via invoke\n\n**Acceptance criteria:**\n- Tray icon appears when app runs\n- Left-click shows popover (or toggles window)\n- Right-click shows context menu\n- Badge count updates reflect actual pending items\n- Works on macOS (primary target)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-25T20:26:14.428110Z","created_by":"tayloreernisse","updated_at":"2026-02-26T13:55:57.526299Z","closed_at":"2026-02-26T13:55:57.526254Z","close_reason":"Completed: System tray icon with menu (Show/Quit), left-click toggles window","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-uh6","depends_on_id":"bd-3fd","type":"blocks","created_at":"2026-02-25T20:27:09.133655Z","created_by":"tayloreernisse"},{"issue_id":"bd-uh6","depends_on_id":"bd-hee","type":"blocks","created_at":"2026-02-25T20:27:07.913228Z","created_by":"tayloreernisse"}]} {"id":"bd-wlg","title":"Implement menu bar popover","description":"# Menu Bar Popover\n\n**Parent epic:** Phase 1: Foundation\n\n**Purpose:** Quick glance at THE ONE THING without opening full window. Click tray icon → popover appears.\n\n**Visual design:**\n```\n ┌─────────────────────────────┐\n │ THE ONE THING │\n │ Review MR !847 │\n │ 2d waiting · @sarah │\n │ │\n │ [Start] [Defer] [Skip] │\n ├─────────────────────────────┤\n │ Queue: 4 Inbox: 3 │\n │ ⌘⇧F Full window │\n └─────────────────────────────┘\n```\n\n**TDD: Popover tests (RED first):**\n\n```typescript\n// tests/components/Popover.test.tsx\ndescribe('TrayPopover', () => {\n it('shows current focus item', () => {\n render();\n \n expect(screen.getByText('THE ONE THING')).toBeInTheDocument();\n expect(screen.getByText('Review MR !847')).toBeInTheDocument();\n });\n \n it('shows queue and inbox counts', () => {\n render();\n \n expect(screen.getByText('Queue: 4')).toBeInTheDocument();\n expect(screen.getByText('Inbox: 3')).toBeInTheDocument();\n });\n \n it('shows empty state when no focus', () => {\n render();\n \n expect(screen.getByText(/Nothing focused/)).toBeInTheDocument();\n });\n \n it('Start action opens browser', async () => {\n const mockOpen = vi.fn();\n render();\n \n await userEvent.click(screen.getByRole('button', { name: /start/i }));\n \n expect(mockOpen).toHaveBeenCalled();\n });\n});\n```\n\n**Implementation:**\n\n```tsx\n// src/components/TrayPopover.tsx\ninterface TrayPopoverProps {\n focusItem: WorkItem | null;\n queueCount: number;\n inboxCount: number;\n onStart: () => void;\n onDefer: (duration: string) => void;\n onSkip: () => void;\n onOpenFull: () => void;\n}\n\nexport function TrayPopover({\n focusItem,\n queueCount,\n inboxCount,\n onStart,\n onDefer,\n onSkip,\n onOpenFull\n}: TrayPopoverProps) {\n return (\n
\n {focusItem ? (\n <>\n

THE ONE THING

\n

{focusItem.title}

\n

\n {formatAge(focusItem.createdAt)} · {focusItem.metadata?.author}\n

\n \n
\n \n \n \n
\n \n ) : (\n

Nothing focused. Pick something from the queue!

\n )}\n \n \n \n
\n Queue: {queueCount}\n Inbox: {inboxCount}\n
\n \n \n
\n );\n}\n```\n\n**Tauri integration:**\n\n```rust\n// Popover window configuration in tauri.conf.json\n{\n \"windows\": [\n {\n \"label\": \"popover\",\n \"width\": 288,\n \"height\": 200,\n \"decorations\": false,\n \"alwaysOnTop\": true,\n \"visible\": false,\n \"skipTaskbar\": true\n }\n ]\n}\n```\n\n**Acceptance criteria:**\n- Popover appears on tray icon click\n- Shows THE ONE THING or empty state\n- Actions work (Start/Defer/Skip)\n- Shows queue and inbox counts\n- Can open full window from popover","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-25T20:50:55.640948Z","created_by":"tayloreernisse","updated_at":"2026-02-25T20:53:41.824670Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-wlg","depends_on_id":"bd-hee","type":"blocks","created_at":"2026-02-25T20:53:41.794862Z","created_by":"tayloreernisse"},{"issue_id":"bd-wlg","depends_on_id":"bd-uh6","type":"blocks","created_at":"2026-02-25T20:53:41.824653Z","created_by":"tayloreernisse"}]} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 14a977d..427ba49 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -11,8 +11,8 @@ use serde::Serialize; use specta::Type; /// Simple greeting command for testing IPC -#[tauri_specta::command] -#[specta(crate = "specta")] +#[tauri::command] +#[specta::specta] pub fn greet(name: &str) -> String { format!("Hello, {}! Welcome to Mission Control.", name) } @@ -35,8 +35,8 @@ pub struct LoreSummaryStatus { } /// Get the current status of lore integration by calling the real CLI. -#[tauri_specta::command] -#[specta(crate = "specta")] +#[tauri::command] +#[specta::specta] pub async fn get_lore_status() -> Result { get_lore_status_with(&RealLoreCli) } @@ -107,8 +107,8 @@ pub struct BridgeStatus { } /// Get the current status of the bridge (mapping counts, sync times). -#[tauri_specta::command] -#[specta(crate = "specta")] +#[tauri::command] +#[specta::specta] pub async fn get_bridge_status() -> Result { // Bridge IO is blocking; run off the async executor tokio::task::spawn_blocking(|| get_bridge_status_inner(None)) @@ -140,8 +140,8 @@ fn get_bridge_status_inner( } /// Trigger an incremental sync (process since_last_check events). -#[tauri_specta::command] -#[specta(crate = "specta")] +#[tauri::command] +#[specta::specta] pub async fn sync_now() -> Result { tokio::task::spawn_blocking(|| sync_now_inner(None)) .await @@ -165,8 +165,8 @@ fn sync_now_inner( } /// Trigger a full reconciliation pass. -#[tauri_specta::command] -#[specta(crate = "specta")] +#[tauri::command] +#[specta::specta] pub async fn reconcile() -> Result { tokio::task::spawn_blocking(|| reconcile_inner(None)) .await @@ -198,8 +198,8 @@ pub struct CaptureResult { } /// Quick-capture a thought as a new bead. -#[tauri_specta::command] -#[specta(crate = "specta")] +#[tauri::command] +#[specta::specta] pub async fn quick_capture(title: String) -> Result { tokio::task::spawn_blocking(move || quick_capture_inner(&RealBeadsCli, &title)) .await @@ -216,8 +216,8 @@ fn quick_capture_inner(cli: &dyn BeadsCli, title: &str) -> Result Result, McError> { tokio::task::spawn_blocking(read_frontend_state) .await @@ -228,8 +228,8 @@ pub async fn read_state() -> Result, McError> { /// Write frontend state to ~/.local/share/mc/state.json. /// /// Uses atomic rename pattern to prevent corruption. -#[tauri_specta::command] -#[specta(crate = "specta")] +#[tauri::command] +#[specta::specta] pub async fn write_state(state: FrontendState) -> Result<(), McError> { tokio::task::spawn_blocking(move || write_frontend_state(&state)) .await @@ -238,8 +238,8 @@ pub async fn write_state(state: FrontendState) -> Result<(), McError> { } /// Clear persisted frontend state. -#[tauri_specta::command] -#[specta(crate = "specta")] +#[tauri::command] +#[specta::specta] pub async fn clear_state() -> Result<(), McError> { tokio::task::spawn_blocking(clear_frontend_state) .await diff --git a/src-tauri/src/data/bridge.rs b/src-tauri/src/data/bridge.rs index 207cd36..d9b1263 100644 --- a/src-tauri/src/data/bridge.rs +++ b/src-tauri/src/data/bridge.rs @@ -219,29 +219,18 @@ impl Bridge { /// Clean up orphaned .tmp files from interrupted atomic writes. /// - /// Called on startup to remove any .json.tmp files left behind from + /// Called on startup to remove any bridge-owned .tmp files left behind from /// crashes during save_map(). Returns the number of files cleaned up. pub fn cleanup_tmp_files(&self) -> Result { - if !self.data_dir.exists() { - return Ok(0); + let tmp_path = self.map_path().with_extension("json.tmp"); + + if tmp_path.exists() { + tracing::info!("Cleaning up orphaned tmp file: {:?}", tmp_path); + fs::remove_file(&tmp_path)?; + return Ok(1); } - let mut cleaned = 0; - for entry in fs::read_dir(&self.data_dir)? { - let entry = entry?; - let path = entry.path(); - if path.extension().is_some_and(|e| e == "tmp") { - tracing::info!("Cleaning up orphaned tmp file: {:?}", path); - fs::remove_file(&path)?; - cleaned += 1; - } - } - - if cleaned > 0 { - tracing::info!("Cleaned up {} orphaned tmp file(s)", cleaned); - } - - Ok(cleaned) + Ok(0) } /// Save the mapping file atomically (write to .tmp, then rename) @@ -1357,6 +1346,23 @@ mod tests { assert!(json_file.exists()); } + #[test] + fn test_cleanup_tmp_files_ignores_other_modules_tmp_files() { + let dir = TempDir::new().unwrap(); + let bridge = test_bridge(MockLoreCli::new(), MockBeadsCli::new(), &dir); + + // Create tmp files belonging to other modules (should not be removed) + let state_tmp = dir.path().join("state.json.tmp"); + std::fs::write(&state_tmp, "state data").unwrap(); + let other_tmp = dir.path().join("other.tmp"); + std::fs::write(&other_tmp, "other data").unwrap(); + + let cleaned = bridge.cleanup_tmp_files().unwrap(); + assert_eq!(cleaned, 0); + assert!(state_tmp.exists(), "state.json.tmp should not be deleted"); + assert!(other_tmp.exists(), "other.tmp should not be deleted"); + } + #[test] fn test_cleanup_tmp_files_handles_missing_dir() { let dir = TempDir::new().unwrap(); diff --git a/src-tauri/src/data/state.rs b/src-tauri/src/data/state.rs index 5982bec..45505f7 100644 --- a/src-tauri/src/data/state.rs +++ b/src-tauri/src/data/state.rs @@ -93,7 +93,25 @@ pub fn write_frontend_state(state: &FrontendState) -> io::Result<()> { let content = serde_json::to_string_pretty(state) .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; - fs::write(&tmp_path, &content)?; + // Use explicit 0600 permissions on Unix -- state may contain user session data + #[cfg(unix)] + { + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + let mut file = fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(&tmp_path)?; + file.write_all(content.as_bytes())?; + file.sync_all()?; + } + #[cfg(not(unix))] + { + fs::write(&tmp_path, &content)?; + } + fs::rename(&tmp_path, &path)?; Ok(()) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b267306..fbe50cd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -25,10 +25,16 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; fn toggle_window_visibility(app: &tauri::AppHandle) { if let Some(window) = app.get_webview_window("main") { if window.is_visible().unwrap_or(false) && window.is_focused().unwrap_or(false) { - let _ = window.hide(); + if let Err(e) = window.hide() { + tracing::warn!("Failed to hide window: {}", e); + } } else { - let _ = window.show(); - let _ = window.set_focus(); + if let Err(e) = window.show() { + tracing::warn!("Failed to show window: {}", e); + } + if let Err(e) = window.set_focus() { + tracing::warn!("Failed to focus window: {}", e); + } } } } @@ -136,13 +142,21 @@ pub fn run() { if shortcut == &capture { // Show window and signal the frontend to open capture overlay if let Some(window) = app.get_webview_window("main") { - let _ = window.show(); - let _ = window.set_focus(); + if let Err(e) = window.show() { + tracing::warn!("Failed to show window for capture: {}", e); + } + if let Err(e) = window.set_focus() { + tracing::warn!("Failed to focus window for capture: {}", e); + } + } + if let Err(e) = app.emit("global-shortcut-triggered", "quick-capture") { + tracing::error!("Failed to emit quick-capture event: {}", e); } - let _ = app.emit("global-shortcut-triggered", "quick-capture"); } else { toggle_window_visibility(app); - let _ = app.emit("global-shortcut-triggered", "toggle-window"); + if let Err(e) = app.emit("global-shortcut-triggered", "toggle-window") { + tracing::error!("Failed to emit toggle-window event: {}", e); + } } } }) diff --git a/src-tauri/src/watcher.rs b/src-tauri/src/watcher.rs index 0279466..cb5a187 100644 --- a/src-tauri/src/watcher.rs +++ b/src-tauri/src/watcher.rs @@ -35,8 +35,16 @@ pub fn start_lore_watcher(app: AppHandle) -> Option { // Create the watcher with a debounce of 1 second let mut watcher = RecommendedWatcher::new( move |res: Result| { - if let Ok(event) = res { - let _ = tx.send(event); + match res { + Ok(event) => { + if tx.send(event).is_err() { + // Receiver dropped -- watcher thread has exited + tracing::debug!("Watcher event channel closed, receiver dropped"); + } + } + Err(e) => { + tracing::warn!("File watcher error: {}", e); + } } }, Config::default().with_poll_interval(Duration::from_secs(2)), @@ -62,7 +70,9 @@ pub fn start_lore_watcher(app: AppHandle) -> Option { let affects_db = event.paths.iter().any(|p| p.ends_with("lore.db")); if affects_db { tracing::debug!("lore.db changed, emitting refresh event"); - let _ = app.emit("lore-data-changed", ()); + if let Err(e) = app.emit("lore-data-changed", ()) { + tracing::warn!("Failed to emit lore-data-changed event: {}", e); + } } } } diff --git a/src/components/ReasonPrompt.tsx b/src/components/ReasonPrompt.tsx new file mode 100644 index 0000000..f12bfde --- /dev/null +++ b/src/components/ReasonPrompt.tsx @@ -0,0 +1,168 @@ +/** + * ReasonPrompt -- capture optional reason for user actions. + * + * Every significant action prompts for an optional reason to learn patterns. + * Quick tags allow fast categorization without typing. + */ + +import { useState, useRef, useEffect, useCallback } from "react"; + +const QUICK_TAGS = [ + { id: "blocking", label: "Blocking" }, + { id: "urgent", label: "Urgent" }, + { id: "context_switch", label: "Context switch" }, + { id: "energy", label: "Energy" }, + { id: "flow", label: "Flow" }, +]; + +const ACTION_TITLES: Record = { + set_focus: "Setting focus to", + defer: "Deferring", + skip: "Skipping", + archive: "Archiving", + complete: "Completing", +}; + +interface ReasonPromptProps { + action: string; + itemTitle: string; + onSubmit: (data: { reason: string | null; tags: string[] }) => void; + onCancel: () => void; +} + +export function ReasonPrompt({ + action, + itemTitle, + onSubmit, + onCancel, +}: ReasonPromptProps): React.ReactElement { + const [reason, setReason] = useState(""); + const [selectedTags, setSelectedTags] = useState([]); + const inputRef = useRef(null); + const dialogRef = useRef(null); + + // Focus input on mount + useEffect(() => { + inputRef.current?.focus(); + }, []); + + // Handle Escape key + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + onCancel(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [onCancel]); + + const handleSubmit = useCallback(() => { + const trimmedReason = reason.trim(); + onSubmit({ + reason: trimmedReason || null, + tags: selectedTags, + }); + }, [reason, selectedTags, onSubmit]); + + const handleSkip = useCallback(() => { + onSubmit({ reason: null, tags: [] }); + }, [onSubmit]); + + const toggleTag = useCallback((tagId: string) => { + setSelectedTags((prev) => + prev.includes(tagId) ? prev.filter((t) => t !== tagId) : [...prev, tagId] + ); + }, []); + + const actionTitle = ACTION_TITLES[action] || action; + + return ( +
{ + if (e.target === e.currentTarget) { + onCancel(); + } + }} + > +
+ {/* Header */} +

+ {actionTitle}: {itemTitle} +

+ + {/* Reason input */} +
+ +