From 6ca265508ea1262d1d5d6865b7e9aea9d2ccf6ff Mon Sep 17 00:00:00 2001 From: VibeWriter User Date: Tue, 10 Mar 2026 07:48:09 -0400 Subject: [PATCH 01/20] Add three-tier AI documentation system Introduce tiered documentation optimized for AI coding agents: - Tier 1: Expand CLAUDE.md with Documentation Hierarchy section - Tier 2: Add 11 topic-specific memory files (.claude/memory/) covering testing, data model, agents, IPC, patterns, performance, platform/SSH, wizard, features, pitfalls, and build/deploy - Tier 1: MEMORY.md index with critical cross-session patterns This system minimizes token waste by loading only relevant docs per task while keeping critical rules always available. Co-Authored-By: Claude Opus 4.6 --- .claude/memory/agents.md | 43 +++++++++++++++++ .claude/memory/build-deploy.md | 86 ++++++++++++++++++++++++++++++++++ .claude/memory/data-model.md | 60 ++++++++++++++++++++++++ .claude/memory/features.md | 40 ++++++++++++++++ .claude/memory/ipc-api.md | 71 ++++++++++++++++++++++++++++ .claude/memory/patterns.md | 69 +++++++++++++++++++++++++++ .claude/memory/performance.md | 60 ++++++++++++++++++++++++ .claude/memory/pitfalls.md | 59 +++++++++++++++++++++++ .claude/memory/platform.md | 59 +++++++++++++++++++++++ .claude/memory/testing.md | 61 ++++++++++++++++++++++++ .claude/memory/wizard.md | 48 +++++++++++++++++++ CLAUDE.md | 58 +++++++++++++++++------ 12 files changed, 699 insertions(+), 15 deletions(-) create mode 100644 .claude/memory/agents.md create mode 100644 .claude/memory/build-deploy.md create mode 100644 .claude/memory/data-model.md create mode 100644 .claude/memory/features.md create mode 100644 .claude/memory/ipc-api.md create mode 100644 .claude/memory/patterns.md create mode 100644 .claude/memory/performance.md create mode 100644 .claude/memory/pitfalls.md create mode 100644 .claude/memory/platform.md create mode 100644 .claude/memory/testing.md create mode 100644 .claude/memory/wizard.md diff --git a/.claude/memory/agents.md b/.claude/memory/agents.md new file mode 100644 index 000000000..c3d64eab0 --- /dev/null +++ b/.claude/memory/agents.md @@ -0,0 +1,43 @@ +# Agent System + +## Active Agents + +| ID | Binary | JSON Flag | Resume Flag | Read-Only Flag | Session Storage | +|----|--------|-----------|-------------|----------------|-----------------| +| `claude-code` | `claude` | `--output-format stream-json` | `--resume ` | `--permission-mode plan` | `~/.claude/projects/` | +| `codex` | `codex` | `--json` | `exec resume ` | `--sandbox read-only` | `~/.codex/sessions/` | +| `opencode` | `opencode` | `--format json` | `--session ` | `--agent plan` | `~/.config/opencode/storage/` | +| `factory-droid` | `factory` | `-o stream-json` | `-s ` | default mode | `~/.factory/` | +| `terminal` | (system shell) | N/A | N/A | N/A | N/A | + +## Capabilities (23 flags per agent, `src/main/agents/capabilities.ts`) + +Key flags: `supportsResume`, `supportsReadOnlyMode`, `supportsJsonOutput`, `supportsSessionId`, `supportsImageInput`, `supportsSlashCommands`, `supportsSessionStorage`, `supportsCostTracking`, `supportsUsageStats`, `supportsBatchMode`, `supportsStreaming`, `supportsModelSelection`, `supportsThinkingDisplay`, `supportsContextMerge` + +UI features auto-enable/disable based on capability flags. + +## Adding a New Agent (6 steps) + +1. Add definition → `src/main/agents/definitions.ts` +2. Define capabilities → `src/main/agents/capabilities.ts` +3. Create output parser → `src/main/parsers/{agent}-output-parser.ts` +4. Register parser → `src/main/parsers/index.ts` +5. (Optional) Session storage → `src/main/storage/{agent}-session-storage.ts` +6. (Optional) Error patterns → `src/main/parsers/error-patterns.ts` + +## Output Parser Interface + +```typescript +// Common parsed event structure +type: 'init' | 'text' | 'tool_use' | 'result' | 'error' | 'usage' | 'system' +sessionId?: string +text?: string +``` + +Registry: `registerOutputParser(agentId, parser)` / `getOutputParser(agentId)` + +## Gotcha: Agent-Specific Session ID Terminology + +- Claude Code: `session_id` +- Codex: `thread_id` +- Different field names, same concept. Parsers normalize this. diff --git a/.claude/memory/build-deploy.md b/.claude/memory/build-deploy.md new file mode 100644 index 000000000..ca10c98f2 --- /dev/null +++ b/.claude/memory/build-deploy.md @@ -0,0 +1,86 @@ +# Build System & CI/CD + +## Tech Stack + +| Technology | Version | Purpose | +|------------|---------|---------| +| Electron | 28.x | Desktop app shell | +| React | 18.x | UI framework | +| TypeScript | 5.x | Type safety | +| Vite | 5.x | Bundler (renderer + web) | +| Vitest | 4.x | Test framework | +| Tailwind CSS | 3.x | Utility-first styling | +| Zustand | 5.x | State management | +| node-pty | 1.x | Terminal emulation | +| better-sqlite3 | 12.x | Stats database | +| Fastify | 4.x | Web server (mobile remote) | +| Commander | 14.x | CLI framework | +| React Flow | 11.x | Document graph visualization | +| Recharts | 3.x | Usage dashboard charts | + +## Build Sequence + +```bash +npm run build + → build:prompts # .md → generated TypeScript + → build:main # tsc → dist/main (CommonJS) + → build:preload # esbuild → dist/main/preload.js + → build:renderer # vite → dist/renderer (ESM) + → build:web # vite → dist/web + → build:cli # esbuild → dist/cli +``` + +## TypeScript Configs (3 separate) + +| Config | Module | Scope | +|--------|--------|-------| +| `tsconfig.main.json` | CommonJS | Main process + shared | +| `tsconfig.json` (root) | ESNext | Renderer + web + shared | +| `tsconfig.cli.json` | CommonJS | CLI + shared + prompts | + +## CI Pipeline (`.github/workflows/ci.yml`) + +**Job 1: Lint** — prettier check, eslint, tsc (all 3 configs) +**Job 2: Test** — vitest run (unit tests only) + +Integration/E2E/performance tests NOT in CI (manual). + +## Pre-commit Hooks + +Husky + lint-staged: +- `*` → `prettier --write` +- `*.{js,ts,tsx,...}` → `eslint --fix` + +Lightweight by design — heavy checks in CI. + +## Packaging + +```bash +npm run package # All platforms +npm run package:mac # macOS (.dmg, .zip) — x64 + arm64 +npm run package:win # Windows (.exe NSIS + portable) +npm run package:linux # Linux (.AppImage, .deb, .rpm) +``` + +Output: `release/` directory. Auto-publish via GitHub Actions on tag push. + +## Key Build Scripts (`scripts/`) + +| Script | Purpose | +|--------|---------| +| `generate-prompts.mjs` | Compile .md prompts to TypeScript | +| `build-preload.mjs` | Bundle preload script with esbuild | +| `build-cli.mjs` | Bundle CLI with sourcemaps | +| `refresh-speckit.mjs` | Sync Spec-Kit prompts from GitHub | +| `refresh-openspec.mjs` | Sync OpenSpec prompts from Fission-AI | +| `set-version.mjs` | Update version across configs | +| `notarize.js` | macOS code signing post-build | + +## Environment Flags + +- `NODE_ENV=development` — dev mode +- `USE_PROD_DATA=1` — use production data dir in dev +- `MAESTRO_DEMO_DIR=` — demo mode with fresh data +- `VITE_PORT=` — custom dev server port (default 5173) +- `DISABLE_HMR=1` — disable hot module replacement +- `DEBUG_GROUP_CHAT=1` — enable group chat debug logging diff --git a/.claude/memory/data-model.md b/.claude/memory/data-model.md new file mode 100644 index 000000000..faf25960f --- /dev/null +++ b/.claude/memory/data-model.md @@ -0,0 +1,60 @@ +# Data Model & State Management + +## Zustand Stores (11 total, `src/renderer/stores/`) + +| Store | Purpose | Key state | +|-------|---------|-----------| +| `sessionStore` | Sessions, groups, active session | `sessions[]`, `groups[]`, `activeSessionId` | +| `settingsStore` | User preferences | themes, fonts, agent config, SSH remotes | +| `modalStore` | 50+ modal visibility + data | Registry pattern, not 50 fields | +| `tabStore` | Tab management | unified tab order, closed tab history | +| `agentStore` | Agent detection/capabilities | detected agents, capabilities map | +| `uiStore` | UI toggles | right panel tab, focus area | +| `batchStore` | Batch runner state | progress, docs, tasks | +| `groupChatStore` | Group chat | messages, participants | +| `notificationStore` | Toast queue | notifications[] | +| `operationStore` | Long-running ops | merge/transfer progress | +| `fileExplorerStore` | File tree | expansion state, scroll | + +## Session Interface (key fields) + +```typescript +interface Session { + id: string; + name: string; + toolType: ToolType; // 'claude-code' | 'codex' | 'opencode' | 'factory-droid' + state: SessionState; // 'idle' | 'busy' | 'connecting' | 'error' + inputMode: 'ai' | 'terminal'; + cwd: string; // Changes via cd + projectRoot: string; // Never changes (used for session storage) + aiTabs: AITab[]; + activeTabId: string; + filePreviewTabs: FilePreviewTab[]; + activeFileTabId: string | null; + unifiedTabOrder: UnifiedTabRef[]; // TabBar source of truth + executionQueue: QueuedItem[]; +} +``` + +## Critical Invariant: unifiedTabOrder + +**Every tab MUST have an entry in `unifiedTabOrder`.** Tabs missing from this array are invisible in TabBar even if content renders. + +- When adding tabs: update both tab array AND `unifiedTabOrder` +- When activating: use `ensureInUnifiedTabOrder()` from `tabHelpers.ts` +- `buildUnifiedTabs(session)` is the canonical tab list builder + +## Settings Persistence + +Settings stored via `electron-store` at platform-specific paths: +- macOS: `~/Library/Application Support/maestro/` +- Windows: `%APPDATA%/maestro/` +- Linux: `~/.config/maestro/` + +Files: `maestro-settings.json`, `maestro-sessions.json`, `maestro-groups.json`, `maestro-agent-configs.json` + +## State Management Pattern + +- **Zustand** for persistent/large state (selector-based subscriptions) +- **React Context** for transient UI state (LayerStack, Input, GitStatus, InlineWizard) +- Minimal prop drilling — components read directly from stores diff --git a/.claude/memory/features.md b/.claude/memory/features.md new file mode 100644 index 000000000..e41dd03d8 --- /dev/null +++ b/.claude/memory/features.md @@ -0,0 +1,40 @@ +# Feature-Specific Notes + +## Usage Dashboard (`src/renderer/components/UsageDashboard/`) + +- Backend: SQLite (`better-sqlite3`) with WAL mode → `src/main/stats-db.ts` +- Tables: `query_events`, `auto_run_sessions`, `auto_run_tasks`, `_migrations` +- Real-time: backend broadcasts `stats:updated` event, frontend debounces refresh +- Colorblind palettes: Wong-based, 3 variants → `src/renderer/constants/colorblindPalettes.ts` +- Charts wrapped in `ChartErrorBoundary` with retry + +## Document Graph (`src/renderer/components/DocumentGraph/`) + +- Scans markdown for `[[wiki-links]]` and `[markdown](links)` +- React Flow for visualization +- Force-directed + hierarchical layout (`layoutAlgorithms.ts`) +- File watching via chokidar (NOT available for SSH remotes) +- Large files truncated: >1MB → parse first 100KB only +- Default 50 nodes, "Load more" adds 25 + +## Group Chat System (`src/main/group-chat/`) + +- Moderator AI orchestrates multi-agent conversations +- @mentions route messages to specific agents +- No @mentions in moderator response = conversation complete +- Output buffer: 10MB limit (larger than process-manager's 100KB) +- Storage: `~/Library/Application Support/maestro/group-chats/{chatId}/` + +## Auto Run + +- File-based document runner: markdown docs with checkbox tasks +- Playbooks: saved configurations for repeated batch runs +- Playbook assets: `assets/` subfolder (config files, YAML, Dockerfiles) +- Worktree support: operates in isolated git directory for true parallelization +- Achievement system: 15 conductor levels (1 min → 200 hours) + +## Director's Notes (Encore Feature) + +- First Encore Feature — canonical gating example +- Flag: `encoreFeatures.directorNotes` +- Generates AI synopsis of work across sessions diff --git a/.claude/memory/ipc-api.md b/.claude/memory/ipc-api.md new file mode 100644 index 000000000..d67a8e1cf --- /dev/null +++ b/.claude/memory/ipc-api.md @@ -0,0 +1,71 @@ +# IPC API Surface + +## Adding an IPC Handler + +1. Create handler in `src/main/ipc/handlers/{domain}.ts`: + ```typescript + export function registerMyHandlers(deps: { getStore: () => Store }) { + ipcMain.handle('myDomain:myAction', async (_, arg) => { + return result; + }); + } + ``` +2. Register in `src/main/index.ts` → `setupIpcHandlers()` +3. Add preload API in `src/main/preload/` (module per namespace) +4. Type the API in preload types + +## Handler Pattern + +All handlers use dependency injection: +```typescript +registerProcessHandlers({ + getProcessManager: () => processManager, + getAgentDetector: () => agentDetector, + agentConfigsStore, + settingsStore, + getMainWindow: () => mainWindow, +}); +``` + +Error handling wrapper: `withIpcErrorLogging()` for standardized logging. + +## Key Namespaces (40+) + +| Namespace | Purpose | +|-----------|---------| +| `settings` | Get/set/getAll app settings | +| `sessions` / `groups` | Agent and group persistence | +| `process` | spawn, write, interrupt, kill, resize | +| `agents` | detect, getCapabilities, discoverModels | +| `agentSessions` | List/read/search provider sessions | +| `git` | status, diff, log, worktrees, createPR | +| `fs` | readDir, readFile, stat | +| `autorun` | Document + image management | +| `playbooks` | Batch run configuration CRUD | +| `stats` | Usage analytics (SQLite + WAL) | +| `groupChat` | Multi-agent coordination | +| `context` | Merge/groom/summarize sessions | +| `documentGraph` | File watching (chokidar) | +| `history` | Per-agent history (5000 entries/agent) | +| `tunnel` | Cloudflare tunnel management | +| `sshRemote` | SSH config management | +| `notification` | Desktop notifications, TTS | +| `web` / `live` / `webserver` | Web interface management | +| `symphony` | Open-source contribution system | + +## IPC Handler Count + +31 handler files in `src/main/ipc/handlers/` (~18,900 LOC total) + +## Service Layer (Renderer) + +Services in `src/renderer/services/` wrap IPC with error handling: +```typescript +// Pattern: never throw, return safe defaults +const gitService = { + async isRepo(cwd: string): Promise { + try { return await window.maestro.git.isRepo(cwd); } + catch { return false; } + } +}; +``` diff --git a/.claude/memory/patterns.md b/.claude/memory/patterns.md new file mode 100644 index 000000000..c3cbc0436 --- /dev/null +++ b/.claude/memory/patterns.md @@ -0,0 +1,69 @@ +# Implementation Patterns + +## Settings Persistence + +```typescript +// 1. State with default +const [mySetting, setMySettingState] = useState(defaultValue); + +// 2. Wrapper that persists +const setMySetting = (value) => { + setMySettingState(value); + window.maestro.settings.set('mySetting', value); +}; + +// 3. Load from batch in useEffect +const allSettings = await window.maestro.settings.getAll(); +if (allSettings['mySetting'] !== undefined) setMySettingState(allSettings['mySetting']); +``` + +## Adding a Modal + +1. Create component in `src/renderer/components/` +2. Add priority in `src/renderer/constants/modalPriorities.ts` +3. Register with layer stack (use `onCloseRef` pattern to avoid re-registration): +```typescript +const onCloseRef = useRef(onClose); +onCloseRef.current = onClose; +useEffect(() => { + if (isOpen) { + const id = registerLayer({ + type: 'modal', + priority: MODAL_PRIORITIES.YOUR_MODAL, + onEscape: () => onCloseRef.current(), + }); + return () => unregisterLayer(id); + } +}, [isOpen, registerLayer, unregisterLayer]); // onClose NOT in deps +``` + +## Theme Colors + +13 required colors per theme. Use inline styles for theme colors: +```typescript +style={{ color: theme.colors.textMain }} // Correct +className="text-gray-500" // Wrong for themed text +``` +Use Tailwind for layout only. + +## Encore Features (Feature Gating) + +Gate ALL access points when adding new Encore Features: +1. Type flag → `EncoreFeatureFlags` in `src/renderer/types/index.ts` +2. Default `false` → `useSettings.ts` +3. Toggle UI → SettingsModal Encore tab +4. App.tsx → conditional rendering + callbacks +5. Keyboard shortcuts → guard with `ctx.encoreFeatures?.yourFeature` +6. Hamburger menu → make setter optional, conditional render +7. Command palette → pass `undefined` handler when disabled + +## Execution Queue + +Messages queue when AI is busy. Write ops queue sequentially; read-only can parallelize. + +## Lazy Component Loading + +Heavy modals loaded on-demand in App.tsx: +```typescript +const SettingsModal = lazy(() => import('./components/Settings/SettingsModal')); +``` diff --git a/.claude/memory/performance.md b/.claude/memory/performance.md new file mode 100644 index 000000000..774618a23 --- /dev/null +++ b/.claude/memory/performance.md @@ -0,0 +1,60 @@ +# Performance Patterns + +## React Optimization + +- **Memoize list items** with `React.memo` (tabs, agents, list items) +- **Consolidate chained `useMemo`** into single computation +- **Pre-compile regex** at module level, not in render +- **Build Map indices** once for O(1) lookups instead of `Array.find()` in loops + +```typescript +// BAD: O(n) per iteration +agents.filter(a => groups.find(g => g.id === a.groupId)); +// GOOD: O(1) lookup +const groupsById = useMemo(() => new Map(groups.map(g => [g.id, g])), [groups]); +agents.filter(a => groupsById.get(a.groupId)); +``` + +## Debouncing & Throttling + +- **Session persistence**: 2s debounce (`useDebouncedPersistence`) +- **Search/filter**: 100ms debounce on keystroke-driven operations +- **Scroll handlers**: 4ms throttle (~240fps max) +- **Auto Run save**: 5s debounce for document auto-save +- **Always flush** on `visibilitychange` and `beforeunload` + +## Update Batching + +During AI streaming, IPC triggers 100+ updates/second. Batch at 150ms → ~6 renders/second. +See `src/renderer/hooks/session/useBatchedSessionUpdates.ts` + +## Main Process + +- **Cache shell paths** in Map for repeated lookups +- **Use async fs** operations (`fs/promises`), never sync in main process +- **Lazy debug logging**: `debugLogLazy('prefix', () => expensiveString)` for hot paths +- **Memory monitoring**: Warns at 500MB heap every 60s (Sentry breadcrumbs) + +## Virtual Scrolling + +Use `@tanstack/react-virtual` for lists >100 items (see `HistoryPanel.tsx`). + +## IPC Parallelization + +```typescript +// GOOD: parallel execution +const [branches, remotes, status] = await Promise.all([ + git.branch(cwd), git.remote(cwd), git.status(cwd) +]); +``` + +## Visibility-Aware Operations + +Pause polling/timers when app is backgrounded: +```typescript +if (document.hidden) stopPolling(); else startPolling(); +``` + +## Context Provider Memoization + +Always memoize context values to prevent consumer re-renders. diff --git a/.claude/memory/pitfalls.md b/.claude/memory/pitfalls.md new file mode 100644 index 000000000..7fe76a75e --- /dev/null +++ b/.claude/memory/pitfalls.md @@ -0,0 +1,59 @@ +# Common Pitfalls & Debugging + +## UI Bug Debugging Checklist + +1. **CSS first:** Check parent `overflow: hidden`, `z-index` conflicts, `position` mismatches +2. **Scroll issues:** Use `scrollIntoView({ block: 'nearest' })`, not centering +3. **Portal escape:** Clipped overlays/tooltips → `createPortal(el, document.body)` +4. **Fixed positioning:** `position: fixed` inside transformed parents won't work — check ancestor transforms + +## Historical Bugs That Wasted Time + +- **Tab naming bug:** "Fixed" modal coordination when real issue was unregistered IPC handler +- **Tooltip clipping:** Attempted `overflow: visible` on element when parent had `overflow: hidden` +- **Session validation:** Fixed renderer calls when handler wasn't wired in main process + +**Lesson:** Always verify the IPC handler exists in `src/main/index.ts` before modifying caller code. + +## Focus Not Working + +1. Add `tabIndex={0}` or `tabIndex={-1}` +2. Add `outline-none` class +3. Use `ref={(el) => el?.focus()}` for auto-focus + +## Settings Not Persisting + +1. Verify wrapper calls `window.maestro.settings.set()` +2. Check loading code in `useSettings.ts` useEffect +3. Verify key name matches in both save and load + +## Modal Escape Not Working + +1. Register with layer stack (don't handle Escape locally) +2. Check priority in `modalPriorities.ts` +3. Use ref pattern to avoid re-registration + +## Theme Colors Not Applying + +1. Use `style={{ color: theme.colors.textMain }}` — never Tailwind color classes for themed elements +2. Check theme prop is passed to component + +## Process Output Not Showing + +1. Check agent ID matches (with `-ai` or `-terminal` suffix) +2. Verify `onData` listener is registered +3. Check process spawned successfully (pid > 0) + +## Root Cause Verification (Before Implementing Fixes) + +**IPC issues:** Verify handler registered in `src/main/index.ts` first. +**UI rendering:** Check CSS on element AND parent containers before changing logic. +**State not updating:** Trace data flow source → consumer. Check if setter is called vs re-render suppressed. +**Feature not working:** Verify code path is actually executing (temporary console.log). + +## Sentry Integration + +- Let unexpected exceptions bubble up (auto-reported) +- Handle expected/recoverable errors explicitly +- Use `captureException(error, context)` for explicit reporting +- Dynamic Sentry import required (electron.app access issue at module load) diff --git a/.claude/memory/platform.md b/.claude/memory/platform.md new file mode 100644 index 000000000..06911faaa --- /dev/null +++ b/.claude/memory/platform.md @@ -0,0 +1,59 @@ +# Cross-Platform & SSH + +## Path Handling + +- Use `path.join()` for local, `path.posix.join()` for SSH remote +- Windows uses `;` delimiter, Unix uses `:` — use `path.delimiter` +- Node.js does NOT expand `~` — use `expandTilde()` from `src/shared/pathUtils.ts` +- Min path length: Windows 4 (`C:\a`), Unix 5 (`/a/b`) +- Windows reserved names: CON, PRN, AUX, NUL, COM1-9, LPT1-9 + +## Shell Detection + +- Windows: `$SHELL` doesn't exist, default to `powershell.exe` +- CLI lookup: `which` (Unix) vs `where` (Windows) +- Executable perms: skip `X_OK` check on Windows + +## SSH Remote Execution (CRITICAL) + +**Two SSH identifiers with different lifecycles:** +```typescript +// sshRemoteId: Set AFTER AI agent spawns +// sessionSshRemoteConfig.remoteId: Set BEFORE spawn (user config) + +// WRONG - fails for terminal-only SSH agents +const sshId = session.sshRemoteId; +// CORRECT +const sshId = session.sshRemoteId || session.sessionSshRemoteConfig?.remoteId; + +// WRONG +const isRemote = !!session.sshRemoteId; +// CORRECT +const isRemote = !!session.sshRemoteId || !!session.sessionSshRemoteConfig?.enabled; +``` + +- File watching (chokidar) NOT available for SSH — use polling +- Prompts go via stdin for SSH (avoids shell escaping + length limits) +- Don't resolve paths locally when operating on remote + +## Keyboard & Input + +- macOS Alt key produces special chars (¬, π, ü) — use `e.code` not `e.key` for Alt combos +- Windows cmd.exe has ~8KB command line limit — use stdin passthrough for long prompts + +## Agent Storage Locations + +- Claude: `~/.claude/projects//` +- Codex: `~/.codex/sessions/YYYY/MM/DD/*.jsonl` +- OpenCode: `~/.config/opencode/storage/` (macOS/Linux), `%APPDATA%/opencode/storage/` (Windows) + +## Key Files + +| Concern | File | +|---------|------| +| Path utils | `src/shared/pathUtils.ts` | +| Shell detection | `src/main/utils/shellDetector.ts` | +| WSL detection | `src/main/utils/wslDetector.ts` | +| SSH spawn wrapper | `src/main/utils/ssh-spawn-wrapper.ts` | +| SSH command builder | `src/main/utils/ssh-command-builder.ts` | +| Safe exec | `src/main/utils/execFile.ts` | diff --git a/.claude/memory/testing.md b/.claude/memory/testing.md new file mode 100644 index 000000000..5afc65ad8 --- /dev/null +++ b/.claude/memory/testing.md @@ -0,0 +1,61 @@ +# Testing + +## Framework & Config + +- **Vitest** with `jsdom` environment. Config: `vitest.config.mts` +- Globals enabled (`describe`, `it`, `expect` without imports) +- Alias: `@` → `./src` +- Timeouts: test 10s, hook 10s, teardown 5s +- Coverage: `v8` provider → `./coverage` (text, json, html) + +## Test Location + +``` +src/__tests__/ +├── cli/ # CLI command + service tests +├── main/ # Main process (agents, parsers, IPC handlers, preload) +├── renderer/ # React components, hooks, stores +├── shared/ # Shared utility tests +├── web/ # Web/mobile interface tests +├── integration/ # Real agent workflows (run separately) +├── performance/ # Perf regression tests (run separately) +├── e2e/ # Playwright E2E (run separately) +└── fixtures/ # Test data +``` + +## Run Commands + +```bash +npm run test # Unit tests only (CI) +npm run test:watch # Watch mode +npm run test:coverage # With coverage report +npm run test:integration # Integration (180s timeout, sequential forks) +npm run test:performance # Perf tests (30s timeout) +npm run test:e2e # Playwright E2E (requires build first) +``` + +## IPC Mocking + +`src/__tests__/setup.ts` (880+ lines) mocks the entire `window.maestro` API. Every IPC namespace is pre-mocked with `vi.fn()`. Reset mocks in `beforeEach` if needed. + +```typescript +// Access mocks directly +(window.maestro.settings.get as any).mockResolvedValue('dark'); +expect(window.maestro.process.spawn).toHaveBeenCalledWith(config); +``` + +## Key Mock Patterns + +- **Lucide icons**: Proxy-based auto-mock (no per-icon mocking needed) +- **React Markdown**: Mocked to simple `
` wrapper +- **ResizeObserver/IntersectionObserver**: Stubbed globally +- **`window.matchMedia`**: Returns `matches: false` by default +- **Component rendering**: Wrap in `` when using modals + +## Conventions + +- One behavior per test, descriptive name +- `vi.mock()` for module mocking, `vi.spyOn()` for tracking +- Integration tests use `vitest.integration.config.ts` (forked, sequential) +- Tests excluded from ESLint (`src/__tests__/**` in eslint ignore) +- `react-hooks/exhaustive-deps` is OFF — use stable refs pattern diff --git a/.claude/memory/wizard.md b/.claude/memory/wizard.md new file mode 100644 index 000000000..b8228683c --- /dev/null +++ b/.claude/memory/wizard.md @@ -0,0 +1,48 @@ +# Wizard & Tour System + +## Onboarding Wizard (`src/renderer/components/Wizard/`) + +**Flow:** Agent Selection → Directory Selection → AI Conversation (confidence 0-100) → Phase Review + +- Confidence threshold: 80 (configurable via `READY_CONFIDENCE_THRESHOLD`) +- Documents generated to `Auto Run Docs/Initiation/` subfolder +- State persists to `wizardResumeState` in settings (resume on relaunch) + +**Two state types:** +1. **In-memory** (React `useReducer` in `WizardContext.tsx`) — lives during session +2. **Persisted** (settings store) — enables resume across restarts + +**Opening wizard after completion:** Must dispatch `RESET_WIZARD` before `OPEN_WIZARD` to clear stale state. + +## Inline Wizard (`/wizard` command) + +- Runs inside existing AI tab (not full-screen) +- State per-tab (`AITab.wizardState`), not per-agent +- Documents written to unique subfolder under Auto Run folder +- Tab renamed to "Project: {SubfolderName}" on completion +- Same `agentSessionId` preserved for context continuity + +## Tour System + +```typescript +// Spotlight elements via data attribute +
...
+ +// Steps defined in tour/tourSteps.ts +{ id: 'autorun-panel', selector: '[data-tour="autorun-panel"]', position: 'left', + uiActions: [{ type: 'setRightTab', value: 'autorun' }] } +``` + +## Customization Points + +| What | Where | +|------|-------| +| Wizard prompts | `src/prompts/wizard-*.md` | +| Confidence threshold | `READY_CONFIDENCE_THRESHOLD` in wizardPrompts.ts | +| Tour steps | `tour/tourSteps.ts` | +| Document format | `src/prompts/wizard-document-generation.md` | +| Keyboard shortcut | `shortcuts.ts` → `openWizard` | + +## Related Settings + +`wizardCompleted`, `tourCompleted`, `firstAutoRunCompleted` (triggers celebration) diff --git a/CLAUDE.md b/CLAUDE.md index 0c9c61127..cdf336d87 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,21 +2,49 @@ Essential guidance for working with this codebase. For detailed architecture, see [ARCHITECTURE.md](ARCHITECTURE.md). For development setup and processes, see [CONTRIBUTING.md](CONTRIBUTING.md). -## Documentation Index - -This guide has been split into focused sub-documents for progressive disclosure: - -| Document | Description | -| ------------------------------------ | ----------------------------------------------------------------------------------------------------------- | -| [[CLAUDE-PATTERNS.md]] | Core implementation patterns (process management, settings, modals, themes, Auto Run, SSH, Encore Features) | -| [[CLAUDE-IPC.md]] | IPC API surface (`window.maestro.*` namespaces) | -| [[CLAUDE-PERFORMANCE.md]] | Performance best practices (React optimization, debouncing, batching) | -| [[CLAUDE-WIZARD.md]] | Onboarding Wizard, Inline Wizard, and Tour System | -| [[CLAUDE-FEATURES.md]] | Usage Dashboard and Document Graph features | -| [[CLAUDE-AGENTS.md]] | Supported agents and capabilities | -| [[CLAUDE-SESSION.md]] | Session interface (agent data model) and code conventions | -| [[CLAUDE-PLATFORM.md]] | Cross-platform concerns (Windows, Linux, macOS, SSH remote) | -| [AGENT_SUPPORT.md](AGENT_SUPPORT.md) | Detailed agent integration guide | +## Documentation Hierarchy + +AI agents pay a token cost for every line loaded. This codebase uses tiered documentation to minimize waste: + +| Layer | Loaded | What goes here | +|-------|--------|---------------| +| **CLAUDE.md** | Every conversation | Rules preventing mistakes on ANY task | +| **MEMORY.md** (`.claude/memory/`) | Every conversation | Cross-cutting index + learned patterns | +| **Topic files** (`.claude/memory/*.md`) | On demand | Feature-specific patterns and pitfalls | +| **CLAUDE-*.md** (root) | On demand | Deep implementation references | +| **ARCHITECTURE.md, CONTRIBUTING.md** | On demand | Human-facing reference docs | + +**Rule:** Prevents mistakes on unrelated tasks → CLAUDE.md. Spans features → MEMORY.md. One topic → `.claude/memory/topic.md`. Deep reference → CLAUDE-*.md or ARCHITECTURE.md. + +### Topic Files (load when relevant) + +| File | When to load | +|------|-------------| +| `.claude/memory/testing.md` | Writing or fixing tests | +| `.claude/memory/data-model.md` | Modifying Session, AITab, stores | +| `.claude/memory/agents.md` | Adding/modifying agent support | +| `.claude/memory/ipc-api.md` | Adding IPC handlers or preload APIs | +| `.claude/memory/patterns.md` | Implementing settings, modals, tabs | +| `.claude/memory/performance.md` | Optimizing React rendering or IPC | +| `.claude/memory/platform.md` | Cross-platform or SSH work | +| `.claude/memory/wizard.md` | Wizard or tour system changes | +| `.claude/memory/features.md` | Usage Dashboard or Document Graph | +| `.claude/memory/pitfalls.md` | Debugging UI or state issues | +| `.claude/memory/build-deploy.md` | Build system, CI/CD, scripts | + +### Deep References (CLAUDE-*.md) + +| Document | Description | +| -------- | ----------- | +| [[CLAUDE-PATTERNS.md]] | Core implementation patterns (process management, settings, modals, themes, Auto Run, SSH, Encore Features) | +| [[CLAUDE-IPC.md]] | IPC API surface (`window.maestro.*` namespaces) | +| [[CLAUDE-PERFORMANCE.md]] | Performance best practices (React optimization, debouncing, batching) | +| [[CLAUDE-WIZARD.md]] | Onboarding Wizard, Inline Wizard, and Tour System | +| [[CLAUDE-FEATURES.md]] | Usage Dashboard and Document Graph features | +| [[CLAUDE-AGENTS.md]] | Supported agents and capabilities | +| [[CLAUDE-SESSION.md]] | Session interface (agent data model) and code conventions | +| [[CLAUDE-PLATFORM.md]] | Cross-platform concerns (Windows, Linux, macOS, SSH remote) | +| [AGENT_SUPPORT.md](AGENT_SUPPORT.md) | Detailed agent integration guide | --- From 2d0bbff09bb0ab5cf6f232c57a6429440c953b8d Mon Sep 17 00:00:00 2001 From: VibeWriter User Date: Tue, 10 Mar 2026 12:38:19 -0400 Subject: [PATCH 02/20] Add design doc for project-centric navigation with inbox Redesigns Maestro from agent-centric to project/repo-centric navigation: projects in left sidebar, session tabs per project, global inbox for tabs needing attention. Co-Authored-By: Claude Opus 4.6 --- ...03-10-project-centric-navigation-design.md | 384 ++++++++++++++++++ 1 file changed, 384 insertions(+) create mode 100644 docs/plans/2026-03-10-project-centric-navigation-design.md diff --git a/docs/plans/2026-03-10-project-centric-navigation-design.md b/docs/plans/2026-03-10-project-centric-navigation-design.md new file mode 100644 index 000000000..ee5b6a688 --- /dev/null +++ b/docs/plans/2026-03-10-project-centric-navigation-design.md @@ -0,0 +1,384 @@ +# Design: Project-Centric Navigation with Inbox + +**Date:** 2026-03-10 +**Approach:** A — New Project Entity (additive, not rewrite) +**Status:** Approved + +--- + +## Summary + +Redesign Maestro's navigation from agent-centric (flat session list) to project/repo-centric (projects in left bar, session tabs per project, global inbox for attention). This is a fork divergence from upstream Maestro. + +### User Decisions + +| Decision | Choice | +|----------|--------| +| Project creation | Explicit (user picks a repo folder) | +| Tab identity | Each tab = one agent session (many per project) | +| Inbox location | Left sidebar section, above projects | +| Inbox triggers | Agent finished, errored, or waiting for input | +| Inbox click behavior | Navigate + auto-dismiss | +| Multi-project view | One project at a time | +| Legacy features | Drop bookmarks/groups; keep group chat as session type | + +--- + +## 1. Data Model + +### New: `Project` + +```typescript +interface Project { + id: string; // UUID + name: string; // User-facing (defaults to repo folder name) + repoPath: string; // Absolute path to git root + createdAt: number; // Timestamp + color?: string; // Optional accent color + collapsed?: boolean; // Collapsed in sidebar +} +``` + +### Modified: `Session` + +```diff +interface Session { ++ projectId: string; // Required — links to a Project +- groupId?: string; // Removed (groups are gone) +- bookmarked?: boolean; // Removed (bookmarks are gone) + // All other fields unchanged +} +``` + +### New: `InboxItem` + +```typescript +interface InboxItem { + id: string; // UUID + sessionId: string; // Which session needs attention + tabId: string; // Which tab specifically + projectId: string; // Which project (for navigation) + reason: 'finished' | 'error' | 'waiting_input'; + agentType: ToolType; // e.g. 'claude-code' + tabName: string; // Snapshot at trigger time + projectName: string; // Snapshot at trigger time + timestamp: number; // When surfaced +} +``` + +### Removed + +- `Group` type (from `src/shared/types.ts`) +- All group-related store actions and selectors +- All bookmark-related store actions and selectors +- `useSessionCategories` hook (categories no longer needed) + +--- + +## 2. Store Architecture + +### New: `projectStore` + +```typescript +// src/renderer/stores/projectStore.ts +interface ProjectState { + projects: Project[]; + activeProjectId: string; +} + +interface ProjectActions { + setProjects(projects: Project[]): void; + addProject(project: Project): void; + removeProject(projectId: string): void; + updateProject(projectId: string, updates: Partial): void; + setActiveProjectId(projectId: string): void; +} + +// Selectors +selectActiveProject(state): Project | undefined; +selectAllProjects(state): Project[]; +``` + +### New: `inboxStore` + +```typescript +// src/renderer/stores/inboxStore.ts +interface InboxState { + items: InboxItem[]; +} + +interface InboxActions { + addItem(item: InboxItem): void; + dismissItem(itemId: string): void; + dismissAllForProject(projectId: string): void; + clearAll(): void; +} + +// Selectors +selectInboxItems(state): InboxItem[]; +selectInboxCount(state): number; +selectInboxByProject(projectId: string): (state) => InboxItem[]; +``` + +### Modified: `sessionStore` + +**Removed actions:** `setGroups`, `addGroup`, `removeGroup`, `updateGroup`, `toggleGroupCollapsed`, `toggleBookmark` + +**Removed state:** `groups: Group[]` + +**Removed selectors:** `selectBookmarkedSessions`, `selectSessionsByGroup`, `selectUngroupedSessions` + +**Added selector:** `selectSessionsByProject(projectId: string)` — returns sessions filtered by projectId + +**Modified:** `addSession()` now requires `projectId` on the session. + +### Unchanged: `tabStore`, `uiStore`, `notificationStore` + +These stores work at the session/tab level and don't need changes. Tab operations are session-scoped, not project-scoped. + +--- + +## 3. Left Sidebar Layout + +``` +┌─────────────────────────────────┐ +│ INBOX (3) [Clear] │ ← Collapsible section, count badge +│ ┌─ 🟢 Claude finished │ Newest first +│ │ Tab 3 · Maestro · 2m ago │ Click → navigate + dismiss +│ ├─ 🔴 Codex error │ +│ │ Fix API · Backend · 5m ago │ +│ └─ 🟡 Claude waiting │ +│ Review · Mobile · 8m ago │ +├─────────────────────────────────┤ +│ PROJECTS [+ New] │ ← Always visible +│ ▸ Maestro ●3 tabs │ Active project highlighted +│ ▾ Backend API ●2 tabs │ Tab count badge +│ ▸ Mobile App ●1 tab │ +└─────────────────────────────────┘ +``` + +### Project Item Display + +Each project row shows: +- Project name (editable on double-click) +- Optional color accent (left border) +- Tab count badge +- Active indicator (background highlight + accent border, same pattern as current SessionItem) +- Expand/collapse chevron (to peek at session list without switching) + +### Sidebar Actions + +- **[+ New Project]** button opens a folder picker → creates Project with folder name as default name +- Right-click context menu: Rename, Change Color, Delete (with confirmation) +- Drag-drop reordering of projects + +--- + +## 4. Middle Area + +### Tab Bar (Per Active Project) + +When a project is selected, the tab bar shows ALL sessions for that project as tabs: + +``` +┌──────────────────────────────────────────────────────────┐ +│ [🤖 Claude: Main] [🤖 Claude: Refactor] [📦 Codex: Fix] │ ← Session tabs +│ ● busy idle ● error │ +└──────────────────────────────────────────────────────────┘ +``` + +Each tab shows: +- Agent icon (from toolType) +- Session name or tab name +- State indicator (green dot = idle, spinner = busy, red dot = error, yellow = waiting) + +### Tab ↔ Session Relationship + +**Simplification:** In the current model, each Session has multiple AITabs inside it. For the new model, the tab bar shows sessions (not AITabs within a session). Each session is one tab in the project view. + +If a user needs multiple conversations within one agent session, they use the existing AITab system — but at the project level, each session is one tab. + +This means: +- Clicking a project tab = selecting that session (`setActiveSessionId`) +- The existing AITab sub-tabs within a session remain available (secondary tab bar or tab dropdown within the session) +- This is a **two-level tab hierarchy**: Project tabs (sessions) → Session sub-tabs (AITabs) + +### Content Area + +Unchanged — `MainPanel.tsx` renders the active session's content (logs, input area, file preview). The session's own AITab system handles sub-tab navigation. + +--- + +## 5. Inbox System + +### Trigger Logic + +The inbox watches session state transitions. A new `useInboxWatcher` hook (or effect within App.tsx) subscribes to sessionStore: + +``` +When session.state transitions: + busy → idle: reason = 'finished' + busy → error: reason = 'error' + * → waiting_input: reason = 'waiting_input' + +Only create inbox item if: + 1. The session is NOT the currently active session, OR + 2. The session IS active but the app window is not focused + +Deduplicate: Don't add if an item for the same session+reason already exists. +``` + +### Dismissal Rules + +- **Click:** Navigate to project + session, dismiss item +- **Clear button:** Dismiss all items in inbox +- **Auto-dismiss on navigate:** If user manually navigates to a session that has inbox items, dismiss those items +- **No persistence:** Inbox is runtime-only. On app restart, inbox is empty (session states reset to idle anyway) + +### Display + +- Sorted by timestamp, newest first +- Color-coded icons: 🟢 finished, 🔴 error, 🟡 waiting +- Each item: reason icon + agent type + tab/session name + project name + relative time ("2m ago") +- Compact layout — each item fits in ~40px height + +### Audio/OS Notifications + +Leverage existing `notificationStore` infrastructure: +- When an inbox item is created, also fire a toast notification +- If `osNotificationsEnabled`, fire an OS-level notification +- If `audioFeedbackEnabled`, play notification sound + +--- + +## 6. Persistence + +### Projects + +New IPC namespace alongside existing sessions/groups: + +**Main process:** +- New `projectsStore` (electron-store) in main process +- IPC handlers: `projects:getAll`, `projects:setAll` +- Registered in `registerPersistenceHandlers()` + +**Renderer:** +- Same debounced persistence pattern as sessions (`useDebouncedPersistence`) +- `projectStore` mutations → debounced IPC write + +**Preload bridge:** +- Add `window.maestro.projects.getAll()` and `window.maestro.projects.setAll()` + +### Sessions + +Existing persistence unchanged. Session now includes `projectId` field (persisted automatically since sessions are saved as JSON). + +### Inbox + +Runtime-only. Not persisted. Inbox items are transient — stale on restart since session states reset to idle. + +--- + +## 7. Migration Strategy + +On first load after the update, the restoration hook detects the old data format and migrates: + +``` +1. Load existing groups from groups:getAll +2. Load existing sessions from sessions:getAll + +3. For each group that has sessions: + a. Create a Project: { name: group.name, repoPath: first session's projectRoot } + b. Set projectId on all sessions in this group + +4. For ungrouped sessions: + a. Group by git root (session.projectRoot or session.cwd) + b. Create one Project per unique root + c. Name = folder basename + d. Set projectId on each session + +5. Save projects via projects:setAll +6. Save migrated sessions via sessions:setAll +7. Delete groups store (or leave inert — it won't be read again) + +8. Set activeProjectId = project containing the previously active session +``` + +**Safety:** Migration runs once. A `migrationVersion` flag in settings tracks whether migration has run. If migration fails, fall back to creating a single "Default" project with all sessions. + +--- + +## 8. Keyboard Shortcuts + +### New Shortcuts + +| Action | Shortcut | Notes | +|--------|----------|-------| +| Cycle projects forward | `Ctrl+Shift+J` | Wraps around | +| Cycle projects backward | `Ctrl+Shift+K` | Wraps around | +| Focus inbox | `Ctrl+I` | Arrow keys navigate items, Enter to jump | +| Dismiss inbox item | `Backspace` (when inbox focused) | Removes without navigating | +| New project | `Ctrl+Shift+N` | Opens folder picker | + +### Existing Shortcuts (Modified Scope) + +| Action | Shortcut | Change | +|--------|----------|--------| +| Cycle sessions | `Cmd+J / Cmd+K` | Now scoped to active project's sessions | +| Jump to session N | `Cmd+Opt+1-9` | Now scoped to active project's sessions | +| New session | `Cmd+N` | Creates session in active project | + +--- + +## 9. Group Chat as Session Type + +Group chat (multi-agent collaboration) becomes a session type within a project rather than a separate sidebar section. + +- New session with `toolType: 'group-chat'` (or existing group chat mechanism) +- Shows as a tab in the project's tab bar alongside regular agent sessions +- Group chat tab can contain multiple agents collaborating +- Uses existing group chat infrastructure, just re-parented under a project + +--- + +## 10. Files Changed (Scope Estimate) + +### New Files +- `src/renderer/stores/projectStore.ts` — Project state management +- `src/renderer/stores/inboxStore.ts` — Inbox state management +- `src/renderer/components/ProjectSidebar/ProjectSidebar.tsx` — New left sidebar +- `src/renderer/components/ProjectSidebar/ProjectItem.tsx` — Project row +- `src/renderer/components/ProjectSidebar/InboxSection.tsx` — Inbox section +- `src/renderer/components/ProjectSidebar/InboxItem.tsx` — Inbox item row +- `src/renderer/hooks/useInboxWatcher.ts` — State transition → inbox trigger +- `src/renderer/hooks/useProjectRestoration.ts` — Load + migrate projects +- `src/main/ipc/handlers/projects.ts` — Project persistence IPC + +### Modified Files +- `src/renderer/types/index.ts` — Add Project, InboxItem; modify Session +- `src/shared/types.ts` — Remove Group; add Project to shared types +- `src/renderer/App.tsx` — Wire projectStore, inbox watcher, new sidebar +- `src/renderer/stores/sessionStore.ts` — Remove group logic, add project selectors +- `src/renderer/components/TabBar.tsx` — Show sessions-as-tabs for active project +- `src/main/index.ts` — Register project IPC handlers +- `src/main/preload.ts` — Expose projects namespace +- `src/main/ipc/handlers/persistence.ts` — Add projects persistence +- `src/renderer/hooks/session/useSessionRestoration.ts` — Migration logic +- `src/renderer/constants/shortcuts.ts` — New keyboard shortcuts + +### Removed Files / Dead Code +- `src/renderer/components/SessionList/` — Replaced by ProjectSidebar +- Group-related hooks/utilities +- Bookmark-related logic throughout + +--- + +## 11. Risk Assessment + +| Risk | Mitigation | +|------|-----------| +| Migration corrupts session data | Migration writes new data alongside old; old data preserved until verified | +| Inbox noise (too many items) | Only trigger on state transitions, not every output; deduplicate by session | +| Performance with many projects | Memoized selectors, virtualized lists if >20 projects | +| Breaking existing keyboard shortcuts | Existing shortcuts keep same keys, just scoped differently | +| Group chat regression | Keep existing group chat infra; only change how it's surfaced in UI | From 3ace44991eb6f60021996d072ee7128fbb86c2ac Mon Sep 17 00:00:00 2001 From: VibeWriter User Date: Tue, 10 Mar 2026 12:46:56 -0400 Subject: [PATCH 03/20] Add implementation plan for project-centric navigation 18-task plan covering: types, stores, IPC persistence, migration, sidebar UI, inbox watcher, App.tsx integration, keyboard shortcuts, and dead code cleanup. TDD throughout. Co-Authored-By: Claude Opus 4.6 --- ...6-03-10-project-centric-navigation-plan.md | 2266 +++++++++++++++++ 1 file changed, 2266 insertions(+) create mode 100644 docs/plans/2026-03-10-project-centric-navigation-plan.md diff --git a/docs/plans/2026-03-10-project-centric-navigation-plan.md b/docs/plans/2026-03-10-project-centric-navigation-plan.md new file mode 100644 index 000000000..c2c158785 --- /dev/null +++ b/docs/plans/2026-03-10-project-centric-navigation-plan.md @@ -0,0 +1,2266 @@ +# Project-Centric Navigation Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Redesign Maestro's navigation from agent-centric (flat session list) to project/repo-centric (projects in left bar, session tabs per project, global inbox for attention items). + +**Architecture:** Add `Project` as a new first-class entity alongside `Session`. Sessions gain a `projectId` linking them to a project. A new `projectStore` (Zustand) manages project state, a new `inboxStore` manages attention items. The left sidebar is rewritten to show Inbox + Projects. The existing tab bar becomes project-scoped (showing sessions-as-tabs). Groups and bookmarks are removed. + +**Tech Stack:** Electron, React, Zustand, TypeScript, Vitest, electron-store + +**Design Doc:** `docs/plans/2026-03-10-project-centric-navigation-design.md` + +--- + +## Phase 1: Data Model & Types + +### Task 1: Add Project and InboxItem types + +**Files:** +- Modify: `src/renderer/types/index.ts` +- Modify: `src/shared/types.ts` + +**Step 1: Add Project interface to shared types** + +In `src/shared/types.ts`, add after the Group interface (keep Group for now — remove in Phase 9): + +```typescript +export interface Project { + id: string; + name: string; + repoPath: string; + createdAt: number; + color?: string; + collapsed?: boolean; +} +``` + +**Step 2: Add InboxItem interface and InboxReason type to renderer types** + +In `src/renderer/types/index.ts`, add after the existing type definitions: + +```typescript +export type InboxReason = 'finished' | 'error' | 'waiting_input'; + +export interface InboxItem { + id: string; + sessionId: string; + tabId: string; + projectId: string; + reason: InboxReason; + agentType: ToolType; + tabName: string; + projectName: string; + timestamp: number; +} +``` + +**Step 3: Add projectId to Session interface** + +In `src/renderer/types/index.ts`, find the Session interface (line 505). Add `projectId` after the `groupId` field: + +```typescript +export interface Session { + id: string; + groupId?: string; // Keep for migration — remove in Phase 9 + projectId?: string; // NEW — links to Project. Optional during migration, required after. + name: string; + // ... rest unchanged +} +``` + +Note: `projectId` is optional during the migration period. After migration runs, all sessions will have one. + +**Step 4: Add ProjectsData type to store types** + +In `src/main/stores/types.ts`, add after GroupsData: + +```typescript +export interface ProjectsData { + projects: Project[]; +} +``` + +Import Project from shared types: + +```typescript +import type { SshRemoteConfig, Group, Project } from '../../shared/types'; +``` + +**Step 5: Commit** + +```bash +git add src/renderer/types/index.ts src/shared/types.ts src/main/stores/types.ts +git commit -m "feat: add Project and InboxItem type definitions" +``` + +--- + +## Phase 2: Zustand Stores + +### Task 2: Create projectStore + +**Files:** +- Create: `src/renderer/stores/projectStore.ts` +- Test: `src/__tests__/renderer/stores/projectStore.test.ts` + +**Step 1: Write failing tests for projectStore** + +Create `src/__tests__/renderer/stores/projectStore.test.ts`: + +```typescript +import { describe, it, expect, beforeEach } from 'vitest'; +import { + useProjectStore, + selectActiveProject, + selectAllProjects, + selectSessionCountByProject, + getProjectState, + getProjectActions, +} from '../../../renderer/stores/projectStore'; + +function createMockProject(overrides: Partial = {}) { + return { + id: overrides.id ?? `project-${Math.random().toString(36).slice(2, 8)}`, + name: overrides.name ?? 'Test Project', + repoPath: overrides.repoPath ?? '/test/repo', + createdAt: overrides.createdAt ?? Date.now(), + ...overrides, + }; +} + +describe('projectStore', () => { + beforeEach(() => { + useProjectStore.setState({ + projects: [], + activeProjectId: '', + }); + }); + + describe('CRUD operations', () => { + it('should add a project', () => { + const project = createMockProject({ id: 'p1', name: 'Maestro' }); + useProjectStore.getState().addProject(project); + expect(useProjectStore.getState().projects).toHaveLength(1); + expect(useProjectStore.getState().projects[0].name).toBe('Maestro'); + }); + + it('should remove a project by ID', () => { + const p1 = createMockProject({ id: 'p1' }); + const p2 = createMockProject({ id: 'p2' }); + useProjectStore.setState({ projects: [p1, p2] }); + useProjectStore.getState().removeProject('p1'); + expect(useProjectStore.getState().projects).toHaveLength(1); + expect(useProjectStore.getState().projects[0].id).toBe('p2'); + }); + + it('should not mutate state when removing non-existent project', () => { + const p1 = createMockProject({ id: 'p1' }); + useProjectStore.setState({ projects: [p1] }); + const before = useProjectStore.getState().projects; + useProjectStore.getState().removeProject('nonexistent'); + expect(useProjectStore.getState().projects).toBe(before); + }); + + it('should update a project', () => { + const p1 = createMockProject({ id: 'p1', name: 'Old' }); + useProjectStore.setState({ projects: [p1] }); + useProjectStore.getState().updateProject('p1', { name: 'New' }); + expect(useProjectStore.getState().projects[0].name).toBe('New'); + }); + + it('should set all projects', () => { + const projects = [createMockProject({ id: 'p1' }), createMockProject({ id: 'p2' })]; + useProjectStore.getState().setProjects(projects); + expect(useProjectStore.getState().projects).toHaveLength(2); + }); + + it('should support functional updater for setProjects', () => { + const p1 = createMockProject({ id: 'p1' }); + useProjectStore.setState({ projects: [p1] }); + useProjectStore.getState().setProjects((prev) => [...prev, createMockProject({ id: 'p2' })]); + expect(useProjectStore.getState().projects).toHaveLength(2); + }); + }); + + describe('active project', () => { + it('should set active project ID and reset to -1 cycle', () => { + useProjectStore.getState().setActiveProjectId('p1'); + expect(useProjectStore.getState().activeProjectId).toBe('p1'); + }); + }); + + describe('selectors', () => { + it('selectActiveProject returns active project', () => { + const p1 = createMockProject({ id: 'p1' }); + const p2 = createMockProject({ id: 'p2' }); + useProjectStore.setState({ projects: [p1, p2], activeProjectId: 'p2' }); + const result = selectActiveProject(useProjectStore.getState()); + expect(result?.id).toBe('p2'); + }); + + it('selectActiveProject falls back to first project', () => { + const p1 = createMockProject({ id: 'p1' }); + useProjectStore.setState({ projects: [p1], activeProjectId: 'nonexistent' }); + const result = selectActiveProject(useProjectStore.getState()); + expect(result?.id).toBe('p1'); + }); + + it('selectActiveProject returns undefined when empty', () => { + const result = selectActiveProject(useProjectStore.getState()); + expect(result).toBeUndefined(); + }); + + it('selectAllProjects returns all projects', () => { + const projects = [createMockProject({ id: 'p1' }), createMockProject({ id: 'p2' })]; + useProjectStore.setState({ projects }); + expect(selectAllProjects(useProjectStore.getState())).toHaveLength(2); + }); + }); + + describe('non-React access', () => { + it('getProjectState returns current state', () => { + const p1 = createMockProject({ id: 'p1' }); + useProjectStore.setState({ projects: [p1], activeProjectId: 'p1' }); + const state = getProjectState(); + expect(state.projects).toHaveLength(1); + expect(state.activeProjectId).toBe('p1'); + }); + + it('getProjectActions returns stable action references', () => { + const actions = getProjectActions(); + expect(typeof actions.addProject).toBe('function'); + expect(typeof actions.removeProject).toBe('function'); + expect(typeof actions.setActiveProjectId).toBe('function'); + }); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +```bash +npx vitest run src/__tests__/renderer/stores/projectStore.test.ts +``` + +Expected: FAIL — module not found. + +**Step 3: Implement projectStore** + +Create `src/renderer/stores/projectStore.ts`: + +```typescript +/** + * projectStore - Zustand store for project (repo) state management + * + * Projects are the top-level organizational unit. Each project maps to a + * git repository. Sessions belong to a project via session.projectId. + */ + +import { create } from 'zustand'; +import type { Project } from '../../shared/types'; + +// ============================================================================ +// Store Types +// ============================================================================ + +export interface ProjectStoreState { + projects: Project[]; + activeProjectId: string; +} + +export interface ProjectStoreActions { + setProjects: (projects: Project[] | ((prev: Project[]) => Project[])) => void; + addProject: (project: Project) => void; + removeProject: (projectId: string) => void; + updateProject: (projectId: string, updates: Partial) => void; + setActiveProjectId: (projectId: string) => void; +} + +export type ProjectStore = ProjectStoreState & ProjectStoreActions; + +// ============================================================================ +// Helpers +// ============================================================================ + +function resolve(valOrFn: T | ((prev: T) => T), prev: T): T { + return typeof valOrFn === 'function' ? (valOrFn as (prev: T) => T)(prev) : valOrFn; +} + +// ============================================================================ +// Store Implementation +// ============================================================================ + +export const useProjectStore = create()((set) => ({ + projects: [], + activeProjectId: '', + + setProjects: (v) => + set((s) => { + const newProjects = resolve(v, s.projects); + if (newProjects === s.projects) return s; + return { projects: newProjects }; + }), + + addProject: (project) => set((s) => ({ projects: [...s.projects, project] })), + + removeProject: (projectId) => + set((s) => { + const filtered = s.projects.filter((p) => p.id !== projectId); + if (filtered.length === s.projects.length) return s; + return { projects: filtered }; + }), + + updateProject: (projectId, updates) => + set((s) => { + let found = false; + const newProjects = s.projects.map((p) => { + if (p.id === projectId) { + found = true; + return { ...p, ...updates }; + } + return p; + }); + if (!found) return s; + return { projects: newProjects }; + }), + + setActiveProjectId: (projectId) => set({ activeProjectId: projectId }), +})); + +// ============================================================================ +// Selectors +// ============================================================================ + +export const selectActiveProject = (state: ProjectStore): Project | undefined => + state.projects.find((p) => p.id === state.activeProjectId) || state.projects[0]; + +export const selectAllProjects = (state: ProjectStore): Project[] => state.projects; + +export const selectProjectById = + (id: string) => + (state: ProjectStore): Project | undefined => + state.projects.find((p) => p.id === id); + +export const selectSessionCountByProject = + (projectId: string, sessions: { projectId?: string }[]) => (): number => + sessions.filter((s) => s.projectId === projectId).length; + +// ============================================================================ +// Non-React Access +// ============================================================================ + +export function getProjectState() { + return useProjectStore.getState(); +} + +export function getProjectActions() { + const state = useProjectStore.getState(); + return { + setProjects: state.setProjects, + addProject: state.addProject, + removeProject: state.removeProject, + updateProject: state.updateProject, + setActiveProjectId: state.setActiveProjectId, + }; +} +``` + +**Step 4: Run tests to verify they pass** + +```bash +npx vitest run src/__tests__/renderer/stores/projectStore.test.ts +``` + +Expected: ALL PASS. + +**Step 5: Commit** + +```bash +git add src/renderer/stores/projectStore.ts src/__tests__/renderer/stores/projectStore.test.ts +git commit -m "feat: add projectStore with CRUD, selectors, and tests" +``` + +--- + +### Task 3: Create inboxStore + +**Files:** +- Create: `src/renderer/stores/inboxStore.ts` +- Test: `src/__tests__/renderer/stores/inboxStore.test.ts` + +**Step 1: Write failing tests for inboxStore** + +Create `src/__tests__/renderer/stores/inboxStore.test.ts`: + +```typescript +import { describe, it, expect, beforeEach } from 'vitest'; +import { + useInboxStore, + selectInboxItems, + selectInboxCount, + selectInboxByProject, + getInboxActions, +} from '../../../renderer/stores/inboxStore'; +import type { InboxItem } from '../../../renderer/types'; + +function createMockInboxItem(overrides: Partial = {}): InboxItem { + return { + id: overrides.id ?? `inbox-${Math.random().toString(36).slice(2, 8)}`, + sessionId: overrides.sessionId ?? 'session-1', + tabId: overrides.tabId ?? 'tab-1', + projectId: overrides.projectId ?? 'project-1', + reason: overrides.reason ?? 'finished', + agentType: overrides.agentType ?? 'claude-code', + tabName: overrides.tabName ?? 'Tab 1', + projectName: overrides.projectName ?? 'Test Project', + timestamp: overrides.timestamp ?? Date.now(), + ...overrides, + }; +} + +describe('inboxStore', () => { + beforeEach(() => { + useInboxStore.setState({ items: [] }); + }); + + describe('addItem', () => { + it('should add an inbox item', () => { + const item = createMockInboxItem({ id: 'i1' }); + useInboxStore.getState().addItem(item); + expect(useInboxStore.getState().items).toHaveLength(1); + }); + + it('should deduplicate by sessionId + reason', () => { + const item1 = createMockInboxItem({ id: 'i1', sessionId: 's1', reason: 'finished' }); + const item2 = createMockInboxItem({ id: 'i2', sessionId: 's1', reason: 'finished' }); + useInboxStore.getState().addItem(item1); + useInboxStore.getState().addItem(item2); + expect(useInboxStore.getState().items).toHaveLength(1); + }); + + it('should allow same session with different reason', () => { + const item1 = createMockInboxItem({ id: 'i1', sessionId: 's1', reason: 'finished' }); + const item2 = createMockInboxItem({ id: 'i2', sessionId: 's1', reason: 'error' }); + useInboxStore.getState().addItem(item1); + useInboxStore.getState().addItem(item2); + expect(useInboxStore.getState().items).toHaveLength(2); + }); + }); + + describe('dismissItem', () => { + it('should remove a specific item by ID', () => { + const item = createMockInboxItem({ id: 'i1' }); + useInboxStore.setState({ items: [item] }); + useInboxStore.getState().dismissItem('i1'); + expect(useInboxStore.getState().items).toHaveLength(0); + }); + + it('should not mutate when dismissing non-existent item', () => { + const item = createMockInboxItem({ id: 'i1' }); + useInboxStore.setState({ items: [item] }); + const before = useInboxStore.getState().items; + useInboxStore.getState().dismissItem('nonexistent'); + expect(useInboxStore.getState().items).toBe(before); + }); + }); + + describe('dismissAllForSession', () => { + it('should remove all items for a session', () => { + const items = [ + createMockInboxItem({ id: 'i1', sessionId: 's1' }), + createMockInboxItem({ id: 'i2', sessionId: 's1', reason: 'error' }), + createMockInboxItem({ id: 'i3', sessionId: 's2' }), + ]; + useInboxStore.setState({ items }); + useInboxStore.getState().dismissAllForSession('s1'); + expect(useInboxStore.getState().items).toHaveLength(1); + expect(useInboxStore.getState().items[0].sessionId).toBe('s2'); + }); + }); + + describe('dismissAllForProject', () => { + it('should remove all items for a project', () => { + const items = [ + createMockInboxItem({ id: 'i1', projectId: 'p1' }), + createMockInboxItem({ id: 'i2', projectId: 'p2' }), + ]; + useInboxStore.setState({ items }); + useInboxStore.getState().dismissAllForProject('p1'); + expect(useInboxStore.getState().items).toHaveLength(1); + expect(useInboxStore.getState().items[0].projectId).toBe('p2'); + }); + }); + + describe('clearAll', () => { + it('should remove all items', () => { + const items = [ + createMockInboxItem({ id: 'i1' }), + createMockInboxItem({ id: 'i2', sessionId: 's2' }), + ]; + useInboxStore.setState({ items }); + useInboxStore.getState().clearAll(); + expect(useInboxStore.getState().items).toHaveLength(0); + }); + }); + + describe('selectors', () => { + it('selectInboxItems returns items sorted newest first', () => { + const items = [ + createMockInboxItem({ id: 'i1', timestamp: 1000 }), + createMockInboxItem({ id: 'i2', sessionId: 's2', timestamp: 2000 }), + ]; + useInboxStore.setState({ items }); + const sorted = selectInboxItems(useInboxStore.getState()); + expect(sorted[0].id).toBe('i2'); + expect(sorted[1].id).toBe('i1'); + }); + + it('selectInboxCount returns item count', () => { + const items = [ + createMockInboxItem({ id: 'i1' }), + createMockInboxItem({ id: 'i2', sessionId: 's2' }), + ]; + useInboxStore.setState({ items }); + expect(selectInboxCount(useInboxStore.getState())).toBe(2); + }); + + it('selectInboxByProject filters by project', () => { + const items = [ + createMockInboxItem({ id: 'i1', projectId: 'p1' }), + createMockInboxItem({ id: 'i2', projectId: 'p2', sessionId: 's2' }), + ]; + useInboxStore.setState({ items }); + const selector = selectInboxByProject('p1'); + expect(selector(useInboxStore.getState())).toHaveLength(1); + }); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +```bash +npx vitest run src/__tests__/renderer/stores/inboxStore.test.ts +``` + +Expected: FAIL — module not found. + +**Step 3: Implement inboxStore** + +Create `src/renderer/stores/inboxStore.ts`: + +```typescript +/** + * inboxStore - Zustand store for attention inbox management + * + * Tracks sessions that need user attention (finished, errored, waiting for input). + * Runtime-only — not persisted to disk. + */ + +import { create } from 'zustand'; +import type { InboxItem } from '../types'; + +// ============================================================================ +// Store Types +// ============================================================================ + +export interface InboxStoreState { + items: InboxItem[]; +} + +export interface InboxStoreActions { + addItem: (item: InboxItem) => void; + dismissItem: (itemId: string) => void; + dismissAllForSession: (sessionId: string) => void; + dismissAllForProject: (projectId: string) => void; + clearAll: () => void; +} + +export type InboxStore = InboxStoreState & InboxStoreActions; + +// ============================================================================ +// Store Implementation +// ============================================================================ + +export const useInboxStore = create()((set) => ({ + items: [], + + addItem: (item) => + set((s) => { + // Deduplicate: don't add if same session+reason already exists + const exists = s.items.some( + (existing) => existing.sessionId === item.sessionId && existing.reason === item.reason + ); + if (exists) return s; + return { items: [...s.items, item] }; + }), + + dismissItem: (itemId) => + set((s) => { + const filtered = s.items.filter((item) => item.id !== itemId); + if (filtered.length === s.items.length) return s; + return { items: filtered }; + }), + + dismissAllForSession: (sessionId) => + set((s) => { + const filtered = s.items.filter((item) => item.sessionId !== sessionId); + if (filtered.length === s.items.length) return s; + return { items: filtered }; + }), + + dismissAllForProject: (projectId) => + set((s) => { + const filtered = s.items.filter((item) => item.projectId !== projectId); + if (filtered.length === s.items.length) return s; + return { items: filtered }; + }), + + clearAll: () => set({ items: [] }), +})); + +// ============================================================================ +// Selectors +// ============================================================================ + +export const selectInboxItems = (state: InboxStore): InboxItem[] => + [...state.items].sort((a, b) => b.timestamp - a.timestamp); + +export const selectInboxCount = (state: InboxStore): number => state.items.length; + +export const selectInboxByProject = + (projectId: string) => + (state: InboxStore): InboxItem[] => + state.items + .filter((item) => item.projectId === projectId) + .sort((a, b) => b.timestamp - a.timestamp); + +// ============================================================================ +// Non-React Access +// ============================================================================ + +export function getInboxActions() { + const state = useInboxStore.getState(); + return { + addItem: state.addItem, + dismissItem: state.dismissItem, + dismissAllForSession: state.dismissAllForSession, + dismissAllForProject: state.dismissAllForProject, + clearAll: state.clearAll, + }; +} +``` + +**Step 4: Run tests to verify they pass** + +```bash +npx vitest run src/__tests__/renderer/stores/inboxStore.test.ts +``` + +Expected: ALL PASS. + +**Step 5: Commit** + +```bash +git add src/renderer/stores/inboxStore.ts src/__tests__/renderer/stores/inboxStore.test.ts +git commit -m "feat: add inboxStore with dedup, dismissal, selectors, and tests" +``` + +--- + +### Task 4: Add selectSessionsByProject to sessionStore + +**Files:** +- Modify: `src/renderer/stores/sessionStore.ts` +- Modify: `src/__tests__/renderer/stores/sessionStore.test.ts` + +**Step 1: Write failing test** + +Add to `src/__tests__/renderer/stores/sessionStore.test.ts` — find the selectors describe block and add: + +```typescript +import { selectSessionsByProject } from '../../../renderer/stores/sessionStore'; + +// Inside the selectors describe block: +describe('selectSessionsByProject', () => { + it('should return sessions matching projectId', () => { + const sessions = [ + createMockSession({ id: 's1', projectId: 'p1' }), + createMockSession({ id: 's2', projectId: 'p2' }), + createMockSession({ id: 's3', projectId: 'p1' }), + ]; + useSessionStore.setState({ sessions }); + const result = selectSessionsByProject('p1')(useSessionStore.getState()); + expect(result).toHaveLength(2); + expect(result.map((s) => s.id)).toEqual(['s1', 's3']); + }); + + it('should return empty array for unknown projectId', () => { + const sessions = [createMockSession({ id: 's1', projectId: 'p1' })]; + useSessionStore.setState({ sessions }); + const result = selectSessionsByProject('nonexistent')(useSessionStore.getState()); + expect(result).toHaveLength(0); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +```bash +npx vitest run src/__tests__/renderer/stores/sessionStore.test.ts +``` + +Expected: FAIL — `selectSessionsByProject` is not exported. + +**Step 3: Add the selector to sessionStore** + +In `src/renderer/stores/sessionStore.ts`, add after the `selectUngroupedSessions` selector (around line 360): + +```typescript +/** + * Select sessions belonging to a specific project. + */ +export const selectSessionsByProject = + (projectId: string) => + (state: SessionStore): Session[] => + state.sessions.filter((s) => s.projectId === projectId); +``` + +**Step 4: Run tests to verify they pass** + +```bash +npx vitest run src/__tests__/renderer/stores/sessionStore.test.ts +``` + +Expected: ALL PASS. + +**Step 5: Commit** + +```bash +git add src/renderer/stores/sessionStore.ts src/__tests__/renderer/stores/sessionStore.test.ts +git commit -m "feat: add selectSessionsByProject selector to sessionStore" +``` + +--- + +## Phase 3: Persistence Layer (IPC) + +### Task 5: Add projects electron-store and IPC handlers + +**Files:** +- Modify: `src/main/stores/types.ts` (already done in Task 1) +- Modify: `src/main/stores/defaults.ts` +- Modify: `src/main/stores/instances.ts` +- Modify: `src/main/stores/getters.ts` +- Modify: `src/main/stores/index.ts` (if it re-exports) +- Modify: `src/main/ipc/handlers/persistence.ts` + +**Step 1: Add PROJECTS_DEFAULTS to defaults.ts** + +In `src/main/stores/defaults.ts`, add import for `ProjectsData` and the default: + +```typescript +import type { ProjectsData } from './types'; + +export const PROJECTS_DEFAULTS: ProjectsData = { + projects: [], +}; +``` + +**Step 2: Add projects store to instances.ts** + +In `src/main/stores/instances.ts`: + +1. Import `ProjectsData` in the type import block. +2. Import `PROJECTS_DEFAULTS` in the defaults import block. +3. Add instance variable: `let _projectsStore: Store | null = null;` +4. In `initializeStores()`, after the groups store init (line 110), add: + +```typescript +_projectsStore = new Store({ + name: 'maestro-projects', + cwd: _syncPath, + defaults: PROJECTS_DEFAULTS, +}); +``` + +5. In `getStoreInstances()`, add `projectsStore: _projectsStore` to the return object. + +**Step 3: Add getter in getters.ts** + +In `src/main/stores/getters.ts`: + +1. Import `ProjectsData` type. +2. Add getter function: + +```typescript +export function getProjectsStore(): Store { + ensureInitialized(); + return getStoreInstances().projectsStore!; +} +``` + +**Step 4: Update store index exports** + +In `src/main/stores/index.ts`, add re-export for `getProjectsStore` if not auto-exported. + +**Step 5: Add IPC handlers in persistence.ts** + +In `src/main/ipc/handlers/persistence.ts`: + +1. Update `PersistenceHandlerDependencies` to include `projectsStore`: + +```typescript +export interface PersistenceHandlerDependencies { + settingsStore: Store; + sessionsStore: Store; + groupsStore: Store; + projectsStore: Store; + getWebServer: () => WebServer | null; +} +``` + +2. Import `ProjectsData` in the type imports. + +3. After the `groups:setAll` handler (line 205), add: + +```typescript +// Projects persistence +ipcMain.handle('projects:getAll', async () => { + return projectsStore.get('projects', []); +}); + +ipcMain.handle('projects:setAll', async (_, projects: Project[]) => { + try { + projectsStore.set('projects', projects); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + logger.warn(`Failed to persist projects: ${code || (err as Error).message}`, 'Projects'); + return false; + } + return true; +}); +``` + +4. Destructure `projectsStore` from `deps` at the top of `registerPersistenceHandlers`. + +**Step 6: Wire projectsStore in main/index.ts** + +In `src/main/index.ts`, find where `sessionsStore` and `groupsStore` are initialized (around line 231). Add: + +```typescript +const projectsStore = getProjectsStore(); +``` + +Import `getProjectsStore` from the stores module. Pass `projectsStore` to `registerPersistenceHandlers` call. + +**Step 7: Commit** + +```bash +git add src/main/stores/ src/main/ipc/handlers/persistence.ts src/main/index.ts +git commit -m "feat: add projects electron-store and IPC persistence handlers" +``` + +--- + +### Task 6: Add projects preload bridge + +**Files:** +- Modify: `src/main/preload/settings.ts` +- Modify: `src/main/preload/index.ts` + +**Step 1: Add createProjectsApi to preload/settings.ts** + +In `src/main/preload/settings.ts`, add after `createGroupsApi()`: + +```typescript +/** + * Creates the projects persistence API object for preload exposure + */ +export function createProjectsApi() { + return { + getAll: () => ipcRenderer.invoke('projects:getAll'), + setAll: (projects: any[]) => ipcRenderer.invoke('projects:setAll', projects), + }; +} + +export type ProjectsApi = ReturnType; +``` + +**Step 2: Expose in preload/index.ts** + +In `src/main/preload/index.ts`: + +1. Import `createProjectsApi` from `'./settings'`. +2. Add to the `contextBridge.exposeInMainWorld('maestro', { ... })` object: + +```typescript +// Projects persistence API +projects: createProjectsApi(), +``` + +**Step 3: Commit** + +```bash +git add src/main/preload/settings.ts src/main/preload/index.ts +git commit -m "feat: expose window.maestro.projects IPC bridge" +``` + +--- + +## Phase 4: Project Restoration & Migration + +### Task 7: Create useProjectRestoration hook + +**Files:** +- Create: `src/renderer/hooks/project/useProjectRestoration.ts` +- Modify: `src/renderer/hooks/session/useSessionRestoration.ts` (add migration) + +**Step 1: Create the hook** + +Create `src/renderer/hooks/project/useProjectRestoration.ts`: + +```typescript +/** + * useProjectRestoration - Loads projects from disk on startup and runs migration. + * + * Migration: Converts existing groups → projects on first run. + * After migration, groups store is left inert (not read again). + */ + +import { useEffect, useRef } from 'react'; +import { useProjectStore } from '../../stores/projectStore'; +import { useSessionStore } from '../../stores/sessionStore'; +import { generateId } from '../../utils/ids'; +import type { Project } from '../../../shared/types'; +import type { Session } from '../../types'; + +const MIGRATION_KEY = 'projectMigrationComplete'; + +/** + * Migrate groups → projects. Runs once. + */ +async function migrateGroupsToProjects(): Promise<{ + projects: Project[]; + updatedSessions: Session[]; +} | null> { + const migrated = await window.maestro.settings.get(MIGRATION_KEY); + if (migrated) return null; + + const groups = await window.maestro.groups.getAll(); + const sessions = useSessionStore.getState().sessions; + + if ((!groups || groups.length === 0) && sessions.every((s) => !s.groupId)) { + // No groups, no groupId on sessions — nothing to migrate + // But still need to create projects for orphaned sessions + } + + const projects: Project[] = []; + const sessionUpdates = new Map(); // sessionId → projectId + + // 1. Convert groups → projects + if (groups && groups.length > 0) { + for (const group of groups) { + const groupSessions = sessions.filter((s) => s.groupId === group.id); + if (groupSessions.length === 0) continue; + + const project: Project = { + id: generateId(), + name: group.name || 'Unnamed Project', + repoPath: groupSessions[0].projectRoot || groupSessions[0].cwd, + createdAt: Date.now(), + }; + projects.push(project); + + for (const session of groupSessions) { + sessionUpdates.set(session.id, project.id); + } + } + } + + // 2. Handle ungrouped sessions — group by projectRoot/cwd + const ungrouped = sessions.filter((s) => !s.groupId && !sessionUpdates.has(s.id)); + const byRoot = new Map(); + for (const session of ungrouped) { + const root = session.projectRoot || session.cwd; + if (!byRoot.has(root)) byRoot.set(root, []); + byRoot.get(root)!.push(session); + } + + for (const [root, rootSessions] of byRoot) { + const folderName = root.split(/[\\/]/).filter(Boolean).pop() || 'Default'; + const project: Project = { + id: generateId(), + name: folderName, + repoPath: root, + createdAt: Date.now(), + }; + projects.push(project); + for (const session of rootSessions) { + sessionUpdates.set(session.id, project.id); + } + } + + // 3. Apply projectId to sessions + const updatedSessions = sessions.map((s) => ({ + ...s, + projectId: sessionUpdates.get(s.id) || s.projectId, + })); + + // 4. Mark migration complete + await window.maestro.settings.set(MIGRATION_KEY, true); + + return { projects, updatedSessions }; +} + +export function useProjectRestoration() { + const hasRun = useRef(false); + const { setProjects, setActiveProjectId } = useProjectStore.getState(); + const { setSessions } = useSessionStore.getState(); + const initialLoadComplete = useSessionStore((s) => s.initialLoadComplete); + + useEffect(() => { + if (!initialLoadComplete || hasRun.current) return; + hasRun.current = true; + + const loadProjects = async () => { + // 1. Try loading existing projects + const savedProjects = await window.maestro.projects.getAll(); + + if (savedProjects && savedProjects.length > 0) { + setProjects(savedProjects); + + // Set active project to the one containing the active session + const activeSessionId = useSessionStore.getState().activeSessionId; + const activeSession = useSessionStore + .getState() + .sessions.find((s) => s.id === activeSessionId); + if (activeSession?.projectId) { + setActiveProjectId(activeSession.projectId); + } else if (savedProjects.length > 0) { + setActiveProjectId(savedProjects[0].id); + } + return; + } + + // 2. No projects saved — run migration + const migrationResult = await migrateGroupsToProjects(); + if (migrationResult) { + setProjects(migrationResult.projects); + setSessions(migrationResult.updatedSessions); + await window.maestro.projects.setAll(migrationResult.projects); + + // Set active project + const activeSessionId = useSessionStore.getState().activeSessionId; + const activeSession = migrationResult.updatedSessions.find( + (s) => s.id === activeSessionId + ); + if (activeSession?.projectId) { + setActiveProjectId(activeSession.projectId); + } else if (migrationResult.projects.length > 0) { + setActiveProjectId(migrationResult.projects[0].id); + } + } + }; + + loadProjects().catch((err) => { + console.error('[useProjectRestoration] Failed to load/migrate projects:', err); + }); + }, [initialLoadComplete, setProjects, setActiveProjectId, setSessions]); +} +``` + +**Step 2: Add debounced persistence for projects** + +In the existing `useDebouncedPersistence` hook (or wherever session persistence is debounced), add a matching effect for the project store. Find the file at `src/renderer/hooks/settings/useDebouncedPersistence.ts` and follow the same pattern used for sessions: + +```typescript +// Projects persistence (same debounce pattern) +useEffect(() => { + if (!initialLoadComplete) return; + const timer = setTimeout(() => { + const { projects } = useProjectStore.getState(); + window.maestro.projects.setAll(projects); + }, DEBOUNCE_MS); + return () => clearTimeout(timer); +}, [projects, initialLoadComplete]); +``` + +The exact wiring depends on how the existing debounce is structured — follow the same subscription pattern. + +**Step 3: Commit** + +```bash +git add src/renderer/hooks/project/useProjectRestoration.ts +git commit -m "feat: add useProjectRestoration with group→project migration" +``` + +--- + +## Phase 5: Left Sidebar UI + +### Task 8: Create InboxItem component + +**Files:** +- Create: `src/renderer/components/ProjectSidebar/InboxItem.tsx` + +**Step 1: Create the component** + +```typescript +/** + * InboxItem - A single attention item in the inbox sidebar section. + * Shows reason icon, agent type, tab name, project name, and relative time. + * Click navigates to the project + session and auto-dismisses. + */ + +import React, { useCallback, useMemo } from 'react'; +import type { InboxItem as InboxItemType } from '../../types'; +import type { Theme } from '../../constants/themes'; + +interface InboxItemProps { + item: InboxItemType; + theme: Theme; + onNavigate: (item: InboxItemType) => void; +} + +const REASON_CONFIG = { + finished: { icon: '●', color: '#22c55e', label: 'Finished' }, + error: { icon: '●', color: '#ef4444', label: 'Error' }, + waiting_input: { icon: '●', color: '#eab308', label: 'Waiting' }, +} as const; + +function formatRelativeTime(timestamp: number): string { + const diff = Date.now() - timestamp; + const minutes = Math.floor(diff / 60000); + if (minutes < 1) return 'just now'; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + return `${Math.floor(hours / 24)}d ago`; +} + +export const InboxItemComponent = React.memo(function InboxItemComponent({ + item, + theme, + onNavigate, +}: InboxItemProps) { + const config = REASON_CONFIG[item.reason]; + + const handleClick = useCallback(() => { + onNavigate(item); + }, [item, onNavigate]); + + const timeAgo = useMemo(() => formatRelativeTime(item.timestamp), [item.timestamp]); + + return ( +
{ + e.currentTarget.style.backgroundColor = theme.colors.backgroundHover; + }} + onMouseLeave={(e) => { + e.currentTarget.style.backgroundColor = 'transparent'; + }} + > + {config.icon} +
+
+ {item.tabName} +
+
+ {item.projectName} · {timeAgo} +
+
+
+ ); +}); +``` + +**Step 2: Commit** + +```bash +git add src/renderer/components/ProjectSidebar/InboxItem.tsx +git commit -m "feat: add InboxItem component for attention inbox" +``` + +--- + +### Task 9: Create InboxSection component + +**Files:** +- Create: `src/renderer/components/ProjectSidebar/InboxSection.tsx` + +**Step 1: Create the component** + +```typescript +/** + * InboxSection - Collapsible section at the top of the left sidebar. + * Shows inbox items with count badge and clear button. + */ + +import React, { useCallback, useState } from 'react'; +import { useInboxStore, selectInboxItems, selectInboxCount } from '../../stores/inboxStore'; +import { InboxItemComponent } from './InboxItem'; +import type { InboxItem } from '../../types'; +import type { Theme } from '../../constants/themes'; + +interface InboxSectionProps { + theme: Theme; + onNavigateToItem: (item: InboxItem) => void; +} + +export const InboxSection = React.memo(function InboxSection({ + theme, + onNavigateToItem, +}: InboxSectionProps) { + const items = useInboxStore(selectInboxItems); + const count = useInboxStore(selectInboxCount); + const clearAll = useInboxStore((s) => s.clearAll); + const [collapsed, setCollapsed] = useState(false); + + const handleClear = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + clearAll(); + }, + [clearAll] + ); + + const toggleCollapsed = useCallback(() => { + setCollapsed((prev) => !prev); + }, []); + + if (count === 0) return null; + + return ( +
+ {/* Header */} +
+
+ + ▼ + + + Inbox + + + {count} + +
+ +
+ + {/* Items */} + {!collapsed && ( +
+ {items.map((item) => ( + + ))} +
+ )} +
+ ); +}); +``` + +**Step 2: Commit** + +```bash +git add src/renderer/components/ProjectSidebar/InboxSection.tsx +git commit -m "feat: add InboxSection collapsible sidebar component" +``` + +--- + +### Task 10: Create ProjectItem component + +**Files:** +- Create: `src/renderer/components/ProjectSidebar/ProjectItem.tsx` + +**Step 1: Create the component** + +A project row in the sidebar showing: name, session count badge, active highlight, color accent. + +```typescript +/** + * ProjectItem - A single project row in the left sidebar. + */ + +import React, { useCallback } from 'react'; +import type { Project } from '../../../shared/types'; +import type { Theme } from '../../constants/themes'; + +interface ProjectItemProps { + project: Project; + isActive: boolean; + sessionCount: number; + theme: Theme; + onSelect: (projectId: string) => void; + onContextMenu: (e: React.MouseEvent, projectId: string) => void; +} + +export const ProjectItem = React.memo(function ProjectItem({ + project, + isActive, + sessionCount, + theme, + onSelect, + onContextMenu, +}: ProjectItemProps) { + const handleClick = useCallback(() => { + onSelect(project.id); + }, [project.id, onSelect]); + + const handleContextMenu = useCallback( + (e: React.MouseEvent) => { + onContextMenu(e, project.id); + }, + [project.id, onContextMenu] + ); + + return ( +
{ + if (!isActive) { + e.currentTarget.style.backgroundColor = theme.colors.backgroundHover; + } + }} + onMouseLeave={(e) => { + if (!isActive) { + e.currentTarget.style.backgroundColor = 'transparent'; + } + }} + > +
+
+ {project.name} +
+
+ {sessionCount > 0 && ( + + {sessionCount} + + )} +
+ ); +}); +``` + +**Step 2: Commit** + +```bash +git add src/renderer/components/ProjectSidebar/ProjectItem.tsx +git commit -m "feat: add ProjectItem sidebar component" +``` + +--- + +### Task 11: Create ProjectSidebar component + +**Files:** +- Create: `src/renderer/components/ProjectSidebar/ProjectSidebar.tsx` +- Create: `src/renderer/components/ProjectSidebar/index.ts` + +**Step 1: Create the main sidebar component** + +This replaces the existing `SessionList` component. It renders: +1. InboxSection (at top, when items exist) +2. Projects list with session counts + +```typescript +/** + * ProjectSidebar - Left sidebar showing inbox + project list. + * Replaces the old SessionList component. + */ + +import React, { useCallback, useMemo } from 'react'; +import { useProjectStore, selectAllProjects } from '../../stores/projectStore'; +import { useSessionStore } from '../../stores/sessionStore'; +import { useInboxStore } from '../../stores/inboxStore'; +import { InboxSection } from './InboxSection'; +import { ProjectItem } from './ProjectItem'; +import type { InboxItem } from '../../types'; +import type { Theme } from '../../constants/themes'; + +interface ProjectSidebarProps { + theme: Theme; + onAddProject: () => void; +} + +export const ProjectSidebar = React.memo(function ProjectSidebar({ + theme, + onAddProject, +}: ProjectSidebarProps) { + const projects = useProjectStore(selectAllProjects); + const activeProjectId = useProjectStore((s) => s.activeProjectId); + const setActiveProjectId = useProjectStore((s) => s.setActiveProjectId); + const sessions = useSessionStore((s) => s.sessions); + const setActiveSessionId = useSessionStore((s) => s.setActiveSessionId); + const dismissItem = useInboxStore((s) => s.dismissItem); + const dismissAllForSession = useInboxStore((s) => s.dismissAllForSession); + + // Count sessions per project + const sessionCounts = useMemo(() => { + const counts = new Map(); + for (const session of sessions) { + if (session.projectId) { + counts.set(session.projectId, (counts.get(session.projectId) || 0) + 1); + } + } + return counts; + }, [sessions]); + + const handleSelectProject = useCallback( + (projectId: string) => { + setActiveProjectId(projectId); + // When switching projects, select the first session in the new project + const projectSessions = sessions.filter((s) => s.projectId === projectId); + if (projectSessions.length > 0) { + setActiveSessionId(projectSessions[0].id); + } + }, + [setActiveProjectId, setActiveSessionId, sessions] + ); + + const handleNavigateToInboxItem = useCallback( + (item: InboxItem) => { + // Switch to the project + setActiveProjectId(item.projectId); + // Switch to the session + setActiveSessionId(item.sessionId); + // Dismiss the item + dismissItem(item.id); + // Also dismiss any other items for this session + dismissAllForSession(item.sessionId); + }, + [setActiveProjectId, setActiveSessionId, dismissItem, dismissAllForSession] + ); + + const handleProjectContextMenu = useCallback( + (e: React.MouseEvent, _projectId: string) => { + e.preventDefault(); + // TODO: Implement context menu (rename, change color, delete) + }, + [] + ); + + return ( +
+ {/* Inbox Section */} + + + {/* Projects Header */} +
+ + Projects + + +
+ + {/* Project List */} +
+ {projects.map((project) => ( + + ))} + + {projects.length === 0 && ( +
+ No projects yet. Click + to add a repo. +
+ )} +
+
+ ); +}); +``` + +**Step 2: Create index barrel export** + +Create `src/renderer/components/ProjectSidebar/index.ts`: + +```typescript +export { ProjectSidebar } from './ProjectSidebar'; +``` + +**Step 3: Commit** + +```bash +git add src/renderer/components/ProjectSidebar/ +git commit -m "feat: add ProjectSidebar component (inbox + project list)" +``` + +--- + +## Phase 6: Inbox Watcher + +### Task 12: Create useInboxWatcher hook + +**Files:** +- Create: `src/renderer/hooks/useInboxWatcher.ts` +- Test: `src/__tests__/renderer/hooks/useInboxWatcher.test.ts` + +**Step 1: Write failing test** + +Create `src/__tests__/renderer/hooks/useInboxWatcher.test.ts`: + +```typescript +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { useSessionStore } from '../../../renderer/stores/sessionStore'; +import { useInboxStore } from '../../../renderer/stores/inboxStore'; +import { useProjectStore } from '../../../renderer/stores/projectStore'; +import { shouldCreateInboxItem } from '../../../renderer/hooks/useInboxWatcher'; +import type { Session } from '../../../renderer/types'; + +function createMockSession(overrides: Partial = {}): Session { + return { + id: 'session-1', + name: 'Test', + toolType: 'claude-code', + state: 'idle', + cwd: '/test', + fullPath: '/test', + projectRoot: '/test', + projectId: 'project-1', + aiLogs: [], + shellLogs: [], + workLog: [], + contextUsage: 0, + inputMode: 'ai', + aiPid: 0, + terminalPid: 0, + port: 0, + isLive: false, + changedFiles: [], + isGitRepo: false, + fileTree: [], + fileExplorerExpanded: [], + fileExplorerScrollPos: 0, + executionQueue: [], + activeTimeMs: 0, + aiTabs: [{ id: 'tab-1', name: 'Tab 1', agentSessionId: null, starred: false, logs: [], inputValue: '', stagedImages: [], createdAt: Date.now(), state: 'idle' }], + activeTabId: 'tab-1', + closedTabHistory: [], + filePreviewTabs: [], + activeFileTabId: null, + unifiedTabOrder: [], + unifiedClosedTabHistory: [], + ...overrides, + } as Session; +} + +describe('shouldCreateInboxItem', () => { + it('returns "finished" when transitioning busy → idle for non-active session', () => { + const result = shouldCreateInboxItem('busy', 'idle', 'session-1', 'session-2'); + expect(result).toBe('finished'); + }); + + it('returns "error" when transitioning busy → error', () => { + const result = shouldCreateInboxItem('busy', 'error', 'session-1', 'session-2'); + expect(result).toBe('error'); + }); + + it('returns "waiting_input" when transitioning to waiting_input', () => { + const result = shouldCreateInboxItem('busy', 'waiting_input', 'session-1', 'session-2'); + expect(result).toBe('waiting_input'); + }); + + it('returns null for active session (user is looking at it)', () => { + const result = shouldCreateInboxItem('busy', 'idle', 'session-1', 'session-1'); + expect(result).toBeNull(); + }); + + it('returns null for non-triggering state transitions', () => { + expect(shouldCreateInboxItem('idle', 'busy', 's1', 's2')).toBeNull(); + expect(shouldCreateInboxItem('idle', 'connecting', 's1', 's2')).toBeNull(); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +```bash +npx vitest run src/__tests__/renderer/hooks/useInboxWatcher.test.ts +``` + +Expected: FAIL — module not found. + +**Step 3: Implement useInboxWatcher** + +Create `src/renderer/hooks/useInboxWatcher.ts`: + +```typescript +/** + * useInboxWatcher - Watches session state transitions and creates inbox items. + * + * Triggers: + * - busy → idle: "finished" + * - busy → error: "error" + * - * → waiting_input: "waiting_input" + * + * Only for sessions the user is NOT currently looking at. + */ + +import { useEffect, useRef } from 'react'; +import { useSessionStore } from '../stores/sessionStore'; +import { useProjectStore } from '../stores/projectStore'; +import { useInboxStore } from '../stores/inboxStore'; +import { generateId } from '../utils/ids'; +import type { SessionState, InboxReason } from '../types'; + +/** + * Pure function to determine if a state transition should create an inbox item. + * Exported for testing. + */ +export function shouldCreateInboxItem( + prevState: string, + newState: string, + sessionId: string, + activeSessionId: string +): InboxReason | null { + // Don't create items for the session the user is currently viewing + if (sessionId === activeSessionId) return null; + + // busy → idle = finished + if (prevState === 'busy' && newState === 'idle') return 'finished'; + + // busy → error = error + if (prevState === 'busy' && newState === 'error') return 'error'; + + // * → waiting_input = waiting + if (newState === 'waiting_input' && prevState !== 'waiting_input') return 'waiting_input'; + + return null; +} + +export function useInboxWatcher() { + const prevStates = useRef>(new Map()); + + useEffect(() => { + // Subscribe to session store changes + const unsubscribe = useSessionStore.subscribe((state, prevState) => { + const activeSessionId = state.activeSessionId; + const { addItem } = useInboxStore.getState(); + + for (const session of state.sessions) { + const prevSession = prevState.sessions.find((s) => s.id === session.id); + if (!prevSession) continue; + + const prevSessionState = prevSession.state; + const newSessionState = session.state; + + if (prevSessionState === newSessionState) continue; + + const reason = shouldCreateInboxItem( + prevSessionState, + newSessionState, + session.id, + activeSessionId + ); + + if (reason) { + const project = useProjectStore + .getState() + .projects.find((p) => p.id === session.projectId); + const activeTab = session.aiTabs.find((t) => t.id === session.activeTabId); + + addItem({ + id: generateId(), + sessionId: session.id, + tabId: session.activeTabId, + projectId: session.projectId || '', + reason, + agentType: session.toolType, + tabName: activeTab?.name || session.name, + projectName: project?.name || 'Unknown', + timestamp: Date.now(), + }); + } + } + }); + + return unsubscribe; + }, []); +} +``` + +**Step 4: Run tests to verify they pass** + +```bash +npx vitest run src/__tests__/renderer/hooks/useInboxWatcher.test.ts +``` + +Expected: ALL PASS. + +**Step 5: Commit** + +```bash +git add src/renderer/hooks/useInboxWatcher.ts src/__tests__/renderer/hooks/useInboxWatcher.test.ts +git commit -m "feat: add useInboxWatcher hook for session state → inbox triggers" +``` + +--- + +## Phase 7: Wire Into App.tsx + +### Task 13: Integrate new sidebar and hooks into App.tsx + +**Files:** +- Modify: `src/renderer/App.tsx` + +This task is the most context-dependent. The changes to App.tsx involve: + +**Step 1: Import new stores and hooks** + +At the top of App.tsx, add imports: + +```typescript +import { useProjectStore, selectActiveProject } from './stores/projectStore'; +import { useInboxStore } from './stores/inboxStore'; +import { useProjectRestoration } from './hooks/project/useProjectRestoration'; +import { useInboxWatcher } from './hooks/useInboxWatcher'; +import { ProjectSidebar } from './components/ProjectSidebar'; +``` + +**Step 2: Add hook calls** + +Inside the App component body, add: + +```typescript +// Project restoration (loads/migrates on startup) +useProjectRestoration(); + +// Inbox watcher (creates inbox items on session state transitions) +useInboxWatcher(); + +// Project state +const activeProject = useProjectStore(selectActiveProject); +``` + +**Step 3: Add "New Project" handler** + +```typescript +const handleAddProject = useCallback(async () => { + const result = await window.maestro.dialog.showOpenDialog({ + properties: ['openDirectory'], + title: 'Select a repository folder', + }); + if (result.canceled || result.filePaths.length === 0) return; + + const repoPath = result.filePaths[0]; + const folderName = repoPath.split(/[\\/]/).filter(Boolean).pop() || 'New Project'; + + const project = { + id: generateId(), + name: folderName, + repoPath, + createdAt: Date.now(), + }; + + useProjectStore.getState().addProject(project); + useProjectStore.getState().setActiveProjectId(project.id); +}, []); +``` + +**Step 4: Replace SessionList with ProjectSidebar in JSX** + +Find where ` +``` + +Keep the existing sidebar wrapper/container div — just swap the inner component. + +**Step 5: Auto-dismiss inbox items on session navigation** + +In the existing `setActiveSessionId` handler (wherever it's wrapped in App.tsx), add inbox dismissal: + +```typescript +const setActiveSessionId = useCallback((id: string) => { + setActiveGroupChatId(null); + storeSetActiveSessionId(id); + // Auto-dismiss inbox items for the session we're navigating to + useInboxStore.getState().dismissAllForSession(id); +}, [storeSetActiveSessionId, setActiveGroupChatId]); +``` + +**Step 6: Scope session cycling (Cmd+J/K) to active project** + +Find where session cycling logic reads `sessions` (likely filtering by visible sessions). Update to filter by active project: + +```typescript +const activeProjectId = useProjectStore.getState().activeProjectId; +const cycleSessions = sessions.filter((s) => s.projectId === activeProjectId); +``` + +**Step 7: Ensure new sessions get projectId** + +Find where new sessions are created (the `addNewSession` handler). Add `projectId` from the active project: + +```typescript +const activeProjectId = useProjectStore.getState().activeProjectId; +// In the new session object: +const newSession = { + ...defaultSessionFields, + projectId: activeProjectId, + // ... rest of fields +}; +``` + +**Step 8: Test manually** + +```bash +npm run dev:win +``` + +Verify: +1. Left sidebar shows Inbox (if items exist) + Projects list +2. Clicking a project highlights it and shows its sessions in the tab bar +3. New sessions are created within the active project +4. Session state changes in non-active sessions create inbox items + +**Step 9: Commit** + +```bash +git add src/renderer/App.tsx +git commit -m "feat: wire ProjectSidebar, inbox watcher, and project restoration into App" +``` + +--- + +## Phase 8: Keyboard Shortcuts + +### Task 14: Add project navigation shortcuts + +**Files:** +- Modify: `src/renderer/constants/shortcuts.ts` +- Modify: `src/renderer/App.tsx` (keyboard handler) + +**Step 1: Add new shortcut definitions** + +In `src/renderer/constants/shortcuts.ts`, add to `DEFAULT_SHORTCUTS`: + +```typescript +cycleProjectPrev: { + id: 'cycleProjectPrev', + label: 'Previous Project', + keys: ['Ctrl', 'Shift', '['], +}, +cycleProjectNext: { + id: 'cycleProjectNext', + label: 'Next Project', + keys: ['Ctrl', 'Shift', ']'], +}, +focusInbox: { + id: 'focusInbox', + label: 'Focus Inbox', + keys: ['Ctrl', 'i'], +}, +newProject: { + id: 'newProject', + label: 'New Project', + keys: ['Ctrl', 'Shift', 'n'], +}, +``` + +**Step 2: Wire keyboard handlers in App.tsx** + +In the keyboard handler section of App.tsx (find where existing shortcuts like `cyclePrev` are handled), add: + +```typescript +// Cycle projects +if (matchesShortcut('cycleProjectNext', e)) { + e.preventDefault(); + const { projects, activeProjectId, setActiveProjectId } = useProjectStore.getState(); + const idx = projects.findIndex((p) => p.id === activeProjectId); + const next = (idx + 1) % projects.length; + if (projects[next]) { + setActiveProjectId(projects[next].id); + // Also select first session in that project + const projectSessions = sessions.filter((s) => s.projectId === projects[next].id); + if (projectSessions.length > 0) { + setActiveSessionId(projectSessions[0].id); + } + } +} + +if (matchesShortcut('cycleProjectPrev', e)) { + e.preventDefault(); + const { projects, activeProjectId, setActiveProjectId } = useProjectStore.getState(); + const idx = projects.findIndex((p) => p.id === activeProjectId); + const prev = (idx - 1 + projects.length) % projects.length; + if (projects[prev]) { + setActiveProjectId(projects[prev].id); + const projectSessions = sessions.filter((s) => s.projectId === projects[prev].id); + if (projectSessions.length > 0) { + setActiveSessionId(projectSessions[0].id); + } + } +} + +if (matchesShortcut('newProject', e)) { + e.preventDefault(); + handleAddProject(); +} +``` + +**Step 3: Commit** + +```bash +git add src/renderer/constants/shortcuts.ts src/renderer/App.tsx +git commit -m "feat: add keyboard shortcuts for project cycling and inbox focus" +``` + +--- + +## Phase 9: Cleanup — Remove Dead Code + +### Task 15: Remove group and bookmark code from sessionStore + +**Files:** +- Modify: `src/renderer/stores/sessionStore.ts` +- Modify: `src/__tests__/renderer/stores/sessionStore.test.ts` + +**Step 1: Remove from store** + +In `src/renderer/stores/sessionStore.ts`: + +1. Remove `groups: Group[]` from state (line 28) +2. Remove group actions: `setGroups`, `addGroup`, `removeGroup`, `updateGroup`, `toggleGroupCollapsed` (lines 84-96, 204-237) +3. Remove bookmark action: `toggleBookmark` (lines 245-250) +4. Remove group selectors: `selectSessionsByGroup`, `selectUngroupedSessions`, `selectGroupById` (lines 348-371) +5. Remove bookmark selector: `selectBookmarkedSessions` (lines 339-340) +6. Remove group-related entries from `getSessionActions` (lines 430-434) +7. Remove `Group` import from types + +**Step 2: Update test file** + +In `src/__tests__/renderer/stores/sessionStore.test.ts`: + +1. Remove imports for deleted selectors (`selectBookmarkedSessions`, `selectSessionsByGroup`, `selectUngroupedSessions`, `selectGroupById`) +2. Remove all test cases that test group or bookmark functionality +3. Remove `Group` import + +**Step 3: Run tests** + +```bash +npx vitest run src/__tests__/renderer/stores/sessionStore.test.ts +``` + +Expected: ALL PASS (remaining tests). + +**Step 4: Commit** + +```bash +git add src/renderer/stores/sessionStore.ts src/__tests__/renderer/stores/sessionStore.test.ts +git commit -m "refactor: remove group and bookmark code from sessionStore" +``` + +--- + +### Task 16: Remove group references across the codebase + +**Files:** Multiple files — search and replace. + +**Step 1: Find all group references** + +```bash +# Find all files referencing groupId, Group type, group store actions +npx grep -rn "groupId\|setGroups\|addGroup\|removeGroup\|toggleGroupCollapsed\|selectSessionsByGroup\|selectBookmarkedSessions\|selectUngroupedSessions" src/renderer/ --include="*.ts" --include="*.tsx" +``` + +**Step 2: Remove group references systematically** + +For each file: +- Remove group-related imports +- Remove group-related props +- Remove group-related state hooks +- Remove group-related callbacks +- Remove group-related JSX + +Key files to check: +- `src/renderer/App.tsx` — remove `groups` state, `setGroups`, group handler functions, group-related props +- `src/renderer/components/SessionList/` — this entire directory is being replaced (skip if already swapped out) +- `src/renderer/hooks/session/useSessionRestoration.ts` — remove `groups.getAll()` call (migration handles it now) +- `src/renderer/hooks/settings/useDebouncedPersistence.ts` — remove groups persistence effect + +**Step 3: Run full test suite** + +```bash +npx vitest run +``` + +Fix any failures caused by removed references. + +**Step 4: Commit** + +```bash +git add -A +git commit -m "refactor: remove group and bookmark references across renderer" +``` + +--- + +### Task 17: Remove Group type from shared types + +**Files:** +- Modify: `src/shared/types.ts` — remove `Group` interface +- Modify: `src/main/stores/types.ts` — remove `GroupsData` and `Group` imports +- Modify: `src/main/ipc/handlers/persistence.ts` — remove group IPC handlers (or leave inert for backward compat) +- Modify: `src/main/preload/settings.ts` — remove `createGroupsApi` (or leave inert) + +**Step 1: Comment out or remove Group-related code** + +Since the migration hook still reads `groups:getAll` once, keep the IPC handler alive but mark it as deprecated. After a few releases, remove entirely. + +In `src/shared/types.ts`, add deprecation comment: + +```typescript +/** @deprecated Replaced by Project. Kept for migration compatibility. */ +export interface Group { + id: string; + name: string; + emoji: string; + collapsed: boolean; +} +``` + +**Step 2: Run tests** + +```bash +npx vitest run +``` + +**Step 3: Commit** + +```bash +git add -A +git commit -m "refactor: deprecate Group type, keep for migration compatibility" +``` + +--- + +## Phase 10: Final Integration & Manual Testing + +### Task 18: Full integration test + +**Step 1: Start the app** + +```bash +npm run dev:win +``` + +**Step 2: Verify core flows** + +Checklist: +- [ ] App starts without errors +- [ ] If first run after migration: groups are converted to projects, sessions get projectIds +- [ ] Left sidebar shows Inbox section (only when items exist) and Projects section +- [ ] Clicking "+" opens folder picker, creates a project +- [ ] Clicking a project selects it, tab bar shows its sessions +- [ ] Creating a new session (Cmd+N) adds it to the active project +- [ ] Session state transitions (busy→idle) create inbox items for non-active sessions +- [ ] Clicking an inbox item navigates to that project + session and dismisses the item +- [ ] Clear button dismisses all inbox items +- [ ] Ctrl+Shift+[ / ] cycles between projects +- [ ] Cmd+[ / ] cycles between sessions within the active project +- [ ] Group chat can be created within a project +- [ ] Settings persist across restart (projects saved and restored) + +**Step 3: Fix any issues found** + +**Step 4: Final commit** + +```bash +git add -A +git commit -m "feat: complete project-centric navigation with inbox" +``` + +--- + +## Summary of All Tasks + +| # | Task | Phase | Files Changed | +|---|------|-------|---------------| +| 1 | Add Project/InboxItem types | Data Model | types, shared/types, stores/types | +| 2 | Create projectStore | Stores | projectStore.ts + test | +| 3 | Create inboxStore | Stores | inboxStore.ts + test | +| 4 | Add selectSessionsByProject | Stores | sessionStore.ts + test | +| 5 | Add projects electron-store + IPC | Persistence | stores/*, persistence.ts, index.ts | +| 6 | Add projects preload bridge | Persistence | preload/settings.ts, preload/index.ts | +| 7 | Create useProjectRestoration | Migration | useProjectRestoration.ts | +| 8 | Create InboxItem component | UI | InboxItem.tsx | +| 9 | Create InboxSection component | UI | InboxSection.tsx | +| 10 | Create ProjectItem component | UI | ProjectItem.tsx | +| 11 | Create ProjectSidebar component | UI | ProjectSidebar.tsx, index.ts | +| 12 | Create useInboxWatcher hook | Inbox | useInboxWatcher.ts + test | +| 13 | Wire into App.tsx | Integration | App.tsx | +| 14 | Add keyboard shortcuts | Shortcuts | shortcuts.ts, App.tsx | +| 15 | Remove group/bookmark from store | Cleanup | sessionStore.ts + test | +| 16 | Remove group refs across codebase | Cleanup | Multiple files | +| 17 | Deprecate Group type | Cleanup | shared/types.ts, stores/types.ts | +| 18 | Full integration test | Testing | Manual verification | From 8c045f827cca1526902c70acb165c92159ee0ef6 Mon Sep 17 00:00:00 2001 From: VibeWriter User Date: Tue, 10 Mar 2026 12:52:19 -0400 Subject: [PATCH 04/20] feat: add Project and InboxItem type definitions Co-Authored-By: Claude Opus 4.6 --- src/main/stores/types.ts | 10 +++++++++- src/renderer/types/index.ts | 15 +++++++++++++++ src/shared/types.ts | 10 ++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/main/stores/types.ts b/src/main/stores/types.ts index 0028f49f7..35ce05c41 100644 --- a/src/main/stores/types.ts +++ b/src/main/stores/types.ts @@ -5,7 +5,7 @@ * These types are used across the main process for type-safe store access. */ -import type { SshRemoteConfig, Group } from '../../shared/types'; +import type { SshRemoteConfig, Group, Project } from '../../shared/types'; // ============================================================================ // Stored Session Type (minimal interface for main process storage) @@ -96,6 +96,14 @@ export interface GroupsData { groups: Group[]; } +// ============================================================================ +// Projects Store +// ============================================================================ + +export interface ProjectsData { + projects: Project[]; +} + // ============================================================================ // Agent Configs Store // ============================================================================ diff --git a/src/renderer/types/index.ts b/src/renderer/types/index.ts index 1dbcddb15..152808346 100644 --- a/src/renderer/types/index.ts +++ b/src/renderer/types/index.ts @@ -505,6 +505,7 @@ export type ClosedTabEntry = export interface Session { id: string; groupId?: string; + projectId?: string; // Links to Project. Optional during migration period. name: string; toolType: ToolType; state: SessionState; @@ -936,3 +937,17 @@ export interface ContextManagementSettings { contextWarningYellowThreshold: number; // Yellow warning threshold percentage (default: 60) contextWarningRedThreshold: number; // Red warning threshold percentage (default: 80) } + +export type InboxReason = 'finished' | 'error' | 'waiting_input'; + +export interface InboxItem { + id: string; + sessionId: string; + tabId: string; + projectId: string; + reason: InboxReason; + agentType: ToolType; + tabName: string; + projectName: string; + timestamp: number; +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 24d4da21e..b4c2631ba 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -19,6 +19,16 @@ export interface Group { collapsed: boolean; } +// Project — represents a git repository that sessions belong to +export interface Project { + id: string; + name: string; + repoPath: string; + createdAt: number; + color?: string; + collapsed?: boolean; +} + // Simplified session interface for CLI (subset of full Session) export interface SessionInfo { id: string; From 0360e762d8c9aa5342689974459f915643c05e6a Mon Sep 17 00:00:00 2001 From: VibeWriter User Date: Tue, 10 Mar 2026 12:56:12 -0400 Subject: [PATCH 05/20] feat: add projectStore with CRUD, selectors, and tests Co-Authored-By: Claude Opus 4.6 --- .../renderer/stores/projectStore.test.ts | 125 ++++++++++++++++++ src/renderer/stores/projectStore.ts | 110 +++++++++++++++ 2 files changed, 235 insertions(+) create mode 100644 src/__tests__/renderer/stores/projectStore.test.ts create mode 100644 src/renderer/stores/projectStore.ts diff --git a/src/__tests__/renderer/stores/projectStore.test.ts b/src/__tests__/renderer/stores/projectStore.test.ts new file mode 100644 index 000000000..31f32c43d --- /dev/null +++ b/src/__tests__/renderer/stores/projectStore.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + useProjectStore, + selectActiveProject, + selectAllProjects, + getProjectState, + getProjectActions, +} from '../../../renderer/stores/projectStore'; + +function createMockProject(overrides: Partial = {}) { + return { + id: overrides.id ?? `project-${Math.random().toString(36).slice(2, 8)}`, + name: overrides.name ?? 'Test Project', + repoPath: overrides.repoPath ?? '/test/repo', + createdAt: overrides.createdAt ?? Date.now(), + ...overrides, + }; +} + +describe('projectStore', () => { + beforeEach(() => { + useProjectStore.setState({ + projects: [], + activeProjectId: '', + }); + }); + + describe('CRUD operations', () => { + it('should add a project', () => { + const project = createMockProject({ id: 'p1', name: 'Maestro' }); + useProjectStore.getState().addProject(project); + expect(useProjectStore.getState().projects).toHaveLength(1); + expect(useProjectStore.getState().projects[0].name).toBe('Maestro'); + }); + + it('should remove a project by ID', () => { + const p1 = createMockProject({ id: 'p1' }); + const p2 = createMockProject({ id: 'p2' }); + useProjectStore.setState({ projects: [p1, p2] }); + useProjectStore.getState().removeProject('p1'); + expect(useProjectStore.getState().projects).toHaveLength(1); + expect(useProjectStore.getState().projects[0].id).toBe('p2'); + }); + + it('should not mutate state when removing non-existent project', () => { + const p1 = createMockProject({ id: 'p1' }); + useProjectStore.setState({ projects: [p1] }); + const before = useProjectStore.getState().projects; + useProjectStore.getState().removeProject('nonexistent'); + expect(useProjectStore.getState().projects).toBe(before); + }); + + it('should update a project', () => { + const p1 = createMockProject({ id: 'p1', name: 'Old' }); + useProjectStore.setState({ projects: [p1] }); + useProjectStore.getState().updateProject('p1', { name: 'New' }); + expect(useProjectStore.getState().projects[0].name).toBe('New'); + }); + + it('should set all projects', () => { + const projects = [createMockProject({ id: 'p1' }), createMockProject({ id: 'p2' })]; + useProjectStore.getState().setProjects(projects); + expect(useProjectStore.getState().projects).toHaveLength(2); + }); + + it('should support functional updater for setProjects', () => { + const p1 = createMockProject({ id: 'p1' }); + useProjectStore.setState({ projects: [p1] }); + useProjectStore.getState().setProjects((prev) => [...prev, createMockProject({ id: 'p2' })]); + expect(useProjectStore.getState().projects).toHaveLength(2); + }); + }); + + describe('active project', () => { + it('should set active project ID', () => { + useProjectStore.getState().setActiveProjectId('p1'); + expect(useProjectStore.getState().activeProjectId).toBe('p1'); + }); + }); + + describe('selectors', () => { + it('selectActiveProject returns active project', () => { + const p1 = createMockProject({ id: 'p1' }); + const p2 = createMockProject({ id: 'p2' }); + useProjectStore.setState({ projects: [p1, p2], activeProjectId: 'p2' }); + const result = selectActiveProject(useProjectStore.getState()); + expect(result?.id).toBe('p2'); + }); + + it('selectActiveProject falls back to first project', () => { + const p1 = createMockProject({ id: 'p1' }); + useProjectStore.setState({ projects: [p1], activeProjectId: 'nonexistent' }); + const result = selectActiveProject(useProjectStore.getState()); + expect(result?.id).toBe('p1'); + }); + + it('selectActiveProject returns undefined when empty', () => { + const result = selectActiveProject(useProjectStore.getState()); + expect(result).toBeUndefined(); + }); + + it('selectAllProjects returns all projects', () => { + const projects = [createMockProject({ id: 'p1' }), createMockProject({ id: 'p2' })]; + useProjectStore.setState({ projects }); + expect(selectAllProjects(useProjectStore.getState())).toHaveLength(2); + }); + }); + + describe('non-React access', () => { + it('getProjectState returns current state', () => { + const p1 = createMockProject({ id: 'p1' }); + useProjectStore.setState({ projects: [p1], activeProjectId: 'p1' }); + const state = getProjectState(); + expect(state.projects).toHaveLength(1); + expect(state.activeProjectId).toBe('p1'); + }); + + it('getProjectActions returns stable action references', () => { + const actions = getProjectActions(); + expect(typeof actions.addProject).toBe('function'); + expect(typeof actions.removeProject).toBe('function'); + expect(typeof actions.setActiveProjectId).toBe('function'); + }); + }); +}); diff --git a/src/renderer/stores/projectStore.ts b/src/renderer/stores/projectStore.ts new file mode 100644 index 000000000..b9b4426f6 --- /dev/null +++ b/src/renderer/stores/projectStore.ts @@ -0,0 +1,110 @@ +/** + * projectStore - Zustand store for project (repo) state management + * + * Projects are the top-level organizational unit. Each project maps to a + * git repository. Sessions belong to a project via session.projectId. + */ + +import { create } from 'zustand'; +import type { Project } from '../../shared/types'; + +// ============================================================================ +// Store Types +// ============================================================================ + +export interface ProjectStoreState { + projects: Project[]; + activeProjectId: string; +} + +export interface ProjectStoreActions { + setProjects: (projects: Project[] | ((prev: Project[]) => Project[])) => void; + addProject: (project: Project) => void; + removeProject: (projectId: string) => void; + updateProject: (projectId: string, updates: Partial) => void; + setActiveProjectId: (projectId: string) => void; +} + +export type ProjectStore = ProjectStoreState & ProjectStoreActions; + +// ============================================================================ +// Helpers +// ============================================================================ + +function resolve(valOrFn: T | ((prev: T) => T), prev: T): T { + return typeof valOrFn === 'function' ? (valOrFn as (prev: T) => T)(prev) : valOrFn; +} + +// ============================================================================ +// Store Implementation +// ============================================================================ + +export const useProjectStore = create()((set) => ({ + projects: [], + activeProjectId: '', + + setProjects: (v) => + set((s) => { + const newProjects = resolve(v, s.projects); + if (newProjects === s.projects) return s; + return { projects: newProjects }; + }), + + addProject: (project) => set((s) => ({ projects: [...s.projects, project] })), + + removeProject: (projectId) => + set((s) => { + const filtered = s.projects.filter((p) => p.id !== projectId); + if (filtered.length === s.projects.length) return s; + return { projects: filtered }; + }), + + updateProject: (projectId, updates) => + set((s) => { + let found = false; + const newProjects = s.projects.map((p) => { + if (p.id === projectId) { + found = true; + return { ...p, ...updates }; + } + return p; + }); + if (!found) return s; + return { projects: newProjects }; + }), + + setActiveProjectId: (projectId) => set({ activeProjectId: projectId }), +})); + +// ============================================================================ +// Selectors +// ============================================================================ + +export const selectActiveProject = (state: ProjectStore): Project | undefined => + state.projects.find((p) => p.id === state.activeProjectId) || state.projects[0]; + +export const selectAllProjects = (state: ProjectStore): Project[] => state.projects; + +export const selectProjectById = + (id: string) => + (state: ProjectStore): Project | undefined => + state.projects.find((p) => p.id === id); + +// ============================================================================ +// Non-React Access +// ============================================================================ + +export function getProjectState() { + return useProjectStore.getState(); +} + +export function getProjectActions() { + const state = useProjectStore.getState(); + return { + setProjects: state.setProjects, + addProject: state.addProject, + removeProject: state.removeProject, + updateProject: state.updateProject, + setActiveProjectId: state.setActiveProjectId, + }; +} From 5b19b61e24218a320ad27ff2cdc98488b17ed682 Mon Sep 17 00:00:00 2001 From: VibeWriter User Date: Tue, 10 Mar 2026 12:56:23 -0400 Subject: [PATCH 06/20] feat: add inboxStore with dedup, dismissal, selectors, and tests Co-Authored-By: Claude Opus 4.6 --- .../renderer/stores/inboxStore.test.ts | 142 ++++++++++++++++++ src/renderer/stores/inboxStore.ts | 99 ++++++++++++ 2 files changed, 241 insertions(+) create mode 100644 src/__tests__/renderer/stores/inboxStore.test.ts create mode 100644 src/renderer/stores/inboxStore.ts diff --git a/src/__tests__/renderer/stores/inboxStore.test.ts b/src/__tests__/renderer/stores/inboxStore.test.ts new file mode 100644 index 000000000..82e0ff63c --- /dev/null +++ b/src/__tests__/renderer/stores/inboxStore.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + useInboxStore, + selectInboxItems, + selectInboxCount, + selectInboxByProject, + getInboxActions, +} from '../../../renderer/stores/inboxStore'; +import type { InboxItem } from '../../../renderer/types'; + +function createMockInboxItem(overrides: Partial = {}): InboxItem { + return { + id: overrides.id ?? `inbox-${Math.random().toString(36).slice(2, 8)}`, + sessionId: overrides.sessionId ?? 'session-1', + tabId: overrides.tabId ?? 'tab-1', + projectId: overrides.projectId ?? 'project-1', + reason: overrides.reason ?? 'finished', + agentType: overrides.agentType ?? 'claude-code', + tabName: overrides.tabName ?? 'Tab 1', + projectName: overrides.projectName ?? 'Test Project', + timestamp: overrides.timestamp ?? Date.now(), + ...overrides, + }; +} + +describe('inboxStore', () => { + beforeEach(() => { + useInboxStore.setState({ items: [] }); + }); + + describe('addItem', () => { + it('should add an inbox item', () => { + const item = createMockInboxItem({ id: 'i1' }); + useInboxStore.getState().addItem(item); + expect(useInboxStore.getState().items).toHaveLength(1); + }); + + it('should deduplicate by sessionId + reason', () => { + const item1 = createMockInboxItem({ id: 'i1', sessionId: 's1', reason: 'finished' }); + const item2 = createMockInboxItem({ id: 'i2', sessionId: 's1', reason: 'finished' }); + useInboxStore.getState().addItem(item1); + useInboxStore.getState().addItem(item2); + expect(useInboxStore.getState().items).toHaveLength(1); + }); + + it('should allow same session with different reason', () => { + const item1 = createMockInboxItem({ id: 'i1', sessionId: 's1', reason: 'finished' }); + const item2 = createMockInboxItem({ id: 'i2', sessionId: 's1', reason: 'error' }); + useInboxStore.getState().addItem(item1); + useInboxStore.getState().addItem(item2); + expect(useInboxStore.getState().items).toHaveLength(2); + }); + }); + + describe('dismissItem', () => { + it('should remove a specific item by ID', () => { + const item = createMockInboxItem({ id: 'i1' }); + useInboxStore.setState({ items: [item] }); + useInboxStore.getState().dismissItem('i1'); + expect(useInboxStore.getState().items).toHaveLength(0); + }); + + it('should not mutate when dismissing non-existent item', () => { + const item = createMockInboxItem({ id: 'i1' }); + useInboxStore.setState({ items: [item] }); + const before = useInboxStore.getState().items; + useInboxStore.getState().dismissItem('nonexistent'); + expect(useInboxStore.getState().items).toBe(before); + }); + }); + + describe('dismissAllForSession', () => { + it('should remove all items for a session', () => { + const items = [ + createMockInboxItem({ id: 'i1', sessionId: 's1' }), + createMockInboxItem({ id: 'i2', sessionId: 's1', reason: 'error' }), + createMockInboxItem({ id: 'i3', sessionId: 's2' }), + ]; + useInboxStore.setState({ items }); + useInboxStore.getState().dismissAllForSession('s1'); + expect(useInboxStore.getState().items).toHaveLength(1); + expect(useInboxStore.getState().items[0].sessionId).toBe('s2'); + }); + }); + + describe('dismissAllForProject', () => { + it('should remove all items for a project', () => { + const items = [ + createMockInboxItem({ id: 'i1', projectId: 'p1' }), + createMockInboxItem({ id: 'i2', projectId: 'p2' }), + ]; + useInboxStore.setState({ items }); + useInboxStore.getState().dismissAllForProject('p1'); + expect(useInboxStore.getState().items).toHaveLength(1); + expect(useInboxStore.getState().items[0].projectId).toBe('p2'); + }); + }); + + describe('clearAll', () => { + it('should remove all items', () => { + const items = [ + createMockInboxItem({ id: 'i1' }), + createMockInboxItem({ id: 'i2', sessionId: 's2' }), + ]; + useInboxStore.setState({ items }); + useInboxStore.getState().clearAll(); + expect(useInboxStore.getState().items).toHaveLength(0); + }); + }); + + describe('selectors', () => { + it('selectInboxItems returns items sorted newest first', () => { + const items = [ + createMockInboxItem({ id: 'i1', timestamp: 1000 }), + createMockInboxItem({ id: 'i2', sessionId: 's2', timestamp: 2000 }), + ]; + useInboxStore.setState({ items }); + const sorted = selectInboxItems(useInboxStore.getState()); + expect(sorted[0].id).toBe('i2'); + expect(sorted[1].id).toBe('i1'); + }); + + it('selectInboxCount returns item count', () => { + const items = [ + createMockInboxItem({ id: 'i1' }), + createMockInboxItem({ id: 'i2', sessionId: 's2' }), + ]; + useInboxStore.setState({ items }); + expect(selectInboxCount(useInboxStore.getState())).toBe(2); + }); + + it('selectInboxByProject filters by project', () => { + const items = [ + createMockInboxItem({ id: 'i1', projectId: 'p1' }), + createMockInboxItem({ id: 'i2', projectId: 'p2', sessionId: 's2' }), + ]; + useInboxStore.setState({ items }); + const selector = selectInboxByProject('p1'); + expect(selector(useInboxStore.getState())).toHaveLength(1); + }); + }); +}); diff --git a/src/renderer/stores/inboxStore.ts b/src/renderer/stores/inboxStore.ts new file mode 100644 index 000000000..65df7db16 --- /dev/null +++ b/src/renderer/stores/inboxStore.ts @@ -0,0 +1,99 @@ +/** + * inboxStore - Zustand store for attention inbox management + * + * Tracks sessions that need user attention (finished, errored, waiting for input). + * Runtime-only — not persisted to disk. + */ + +import { create } from 'zustand'; +import type { InboxItem } from '../types'; + +// ============================================================================ +// Store Types +// ============================================================================ + +export interface InboxStoreState { + items: InboxItem[]; +} + +export interface InboxStoreActions { + addItem: (item: InboxItem) => void; + dismissItem: (itemId: string) => void; + dismissAllForSession: (sessionId: string) => void; + dismissAllForProject: (projectId: string) => void; + clearAll: () => void; +} + +export type InboxStore = InboxStoreState & InboxStoreActions; + +// ============================================================================ +// Store Implementation +// ============================================================================ + +export const useInboxStore = create()((set) => ({ + items: [], + + addItem: (item) => + set((s) => { + // Deduplicate: don't add if same session+reason already exists + const exists = s.items.some( + (existing) => existing.sessionId === item.sessionId && existing.reason === item.reason + ); + if (exists) return s; + return { items: [...s.items, item] }; + }), + + dismissItem: (itemId) => + set((s) => { + const filtered = s.items.filter((item) => item.id !== itemId); + if (filtered.length === s.items.length) return s; + return { items: filtered }; + }), + + dismissAllForSession: (sessionId) => + set((s) => { + const filtered = s.items.filter((item) => item.sessionId !== sessionId); + if (filtered.length === s.items.length) return s; + return { items: filtered }; + }), + + dismissAllForProject: (projectId) => + set((s) => { + const filtered = s.items.filter((item) => item.projectId !== projectId); + if (filtered.length === s.items.length) return s; + return { items: filtered }; + }), + + clearAll: () => set({ items: [] }), +})); + +// ============================================================================ +// Selectors +// ============================================================================ + +export const selectInboxItems = (state: InboxStore): InboxItem[] => + [...state.items].sort((a, b) => b.timestamp - a.timestamp); + +export const selectInboxCount = (state: InboxStore): number => state.items.length; + +export const selectInboxByProject = + (projectId: string) => + (state: InboxStore): InboxItem[] => + state.items + .filter((item) => item.projectId === projectId) + .sort((a, b) => b.timestamp - a.timestamp); + +// ============================================================================ +// Non-React Access +// ============================================================================ + +export function getInboxActions() { + const state = useInboxStore.getState(); + return { + addItem: state.addItem, + dismissItem: state.dismissItem, + dismissAllForSession: state.dismissAllForSession, + dismissAllForProject: state.dismissAllForProject, + clearAll: state.clearAll, + }; +} From ab1362d9e9876bc4bf64176f9e64ee3551c46089 Mon Sep 17 00:00:00 2001 From: VibeWriter User Date: Tue, 10 Mar 2026 12:58:48 -0400 Subject: [PATCH 07/20] feat: add selectSessionsByProject selector to sessionStore Co-Authored-By: Claude Opus 4.6 --- .../renderer/stores/sessionStore.test.ts | 22 +++++++++++++++++++ src/renderer/stores/sessionStore.ts | 11 ++++++++++ 2 files changed, 33 insertions(+) diff --git a/src/__tests__/renderer/stores/sessionStore.test.ts b/src/__tests__/renderer/stores/sessionStore.test.ts index d647774be..a1e586b47 100644 --- a/src/__tests__/renderer/stores/sessionStore.test.ts +++ b/src/__tests__/renderer/stores/sessionStore.test.ts @@ -7,6 +7,7 @@ import { selectBookmarkedSessions, selectSessionsByGroup, selectUngroupedSessions, + selectSessionsByProject, selectGroupById, selectSessionCount, selectIsReady, @@ -563,6 +564,27 @@ describe('sessionStore', () => { }); }); + describe('selectSessionsByProject', () => { + it('should return sessions matching projectId', () => { + const sessions = [ + createMockSession({ id: 's1', projectId: 'p1' }), + createMockSession({ id: 's2', projectId: 'p2' }), + createMockSession({ id: 's3', projectId: 'p1' }), + ]; + useSessionStore.setState({ sessions }); + const result = selectSessionsByProject('p1')(useSessionStore.getState()); + expect(result).toHaveLength(2); + expect(result.map((s) => s.id)).toEqual(['s1', 's3']); + }); + + it('should return empty array for unknown projectId', () => { + const sessions = [createMockSession({ id: 's1', projectId: 'p1' })]; + useSessionStore.setState({ sessions }); + const result = selectSessionsByProject('nonexistent')(useSessionStore.getState()); + expect(result).toHaveLength(0); + }); + }); + describe('selectGroupById', () => { it('returns the group with the given ID', () => { useSessionStore.getState().setGroups([createMockGroup({ id: 'g1', name: 'Group One' })]); diff --git a/src/renderer/stores/sessionStore.ts b/src/renderer/stores/sessionStore.ts index 5be7bcdfe..a00d5b76d 100644 --- a/src/renderer/stores/sessionStore.ts +++ b/src/renderer/stores/sessionStore.ts @@ -359,6 +359,17 @@ export const selectSessionsByGroup = export const selectUngroupedSessions = (state: SessionStore): Session[] => state.sessions.filter((s) => !s.groupId && !s.parentSessionId); +/** + * Select sessions belonging to a specific project. + * + * @example + * const projectSessions = useSessionStore(selectSessionsByProject('project-1')); + */ +export const selectSessionsByProject = + (projectId: string) => + (state: SessionStore): Session[] => + state.sessions.filter((s) => s.projectId === projectId); + /** * Select a group by ID. * From 6b3043e0f1641f7af51d6b3ef25904d80467abb6 Mon Sep 17 00:00:00 2001 From: VibeWriter User Date: Tue, 10 Mar 2026 13:01:21 -0400 Subject: [PATCH 08/20] feat: add projects electron-store and IPC persistence handlers Co-Authored-By: Claude Opus 4.6 --- src/main/index.ts | 3 +++ src/main/ipc/handlers/persistence.ts | 25 +++++++++++++++++++++---- src/main/stores/defaults.ts | 5 +++++ src/main/stores/getters.ts | 6 ++++++ src/main/stores/index.ts | 2 ++ src/main/stores/instances.ts | 10 ++++++++++ 6 files changed, 47 insertions(+), 4 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 577f81530..f6ba7d661 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -18,6 +18,7 @@ import { getSettingsStore, getSessionsStore, getGroupsStore, + getProjectsStore, getAgentConfigsStore, getWindowStateStore, getClaudeSessionOriginsStore, @@ -230,6 +231,7 @@ if (crashReportingEnabled && !isDevelopment) { // These are convenience variables - the actual stores are managed by ./stores module const sessionsStore = getSessionsStore(); const groupsStore = getGroupsStore(); +const projectsStore = getProjectsStore(); const agentConfigsStore = getAgentConfigsStore(); const windowStateStore = getWindowStateStore(); const claudeSessionOriginsStore = getClaudeSessionOriginsStore(); @@ -507,6 +509,7 @@ function setupIpcHandlers() { settingsStore: store, sessionsStore, groupsStore, + projectsStore, getWebServer: () => webServer, }); diff --git a/src/main/ipc/handlers/persistence.ts b/src/main/ipc/handlers/persistence.ts index 4e86fe3fc..8feb14fda 100644 --- a/src/main/ipc/handlers/persistence.ts +++ b/src/main/ipc/handlers/persistence.ts @@ -19,9 +19,9 @@ import { getThemeById } from '../../themes'; import { WebServer } from '../../web-server'; // Re-export types from canonical source so existing imports from './persistence' still work -export type { MaestroSettings, SessionsData, GroupsData } from '../../stores/types'; -import type { MaestroSettings, SessionsData, GroupsData, StoredSession } from '../../stores/types'; -import type { Group } from '../../../shared/types'; +export type { MaestroSettings, SessionsData, GroupsData, ProjectsData } from '../../stores/types'; +import type { MaestroSettings, SessionsData, GroupsData, ProjectsData, StoredSession } from '../../stores/types'; +import type { Group, Project } from '../../../shared/types'; /** * Dependencies required for persistence handlers @@ -30,6 +30,7 @@ export interface PersistenceHandlerDependencies { settingsStore: Store; sessionsStore: Store; groupsStore: Store; + projectsStore: Store; getWebServer: () => WebServer | null; } @@ -37,7 +38,7 @@ export interface PersistenceHandlerDependencies { * Register all persistence-related IPC handlers. */ export function registerPersistenceHandlers(deps: PersistenceHandlerDependencies): void { - const { settingsStore, sessionsStore, groupsStore, getWebServer } = deps; + const { settingsStore, sessionsStore, groupsStore, projectsStore, getWebServer } = deps; // Settings management ipcMain.handle('settings:get', async (_, key: string) => { @@ -204,6 +205,22 @@ export function registerPersistenceHandlers(deps: PersistenceHandlerDependencies return true; }); + // Projects persistence + ipcMain.handle('projects:getAll', async () => { + return projectsStore.get('projects', []); + }); + + ipcMain.handle('projects:setAll', async (_, projects: Project[]) => { + try { + projectsStore.set('projects', projects); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + logger.warn(`Failed to persist projects: ${code || (err as Error).message}`, 'Projects'); + return false; + } + return true; + }); + // CLI activity (for detecting when CLI is running playbooks) ipcMain.handle('cli:getActivity', async () => { try { diff --git a/src/main/stores/defaults.ts b/src/main/stores/defaults.ts index 17c228215..aec03fd28 100644 --- a/src/main/stores/defaults.ts +++ b/src/main/stores/defaults.ts @@ -12,6 +12,7 @@ import type { MaestroSettings, SessionsData, GroupsData, + ProjectsData, AgentConfigsData, WindowState, ClaudeSessionOriginsData, @@ -81,6 +82,10 @@ export const GROUPS_DEFAULTS: GroupsData = { groups: [], }; +export const PROJECTS_DEFAULTS: ProjectsData = { + projects: [], +}; + export const AGENT_CONFIGS_DEFAULTS: AgentConfigsData = { configs: {}, }; diff --git a/src/main/stores/getters.ts b/src/main/stores/getters.ts index 84be70710..f497a54a2 100644 --- a/src/main/stores/getters.ts +++ b/src/main/stores/getters.ts @@ -12,6 +12,7 @@ import type { MaestroSettings, SessionsData, GroupsData, + ProjectsData, AgentConfigsData, WindowState, ClaudeSessionOriginsData, @@ -58,6 +59,11 @@ export function getGroupsStore(): Store { return getStoreInstances().groupsStore!; } +export function getProjectsStore(): Store { + ensureInitialized(); + return getStoreInstances().projectsStore!; +} + export function getAgentConfigsStore(): Store { ensureInitialized(); return getStoreInstances().agentConfigsStore!; diff --git a/src/main/stores/index.ts b/src/main/stores/index.ts index f47486596..100e79bc8 100644 --- a/src/main/stores/index.ts +++ b/src/main/stores/index.ts @@ -42,6 +42,7 @@ export { getSettingsStore, getSessionsStore, getGroupsStore, + getProjectsStore, getAgentConfigsStore, getWindowStateStore, getClaudeSessionOriginsStore, @@ -65,6 +66,7 @@ export { SETTINGS_DEFAULTS, SESSIONS_DEFAULTS, GROUPS_DEFAULTS, + PROJECTS_DEFAULTS, AGENT_CONFIGS_DEFAULTS, WINDOW_STATE_DEFAULTS, CLAUDE_SESSION_ORIGINS_DEFAULTS, diff --git a/src/main/stores/instances.ts b/src/main/stores/instances.ts index a38950799..84c024bfa 100644 --- a/src/main/stores/instances.ts +++ b/src/main/stores/instances.ts @@ -18,6 +18,7 @@ import type { MaestroSettings, SessionsData, GroupsData, + ProjectsData, AgentConfigsData, WindowState, ClaudeSessionOriginsData, @@ -28,6 +29,7 @@ import { SETTINGS_DEFAULTS, SESSIONS_DEFAULTS, GROUPS_DEFAULTS, + PROJECTS_DEFAULTS, AGENT_CONFIGS_DEFAULTS, WINDOW_STATE_DEFAULTS, CLAUDE_SESSION_ORIGINS_DEFAULTS, @@ -44,6 +46,7 @@ let _bootstrapStore: Store | null = null; let _settingsStore: Store | null = null; let _sessionsStore: Store | null = null; let _groupsStore: Store | null = null; +let _projectsStore: Store | null = null; let _agentConfigsStore: Store | null = null; let _windowStateStore: Store | null = null; let _claudeSessionOriginsStore: Store | null = null; @@ -109,6 +112,12 @@ export function initializeStores(options: StoreInitOptions): { defaults: GROUPS_DEFAULTS, }); + _projectsStore = new Store({ + name: 'maestro-projects', + cwd: _syncPath, + defaults: PROJECTS_DEFAULTS, + }); + // Agent configs are ALWAYS stored in the production path, even in dev mode // This ensures agent paths, custom args, and env vars are shared between dev and prod _agentConfigsStore = new Store({ @@ -159,6 +168,7 @@ export function getStoreInstances() { settingsStore: _settingsStore, sessionsStore: _sessionsStore, groupsStore: _groupsStore, + projectsStore: _projectsStore, agentConfigsStore: _agentConfigsStore, windowStateStore: _windowStateStore, claudeSessionOriginsStore: _claudeSessionOriginsStore, From 5d2d86b1f94bf30393858a6d5a617c24015b831f Mon Sep 17 00:00:00 2001 From: VibeWriter User Date: Tue, 10 Mar 2026 13:02:14 -0400 Subject: [PATCH 09/20] feat: expose window.maestro.projects IPC bridge Co-Authored-By: Claude Opus 4.6 --- src/main/preload/index.ts | 6 ++++++ src/main/preload/settings.ts | 11 +++++++++++ 2 files changed, 17 insertions(+) diff --git a/src/main/preload/index.ts b/src/main/preload/index.ts index e91a1f1f8..428c6b23c 100644 --- a/src/main/preload/index.ts +++ b/src/main/preload/index.ts @@ -14,6 +14,7 @@ import { createSettingsApi, createSessionsApi, createGroupsApi, + createProjectsApi, createAgentErrorApi, } from './settings'; import { createContextApi } from './context'; @@ -63,6 +64,9 @@ contextBridge.exposeInMainWorld('maestro', { // Groups persistence API groups: createGroupsApi(), + // Projects persistence API + projects: createProjectsApi(), + // Process/Session API process: createProcessApi(), @@ -199,6 +203,7 @@ export { createSettingsApi, createSessionsApi, createGroupsApi, + createProjectsApi, createAgentErrorApi, // Context createContextApi, @@ -272,6 +277,7 @@ export type { SettingsApi, SessionsApi, GroupsApi, + ProjectsApi, AgentErrorApi, } from './settings'; export type { diff --git a/src/main/preload/settings.ts b/src/main/preload/settings.ts index 717cf9a4b..3a1644c9f 100644 --- a/src/main/preload/settings.ts +++ b/src/main/preload/settings.ts @@ -49,6 +49,16 @@ export function createGroupsApi() { }; } +/** + * Creates the projects persistence API object for preload exposure + */ +export function createProjectsApi() { + return { + getAll: () => ipcRenderer.invoke('projects:getAll'), + setAll: (projects: any[]) => ipcRenderer.invoke('projects:setAll', projects), + }; +} + /** * Creates the agent error handling API object for preload exposure */ @@ -68,4 +78,5 @@ export function createAgentErrorApi() { export type SettingsApi = ReturnType; export type SessionsApi = ReturnType; export type GroupsApi = ReturnType; +export type ProjectsApi = ReturnType; export type AgentErrorApi = ReturnType; From 3418facfdb9325de40ce1e05229883984effa48e Mon Sep 17 00:00:00 2001 From: VibeWriter User Date: Tue, 10 Mar 2026 13:09:46 -0400 Subject: [PATCH 10/20] feat: add useProjectRestoration with group-to-project migration Co-Authored-By: Claude Opus 4.6 --- .../hooks/useProjectRestoration.test.ts | 699 ++++++++++++++++++ .../hooks/project/useProjectRestoration.ts | 178 +++++ 2 files changed, 877 insertions(+) create mode 100644 src/__tests__/renderer/hooks/useProjectRestoration.test.ts create mode 100644 src/renderer/hooks/project/useProjectRestoration.ts diff --git a/src/__tests__/renderer/hooks/useProjectRestoration.test.ts b/src/__tests__/renderer/hooks/useProjectRestoration.test.ts new file mode 100644 index 000000000..d3790b6ec --- /dev/null +++ b/src/__tests__/renderer/hooks/useProjectRestoration.test.ts @@ -0,0 +1,699 @@ +/** + * Tests for useProjectRestoration hook + * + * Tests project loading from disk, group-to-project migration, + * active project selection, and debounced persistence. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act, waitFor } from '@testing-library/react'; + +// Mock generateId to produce deterministic IDs +let idCounter = 0; +vi.mock('../../../renderer/utils/ids', () => ({ + generateId: vi.fn(() => `mock-id-${++idCounter}`), +})); + +import { useProjectRestoration } from '../../../renderer/hooks/project/useProjectRestoration'; +import { useProjectStore } from '../../../renderer/stores/projectStore'; +import { useSessionStore } from '../../../renderer/stores/sessionStore'; +import type { Project } from '../../../shared/types'; +import type { Session } from '../../../renderer/types'; + +// ============================================================================ +// Mock Setup +// ============================================================================ + +const mockProjectsApi = { + getAll: vi.fn().mockResolvedValue([]), + setAll: vi.fn().mockResolvedValue(true), +}; + +const mockGroupsApi = { + getAll: vi.fn().mockResolvedValue([]), +}; + +const mockSettingsApi = { + get: vi.fn().mockResolvedValue(null), + set: vi.fn().mockResolvedValue(true), +}; + +// ============================================================================ +// Test Helpers +// ============================================================================ + +function createMockSession(overrides: Partial = {}): Session { + return { + id: 'session-1', + name: 'Test Agent', + cwd: '/projects/myapp', + fullPath: '/projects/myapp', + projectRoot: '/projects/myapp', + toolType: 'claude-code' as any, + inputMode: 'ai' as any, + state: 'idle' as any, + aiTabs: [ + { + id: 'tab-1', + agentSessionId: null, + name: null, + state: 'idle' as const, + logs: [], + starred: false, + inputValue: '', + stagedImages: [], + createdAt: Date.now(), + }, + ], + activeTabId: 'tab-1', + aiLogs: [], + shellLogs: [], + workLog: [], + contextUsage: 0, + aiPid: 0, + terminalPid: 0, + port: 0, + isLive: false, + changedFiles: [], + isGitRepo: false, + fileTree: [], + fileExplorerExpanded: [], + fileExplorerScrollPos: 0, + executionQueue: [], + activeTimeMs: 0, + filePreviewTabs: [], + activeFileTabId: null, + unifiedTabOrder: [{ type: 'ai' as const, id: 'tab-1' }], + unifiedClosedTabHistory: [], + closedTabHistory: [], + ...overrides, + } as Session; +} + +function createMockProject(overrides: Partial = {}): Project { + return { + id: 'project-1', + name: 'My App', + repoPath: '/projects/myapp', + createdAt: Date.now(), + ...overrides, + }; +} + +// ============================================================================ +// Test Suite +// ============================================================================ + +describe('useProjectRestoration', () => { + beforeEach(() => { + vi.useFakeTimers(); + idCounter = 0; + + // Reset stores to clean state + useProjectStore.setState({ projects: [], activeProjectId: '' }); + useSessionStore.setState({ + sessions: [], + initialLoadComplete: false, + activeSessionId: '', + }); + + // Setup window.maestro mocks + (window as any).maestro = { + ...(window as any).maestro, + projects: mockProjectsApi, + groups: mockGroupsApi, + settings: mockSettingsApi, + }; + + // Reset all mocks + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + // ======================================================================== + // 1. Does not run before initialLoadComplete + // ======================================================================== + describe('startup guard', () => { + it('does not run before initialLoadComplete is true', async () => { + // initialLoadComplete is false by default from beforeEach + renderHook(() => useProjectRestoration()); + + // Flush microtasks + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + // Should NOT have called any IPC methods yet + expect(mockProjectsApi.getAll).not.toHaveBeenCalled(); + expect(mockGroupsApi.getAll).not.toHaveBeenCalled(); + expect(mockSettingsApi.get).not.toHaveBeenCalled(); + }); + }); + + // ======================================================================== + // 2. Loads existing projects from disk + // ======================================================================== + describe('loading saved projects', () => { + it('loads existing projects from disk and sets them in the store', async () => { + const savedProjects = [ + createMockProject({ id: 'p1', name: 'Project One' }), + createMockProject({ id: 'p2', name: 'Project Two' }), + ]; + mockProjectsApi.getAll.mockResolvedValue(savedProjects); + + useSessionStore.setState({ + initialLoadComplete: true, + sessions: [createMockSession({ id: 's1', projectId: 'p1' })], + activeSessionId: 's1', + }); + + renderHook(() => useProjectRestoration()); + + // Flush the async loadProjects call + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + expect(mockProjectsApi.getAll).toHaveBeenCalledOnce(); + expect(useProjectStore.getState().projects).toEqual(savedProjects); + }); + }); + + // ======================================================================== + // 3. Sets active project from active session + // ======================================================================== + describe('active project selection', () => { + it('sets active project from active session projectId', async () => { + const savedProjects = [ + createMockProject({ id: 'p1', name: 'Project One' }), + createMockProject({ id: 'p2', name: 'Project Two' }), + ]; + mockProjectsApi.getAll.mockResolvedValue(savedProjects); + + useSessionStore.setState({ + initialLoadComplete: true, + sessions: [ + createMockSession({ id: 's1', projectId: 'p2' }), + createMockSession({ id: 's2', projectId: 'p1' }), + ], + activeSessionId: 's1', + }); + + renderHook(() => useProjectRestoration()); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + // Active session s1 has projectId 'p2', so active project should be p2 + expect(useProjectStore.getState().activeProjectId).toBe('p2'); + }); + + // ==================================================================== + // 4. Falls back to first project if active session has no projectId + // ==================================================================== + it('falls back to first project if active session has no projectId', async () => { + const savedProjects = [ + createMockProject({ id: 'p1', name: 'Project One' }), + createMockProject({ id: 'p2', name: 'Project Two' }), + ]; + mockProjectsApi.getAll.mockResolvedValue(savedProjects); + + useSessionStore.setState({ + initialLoadComplete: true, + sessions: [createMockSession({ id: 's1' })], // no projectId + activeSessionId: 's1', + }); + + renderHook(() => useProjectRestoration()); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + // Should fall back to first project + expect(useProjectStore.getState().activeProjectId).toBe('p1'); + }); + }); + + // ======================================================================== + // 5. Runs migration when no projects saved + // ======================================================================== + describe('group-to-project migration', () => { + it('migrates groups to projects when no saved projects exist', async () => { + mockProjectsApi.getAll.mockResolvedValue([]); + mockSettingsApi.get.mockResolvedValue(null); // migration not done + mockGroupsApi.getAll.mockResolvedValue([ + { id: 'g1', name: 'Frontend', emoji: '', collapsed: false }, + { id: 'g2', name: 'Backend', emoji: '', collapsed: false }, + ]); + + const sessions = [ + createMockSession({ + id: 's1', + groupId: 'g1', + projectRoot: '/projects/frontend', + cwd: '/projects/frontend', + }), + createMockSession({ + id: 's2', + groupId: 'g2', + projectRoot: '/projects/backend', + cwd: '/projects/backend', + }), + ]; + useSessionStore.setState({ + initialLoadComplete: true, + sessions, + activeSessionId: 's1', + }); + + renderHook(() => useProjectRestoration()); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + const storeProjects = useProjectStore.getState().projects; + expect(storeProjects).toHaveLength(2); + expect(storeProjects[0].name).toBe('Frontend'); + expect(storeProjects[0].repoPath).toBe('/projects/frontend'); + expect(storeProjects[1].name).toBe('Backend'); + expect(storeProjects[1].repoPath).toBe('/projects/backend'); + + // Sessions should have been updated with projectIds + const updatedSessions = useSessionStore.getState().sessions; + expect(updatedSessions[0].projectId).toBe(storeProjects[0].id); + expect(updatedSessions[1].projectId).toBe(storeProjects[1].id); + + // Projects should have been persisted + expect(mockProjectsApi.setAll).toHaveBeenCalledWith(storeProjects); + }); + + // ==================================================================== + // 6. Groups ungrouped sessions by projectRoot + // ==================================================================== + it('groups ungrouped sessions by projectRoot into auto-created projects', async () => { + mockProjectsApi.getAll.mockResolvedValue([]); + mockSettingsApi.get.mockResolvedValue(null); + mockGroupsApi.getAll.mockResolvedValue([]); // no groups + + const sessions = [ + createMockSession({ + id: 's1', + projectRoot: '/projects/alpha', + cwd: '/projects/alpha', + }), + createMockSession({ + id: 's2', + projectRoot: '/projects/alpha', + cwd: '/projects/alpha', + }), + createMockSession({ + id: 's3', + projectRoot: '/projects/beta', + cwd: '/projects/beta', + }), + ]; + useSessionStore.setState({ + initialLoadComplete: true, + sessions, + activeSessionId: 's1', + }); + + renderHook(() => useProjectRestoration()); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + const storeProjects = useProjectStore.getState().projects; + expect(storeProjects).toHaveLength(2); + + // Folder name is last segment of the path + const projectNames = storeProjects.map((p) => p.name).sort(); + expect(projectNames).toEqual(['alpha', 'beta']); + + // s1 and s2 share the same projectRoot, so they should have the same projectId + const updatedSessions = useSessionStore.getState().sessions; + expect(updatedSessions[0].projectId).toBe(updatedSessions[1].projectId); + // s3 has a different root, different project + expect(updatedSessions[2].projectId).not.toBe(updatedSessions[0].projectId); + }); + + // ==================================================================== + // 7. Marks migration complete + // ==================================================================== + it('marks migration complete by writing settings flag', async () => { + mockProjectsApi.getAll.mockResolvedValue([]); + mockSettingsApi.get.mockResolvedValue(null); + mockGroupsApi.getAll.mockResolvedValue([]); + + useSessionStore.setState({ + initialLoadComplete: true, + sessions: [createMockSession({ id: 's1' })], + activeSessionId: 's1', + }); + + renderHook(() => useProjectRestoration()); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + expect(mockSettingsApi.set).toHaveBeenCalledWith('projectMigrationComplete', true); + }); + + // ==================================================================== + // 8. Skips migration if already migrated + // ==================================================================== + it('skips migration if already migrated', async () => { + mockProjectsApi.getAll.mockResolvedValue([]); + mockSettingsApi.get.mockResolvedValue(true); // migration already done + + useSessionStore.setState({ + initialLoadComplete: true, + sessions: [createMockSession({ id: 's1' })], + activeSessionId: 's1', + }); + + renderHook(() => useProjectRestoration()); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + // groups.getAll is called to check for groups, but migration + // short-circuits because settings returns true + expect(mockGroupsApi.getAll).not.toHaveBeenCalled(); + + // No projects should have been set (migration returned null) + expect(useProjectStore.getState().projects).toEqual([]); + }); + }); + + // ======================================================================== + // 9. Handles errors gracefully + // ======================================================================== + describe('error handling', () => { + it('handles errors gracefully without crashing', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + mockProjectsApi.getAll.mockRejectedValue(new Error('Disk read failed')); + + useSessionStore.setState({ + initialLoadComplete: true, + sessions: [], + activeSessionId: '', + }); + + // Should not throw + renderHook(() => useProjectRestoration()); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + expect(consoleSpy).toHaveBeenCalledWith( + '[useProjectRestoration] Failed to load/migrate projects:', + expect.any(Error) + ); + + consoleSpy.mockRestore(); + }); + }); + + // ======================================================================== + // 10. Debounced persistence writes projects on change + // ======================================================================== + describe('debounced persistence', () => { + it('persists projects to disk after debounce delay when projects change', async () => { + // Start with initialLoadComplete true and saved projects + const savedProjects = [createMockProject({ id: 'p1', name: 'Existing' })]; + mockProjectsApi.getAll.mockResolvedValue(savedProjects); + + useSessionStore.setState({ + initialLoadComplete: true, + sessions: [createMockSession({ id: 's1', projectId: 'p1' })], + activeSessionId: 's1', + }); + + renderHook(() => useProjectRestoration()); + + // Let the initial load complete + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + // Clear the calls from initial load + mockProjectsApi.setAll.mockClear(); + + // Simulate a project change by updating the store directly + const updatedProjects = [ + createMockProject({ id: 'p1', name: 'Updated Name' }), + ]; + act(() => { + useProjectStore.getState().setProjects(updatedProjects); + }); + + // Before debounce fires, setAll should not have been called + expect(mockProjectsApi.setAll).not.toHaveBeenCalled(); + + // Advance past the 2000ms debounce + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + + // Now it should have persisted + expect(mockProjectsApi.setAll).toHaveBeenCalledWith(updatedProjects); + }); + + it('debounces rapid project changes into a single write', async () => { + const savedProjects = [createMockProject({ id: 'p1', name: 'Existing' })]; + mockProjectsApi.getAll.mockResolvedValue(savedProjects); + + useSessionStore.setState({ + initialLoadComplete: true, + sessions: [createMockSession({ id: 's1', projectId: 'p1' })], + activeSessionId: 's1', + }); + + renderHook(() => useProjectRestoration()); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + mockProjectsApi.setAll.mockClear(); + + // Rapid changes + act(() => { + useProjectStore.getState().setProjects([ + createMockProject({ id: 'p1', name: 'Change 1' }), + ]); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(500); + }); + + act(() => { + useProjectStore.getState().setProjects([ + createMockProject({ id: 'p1', name: 'Change 2' }), + ]); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(500); + }); + + act(() => { + useProjectStore.getState().setProjects([ + createMockProject({ id: 'p1', name: 'Final Change' }), + ]); + }); + + // Still within debounce window for the last change + expect(mockProjectsApi.setAll).not.toHaveBeenCalled(); + + // Advance past the debounce from the last change + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + + // Should only write the final state + expect(mockProjectsApi.setAll).toHaveBeenCalledTimes(1); + expect(mockProjectsApi.setAll).toHaveBeenCalledWith([ + expect.objectContaining({ name: 'Final Change' }), + ]); + }); + + it('does not persist when initialLoadComplete is false', async () => { + // Start with initialLoadComplete false + useSessionStore.setState({ + initialLoadComplete: false, + sessions: [], + activeSessionId: '', + }); + + renderHook(() => useProjectRestoration()); + + // Set some projects directly in the store (simulating external change) + act(() => { + useProjectStore.getState().setProjects([ + createMockProject({ id: 'p1', name: 'Test' }), + ]); + }); + + // Advance past debounce + await act(async () => { + await vi.advanceTimersByTimeAsync(3000); + }); + + // setAll should NOT have been called for persistence + // (it may have been called 0 times since initialLoadComplete is false) + expect(mockProjectsApi.setAll).not.toHaveBeenCalled(); + }); + }); + + // ======================================================================== + // Additional edge cases + // ======================================================================== + describe('edge cases', () => { + it('does not run restoration twice in React strict mode', async () => { + const savedProjects = [createMockProject({ id: 'p1' })]; + mockProjectsApi.getAll.mockResolvedValue(savedProjects); + + useSessionStore.setState({ + initialLoadComplete: true, + sessions: [], + activeSessionId: '', + }); + + const { rerender } = renderHook(() => useProjectRestoration()); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + // Rerender to simulate strict mode double-render + rerender(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + // getAll should have been called only once + expect(mockProjectsApi.getAll).toHaveBeenCalledTimes(1); + }); + + it('skips empty groups during migration', async () => { + mockProjectsApi.getAll.mockResolvedValue([]); + mockSettingsApi.get.mockResolvedValue(null); + mockGroupsApi.getAll.mockResolvedValue([ + { id: 'g1', name: 'Empty Group', emoji: '', collapsed: false }, + { id: 'g2', name: 'Has Sessions', emoji: '', collapsed: false }, + ]); + + const sessions = [ + createMockSession({ + id: 's1', + groupId: 'g2', // only in g2 + projectRoot: '/projects/app', + cwd: '/projects/app', + }), + ]; + useSessionStore.setState({ + initialLoadComplete: true, + sessions, + activeSessionId: 's1', + }); + + renderHook(() => useProjectRestoration()); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + const storeProjects = useProjectStore.getState().projects; + // Only g2 should produce a project (g1 has no sessions) + expect(storeProjects).toHaveLength(1); + expect(storeProjects[0].name).toBe('Has Sessions'); + }); + + it('sets active project after migration based on active session', async () => { + mockProjectsApi.getAll.mockResolvedValue([]); + mockSettingsApi.get.mockResolvedValue(null); + mockGroupsApi.getAll.mockResolvedValue([ + { id: 'g1', name: 'ProjectA', emoji: '', collapsed: false }, + { id: 'g2', name: 'ProjectB', emoji: '', collapsed: false }, + ]); + + const sessions = [ + createMockSession({ + id: 's1', + groupId: 'g1', + projectRoot: '/projects/a', + cwd: '/projects/a', + }), + createMockSession({ + id: 's2', + groupId: 'g2', + projectRoot: '/projects/b', + cwd: '/projects/b', + }), + ]; + useSessionStore.setState({ + initialLoadComplete: true, + sessions, + activeSessionId: 's2', // active session is in g2 + }); + + renderHook(() => useProjectRestoration()); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + const storeProjects = useProjectStore.getState().projects; + const projectB = storeProjects.find((p) => p.name === 'ProjectB'); + expect(projectB).toBeDefined(); + + // Active project should be the one containing s2 + expect(useProjectStore.getState().activeProjectId).toBe(projectB!.id); + }); + + it('uses cwd as fallback when projectRoot is not set', async () => { + mockProjectsApi.getAll.mockResolvedValue([]); + mockSettingsApi.get.mockResolvedValue(null); + mockGroupsApi.getAll.mockResolvedValue([]); + + const sessions = [ + createMockSession({ + id: 's1', + projectRoot: '', // empty projectRoot + cwd: '/home/user/workspace', + }), + ]; + useSessionStore.setState({ + initialLoadComplete: true, + sessions, + activeSessionId: 's1', + }); + + renderHook(() => useProjectRestoration()); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + const storeProjects = useProjectStore.getState().projects; + expect(storeProjects).toHaveLength(1); + // Should use cwd since projectRoot is empty + expect(storeProjects[0].repoPath).toBe('/home/user/workspace'); + expect(storeProjects[0].name).toBe('workspace'); + }); + }); +}); diff --git a/src/renderer/hooks/project/useProjectRestoration.ts b/src/renderer/hooks/project/useProjectRestoration.ts new file mode 100644 index 000000000..830f70e2d --- /dev/null +++ b/src/renderer/hooks/project/useProjectRestoration.ts @@ -0,0 +1,178 @@ +/** + * useProjectRestoration - Loads projects from disk on startup and runs migration. + * + * Migration: Converts existing groups -> projects on first run. + * After migration, groups store is left inert (not read again for project purposes). + * + * Effects: + * - Project loading/migration after sessions are loaded (with React Strict Mode guard) + * - Debounced persistence of project store changes to disk + */ + +import { useEffect, useRef, useMemo } from 'react'; +import { useProjectStore } from '../../stores/projectStore'; +import { useSessionStore } from '../../stores/sessionStore'; +import { generateId } from '../../utils/ids'; +import type { Project } from '../../../shared/types'; +import type { Session } from '../../types'; + +const MIGRATION_KEY = 'projectMigrationComplete'; +const PERSISTENCE_DEBOUNCE_MS = 2000; + +// ============================================================================ +// Migration Logic +// ============================================================================ + +/** + * Migrate groups -> projects. Runs once on first launch after upgrade. + * + * 1. Each group with sessions becomes a project (name from group, repoPath from first session). + * 2. Ungrouped sessions are grouped by projectRoot/cwd into auto-created projects. + * 3. All affected sessions get a projectId assignment. + * 4. Migration flag is written so it never runs again. + * + * Returns null if migration was already completed. + */ +async function migrateGroupsToProjects(): Promise<{ + projects: Project[]; + updatedSessions: Session[]; +} | null> { + const migrated = await window.maestro.settings.get(MIGRATION_KEY); + if (migrated) return null; + + const groups = await window.maestro.groups.getAll(); + const sessions = useSessionStore.getState().sessions; + + const projects: Project[] = []; + const sessionUpdates = new Map(); // sessionId -> projectId + + // 1. Convert groups -> projects + if (groups && groups.length > 0) { + for (const group of groups) { + const groupSessions = sessions.filter((s) => s.groupId === group.id); + if (groupSessions.length === 0) continue; + + const project: Project = { + id: generateId(), + name: group.name || 'Unnamed Project', + repoPath: groupSessions[0].projectRoot || groupSessions[0].cwd, + createdAt: Date.now(), + }; + projects.push(project); + + for (const session of groupSessions) { + sessionUpdates.set(session.id, project.id); + } + } + } + + // 2. Handle ungrouped sessions -- group by projectRoot/cwd + const ungrouped = sessions.filter((s) => !s.groupId && !sessionUpdates.has(s.id)); + const byRoot = new Map(); + for (const session of ungrouped) { + const root = session.projectRoot || session.cwd; + if (!byRoot.has(root)) byRoot.set(root, []); + byRoot.get(root)!.push(session); + } + + for (const [root, rootSessions] of byRoot) { + const folderName = root.split(/[\\/]/).filter(Boolean).pop() || 'Default'; + const project: Project = { + id: generateId(), + name: folderName, + repoPath: root, + createdAt: Date.now(), + }; + projects.push(project); + for (const session of rootSessions) { + sessionUpdates.set(session.id, project.id); + } + } + + // 3. Apply projectId to sessions + const updatedSessions = sessions.map((s) => ({ + ...s, + projectId: sessionUpdates.get(s.id) || s.projectId, + })); + + // 4. Mark migration complete + await window.maestro.settings.set(MIGRATION_KEY, true); + + return { projects, updatedSessions }; +} + +// ============================================================================ +// Hook +// ============================================================================ + +export function useProjectRestoration() { + const hasRun = useRef(false); + const initialLoadComplete = useSessionStore((s) => s.initialLoadComplete); + + // Extract stable action references (Zustand actions are singletons) + const { setProjects, setActiveProjectId } = useMemo(() => useProjectStore.getState(), []); + const { setSessions } = useMemo(() => useSessionStore.getState(), []); + + // --- Restoration effect --- + useEffect(() => { + if (!initialLoadComplete || hasRun.current) return; + hasRun.current = true; + + const loadProjects = async () => { + // 1. Try loading existing projects from disk + const savedProjects = await window.maestro.projects.getAll(); + + if (savedProjects && savedProjects.length > 0) { + setProjects(savedProjects); + + // Set active project to the one containing the active session + const activeSessionId = useSessionStore.getState().activeSessionId; + const activeSession = useSessionStore + .getState() + .sessions.find((s) => s.id === activeSessionId); + if (activeSession?.projectId) { + setActiveProjectId(activeSession.projectId); + } else if (savedProjects.length > 0) { + setActiveProjectId(savedProjects[0].id); + } + return; + } + + // 2. No projects saved -- run migration + const migrationResult = await migrateGroupsToProjects(); + if (migrationResult) { + setProjects(migrationResult.projects); + setSessions(migrationResult.updatedSessions); + await window.maestro.projects.setAll(migrationResult.projects); + + // Set active project from active session or fall back to first + const activeSessionId = useSessionStore.getState().activeSessionId; + const activeSession = migrationResult.updatedSessions.find( + (s) => s.id === activeSessionId + ); + if (activeSession?.projectId) { + setActiveProjectId(activeSession.projectId); + } else if (migrationResult.projects.length > 0) { + setActiveProjectId(migrationResult.projects[0].id); + } + } + }; + + loadProjects().catch((err) => { + console.error('[useProjectRestoration] Failed to load/migrate projects:', err); + }); + }, [initialLoadComplete, setProjects, setActiveProjectId, setSessions]); + + // --- Debounced persistence effect --- + const projects = useProjectStore((s) => s.projects); + + useEffect(() => { + if (!initialLoadComplete) return; + + const timer = setTimeout(() => { + window.maestro.projects.setAll(projects); + }, PERSISTENCE_DEBOUNCE_MS); + + return () => clearTimeout(timer); + }, [projects, initialLoadComplete]); +} From 209f17ee9a796e7f9919b57f8d7e706b730d13c4 Mon Sep 17 00:00:00 2001 From: VibeWriter User Date: Tue, 10 Mar 2026 13:17:39 -0400 Subject: [PATCH 11/20] =?UTF-8?q?feat:=20add=20useProjectRestoration=20wit?= =?UTF-8?q?h=20group=E2=86=92project=20migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loads projects from disk on startup, runs one-time migration to convert existing groups into projects, and provides debounced persistence for the project store. Migration flag is only set after data is safely on disk. Co-Authored-By: Claude Opus 4.6 --- .../hooks/useProjectRestoration.test.ts | 52 +++++++++++++++++-- .../hooks/project/useProjectRestoration.ts | 20 +++++-- 2 files changed, 62 insertions(+), 10 deletions(-) diff --git a/src/__tests__/renderer/hooks/useProjectRestoration.test.ts b/src/__tests__/renderer/hooks/useProjectRestoration.test.ts index d3790b6ec..645b9b941 100644 --- a/src/__tests__/renderer/hooks/useProjectRestoration.test.ts +++ b/src/__tests__/renderer/hooks/useProjectRestoration.test.ts @@ -29,6 +29,10 @@ const mockProjectsApi = { setAll: vi.fn().mockResolvedValue(true), }; +const mockSessionsApi = { + setAll: vi.fn().mockResolvedValue(true), +}; + const mockGroupsApi = { getAll: vi.fn().mockResolvedValue([]), }; @@ -121,6 +125,7 @@ describe('useProjectRestoration', () => { (window as any).maestro = { ...(window as any).maestro, projects: mockProjectsApi, + sessions: { ...(window as any).maestro?.sessions, ...mockSessionsApi }, groups: mockGroupsApi, settings: mockSettingsApi, }; @@ -291,6 +296,9 @@ describe('useProjectRestoration', () => { // Projects should have been persisted expect(mockProjectsApi.setAll).toHaveBeenCalledWith(storeProjects); + + // Sessions (with new projectId fields) should also have been persisted + expect(mockSessionsApi.setAll).toHaveBeenCalledWith(updatedSessions); }); // ==================================================================== @@ -347,11 +355,26 @@ describe('useProjectRestoration', () => { // ==================================================================== // 7. Marks migration complete // ==================================================================== - it('marks migration complete by writing settings flag', async () => { + it('marks migration complete AFTER persisting projects and sessions to disk', async () => { mockProjectsApi.getAll.mockResolvedValue([]); mockSettingsApi.get.mockResolvedValue(null); mockGroupsApi.getAll.mockResolvedValue([]); + // Track call order to verify flag is set after data is on disk + const callOrder: string[] = []; + mockProjectsApi.setAll.mockImplementation(async () => { + callOrder.push('projects.setAll'); + return true; + }); + mockSessionsApi.setAll.mockImplementation(async () => { + callOrder.push('sessions.setAll'); + return true; + }); + mockSettingsApi.set.mockImplementation(async () => { + callOrder.push('settings.set'); + return true; + }); + useSessionStore.setState({ initialLoadComplete: true, sessions: [createMockSession({ id: 's1' })], @@ -365,6 +388,15 @@ describe('useProjectRestoration', () => { }); expect(mockSettingsApi.set).toHaveBeenCalledWith('projectMigrationComplete', true); + + // Verify ordering: projects + sessions must be persisted before flag is written + const projectsSetAllIndex = callOrder.indexOf('projects.setAll'); + const sessionsSetAllIndex = callOrder.indexOf('sessions.setAll'); + const settingsSetIndex = callOrder.indexOf('settings.set'); + expect(projectsSetAllIndex).toBeGreaterThanOrEqual(0); + expect(sessionsSetAllIndex).toBeGreaterThanOrEqual(0); + expect(settingsSetIndex).toBeGreaterThan(projectsSetAllIndex); + expect(settingsSetIndex).toBeGreaterThan(sessionsSetAllIndex); }); // ==================================================================== @@ -442,15 +474,21 @@ describe('useProjectRestoration', () => { renderHook(() => useProjectRestoration()); - // Let the initial load complete + // Let the initial load complete (loads projects into store) await act(async () => { await vi.advanceTimersByTimeAsync(0); }); - // Clear the calls from initial load + // The first persistence cycle is skipped (initial load from disk) + // Advance past the would-be debounce to confirm no write + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + + // Clear any calls from initial load mockProjectsApi.setAll.mockClear(); - // Simulate a project change by updating the store directly + // Now simulate a real user-driven project change const updatedProjects = [ createMockProject({ id: 'p1', name: 'Updated Name' }), ]; @@ -482,13 +520,17 @@ describe('useProjectRestoration', () => { renderHook(() => useProjectRestoration()); + // Let initial load complete + exhaust first-render skip await act(async () => { await vi.advanceTimersByTimeAsync(0); }); + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); mockProjectsApi.setAll.mockClear(); - // Rapid changes + // Rapid changes (each resets the debounce timer) act(() => { useProjectStore.getState().setProjects([ createMockProject({ id: 'p1', name: 'Change 1' }), diff --git a/src/renderer/hooks/project/useProjectRestoration.ts b/src/renderer/hooks/project/useProjectRestoration.ts index 830f70e2d..efe797fc0 100644 --- a/src/renderer/hooks/project/useProjectRestoration.ts +++ b/src/renderer/hooks/project/useProjectRestoration.ts @@ -29,9 +29,9 @@ const PERSISTENCE_DEBOUNCE_MS = 2000; * 1. Each group with sessions becomes a project (name from group, repoPath from first session). * 2. Ungrouped sessions are grouped by projectRoot/cwd into auto-created projects. * 3. All affected sessions get a projectId assignment. - * 4. Migration flag is written so it never runs again. * * Returns null if migration was already completed. + * Caller is responsible for persisting projects and marking migration complete. */ async function migrateGroupsToProjects(): Promise<{ projects: Project[]; @@ -95,9 +95,6 @@ async function migrateGroupsToProjects(): Promise<{ projectId: sessionUpdates.get(s.id) || s.projectId, })); - // 4. Mark migration complete - await window.maestro.settings.set(MIGRATION_KEY, true); - return { projects, updatedSessions }; } @@ -107,6 +104,7 @@ async function migrateGroupsToProjects(): Promise<{ export function useProjectRestoration() { const hasRun = useRef(false); + const skipFirstPersist = useRef(true); const initialLoadComplete = useSessionStore((s) => s.initialLoadComplete); // Extract stable action references (Zustand actions are singletons) @@ -144,6 +142,10 @@ export function useProjectRestoration() { setProjects(migrationResult.projects); setSessions(migrationResult.updatedSessions); await window.maestro.projects.setAll(migrationResult.projects); + await window.maestro.sessions.setAll(migrationResult.updatedSessions); + + // Mark migration complete only after data is safely on disk + await window.maestro.settings.set(MIGRATION_KEY, true); // Set active project from active session or fall back to first const activeSessionId = useSessionStore.getState().activeSessionId; @@ -164,13 +166,21 @@ export function useProjectRestoration() { }, [initialLoadComplete, setProjects, setActiveProjectId, setSessions]); // --- Debounced persistence effect --- + // Skips the first change (initial load from disk) to avoid a redundant write. const projects = useProjectStore((s) => s.projects); useEffect(() => { if (!initialLoadComplete) return; + if (skipFirstPersist.current) { + skipFirstPersist.current = false; + return; + } + const timer = setTimeout(() => { - window.maestro.projects.setAll(projects); + window.maestro.projects.setAll(projects).catch((err: unknown) => { + console.error('[useProjectRestoration] Failed to persist projects:', err); + }); }, PERSISTENCE_DEBOUNCE_MS); return () => clearTimeout(timer); From b4d8ca58957e2c58fbfda5cfb680667e63cd05a7 Mon Sep 17 00:00:00 2001 From: VibeWriter User Date: Tue, 10 Mar 2026 13:23:43 -0400 Subject: [PATCH 12/20] feat: add ProjectSidebar components (InboxItem, InboxSection, ProjectItem, ProjectSidebar) Implements Tasks 8-11 of the project-centric navigation redesign: - InboxItem: attention item with reason dot, tab name, relative time - InboxSection: collapsible inbox with count badge and clear button - ProjectItem: project row with color accent, active highlight, session count - ProjectSidebar: main sidebar composing inbox + project list with navigation Co-Authored-By: Claude Opus 4.6 --- .../components/ProjectSidebar/InboxItem.tsx | 95 +++++++++++ .../ProjectSidebar/InboxSection.tsx | 124 ++++++++++++++ .../components/ProjectSidebar/ProjectItem.tsx | 92 ++++++++++ .../ProjectSidebar/ProjectSidebar.tsx | 157 ++++++++++++++++++ .../components/ProjectSidebar/index.ts | 1 + 5 files changed, 469 insertions(+) create mode 100644 src/renderer/components/ProjectSidebar/InboxItem.tsx create mode 100644 src/renderer/components/ProjectSidebar/InboxSection.tsx create mode 100644 src/renderer/components/ProjectSidebar/ProjectItem.tsx create mode 100644 src/renderer/components/ProjectSidebar/ProjectSidebar.tsx create mode 100644 src/renderer/components/ProjectSidebar/index.ts diff --git a/src/renderer/components/ProjectSidebar/InboxItem.tsx b/src/renderer/components/ProjectSidebar/InboxItem.tsx new file mode 100644 index 000000000..b84ec11f0 --- /dev/null +++ b/src/renderer/components/ProjectSidebar/InboxItem.tsx @@ -0,0 +1,95 @@ +/** + * InboxItem - A single attention item in the inbox sidebar section. + * Shows reason icon (colored dot), tab name, project name, and relative time. + * Click navigates to the project + session and auto-dismisses. + */ + +import React, { useCallback, useMemo } from 'react'; +import type { InboxItem as InboxItemType } from '../../types'; +import type { Theme } from '../../constants/themes'; + +interface InboxItemProps { + item: InboxItemType; + theme: Theme; + onNavigate: (item: InboxItemType) => void; +} + +const REASON_CONFIG = { + finished: { icon: '\u25CF', color: '#22c55e', label: 'Finished' }, + error: { icon: '\u25CF', color: '#ef4444', label: 'Error' }, + waiting_input: { icon: '\u25CF', color: '#eab308', label: 'Waiting' }, +} as const; + +function formatRelativeTime(timestamp: number): string { + const diff = Date.now() - timestamp; + const minutes = Math.floor(diff / 60000); + if (minutes < 1) return 'just now'; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + return `${Math.floor(hours / 24)}d ago`; +} + +export const InboxItemComponent = React.memo(function InboxItemComponent({ + item, + theme, + onNavigate, +}: InboxItemProps) { + const config = REASON_CONFIG[item.reason]; + + const handleClick = useCallback(() => { + onNavigate(item); + }, [item, onNavigate]); + + const timeAgo = useMemo(() => formatRelativeTime(item.timestamp), [item.timestamp]); + + return ( +
{ + e.currentTarget.style.backgroundColor = theme.colors.bgActivity; + }} + onMouseLeave={(e) => { + e.currentTarget.style.backgroundColor = 'transparent'; + }} + > + {config.icon} +
+
+ {item.tabName} +
+
+ {item.projectName} · {timeAgo} +
+
+
+ ); +}); diff --git a/src/renderer/components/ProjectSidebar/InboxSection.tsx b/src/renderer/components/ProjectSidebar/InboxSection.tsx new file mode 100644 index 000000000..e361d6409 --- /dev/null +++ b/src/renderer/components/ProjectSidebar/InboxSection.tsx @@ -0,0 +1,124 @@ +/** + * InboxSection - Collapsible section at the top of the left sidebar. + * Shows inbox items with count badge and clear button. + * Hides entirely when there are no inbox items. + */ + +import React, { useCallback, useState } from 'react'; +import { useInboxStore, selectInboxItems, selectInboxCount } from '../../stores/inboxStore'; +import { InboxItemComponent } from './InboxItem'; +import type { InboxItem } from '../../types'; +import type { Theme } from '../../constants/themes'; + +interface InboxSectionProps { + theme: Theme; + onNavigateToItem: (item: InboxItem) => void; +} + +export const InboxSection = React.memo(function InboxSection({ + theme, + onNavigateToItem, +}: InboxSectionProps) { + const items = useInboxStore(selectInboxItems); + const count = useInboxStore(selectInboxCount); + const clearAll = useInboxStore((s) => s.clearAll); + const [collapsed, setCollapsed] = useState(false); + + const handleClear = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + clearAll(); + }, + [clearAll] + ); + + const toggleCollapsed = useCallback(() => { + setCollapsed((prev) => !prev); + }, []); + + if (count === 0) return null; + + return ( +
+ {/* Header */} +
+
+ + ▼ + + + Inbox + + + {count} + +
+ +
+ + {/* Items */} + {!collapsed && ( +
+ {items.map((item) => ( + + ))} +
+ )} +
+ ); +}); diff --git a/src/renderer/components/ProjectSidebar/ProjectItem.tsx b/src/renderer/components/ProjectSidebar/ProjectItem.tsx new file mode 100644 index 000000000..ccca874c3 --- /dev/null +++ b/src/renderer/components/ProjectSidebar/ProjectItem.tsx @@ -0,0 +1,92 @@ +/** + * ProjectItem - A single project row in the left sidebar. + * Shows name, session count, active highlight, and optional color accent. + */ + +import React, { useCallback } from 'react'; +import type { Project } from '../../../shared/types'; +import type { Theme } from '../../constants/themes'; + +interface ProjectItemProps { + project: Project; + isActive: boolean; + sessionCount: number; + theme: Theme; + onSelect: (projectId: string) => void; + onContextMenu: (e: React.MouseEvent, projectId: string) => void; +} + +export const ProjectItem = React.memo(function ProjectItem({ + project, + isActive, + sessionCount, + theme, + onSelect, + onContextMenu, +}: ProjectItemProps) { + const handleClick = useCallback(() => { + onSelect(project.id); + }, [project.id, onSelect]); + + const handleContextMenu = useCallback( + (e: React.MouseEvent) => { + onContextMenu(e, project.id); + }, + [project.id, onContextMenu] + ); + + return ( +
{ + if (!isActive) { + e.currentTarget.style.backgroundColor = theme.colors.bgActivity; + } + }} + onMouseLeave={(e) => { + if (!isActive) { + e.currentTarget.style.backgroundColor = 'transparent'; + } + }} + > +
+
+ {project.name} +
+
+ {sessionCount > 0 && ( + + {sessionCount} + + )} +
+ ); +}); diff --git a/src/renderer/components/ProjectSidebar/ProjectSidebar.tsx b/src/renderer/components/ProjectSidebar/ProjectSidebar.tsx new file mode 100644 index 000000000..cbb91b6e2 --- /dev/null +++ b/src/renderer/components/ProjectSidebar/ProjectSidebar.tsx @@ -0,0 +1,157 @@ +/** + * ProjectSidebar - Left sidebar showing inbox + project list. + * Replaces the old SessionList component with a project-centric layout. + * Renders InboxSection (when items exist) + project list with session counts. + * Handles navigation between projects and inbox item clicks. + */ + +import React, { useCallback, useMemo } from 'react'; +import { useProjectStore, selectAllProjects } from '../../stores/projectStore'; +import { useSessionStore } from '../../stores/sessionStore'; +import { useInboxStore } from '../../stores/inboxStore'; +import { InboxSection } from './InboxSection'; +import { ProjectItem } from './ProjectItem'; +import type { InboxItem } from '../../types'; +import type { Theme } from '../../constants/themes'; + +interface ProjectSidebarProps { + theme: Theme; + onAddProject: () => void; +} + +export const ProjectSidebar = React.memo(function ProjectSidebar({ + theme, + onAddProject, +}: ProjectSidebarProps) { + const projects = useProjectStore(selectAllProjects); + const activeProjectId = useProjectStore((s) => s.activeProjectId); + const setActiveProjectId = useProjectStore((s) => s.setActiveProjectId); + const sessions = useSessionStore((s) => s.sessions); + const setActiveSessionId = useSessionStore((s) => s.setActiveSessionId); + const dismissItem = useInboxStore((s) => s.dismissItem); + const dismissAllForSession = useInboxStore((s) => s.dismissAllForSession); + + // Count sessions per project + const sessionCounts = useMemo(() => { + const counts = new Map(); + for (const session of sessions) { + if (session.projectId) { + counts.set(session.projectId, (counts.get(session.projectId) || 0) + 1); + } + } + return counts; + }, [sessions]); + + const handleSelectProject = useCallback( + (projectId: string) => { + setActiveProjectId(projectId); + // When switching projects, select the first session in the new project + const projectSessions = sessions.filter((s) => s.projectId === projectId); + if (projectSessions.length > 0) { + setActiveSessionId(projectSessions[0].id); + } + }, + [setActiveProjectId, setActiveSessionId, sessions] + ); + + const handleNavigateToInboxItem = useCallback( + (item: InboxItem) => { + // Switch to the project + setActiveProjectId(item.projectId); + // Switch to the session + setActiveSessionId(item.sessionId); + // Dismiss the item + dismissItem(item.id); + // Also dismiss any other items for this session + dismissAllForSession(item.sessionId); + }, + [setActiveProjectId, setActiveSessionId, dismissItem, dismissAllForSession] + ); + + const handleProjectContextMenu = useCallback( + (e: React.MouseEvent, _projectId: string) => { + e.preventDefault(); + // TODO: Implement context menu (rename, change color, delete) + }, + [] + ); + + return ( +
+ {/* Inbox Section */} + + + {/* Projects Header */} +
+ + Projects + + +
+ + {/* Project List */} +
+ {projects.map((project) => ( + + ))} + + {projects.length === 0 && ( +
+ No projects yet. Click + to add a repo. +
+ )} +
+
+ ); +}); diff --git a/src/renderer/components/ProjectSidebar/index.ts b/src/renderer/components/ProjectSidebar/index.ts new file mode 100644 index 000000000..6bf31f268 --- /dev/null +++ b/src/renderer/components/ProjectSidebar/index.ts @@ -0,0 +1 @@ +export { ProjectSidebar } from './ProjectSidebar'; From 0cbcd9a98c97a79e0b2b9edcb591145703a1d47b Mon Sep 17 00:00:00 2001 From: VibeWriter User Date: Tue, 10 Mar 2026 13:27:54 -0400 Subject: [PATCH 13/20] fix: use theme colors for inbox dots, optimize selectInboxItems - InboxItem reason dots now use theme.colors.success/error/warning instead of hardcoded hex values for theme consistency - selectInboxItems returns stable reference (items stored pre-sorted) - addItem prepends to maintain newest-first order Co-Authored-By: Claude Opus 4.6 --- .../renderer/stores/inboxStore.test.ts | 19 ++++++++++--------- .../components/ProjectSidebar/InboxItem.tsx | 19 +++++++++++++------ src/renderer/stores/inboxStore.ts | 7 ++++--- 3 files changed, 27 insertions(+), 18 deletions(-) diff --git a/src/__tests__/renderer/stores/inboxStore.test.ts b/src/__tests__/renderer/stores/inboxStore.test.ts index 82e0ff63c..2592f701c 100644 --- a/src/__tests__/renderer/stores/inboxStore.test.ts +++ b/src/__tests__/renderer/stores/inboxStore.test.ts @@ -109,15 +109,16 @@ describe('inboxStore', () => { }); describe('selectors', () => { - it('selectInboxItems returns items sorted newest first', () => { - const items = [ - createMockInboxItem({ id: 'i1', timestamp: 1000 }), - createMockInboxItem({ id: 'i2', sessionId: 's2', timestamp: 2000 }), - ]; - useInboxStore.setState({ items }); - const sorted = selectInboxItems(useInboxStore.getState()); - expect(sorted[0].id).toBe('i2'); - expect(sorted[1].id).toBe('i1'); + it('selectInboxItems returns items newest first (pre-sorted by addItem)', () => { + // addItem prepends (newest first), so adding older then newer + // results in newest at index 0 + const { addItem } = useInboxStore.getState(); + addItem(createMockInboxItem({ id: 'i1', timestamp: 1000 })); + addItem(createMockInboxItem({ id: 'i2', sessionId: 's2', timestamp: 2000 })); + + const items = selectInboxItems(useInboxStore.getState()); + expect(items[0].id).toBe('i2'); + expect(items[1].id).toBe('i1'); }); it('selectInboxCount returns item count', () => { diff --git a/src/renderer/components/ProjectSidebar/InboxItem.tsx b/src/renderer/components/ProjectSidebar/InboxItem.tsx index b84ec11f0..92a40642f 100644 --- a/src/renderer/components/ProjectSidebar/InboxItem.tsx +++ b/src/renderer/components/ProjectSidebar/InboxItem.tsx @@ -14,12 +14,19 @@ interface InboxItemProps { onNavigate: (item: InboxItemType) => void; } -const REASON_CONFIG = { - finished: { icon: '\u25CF', color: '#22c55e', label: 'Finished' }, - error: { icon: '\u25CF', color: '#ef4444', label: 'Error' }, - waiting_input: { icon: '\u25CF', color: '#eab308', label: 'Waiting' }, +const REASON_LABELS = { + finished: 'Finished', + error: 'Error', + waiting_input: 'Waiting', } as const; +/** Map reason to theme color key */ +const REASON_COLOR_KEY = { + finished: 'success', + error: 'error', + waiting_input: 'warning', +} as const satisfies Record; + function formatRelativeTime(timestamp: number): string { const diff = Date.now() - timestamp; const minutes = Math.floor(diff / 60000); @@ -35,7 +42,7 @@ export const InboxItemComponent = React.memo(function InboxItemComponent({ theme, onNavigate, }: InboxItemProps) { - const config = REASON_CONFIG[item.reason]; + const reasonColor = theme.colors[REASON_COLOR_KEY[item.reason]]; const handleClick = useCallback(() => { onNavigate(item); @@ -64,7 +71,7 @@ export const InboxItemComponent = React.memo(function InboxItemComponent({ e.currentTarget.style.backgroundColor = 'transparent'; }} > - {config.icon} + {'\u25CF'}
()((set) => ({ (existing) => existing.sessionId === item.sessionId && existing.reason === item.reason ); if (exists) return s; - return { items: [...s.items, item] }; + // Insert at beginning (newest first) so items are pre-sorted + return { items: [item, ...s.items] }; }), dismissItem: (itemId) => @@ -71,8 +72,8 @@ export const useInboxStore = create()((set) => ({ // Selectors // ============================================================================ -export const selectInboxItems = (state: InboxStore): InboxItem[] => - [...state.items].sort((a, b) => b.timestamp - a.timestamp); +/** Items are stored newest-first (pre-sorted by addItem) */ +export const selectInboxItems = (state: InboxStore): InboxItem[] => state.items; export const selectInboxCount = (state: InboxStore): number => state.items.length; From 4593046317666d2e6d4a1a824995af297d4a2a49 Mon Sep 17 00:00:00 2001 From: VibeWriter User Date: Tue, 10 Mar 2026 13:31:59 -0400 Subject: [PATCH 14/20] =?UTF-8?q?feat:=20add=20useInboxWatcher=20hook=20fo?= =?UTF-8?q?r=20session=20state=20=E2=86=92=20inbox=20triggers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../renderer/hooks/useInboxWatcher.test.ts | 524 ++++++++++++++++++ src/renderer/hooks/useInboxWatcher.ts | 90 +++ 2 files changed, 614 insertions(+) create mode 100644 src/__tests__/renderer/hooks/useInboxWatcher.test.ts create mode 100644 src/renderer/hooks/useInboxWatcher.ts diff --git a/src/__tests__/renderer/hooks/useInboxWatcher.test.ts b/src/__tests__/renderer/hooks/useInboxWatcher.test.ts new file mode 100644 index 000000000..764f548ad --- /dev/null +++ b/src/__tests__/renderer/hooks/useInboxWatcher.test.ts @@ -0,0 +1,524 @@ +/** + * Tests for useInboxWatcher hook + * + * Tests the pure shouldCreateInboxItem function and the hook's + * Zustand subscription behavior for creating inbox items on + * session state transitions. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; + +// Mock generateId for deterministic IDs +let idCounter = 0; +vi.mock('../../../renderer/utils/ids', () => ({ + generateId: vi.fn(() => `inbox-id-${++idCounter}`), +})); + +import { shouldCreateInboxItem, useInboxWatcher } from '../../../renderer/hooks/useInboxWatcher'; +import { useSessionStore } from '../../../renderer/stores/sessionStore'; +import { useInboxStore } from '../../../renderer/stores/inboxStore'; +import { useProjectStore } from '../../../renderer/stores/projectStore'; +import type { Session } from '../../../renderer/types'; + +// ============================================================================ +// Test Helpers +// ============================================================================ + +function createMockSession(overrides: Partial = {}): Session { + return { + id: 'session-1', + name: 'Test Agent', + cwd: '/projects/test', + fullPath: '/projects/test', + projectRoot: '/projects/test', + toolType: 'claude-code' as any, + inputMode: 'ai' as any, + state: 'idle' as any, + projectId: 'project-1', + aiTabs: [ + { + id: 'tab-1', + agentSessionId: null, + name: 'Main Tab', + state: 'idle' as const, + logs: [], + starred: false, + inputValue: '', + stagedImages: [], + createdAt: Date.now(), + }, + ], + activeTabId: 'tab-1', + aiLogs: [], + shellLogs: [], + workLog: [], + contextUsage: 0, + aiPid: 0, + terminalPid: 0, + port: 0, + isLive: false, + changedFiles: [], + isGitRepo: false, + fileTree: [], + fileExplorerExpanded: [], + fileExplorerScrollPos: 0, + executionQueue: [], + activeTimeMs: 0, + filePreviewTabs: [], + activeFileTabId: null, + unifiedTabOrder: [{ type: 'ai' as const, id: 'tab-1' }], + unifiedClosedTabHistory: [], + closedTabHistory: [], + ...overrides, + } as Session; +} + +// ============================================================================ +// shouldCreateInboxItem (pure function) +// ============================================================================ + +describe('shouldCreateInboxItem', () => { + it('returns "finished" when transitioning busy -> idle for non-active session', () => { + const result = shouldCreateInboxItem('busy', 'idle', 'session-1', 'session-2'); + expect(result).toBe('finished'); + }); + + it('returns "error" when transitioning busy -> error for non-active session', () => { + const result = shouldCreateInboxItem('busy', 'error', 'session-1', 'session-2'); + expect(result).toBe('error'); + }); + + it('returns "waiting_input" when transitioning busy -> waiting_input', () => { + const result = shouldCreateInboxItem('busy', 'waiting_input', 'session-1', 'session-2'); + expect(result).toBe('waiting_input'); + }); + + it('returns "waiting_input" when transitioning idle -> waiting_input', () => { + const result = shouldCreateInboxItem('idle', 'waiting_input', 'session-1', 'session-2'); + expect(result).toBe('waiting_input'); + }); + + it('returns "waiting_input" when transitioning connecting -> waiting_input', () => { + const result = shouldCreateInboxItem('connecting', 'waiting_input', 'session-1', 'session-2'); + expect(result).toBe('waiting_input'); + }); + + it('returns "waiting_input" when transitioning error -> waiting_input', () => { + const result = shouldCreateInboxItem('error', 'waiting_input', 'session-1', 'session-2'); + expect(result).toBe('waiting_input'); + }); + + it('returns null for active session (user is looking at it)', () => { + const result = shouldCreateInboxItem('busy', 'idle', 'session-1', 'session-1'); + expect(result).toBeNull(); + }); + + it('returns null when active session transitions busy -> error', () => { + const result = shouldCreateInboxItem('busy', 'error', 'session-1', 'session-1'); + expect(result).toBeNull(); + }); + + it('returns null when active session transitions to waiting_input', () => { + const result = shouldCreateInboxItem('busy', 'waiting_input', 'session-1', 'session-1'); + expect(result).toBeNull(); + }); + + it('returns null for idle -> busy (not a completion transition)', () => { + expect(shouldCreateInboxItem('idle', 'busy', 's1', 's2')).toBeNull(); + }); + + it('returns null for idle -> connecting', () => { + expect(shouldCreateInboxItem('idle', 'connecting', 's1', 's2')).toBeNull(); + }); + + it('returns null for connecting -> busy', () => { + expect(shouldCreateInboxItem('connecting', 'busy', 's1', 's2')).toBeNull(); + }); + + it('returns null for same state (no transition)', () => { + expect(shouldCreateInboxItem('idle', 'idle', 's1', 's2')).toBeNull(); + expect(shouldCreateInboxItem('busy', 'busy', 's1', 's2')).toBeNull(); + expect(shouldCreateInboxItem('error', 'error', 's1', 's2')).toBeNull(); + }); + + it('returns null when waiting_input stays waiting_input (no-op)', () => { + expect(shouldCreateInboxItem('waiting_input', 'waiting_input', 's1', 's2')).toBeNull(); + }); + + it('returns null for idle -> error (not from busy)', () => { + expect(shouldCreateInboxItem('idle', 'error', 's1', 's2')).toBeNull(); + }); + + it('returns null for connecting -> idle (not from busy)', () => { + expect(shouldCreateInboxItem('connecting', 'idle', 's1', 's2')).toBeNull(); + }); + + it('returns null for error -> idle (not from busy)', () => { + expect(shouldCreateInboxItem('error', 'idle', 's1', 's2')).toBeNull(); + }); +}); + +// ============================================================================ +// useInboxWatcher hook (integration with stores) +// ============================================================================ + +describe('useInboxWatcher hook', () => { + beforeEach(() => { + idCounter = 0; + // Reset stores to clean state + useSessionStore.setState({ + sessions: [], + activeSessionId: '', + sessionsLoaded: false, + initialLoadComplete: false, + }); + useInboxStore.setState({ items: [] }); + useProjectStore.setState({ + projects: [{ id: 'project-1', name: 'Test Project', repoPath: '/test', createdAt: 1 }], + activeProjectId: 'project-1', + }); + }); + + it('creates inbox item when non-active session transitions busy -> idle', () => { + const session = createMockSession({ id: 's1', state: 'busy' as any }); + useSessionStore.setState({ + sessions: [session], + activeSessionId: 's2', // different session is active + }); + + renderHook(() => useInboxWatcher()); + + // Simulate state transition: busy -> idle + act(() => { + useSessionStore.setState({ + sessions: [createMockSession({ id: 's1', state: 'idle' as any })], + activeSessionId: 's2', + }); + }); + + const items = useInboxStore.getState().items; + expect(items).toHaveLength(1); + expect(items[0]).toMatchObject({ + id: 'inbox-id-1', + reason: 'finished', + sessionId: 's1', + tabId: 'tab-1', + projectId: 'project-1', + agentType: 'claude-code', + projectName: 'Test Project', + tabName: 'Main Tab', + }); + expect(items[0].timestamp).toBeGreaterThan(0); + }); + + it('creates inbox item when non-active session transitions busy -> error', () => { + const session = createMockSession({ id: 's1', state: 'busy' as any }); + useSessionStore.setState({ + sessions: [session], + activeSessionId: 's2', + }); + + renderHook(() => useInboxWatcher()); + + act(() => { + useSessionStore.setState({ + sessions: [createMockSession({ id: 's1', state: 'error' as any })], + activeSessionId: 's2', + }); + }); + + const items = useInboxStore.getState().items; + expect(items).toHaveLength(1); + expect(items[0].reason).toBe('error'); + expect(items[0].sessionId).toBe('s1'); + }); + + it('creates inbox item when non-active session transitions to waiting_input', () => { + const session = createMockSession({ id: 's1', state: 'busy' as any }); + useSessionStore.setState({ + sessions: [session], + activeSessionId: 's2', + }); + + renderHook(() => useInboxWatcher()); + + act(() => { + useSessionStore.setState({ + sessions: [createMockSession({ id: 's1', state: 'waiting_input' as any })], + activeSessionId: 's2', + }); + }); + + const items = useInboxStore.getState().items; + expect(items).toHaveLength(1); + expect(items[0].reason).toBe('waiting_input'); + }); + + it('does NOT create inbox item for the active session', () => { + const session = createMockSession({ id: 's1', state: 'busy' as any }); + useSessionStore.setState({ + sessions: [session], + activeSessionId: 's1', // same session is active + }); + + renderHook(() => useInboxWatcher()); + + act(() => { + useSessionStore.setState({ + sessions: [createMockSession({ id: 's1', state: 'idle' as any })], + activeSessionId: 's1', + }); + }); + + expect(useInboxStore.getState().items).toHaveLength(0); + }); + + it('does NOT create inbox item for non-triggering transitions', () => { + const session = createMockSession({ id: 's1', state: 'idle' as any }); + useSessionStore.setState({ + sessions: [session], + activeSessionId: 's2', + }); + + renderHook(() => useInboxWatcher()); + + // idle -> busy is not a trigger + act(() => { + useSessionStore.setState({ + sessions: [createMockSession({ id: 's1', state: 'busy' as any })], + activeSessionId: 's2', + }); + }); + + expect(useInboxStore.getState().items).toHaveLength(0); + }); + + it('handles multiple sessions with different transitions simultaneously', () => { + const sessions = [ + createMockSession({ id: 's1', state: 'busy' as any }), + createMockSession({ id: 's2', state: 'busy' as any, name: 'Agent 2' }), + createMockSession({ id: 's3', state: 'idle' as any, name: 'Agent 3' }), + ]; + useSessionStore.setState({ + sessions, + activeSessionId: 's3', // s3 is active + }); + + renderHook(() => useInboxWatcher()); + + act(() => { + useSessionStore.setState({ + sessions: [ + createMockSession({ id: 's1', state: 'idle' as any }), // busy -> idle = finished + createMockSession({ id: 's2', state: 'error' as any, name: 'Agent 2' }), // busy -> error = error + createMockSession({ id: 's3', state: 'busy' as any, name: 'Agent 3' }), // idle -> busy = no trigger (also active) + ], + activeSessionId: 's3', + }); + }); + + const items = useInboxStore.getState().items; + expect(items).toHaveLength(2); + // Items are stored newest-first by inboxStore + const reasons = items.map((i) => i.reason).sort(); + expect(reasons).toEqual(['error', 'finished']); + }); + + it('uses session name when tab has no name', () => { + const session = createMockSession({ + id: 's1', + name: 'My Agent', + state: 'busy' as any, + aiTabs: [ + { + id: 'tab-1', + agentSessionId: null, + name: null, + state: 'idle' as const, + logs: [], + starred: false, + inputValue: '', + stagedImages: [], + createdAt: Date.now(), + }, + ], + }); + useSessionStore.setState({ + sessions: [session], + activeSessionId: 's2', + }); + + renderHook(() => useInboxWatcher()); + + act(() => { + useSessionStore.setState({ + sessions: [ + createMockSession({ + id: 's1', + name: 'My Agent', + state: 'idle' as any, + aiTabs: [ + { + id: 'tab-1', + agentSessionId: null, + name: null, + state: 'idle' as const, + logs: [], + starred: false, + inputValue: '', + stagedImages: [], + createdAt: Date.now(), + }, + ], + }), + ], + activeSessionId: 's2', + }); + }); + + const items = useInboxStore.getState().items; + expect(items).toHaveLength(1); + expect(items[0].tabName).toBe('My Agent'); // Falls back to session name + }); + + it('uses "Unknown" when project is not found', () => { + const session = createMockSession({ + id: 's1', + state: 'busy' as any, + projectId: 'nonexistent-project', + }); + useSessionStore.setState({ + sessions: [session], + activeSessionId: 's2', + }); + + renderHook(() => useInboxWatcher()); + + act(() => { + useSessionStore.setState({ + sessions: [ + createMockSession({ + id: 's1', + state: 'idle' as any, + projectId: 'nonexistent-project', + }), + ], + activeSessionId: 's2', + }); + }); + + const items = useInboxStore.getState().items; + expect(items).toHaveLength(1); + expect(items[0].projectName).toBe('Unknown'); + }); + + it('uses empty string for projectId when session has no projectId', () => { + const session = createMockSession({ + id: 's1', + state: 'busy' as any, + projectId: undefined, + }); + useSessionStore.setState({ + sessions: [session], + activeSessionId: 's2', + }); + + renderHook(() => useInboxWatcher()); + + act(() => { + useSessionStore.setState({ + sessions: [ + createMockSession({ + id: 's1', + state: 'idle' as any, + projectId: undefined, + }), + ], + activeSessionId: 's2', + }); + }); + + const items = useInboxStore.getState().items; + expect(items).toHaveLength(1); + expect(items[0].projectId).toBe(''); + }); + + it('ignores newly added sessions (no previous state to compare)', () => { + useSessionStore.setState({ + sessions: [], + activeSessionId: 's2', + }); + + renderHook(() => useInboxWatcher()); + + // Add a brand new session that is already idle + act(() => { + useSessionStore.setState({ + sessions: [createMockSession({ id: 's1', state: 'idle' as any })], + activeSessionId: 's2', + }); + }); + + expect(useInboxStore.getState().items).toHaveLength(0); + }); + + it('unsubscribes on unmount', () => { + const session = createMockSession({ id: 's1', state: 'busy' as any }); + useSessionStore.setState({ + sessions: [session], + activeSessionId: 's2', + }); + + const { unmount } = renderHook(() => useInboxWatcher()); + unmount(); + + // After unmount, state changes should not create inbox items + act(() => { + useSessionStore.setState({ + sessions: [createMockSession({ id: 's1', state: 'idle' as any })], + activeSessionId: 's2', + }); + }); + + expect(useInboxStore.getState().items).toHaveLength(0); + }); + + it('does not create duplicate items for same session+reason', () => { + // The inboxStore.addItem already deduplicates, but let's verify end-to-end + const session = createMockSession({ id: 's1', state: 'busy' as any }); + useSessionStore.setState({ + sessions: [session], + activeSessionId: 's2', + }); + + renderHook(() => useInboxWatcher()); + + // First transition: busy -> idle + act(() => { + useSessionStore.setState({ + sessions: [createMockSession({ id: 's1', state: 'idle' as any })], + activeSessionId: 's2', + }); + }); + + expect(useInboxStore.getState().items).toHaveLength(1); + + // Go back to busy and then idle again + act(() => { + useSessionStore.setState({ + sessions: [createMockSession({ id: 's1', state: 'busy' as any })], + activeSessionId: 's2', + }); + }); + act(() => { + useSessionStore.setState({ + sessions: [createMockSession({ id: 's1', state: 'idle' as any })], + activeSessionId: 's2', + }); + }); + + // Should still be 1 due to inboxStore deduplication (same session+reason) + expect(useInboxStore.getState().items).toHaveLength(1); + }); +}); diff --git a/src/renderer/hooks/useInboxWatcher.ts b/src/renderer/hooks/useInboxWatcher.ts new file mode 100644 index 000000000..44aeb9a5f --- /dev/null +++ b/src/renderer/hooks/useInboxWatcher.ts @@ -0,0 +1,90 @@ +/** + * useInboxWatcher - Watches session state transitions and creates inbox items. + * + * Triggers: + * - busy -> idle: "finished" + * - busy -> error: "error" + * - * -> waiting_input: "waiting_input" + * + * Only for sessions the user is NOT currently looking at. + */ + +import { useEffect } from 'react'; +import { useSessionStore } from '../stores/sessionStore'; +import { useProjectStore } from '../stores/projectStore'; +import { useInboxStore } from '../stores/inboxStore'; +import { generateId } from '../utils/ids'; +import type { InboxReason } from '../types'; + +/** + * Pure function to determine if a state transition should create an inbox item. + * Exported for testing. + */ +export function shouldCreateInboxItem( + prevState: string, + newState: string, + sessionId: string, + activeSessionId: string +): InboxReason | null { + // Don't create items for the session the user is currently viewing + if (sessionId === activeSessionId) return null; + + // busy -> idle = finished + if (prevState === 'busy' && newState === 'idle') return 'finished'; + + // busy -> error = error + if (prevState === 'busy' && newState === 'error') return 'error'; + + // * -> waiting_input = waiting + if (newState === 'waiting_input' && prevState !== 'waiting_input') return 'waiting_input'; + + return null; +} + +export function useInboxWatcher() { + useEffect(() => { + // Subscribe to session store changes + const unsubscribe = useSessionStore.subscribe((state, prevState) => { + const activeSessionId = state.activeSessionId; + const { addItem } = useInboxStore.getState(); + + for (const session of state.sessions) { + const prevSession = prevState.sessions.find((s) => s.id === session.id); + if (!prevSession) continue; + + const prevSessionState = prevSession.state; + const newSessionState = session.state; + + if (prevSessionState === newSessionState) continue; + + const reason = shouldCreateInboxItem( + prevSessionState, + newSessionState, + session.id, + activeSessionId + ); + + if (reason) { + const project = useProjectStore + .getState() + .projects.find((p) => p.id === session.projectId); + const activeTab = session.aiTabs.find((t) => t.id === session.activeTabId); + + addItem({ + id: generateId(), + sessionId: session.id, + tabId: session.activeTabId, + projectId: session.projectId || '', + reason, + agentType: session.toolType, + tabName: activeTab?.name || session.name, + projectName: project?.name || 'Unknown', + timestamp: Date.now(), + }); + } + } + }); + + return unsubscribe; + }, []); +} From f549fa9fadaf8ad0c9eb13765c5876cb3908d4f6 Mon Sep 17 00:00:00 2001 From: VibeWriter User Date: Tue, 10 Mar 2026 13:40:49 -0400 Subject: [PATCH 15/20] feat: wire ProjectSidebar, useProjectRestoration, and useInboxWatcher into App Co-Authored-By: Claude Opus 4.6 --- src/renderer/App.tsx | 38 +++++++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 667edde5c..a19444e89 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -3,7 +3,7 @@ import React, { useState, useEffect, useRef, useMemo, useCallback, lazy, Suspens const SettingsModal = lazy(() => import('./components/Settings/SettingsModal').then((m) => ({ default: m.SettingsModal })) ); -import { SessionList } from './components/SessionList'; +import { SessionList as _SessionList } from './components/SessionList'; import { RightPanel, RightPanelHandle } from './components/RightPanel'; import { slashCommands } from './slashCommands'; import { AppModals, type PRDetails, type FlatFileItem } from './components/AppModals'; @@ -21,6 +21,7 @@ import { TourOverlay } from './components/Wizard/tour'; // CONDUCTOR_BADGES moved to useAutoRunAchievements hook import { EmptyStateView } from './components/EmptyStateView'; import { DeleteAgentConfirmModal } from './components/DeleteAgentConfirmModal'; +import { ProjectSidebar } from './components/ProjectSidebar'; // Lazy-loaded components for performance (rarely-used heavy modals) // These are loaded on-demand when the user first opens them @@ -135,6 +136,8 @@ import { import { useMainPanelProps, useSessionListProps, useRightPanelProps } from './hooks/props'; import { useAgentListeners } from './hooks/agent/useAgentListeners'; import { useSymphonyContribution } from './hooks/symphony/useSymphonyContribution'; +import { useProjectRestoration } from './hooks/project/useProjectRestoration'; +import { useInboxWatcher } from './hooks/useInboxWatcher'; // Import contexts import { useLayerStack } from './contexts/LayerStackContext'; @@ -146,6 +149,8 @@ import { useGroupChatStore } from './stores/groupChatStore'; import { useBatchStore } from './stores/batchStore'; // All session state is read directly from useSessionStore in MaestroConsoleInner. import { useSessionStore, selectActiveSession } from './stores/sessionStore'; +import { useProjectStore } from './stores/projectStore'; +import { useInboxStore } from './stores/inboxStore'; // useAgentStore moved to useQueueProcessing hook import { InlineWizardProvider, useInlineWizardContext } from './contexts/InlineWizardContext'; import { ToastContainer } from './components/Toast'; @@ -561,15 +566,41 @@ function MaestroConsoleInner() { const { ghCliAvailable, sshRemoteConfigs, speckitCommands, openspecCommands, saveFileGistUrl } = useAppInitialization(); + // Project restoration (loads/migrates on startup) + useProjectRestoration(); + + // Inbox watcher (creates inbox items on session state transitions) + useInboxWatcher(); + // Wrapper for setActiveSessionId that also dismisses active group chat const setActiveSessionId = useCallback( (id: string) => { setActiveGroupChatId(null); // Dismiss group chat when selecting an agent setActiveSessionIdFromContext(id); + // Auto-dismiss inbox items for the session we're navigating to + useInboxStore.getState().dismissAllForSession(id); }, [setActiveSessionIdFromContext, setActiveGroupChatId] ); + // Handler for adding a new project from the ProjectSidebar + const handleAddProject = useCallback(async () => { + const folder = await window.maestro.dialog.selectFolder(); + if (!folder) return; + + const folderName = folder.split(/[\\/]/).filter(Boolean).pop() || 'New Project'; + + const project = { + id: generateId(), + name: folderName, + repoPath: folder, + createdAt: Date.now(), + }; + + useProjectStore.getState().addProject(project); + useProjectStore.getState().setActiveProjectId(project.id); + }, []); + // Completion states from InputContext (these change infrequently) const { slashCommandOpen, @@ -2245,7 +2276,8 @@ function MaestroConsoleInner() { // Helper functions getActiveTab, }); - const sessionListProps = useSessionListProps({ + // @ts-expect-error -- sessionListProps temporarily unused while ProjectSidebar replaces SessionList + const _sessionListProps = useSessionListProps({ // Theme (computed externally from settingsStore + themeId) theme, @@ -2982,7 +3014,7 @@ function MaestroConsoleInner() { {/* --- LEFT SIDEBAR (hidden in mobile landscape and when no sessions) --- */} {!isMobileLandscape && sessions.length > 0 && ( - + )} From cb26704e4ea34d2dfe030edb0408ce62938c3370 Mon Sep 17 00:00:00 2001 From: VibeWriter User Date: Tue, 10 Mar 2026 13:45:06 -0400 Subject: [PATCH 16/20] feat: add keyboard shortcuts for project cycling and new project Co-Authored-By: Claude Opus 4.6 --- src/renderer/App.tsx | 3 ++ src/renderer/constants/shortcuts.ts | 15 ++++++++ .../hooks/keyboard/useMainKeyboardHandler.ts | 38 +++++++++++++++++++ 3 files changed, 56 insertions(+) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index a19444e89..05ee8e365 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -2043,6 +2043,9 @@ function MaestroConsoleInner() { // Auto-scroll AI mode toggle autoScrollAiMode, setAutoScrollAiMode, + + // Project management + handleAddProject, }; // NOTE: File explorer effects (flat file list, pending jump path, scroll, keyboard nav) are diff --git a/src/renderer/constants/shortcuts.ts b/src/renderer/constants/shortcuts.ts index 37f530386..9eda593e9 100644 --- a/src/renderer/constants/shortcuts.ts +++ b/src/renderer/constants/shortcuts.ts @@ -13,6 +13,21 @@ export const DEFAULT_SHORTCUTS: Record = { }, cyclePrev: { id: 'cyclePrev', label: 'Previous Agent', keys: ['Meta', '['] }, cycleNext: { id: 'cycleNext', label: 'Next Agent', keys: ['Meta', ']'] }, + cycleProjectPrev: { + id: 'cycleProjectPrev', + label: 'Previous Project', + keys: ['Ctrl', 'Shift', '['], + }, + cycleProjectNext: { + id: 'cycleProjectNext', + label: 'Next Project', + keys: ['Ctrl', 'Shift', ']'], + }, + newProject: { + id: 'newProject', + label: 'New Project', + keys: ['Ctrl', 'Shift', 'n'], + }, navBack: { id: 'navBack', label: 'Navigate Back', keys: ['Meta', 'Shift', ','] }, navForward: { id: 'navForward', label: 'Navigate Forward', keys: ['Meta', 'Shift', '.'] }, newInstance: { id: 'newInstance', label: 'New Agent', keys: ['Meta', 'n'] }, diff --git a/src/renderer/hooks/keyboard/useMainKeyboardHandler.ts b/src/renderer/hooks/keyboard/useMainKeyboardHandler.ts index 4a4926f56..ddffa7638 100644 --- a/src/renderer/hooks/keyboard/useMainKeyboardHandler.ts +++ b/src/renderer/hooks/keyboard/useMainKeyboardHandler.ts @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'; import type { Session, AITab, ThinkingMode } from '../../types'; import { getInitialRenameValue } from '../../utils/tabHelpers'; import { useModalStore } from '../../stores/modalStore'; +import { useProjectStore } from '../../stores/projectStore'; import { useSettingsStore } from '../../stores/settingsStore'; // Font size keyboard shortcut constants @@ -266,6 +267,43 @@ export function useMainKeyboardHandler(): UseMainKeyboardHandlerReturn { e.preventDefault(); ctx.cycleSession('next'); trackShortcut('cycleNext'); + } else if (ctx.isShortcut(e, 'cycleProjectNext')) { + // Cycle to next project + e.preventDefault(); + const { projects, activeProjectId, setActiveProjectId } = useProjectStore.getState(); + const idx = projects.findIndex((p) => p.id === activeProjectId); + const next = (idx + 1) % projects.length; + if (projects[next]) { + setActiveProjectId(projects[next].id); + const projectSessions = ctx.sessions.filter( + (s: Session) => s.projectId === projects[next].id + ); + if (projectSessions.length > 0) { + ctx.setActiveSessionId(projectSessions[0].id); + } + } + trackShortcut('cycleProjectNext'); + } else if (ctx.isShortcut(e, 'cycleProjectPrev')) { + // Cycle to previous project + e.preventDefault(); + const { projects, activeProjectId, setActiveProjectId } = useProjectStore.getState(); + const idx = projects.findIndex((p) => p.id === activeProjectId); + const prev = (idx - 1 + projects.length) % projects.length; + if (projects[prev]) { + setActiveProjectId(projects[prev].id); + const projectSessions = ctx.sessions.filter( + (s: Session) => s.projectId === projects[prev].id + ); + if (projectSessions.length > 0) { + ctx.setActiveSessionId(projectSessions[0].id); + } + } + trackShortcut('cycleProjectPrev'); + } else if (ctx.isShortcut(e, 'newProject')) { + // Create a new project + e.preventDefault(); + ctx.handleAddProject(); + trackShortcut('newProject'); } else if (ctx.isShortcut(e, 'navBack')) { // Navigate back in history (through sessions and tabs) e.preventDefault(); From 1972888d0aed354bcf5addf2c5576af36af4e6cd Mon Sep 17 00:00:00 2001 From: VibeWriter User Date: Tue, 10 Mar 2026 13:52:25 -0400 Subject: [PATCH 17/20] refactor: remove group and bookmark code from sessionStore Remove group CRUD actions (addGroup, removeGroup, updateGroup, toggleGroupCollapsed), toggleBookmark action, and group/bookmark selectors (selectBookmarkedSessions, selectSessionsByGroup, selectUngroupedSessions, selectGroupById) from the session store. Retained: groups state array, Group import, and setGroups action because they are still consumed by production code (SessionList, useSessionFilterMode, and ~12 other renderer files) for legacy migration purposes. Co-Authored-By: Claude Opus 4.6 --- .../renderer/stores/sessionStore.test.ts | 255 +----------------- src/renderer/stores/sessionStore.ts | 110 +------- 2 files changed, 15 insertions(+), 350 deletions(-) diff --git a/src/__tests__/renderer/stores/sessionStore.test.ts b/src/__tests__/renderer/stores/sessionStore.test.ts index a1e586b47..2f0c2e446 100644 --- a/src/__tests__/renderer/stores/sessionStore.test.ts +++ b/src/__tests__/renderer/stores/sessionStore.test.ts @@ -4,18 +4,14 @@ import { useSessionStore, selectActiveSession, selectSessionById, - selectBookmarkedSessions, - selectSessionsByGroup, - selectUngroupedSessions, selectSessionsByProject, - selectGroupById, selectSessionCount, selectIsReady, selectIsAnySessionBusy, getSessionState, getSessionActions, } from '../../../renderer/stores/sessionStore'; -import type { Session, Group, FilePreviewTab } from '../../../renderer/types'; +import type { Session, FilePreviewTab } from '../../../renderer/types'; // ============================================================================ // Test Helpers @@ -82,15 +78,6 @@ function createMockFilePreviewTab(overrides: Partial = {}): File }; } -function createMockGroup(overrides: Partial = {}): Group { - return { - id: overrides.id ?? `group-${Math.random().toString(36).slice(2, 8)}`, - name: overrides.name ?? 'Test Group', - emoji: overrides.emoji ?? '📁', - collapsed: overrides.collapsed ?? false, - }; -} - /** * Reset the Zustand store to initial state between tests. * Zustand stores are singletons, so state persists across tests unless explicitly reset. @@ -98,7 +85,7 @@ function createMockGroup(overrides: Partial = {}): Group { function resetStore() { useSessionStore.setState({ sessions: [], - groups: [], + groups: [], // retained for legacy migration code activeSessionId: '', sessionsLoaded: false, initialLoadComplete: false, @@ -125,7 +112,6 @@ describe('sessionStore', () => { const state = useSessionStore.getState(); expect(state.sessions).toEqual([]); - expect(state.groups).toEqual([]); expect(state.activeSessionId).toBe(''); expect(state.sessionsLoaded).toBe(false); expect(state.initialLoadComplete).toBe(false); @@ -274,95 +260,6 @@ describe('sessionStore', () => { }); }); - // ======================================================================== - // Groups - // ======================================================================== - - describe('groups', () => { - it('sets groups with a direct value', () => { - const groups = [createMockGroup({ id: 'g1' }), createMockGroup({ id: 'g2' })]; - useSessionStore.getState().setGroups(groups); - - expect(useSessionStore.getState().groups).toEqual(groups); - }); - - it('sets groups with an updater function', () => { - useSessionStore.getState().setGroups([createMockGroup({ id: 'g1' })]); - useSessionStore.getState().setGroups((prev) => [...prev, createMockGroup({ id: 'g2' })]); - - expect(useSessionStore.getState().groups).toHaveLength(2); - }); - - it('skips no-op setGroups when same reference returned', () => { - const groups = [createMockGroup({ id: 'g1' })]; - useSessionStore.getState().setGroups(groups); - - const stateBefore = useSessionStore.getState(); - useSessionStore.getState().setGroups((prev) => prev); - const stateAfter = useSessionStore.getState(); - - expect(stateAfter.groups).toBe(stateBefore.groups); - }); - - it('adds a group', () => { - useSessionStore.getState().addGroup(createMockGroup({ id: 'g1', name: 'My Group' })); - - expect(useSessionStore.getState().groups).toHaveLength(1); - expect(useSessionStore.getState().groups[0].name).toBe('My Group'); - }); - - it('removes a group by ID', () => { - useSessionStore - .getState() - .setGroups([createMockGroup({ id: 'g1' }), createMockGroup({ id: 'g2' })]); - - useSessionStore.getState().removeGroup('g1'); - - expect(useSessionStore.getState().groups).toHaveLength(1); - expect(useSessionStore.getState().groups[0].id).toBe('g2'); - }); - - it('skips removeGroup if ID not found', () => { - const groups = [createMockGroup({ id: 'g1' })]; - useSessionStore.getState().setGroups(groups); - - const stateBefore = useSessionStore.getState(); - useSessionStore.getState().removeGroup('nonexistent'); - const stateAfter = useSessionStore.getState(); - - expect(stateAfter.groups).toBe(stateBefore.groups); - }); - - it('updates a group by ID', () => { - useSessionStore.getState().setGroups([createMockGroup({ id: 'g1', name: 'Old Name' })]); - - useSessionStore.getState().updateGroup('g1', { name: 'New Name' }); - - expect(useSessionStore.getState().groups[0].name).toBe('New Name'); - }); - - it('skips updateGroup if ID not found', () => { - const groups = [createMockGroup({ id: 'g1' })]; - useSessionStore.getState().setGroups(groups); - - const stateBefore = useSessionStore.getState(); - useSessionStore.getState().updateGroup('nonexistent', { name: 'x' }); - const stateAfter = useSessionStore.getState(); - - expect(stateAfter.groups).toBe(stateBefore.groups); - }); - - it('toggles group collapsed state', () => { - useSessionStore.getState().setGroups([createMockGroup({ id: 'g1', collapsed: false })]); - - useSessionStore.getState().toggleGroupCollapsed('g1'); - expect(useSessionStore.getState().groups[0].collapsed).toBe(true); - - useSessionStore.getState().toggleGroupCollapsed('g1'); - expect(useSessionStore.getState().groups[0].collapsed).toBe(false); - }); - }); - // ======================================================================== // Initialization // ======================================================================== @@ -379,36 +276,6 @@ describe('sessionStore', () => { }); }); - // ======================================================================== - // Bookmarks - // ======================================================================== - - describe('bookmarks', () => { - it('toggles bookmark on a session', () => { - useSessionStore.getState().setSessions([createMockSession({ id: 'a', bookmarked: false })]); - - useSessionStore.getState().toggleBookmark('a'); - expect(useSessionStore.getState().sessions[0].bookmarked).toBe(true); - - useSessionStore.getState().toggleBookmark('a'); - expect(useSessionStore.getState().sessions[0].bookmarked).toBe(false); - }); - - it('toggles bookmark only for the targeted session', () => { - useSessionStore - .getState() - .setSessions([ - createMockSession({ id: 'a', bookmarked: false }), - createMockSession({ id: 'b', bookmarked: true }), - ]); - - useSessionStore.getState().toggleBookmark('a'); - - expect(useSessionStore.getState().sessions[0].bookmarked).toBe(true); - expect(useSessionStore.getState().sessions[1].bookmarked).toBe(true); // unchanged - }); - }); - // ======================================================================== // Worktree Tracking // ======================================================================== @@ -511,59 +378,6 @@ describe('sessionStore', () => { }); }); - describe('selectBookmarkedSessions', () => { - it('returns only bookmarked sessions', () => { - useSessionStore - .getState() - .setSessions([ - createMockSession({ id: 'a', bookmarked: true }), - createMockSession({ id: 'b', bookmarked: false }), - createMockSession({ id: 'c', bookmarked: true }), - ]); - - const bookmarked = selectBookmarkedSessions(useSessionStore.getState()); - expect(bookmarked).toHaveLength(2); - expect(bookmarked.map((s) => s.id)).toEqual(['a', 'c']); - }); - - it('returns empty array when none bookmarked', () => { - useSessionStore.getState().setSessions([createMockSession({ id: 'a', bookmarked: false })]); - - expect(selectBookmarkedSessions(useSessionStore.getState())).toHaveLength(0); - }); - }); - - describe('selectSessionsByGroup', () => { - it('returns sessions belonging to a group', () => { - useSessionStore - .getState() - .setSessions([ - createMockSession({ id: 'a', groupId: 'g1' }), - createMockSession({ id: 'b', groupId: 'g2' }), - createMockSession({ id: 'c', groupId: 'g1' }), - ]); - - const group1 = selectSessionsByGroup('g1')(useSessionStore.getState()); - expect(group1).toHaveLength(2); - expect(group1.map((s) => s.id)).toEqual(['a', 'c']); - }); - }); - - describe('selectUngroupedSessions', () => { - it('returns sessions without a group and not worktree children', () => { - useSessionStore.getState().setSessions([ - createMockSession({ id: 'a' }), // ungrouped - createMockSession({ id: 'b', groupId: 'g1' }), // grouped - createMockSession({ id: 'c', parentSessionId: 'a' }), // worktree child - createMockSession({ id: 'd' }), // ungrouped - ]); - - const ungrouped = selectUngroupedSessions(useSessionStore.getState()); - expect(ungrouped).toHaveLength(2); - expect(ungrouped.map((s) => s.id)).toEqual(['a', 'd']); - }); - }); - describe('selectSessionsByProject', () => { it('should return sessions matching projectId', () => { const sessions = [ @@ -585,20 +399,6 @@ describe('sessionStore', () => { }); }); - describe('selectGroupById', () => { - it('returns the group with the given ID', () => { - useSessionStore.getState().setGroups([createMockGroup({ id: 'g1', name: 'Group One' })]); - - const group = selectGroupById('g1')(useSessionStore.getState()); - expect(group?.name).toBe('Group One'); - }); - - it('returns undefined if not found', () => { - const group = selectGroupById('nope')(useSessionStore.getState()); - expect(group).toBeUndefined(); - }); - }); - describe('selectSessionCount', () => { it('returns the number of sessions', () => { expect(selectSessionCount(useSessionStore.getState())).toBe(0); @@ -703,9 +503,9 @@ describe('sessionStore', () => { const initialRenderCount = renderCount; - // Change unrelated state (groups) + // Change unrelated state (sessionsLoaded) act(() => { - useSessionStore.getState().setGroups([createMockGroup({ id: 'g1' })]); + useSessionStore.getState().setSessionsLoaded(true); }); // Should not have re-rendered (selector isolation) @@ -724,15 +524,15 @@ describe('sessionStore', () => { it('works with multiple selectors in the same component', () => { useSessionStore.getState().setSessions([createMockSession({ id: 'a' })]); - useSessionStore.getState().setGroups([createMockGroup({ id: 'g1' })]); + useSessionStore.getState().setActiveSessionId('a'); const { result } = renderHook(() => ({ sessionCount: useSessionStore((s) => s.sessions.length), - groupCount: useSessionStore((s) => s.groups.length), + activeId: useSessionStore((s) => s.activeSessionId), })); expect(result.current.sessionCount).toBe(1); - expect(result.current.groupCount).toBe(1); + expect(result.current.activeId).toBe('a'); }); }); @@ -745,7 +545,6 @@ describe('sessionStore', () => { const actionsBefore = useSessionStore.getState(); useSessionStore.getState().setSessions([createMockSession({ id: 'a' })]); - useSessionStore.getState().setGroups([createMockGroup({ id: 'g1' })]); useSessionStore.getState().setActiveSessionId('a'); const actionsAfter = useSessionStore.getState(); @@ -756,20 +555,16 @@ describe('sessionStore', () => { expect(actionsAfter.updateSession).toBe(actionsBefore.updateSession); expect(actionsAfter.setActiveSessionId).toBe(actionsBefore.setActiveSessionId); expect(actionsAfter.setGroups).toBe(actionsBefore.setGroups); - expect(actionsAfter.toggleBookmark).toBe(actionsBefore.toggleBookmark); }); it('extracted actions still mutate state correctly', () => { - const { setSessions, setActiveSessionId, setGroups } = useSessionStore.getState(); + const { setSessions, setActiveSessionId } = useSessionStore.getState(); setSessions([createMockSession({ id: 'a' })]); expect(useSessionStore.getState().sessions).toHaveLength(1); setActiveSessionId('a'); expect(useSessionStore.getState().activeSessionId).toBe('a'); - - setGroups([createMockGroup({ id: 'g1' })]); - expect(useSessionStore.getState().groups).toHaveLength(1); }); it('extracted actions work with updater functions', () => { @@ -804,9 +599,6 @@ describe('sessionStore', () => { actions.setActiveSessionId('a'); expect(useSessionStore.getState().activeSessionId).toBe('a'); - - actions.toggleBookmark('a'); - expect(useSessionStore.getState().sessions[0].bookmarked).toBe(true); }); it('replaces ref pattern: getState().sessions instead of sessionsRef.current', () => { @@ -832,7 +624,7 @@ describe('sessionStore', () => { // ======================================================================== describe('complex scenarios', () => { - it('handles session lifecycle: create → update → bookmark → remove', () => { + it('handles session lifecycle: create → update → remove', () => { // Create const session = createMockSession({ id: 'lifecycle', name: 'New Session' }); useSessionStore.getState().addSession(session); @@ -846,37 +638,9 @@ describe('sessionStore', () => { }); expect(selectActiveSession(useSessionStore.getState())?.name).toBe('Renamed Session'); - // Bookmark - useSessionStore.getState().toggleBookmark('lifecycle'); - expect(selectBookmarkedSessions(useSessionStore.getState())).toHaveLength(1); - // Remove useSessionStore.getState().removeSession('lifecycle'); expect(useSessionStore.getState().sessions).toHaveLength(0); - expect(selectBookmarkedSessions(useSessionStore.getState())).toHaveLength(0); - }); - - it('handles group lifecycle: create → add sessions → collapse → remove', () => { - // Create group - useSessionStore.getState().addGroup(createMockGroup({ id: 'g1', name: 'Backend' })); - - // Add sessions to group - useSessionStore - .getState() - .addSession(createMockSession({ id: 'a', groupId: 'g1', name: 'API' })); - useSessionStore - .getState() - .addSession(createMockSession({ id: 'b', groupId: 'g1', name: 'DB' })); - - expect(selectSessionsByGroup('g1')(useSessionStore.getState())).toHaveLength(2); - - // Collapse - useSessionStore.getState().toggleGroupCollapsed('g1'); - expect(useSessionStore.getState().groups[0].collapsed).toBe(true); - - // Remove group (sessions keep their groupId — that's handled by business logic) - useSessionStore.getState().removeGroup('g1'); - expect(useSessionStore.getState().groups).toHaveLength(0); }); it('handles concurrent updates from setSessions updater (batching pattern)', () => { @@ -907,7 +671,6 @@ describe('sessionStore', () => { createMockSession({ id: 'restored-1' }), createMockSession({ id: 'restored-2' }), ]); - useSessionStore.getState().setGroups([createMockGroup({ id: 'g1' })]); // Step 2: Mark as loaded useSessionStore.getState().setSessionsLoaded(true); diff --git a/src/renderer/stores/sessionStore.ts b/src/renderer/stores/sessionStore.ts index a00d5b76d..afc7298d9 100644 --- a/src/renderer/stores/sessionStore.ts +++ b/src/renderer/stores/sessionStore.ts @@ -1,9 +1,9 @@ /** - * sessionStore - Zustand store for centralized session and group state management + * sessionStore - Zustand store for centralized session state management * - * All session, group, active session, bookmark, worktree tracking, and - * initialization states live here. Components subscribe to individual slices - * via selectors to avoid unnecessary re-renders. + * All session, active session, worktree tracking, and initialization states + * live here. Components subscribe to individual slices via selectors to avoid + * unnecessary re-renders. * * Key advantages: * - Selector-based subscriptions: components only re-render when their slice changes @@ -76,35 +76,18 @@ export interface SessionStoreActions { */ setActiveSessionIdInternal: (id: string | ((prev: string) => string)) => void; - // === Groups === + // === Groups (legacy — retained for migration consumers) === /** * Set the groups array. Supports both direct value and functional updater. */ setGroups: (groups: Group[] | ((prev: Group[]) => Group[])) => void; - /** Add a single group. */ - addGroup: (group: Group) => void; - - /** Remove a group by ID. */ - removeGroup: (id: string) => void; - - /** Update a group by ID with a partial update. */ - updateGroup: (id: string, updates: Partial) => void; - - /** Toggle a group's collapsed state. */ - toggleGroupCollapsed: (id: string) => void; - // === Initialization === setSessionsLoaded: (loaded: boolean | ((prev: boolean) => boolean)) => void; setInitialLoadComplete: (complete: boolean | ((prev: boolean) => boolean)) => void; - // === Bookmarks === - - /** Toggle the bookmark flag on a session. */ - toggleBookmark: (sessionId: string) => void; - // === Worktree tracking === /** Mark a worktree path as removed (prevents re-discovery during this session). */ @@ -200,7 +183,7 @@ export const useSessionStore = create()((set) => ({ setActiveSessionIdInternal: (v) => set((s) => ({ activeSessionId: resolve(v, s.activeSessionId) })), - // Groups + // Groups (legacy — retained for migration consumers) setGroups: (v) => set((s) => { const newGroups = resolve(v, s.groups); @@ -208,47 +191,11 @@ export const useSessionStore = create()((set) => ({ return { groups: newGroups }; }), - addGroup: (group) => set((s) => ({ groups: [...s.groups, group] })), - - removeGroup: (id) => - set((s) => { - const filtered = s.groups.filter((g) => g.id !== id); - if (filtered.length === s.groups.length) return s; - return { groups: filtered }; - }), - - updateGroup: (id, updates) => - set((s) => { - let found = false; - const newGroups = s.groups.map((g) => { - if (g.id === id) { - found = true; - return { ...g, ...updates }; - } - return g; - }); - if (!found) return s; - return { groups: newGroups }; - }), - - toggleGroupCollapsed: (id) => - set((s) => ({ - groups: s.groups.map((g) => (g.id === id ? { ...g, collapsed: !g.collapsed } : g)), - })), - // Initialization setSessionsLoaded: (v) => set((s) => ({ sessionsLoaded: resolve(v, s.sessionsLoaded) })), setInitialLoadComplete: (v) => set((s) => ({ initialLoadComplete: resolve(v, s.initialLoadComplete) })), - // Bookmarks - toggleBookmark: (sessionId) => - set((s) => ({ - sessions: s.sessions.map((session) => - session.id === sessionId ? { ...session, bookmarked: !session.bookmarked } : session - ), - })), - // Worktree tracking addRemovedWorktreePath: (path) => set((s) => { @@ -330,35 +277,6 @@ export const selectSessionById = (state: SessionStore): Session | undefined => state.sessions.find((s) => s.id === id); -/** - * Select all bookmarked sessions. - * - * @example - * const bookmarked = useSessionStore(selectBookmarkedSessions); - */ -export const selectBookmarkedSessions = (state: SessionStore): Session[] => - state.sessions.filter((s) => s.bookmarked); - -/** - * Select sessions belonging to a specific group. - * - * @example - * const groupSessions = useSessionStore(selectSessionsByGroup('group-1')); - */ -export const selectSessionsByGroup = - (groupId: string) => - (state: SessionStore): Session[] => - state.sessions.filter((s) => s.groupId === groupId); - -/** - * Select ungrouped sessions (no groupId set). - * - * @example - * const ungrouped = useSessionStore(selectUngroupedSessions); - */ -export const selectUngroupedSessions = (state: SessionStore): Session[] => - state.sessions.filter((s) => !s.groupId && !s.parentSessionId); - /** * Select sessions belonging to a specific project. * @@ -370,17 +288,6 @@ export const selectSessionsByProject = (state: SessionStore): Session[] => state.sessions.filter((s) => s.projectId === projectId); -/** - * Select a group by ID. - * - * @example - * const group = useSessionStore(selectGroupById('group-1')); - */ -export const selectGroupById = - (id: string) => - (state: SessionStore): Group | undefined => - state.groups.find((g) => g.id === id); - /** * Select session count. * @@ -439,13 +346,8 @@ export function getSessionActions() { setActiveSessionId: state.setActiveSessionId, setActiveSessionIdInternal: state.setActiveSessionIdInternal, setGroups: state.setGroups, - addGroup: state.addGroup, - removeGroup: state.removeGroup, - updateGroup: state.updateGroup, - toggleGroupCollapsed: state.toggleGroupCollapsed, setSessionsLoaded: state.setSessionsLoaded, setInitialLoadComplete: state.setInitialLoadComplete, - toggleBookmark: state.toggleBookmark, addRemovedWorktreePath: state.addRemovedWorktreePath, setRemovedWorktreePaths: state.setRemovedWorktreePaths, setCyclePosition: state.setCyclePosition, From a56e3216ccdb38eb80f6cc82a598659ced6e6b56 Mon Sep 17 00:00:00 2001 From: VibeWriter User Date: Tue, 10 Mar 2026 14:01:59 -0400 Subject: [PATCH 18/20] fix: update persistence test for new projects IPC handlers Added mockProjectsStore to test setup and projects:getAll/projects:setAll to expected channels list. Added 5 new test cases for project persistence. Co-Authored-By: Claude Opus 4.6 --- .../main/ipc/handlers/persistence.test.ts | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/src/__tests__/main/ipc/handlers/persistence.test.ts b/src/__tests__/main/ipc/handlers/persistence.test.ts index 559b6bba3..04d79b1b1 100644 --- a/src/__tests__/main/ipc/handlers/persistence.test.ts +++ b/src/__tests__/main/ipc/handlers/persistence.test.ts @@ -15,6 +15,7 @@ import { MaestroSettings, SessionsData, GroupsData, + ProjectsData, } from '../../../../main/ipc/handlers/persistence'; import type Store from 'electron-store'; import type { WebServer } from '../../../../main/web-server'; @@ -72,6 +73,10 @@ describe('persistence IPC handlers', () => { get: ReturnType; set: ReturnType; }; + let mockProjectsStore: { + get: ReturnType; + set: ReturnType; + }; let mockWebServer: { getWebClientCount: ReturnType; broadcastThemeChange: ReturnType; @@ -103,6 +108,11 @@ describe('persistence IPC handlers', () => { set: vi.fn(), }; + mockProjectsStore = { + get: vi.fn().mockReturnValue([]), + set: vi.fn(), + }; + mockWebServer = { getWebClientCount: vi.fn().mockReturnValue(0), broadcastThemeChange: vi.fn(), @@ -125,6 +135,7 @@ describe('persistence IPC handlers', () => { settingsStore: mockSettingsStore as unknown as Store, sessionsStore: mockSessionsStore as unknown as Store, groupsStore: mockGroupsStore as unknown as Store, + projectsStore: mockProjectsStore as unknown as Store, getWebServer: getWebServerFn, }; registerPersistenceHandlers(deps); @@ -144,6 +155,8 @@ describe('persistence IPC handlers', () => { 'sessions:setAll', 'groups:getAll', 'groups:setAll', + 'projects:getAll', + 'projects:setAll', 'cli:getActivity', ]; @@ -261,6 +274,7 @@ describe('persistence IPC handlers', () => { settingsStore: mockSettingsStore as unknown as Store, sessionsStore: mockSessionsStore as unknown as Store, groupsStore: mockGroupsStore as unknown as Store, + projectsStore: mockProjectsStore as unknown as Store, getWebServer: () => null, }; registerPersistenceHandlers(deps); @@ -722,6 +736,66 @@ describe('persistence IPC handlers', () => { }); }); + describe('projects:getAll', () => { + it('should load projects from store', async () => { + const mockProjects = [ + { id: 'proj-1', name: 'Project 1', repoPath: '/code/proj1', createdAt: 1000 }, + { id: 'proj-2', name: 'Project 2', repoPath: '/code/proj2', createdAt: 2000 }, + ]; + mockProjectsStore.get.mockReturnValue(mockProjects); + + const handler = handlers.get('projects:getAll'); + const result = await handler!({} as any); + + expect(mockProjectsStore.get).toHaveBeenCalledWith('projects', []); + expect(result).toEqual(mockProjects); + }); + + it('should return empty array for missing projects', async () => { + mockProjectsStore.get.mockReturnValue([]); + + const handler = handlers.get('projects:getAll'); + const result = await handler!({} as any); + + expect(result).toEqual([]); + }); + }); + + describe('projects:setAll', () => { + it('should write projects to store', async () => { + const projects = [ + { id: 'proj-1', name: 'Project 1', repoPath: '/code/proj1', createdAt: 1000 }, + ]; + + const handler = handlers.get('projects:setAll'); + const result = await handler!({} as any, projects); + + expect(mockProjectsStore.set).toHaveBeenCalledWith('projects', projects); + expect(result).toBe(true); + }); + + it('should handle empty projects array', async () => { + const handler = handlers.get('projects:setAll'); + const result = await handler!({} as any, []); + + expect(mockProjectsStore.set).toHaveBeenCalledWith('projects', []); + expect(result).toBe(true); + }); + + it('should return false on ENOSPC write error', async () => { + const error = new Error('ENOSPC: no space left on device') as NodeJS.ErrnoException; + error.code = 'ENOSPC'; + mockProjectsStore.set.mockImplementation(() => { + throw error; + }); + + const handler = handlers.get('projects:setAll'); + const result = await handler!({} as any, [{ id: 'p1', name: 'P1', repoPath: '/p', createdAt: 1 }]); + + expect(result).toBe(false); + }); + }); + describe('cli:getActivity', () => { it('should return activities from CLI activity file', async () => { const mockActivities = [ From f8740897690562ffff2645ba8e57a3afa99bd5e7 Mon Sep 17 00:00:00 2001 From: VibeWriter User Date: Tue, 10 Mar 2026 14:09:16 -0400 Subject: [PATCH 19/20] docs: update CLAUDE.md for project-centric navigation Added Navigation Model section, ProjectSidebar references, and key files for project sidebar, inbox triggers, and project persistence. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index cdf336d87..b98510828 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,6 +31,7 @@ AI agents pay a token cost for every line loaded. This codebase uses tiered docu | `.claude/memory/features.md` | Usage Dashboard or Document Graph | | `.claude/memory/pitfalls.md` | Debugging UI or state issues | | `.claude/memory/build-deploy.md` | Build system, CI/CD, scripts | +| `.claude/memory/navigation.md` | Project-centric sidebar, inbox system, project restoration | ### Deep References (CLAUDE-*.md) @@ -93,13 +94,19 @@ Use "agent" in user-facing language. Reserve "session" for provider-level conver ### UI Components -- **Left Bar** - Left sidebar with agent list and groups (`SessionList.tsx`) +- **Left Bar** - Left sidebar with project list and inbox (`ProjectSidebar.tsx`). Legacy `SessionList.tsx` still exists but is no longer wired into `App.tsx`. - **Right Bar** - Right sidebar with Files, History, Auto Run tabs (`RightPanel.tsx`) - **Main Window** - Center workspace (`MainPanel.tsx`) - **AI Terminal** - Main window in AI mode (interacting with AI agents) - **Command Terminal** - Main window in terminal/shell mode - **System Log Viewer** - Special view for system logs (`LogViewer.tsx`) +### Navigation Model + +Maestro uses **project-centric navigation**: the left sidebar lists projects (repos), and selecting one scopes the tab bar to that project's sessions. An **Inbox** section at the top of the sidebar surfaces sessions that need attention (finished, errored, or waiting for input). Clicking an inbox item navigates to the project + session and auto-dismisses. + +Key stores: `projectStore` (projects, activeProjectId), `inboxStore` (attention items), `sessionStore` (sessions with `projectId` field). + ### Agent States (color-coded) - **Green** - Ready/idle @@ -136,7 +143,8 @@ See [[CLAUDE-AGENTS.md]] for capabilities and integration details. ## Quick Commands ```bash -npm run dev # Development with hot reload (isolated data, can run alongside production) +npm run dev # Development with hot reload (Unix/macOS only) +npm run dev:win # Development with hot reload (Windows — use this from VSCode/Claude Code) npm run dev:prod-data # Development using production data (close production app first) npm run dev:web # Web interface development npm run build # Full production build @@ -148,6 +156,25 @@ npm run test # Run test suite npm run test:watch # Run tests in watch mode ``` +### Launching on Windows (Important) + +**`npm run dev` does not work on Windows** — it uses Unix-style `NODE_ENV=development` syntax. + +Use `npm run dev:win` instead, which runs `scripts/start-dev.ps1`. This opens two PowerShell windows (renderer + main) and handles environment variables correctly. + +**ELECTRON_RUN_AS_NODE pitfall:** VSCode and Claude Code set `ELECTRON_RUN_AS_NODE=1` in their child process environment. This tells Electron to run as plain Node.js, which breaks `require('electron')` (returns a path string instead of the built-in module). The `start-dev.ps1` script clears this variable automatically. If launching Electron manually from a VSCode/Claude Code terminal, you must unset it first: + +```bash +# Bash (Git Bash / WSL) +unset ELECTRON_RUN_AS_NODE && NODE_ENV=development node_modules/electron/dist/electron.exe . + +# PowerShell +Remove-Item Env:ELECTRON_RUN_AS_NODE -ErrorAction SilentlyContinue +$env:NODE_ENV='development'; npx electron . +``` + +Make sure the Vite dev server is running on port 5173 first (`npm run dev:renderer`). + --- ## Architecture at a Glance @@ -230,6 +257,9 @@ src/ | Add Director's Notes feature | `src/renderer/components/DirectorNotes/`, `src/main/ipc/handlers/director-notes.ts` | | Add Encore Feature | `src/renderer/types/index.ts` (flag), `useSettings.ts` (state), `SettingsModal.tsx` (toggle UI), gate in `App.tsx` + keyboard handler | | Modify history components | `src/renderer/components/History/` | +| Modify project sidebar | `src/renderer/components/ProjectSidebar/`, `src/renderer/stores/projectStore.ts` | +| Add inbox trigger | `src/renderer/stores/inboxStore.ts`, `src/renderer/hooks/useInboxWatcher.ts` | +| Modify project persistence | `src/main/ipc/handlers/persistence.ts` (`projects:getAll`, `projects:setAll`), `src/main/preload.ts` | --- From 8873b3e758925703b8f2e18be478fdf8ba94fbc8 Mon Sep 17 00:00:00 2001 From: VibeWriter User Date: Tue, 10 Mar 2026 14:13:48 -0400 Subject: [PATCH 20/20] fix: add projectsStore to HandlerDependencies in IPC handler index The registerAllHandlers function was missing projectsStore in its deps, causing a TypeScript build error after adding projects persistence. Co-Authored-By: Claude Opus 4.6 --- src/main/ipc/handlers/index.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/ipc/handlers/index.ts b/src/main/ipc/handlers/index.ts index 1dfd2e805..8079d2c26 100644 --- a/src/main/ipc/handlers/index.ts +++ b/src/main/ipc/handlers/index.ts @@ -21,6 +21,7 @@ import { MaestroSettings, SessionsData, GroupsData, + ProjectsData, } from './persistence'; import { registerSystemHandlers, @@ -111,7 +112,7 @@ export type { DocumentGraphHandlerDependencies }; export type { SshRemoteHandlerDependencies }; export type { GitHandlerDependencies }; export type { SymphonyHandlerDependencies }; -export type { MaestroSettings, SessionsData, GroupsData }; +export type { MaestroSettings, SessionsData, GroupsData, ProjectsData }; /** * Interface for agent configuration store data @@ -150,6 +151,7 @@ export interface HandlerDependencies { // Persistence-specific dependencies sessionsStore: Store; groupsStore: Store; + projectsStore: Store; getWebServer: () => WebServer | null; // System-specific dependencies tunnelManager: TunnelManagerType; @@ -190,6 +192,7 @@ export function registerAllHandlers(deps: HandlerDependencies): void { settingsStore: deps.settingsStore, sessionsStore: deps.sessionsStore, groupsStore: deps.groupsStore, + projectsStore: deps.projectsStore, getWebServer: deps.getWebServer, }); registerSystemHandlers({