From bd54e973ff6410324940de7d7b4fd7a79ec86345 Mon Sep 17 00:00:00 2001 From: matt Date: Thu, 12 Feb 2026 05:54:04 +0000 Subject: [PATCH] wip --- .planning/STATE.md | 5 +- .../02-service-infrastructure/02-RESEARCH.md | 624 ++++++++++++++++++ .../phases/03-state-management/03-01-PLAN.md | 476 +++++++++++++ .../phases/03-state-management/03-RESEARCH.md | 618 +++++++++++++++++ .../phases/04-workspace-ui/04-01-SUMMARY.md | 97 +++ .../phases/04-workspace-ui/04-RESEARCH.md | 463 +++++++++++++ 6 files changed, 2282 insertions(+), 1 deletion(-) create mode 100644 .planning/phases/02-service-infrastructure/02-RESEARCH.md create mode 100644 .planning/phases/03-state-management/03-01-PLAN.md create mode 100644 .planning/phases/03-state-management/03-RESEARCH.md create mode 100644 .planning/phases/04-workspace-ui/04-01-SUMMARY.md create mode 100644 .planning/phases/04-workspace-ui/04-RESEARCH.md diff --git a/.planning/STATE.md b/.planning/STATE.md index 6bcfa1b3..a9794b49 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -65,6 +65,9 @@ Recent decisions affecting current work: - [Phase 03-01]: Exclude all transient state from snapshots (loading flags, errors, Maps/Sets) - [Phase 03-01]: Validate restored tabs against fresh project/worktree data from target context - [Phase 03-01]: Full-screen overlay prevents stale data flash during context transitions +- [Phase 04-01]: ContextSwitcher placed first in SidebarHeader Row 1, before project name, with vertical separator +- [Phase 04-01]: Cmd+Shift+K check placed before Cmd+K to avoid shortcut shadowing +- [Phase 04-01]: SSH status listener refreshes available contexts automatically on connection changes - [Phase 04-02]: HardDrive icon for Workspaces tab to differentiate from Server icon on Connection tab - [Phase 04-02]: WorkspaceSection manages own state internally (no props), matching ConnectionSection pattern - [Phase 04-02]: AppConfig type cast via unknown for ssh field access since AppConfig interface lacks ssh property @@ -88,7 +91,7 @@ None yet. - ✓ RESOLVED: Snapshot validation filters invalid tabs and ensures at-least-one-pane invariant (03-01) **Phase 4:** -- Context switcher placement in sidebar needs to fit with existing SidebarHeader without disrupting current layout +- ✓ RESOLVED: Context switcher placed in Row 1 before project name with vertical separator — fits naturally without disrupting layout (04-01) ## Session Continuity diff --git a/.planning/phases/02-service-infrastructure/02-RESEARCH.md b/.planning/phases/02-service-infrastructure/02-RESEARCH.md new file mode 100644 index 00000000..00a5f485 --- /dev/null +++ b/.planning/phases/02-service-infrastructure/02-RESEARCH.md @@ -0,0 +1,624 @@ +# Phase 2: Service Infrastructure - Research + +**Researched:** 2026-02-12 +**Domain:** Service lifecycle management, multi-context architecture, IPC routing +**Confidence:** HIGH + +## Summary + +Phase 2 establishes the infrastructure for managing multiple independent service contexts (local + N SSH connections) with proper lifecycle management, cleanup, and IPC routing. The core challenge is transforming a single-mode application into a multi-context system where the local context is always alive and each SSH connection gets its own isolated service instances. + +**Key insight:** The codebase already has all the building blocks needed — FileSystemProvider abstraction (Phase 1), EventEmitter-based services with cleanup paths, and module-level IPC handler references that support re-initialization. The registry pattern will coordinate these existing pieces rather than introducing fundamentally new mechanisms. + +**Primary recommendation:** Build ServiceContextRegistry as a Map-based coordinator that creates/destroys service bundles, route IPC requests using context ID stamping, and implement bulletproof dispose() methods on all EventEmitter-based services. + +## Standard Stack + +### Core +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| Node.js EventEmitter | Built-in | Event-driven service communication | Already used by FileWatcher, NotificationManager, SshConnectionManager | +| Map | Built-in | Context registry storage | Fast O(1) lookups, iteration support, built-in size tracking | +| Electron IPC (ipcMain/ipcRenderer) | 28.x | Process communication | Existing IPC infrastructure | + +### Supporting +| Library | Version | Purpose | When to Use | +|---------|---------|---------|-------------| +| ssh2 | Current (Phase 1) | SSH connections | Already integrated for SFTP | +| fs.FSWatcher | Built-in | File watching | Already used in FileWatcher service | + +### Alternatives Considered +| Instead of | Could Use | Tradeoff | +|------------|-----------|----------| +| Map | WeakMap | Would enable GC of contexts, but need explicit lifecycle control for cleanup | +| IPC context stamping | Separate IPC channels per context | Would explode channel count (5-10 channels × N contexts), harder to manage | +| Module-level service refs | Dependency injection container | More complex, unnecessary when re-initialization pattern already works | + +**Installation:** +No new dependencies required — using existing stack. + +## Architecture Patterns + +### Recommended Project Structure +``` +src/main/ +├── services/ +│ ├── infrastructure/ +│ │ ├── ServiceContext.ts # NEW: Context bundle class +│ │ ├── ServiceContextRegistry.ts # NEW: Registry coordinator +│ │ └── [existing services...] # MODIFY: Add dispose() methods +│ └── [domain services...] +├── ipc/ +│ ├── handlers.ts # MODIFY: Add context routing +│ ├── context.ts # NEW: Context management IPC +│ └── [domain handlers...] # MODIFY: Route via context ID +└── index.ts # MODIFY: Use registry instead of globals +``` + +### Pattern 1: Service Context Bundle +**What:** A ServiceContext class encapsulates all service instances for a single context (local or SSH). +**When to use:** For each workspace context that needs independent service lifecycle. + +**Example:** +```typescript +// Source: Based on existing service initialization in src/main/index.ts (lines 78-92) +export interface ServiceContextConfig { + id: string; + type: 'local' | 'ssh'; + fsProvider: FileSystemProvider; + projectsDir?: string; + todosDir?: string; +} + +export class ServiceContext { + readonly id: string; + readonly type: 'local' | 'ssh'; + + // Service instances + readonly projectScanner: ProjectScanner; + readonly sessionParser: SessionParser; + readonly subagentResolver: SubagentResolver; + readonly chunkBuilder: ChunkBuilder; + readonly dataCache: DataCache; + readonly fileWatcher: FileWatcher; + + constructor(config: ServiceContextConfig) { + this.id = config.id; + this.type = config.type; + + // Initialize services with provider + this.projectScanner = new ProjectScanner( + config.projectsDir, + config.todosDir, + config.fsProvider + ); + this.sessionParser = new SessionParser(this.projectScanner); + this.subagentResolver = new SubagentResolver(this.projectScanner); + this.chunkBuilder = new ChunkBuilder(); + this.dataCache = new DataCache(MAX_CACHE_SESSIONS, CACHE_TTL_MINUTES); + + // FileWatcher with provider + this.fileWatcher = new FileWatcher( + this.dataCache, + config.projectsDir, + config.todosDir, + config.fsProvider + ); + } + + // Start active services + start(): void { + this.fileWatcher.start(); + this.dataCache.startAutoCleanup(CACHE_CLEANUP_INTERVAL_MINUTES); + } + + // Critical: Proper cleanup + dispose(): void { + this.fileWatcher.stop(); + this.dataCache.clear(); + // FileSystemProvider cleanup handled by caller + } +} +``` + +### Pattern 2: Registry with Active Context Tracking +**What:** ServiceContextRegistry manages Map of contexts and tracks which one is currently active. +**When to use:** Single registry instance in main process coordinates all context operations. + +**Example:** +```typescript +// Source: Adapted from existing SshConnectionManager state management pattern +export class ServiceContextRegistry { + private contexts = new Map(); + private activeContextId: string = 'local'; + + constructor() { + // Local context is always alive + const localContext = this.createContext({ + id: 'local', + type: 'local', + fsProvider: new LocalFileSystemProvider(), + }); + this.contexts.set('local', localContext); + localContext.start(); + } + + getActive(): ServiceContext { + const context = this.contexts.get(this.activeContextId); + if (!context) { + throw new Error(`Active context ${this.activeContextId} not found`); + } + return context; + } + + switch(contextId: string): void { + if (!this.contexts.has(contextId)) { + throw new Error(`Context ${contextId} does not exist`); + } + this.activeContextId = contextId; + } + + createSshContext( + id: string, + fsProvider: FileSystemProvider, + projectsDir: string + ): ServiceContext { + if (this.contexts.has(id)) { + throw new Error(`Context ${id} already exists`); + } + const context = this.createContext({ + id, + type: 'ssh', + fsProvider, + projectsDir, + }); + this.contexts.set(id, context); + context.start(); + return context; + } + + destroy(contextId: string): void { + if (contextId === 'local') { + throw new Error('Cannot destroy local context'); + } + const context = this.contexts.get(contextId); + if (context) { + context.dispose(); + this.contexts.delete(contextId); + } + } + + private createContext(config: ServiceContextConfig): ServiceContext { + return new ServiceContext(config); + } +} +``` + +### Pattern 3: IPC Context Routing +**What:** IPC handlers read context ID from event args and route to correct service context. +**When to use:** All session-data IPC handlers (projects, sessions, search, subagents). + +**Example:** +```typescript +// Source: Based on existing IPC handler pattern in src/main/ipc/sessions.ts +let contextRegistry: ServiceContextRegistry; + +export function initializeContextHandlers(registry: ServiceContextRegistry): void { + contextRegistry = registry; +} + +// Modified handler with context routing +ipcMain.handle('get-projects', async (event, contextId?: string) => { + const context = contextId + ? contextRegistry.get(contextId) + : contextRegistry.getActive(); + + if (!context) { + return []; + } + + return context.projectScanner.scan(); +}); +``` + +### Pattern 4: EventEmitter Dispose Pattern +**What:** Services extending EventEmitter must remove all listeners and clear resources in dispose(). +**When to use:** FileWatcher, NotificationManager, and any EventEmitter-based service. + +**Example:** +```typescript +// Source: Best practices from Node.js EventEmitter cleanup research +export class FileWatcher extends EventEmitter { + private projectsWatcher: fs.FSWatcher | null = null; + private todosWatcher: fs.FSWatcher | null = null; + private debounceTimers = new Map(); + private catchUpTimer: NodeJS.Timeout | null = null; + private pollingTimer: NodeJS.Timeout | null = null; + + dispose(): void { + // Stop watchers first + this.stop(); + + // Clear all timers + for (const timer of this.debounceTimers.values()) { + clearTimeout(timer); + } + this.debounceTimers.clear(); + + if (this.catchUpTimer) { + clearInterval(this.catchUpTimer); + this.catchUpTimer = null; + } + + if (this.pollingTimer) { + clearInterval(this.pollingTimer); + this.pollingTimer = null; + } + + // CRITICAL: Remove ALL listeners to prevent memory leaks + this.removeAllListeners(); + + // Clear tracking state + this.lastProcessedLineCount.clear(); + this.lastProcessedSize.clear(); + this.activeSessionFiles.clear(); + this.processingInProgress.clear(); + this.pendingReprocess.clear(); + } +} +``` + +### Anti-Patterns to Avoid + +- **Global service instances:** Don't keep services as global module variables when using registry — use registry.getActive() instead +- **Async dispose:** Keep dispose() synchronous — cleanup should be immediate and deterministic +- **Partial cleanup:** Missing even one timer or listener causes memory leaks — use comprehensive checklists +- **Shared caches across contexts:** Each context must have its own DataCache instance to prevent cross-contamination + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| Service lifecycle management | Custom dependency injection container | Map-based registry with explicit create/dispose | DI containers add complexity; explicit lifecycle is easier to debug | +| IPC routing | Separate channels per context | Context ID stamping on existing channels | Channel explosion (N contexts × M channels); harder to manage | +| Event cleanup | Manual tracking of each listener | removeAllListeners() in dispose() | Guaranteed cleanup; prevents missed listeners | +| Context switching race conditions | Custom mutex/lock | Node.js single-threaded execution + synchronous switch | IPC handlers run sequentially; no parallelism within main process | + +**Key insight:** The main process is single-threaded, so context switching is naturally serialized. No need for complex synchronization primitives. + +## Common Pitfalls + +### Pitfall 1: Memory Leaks from Orphaned EventEmitter Listeners +**What goes wrong:** Services extend EventEmitter but don't call removeAllListeners() in dispose(), causing listeners to persist after context destruction. + +**Why it happens:** EventEmitter automatically manages a listeners array, but it never auto-clears. Each listener closure captures references to the service instance and any data it touches. + +**How to avoid:** +- Add dispose() method to ALL services extending EventEmitter +- Call removeAllListeners() at the END of dispose() (after stopping watchers/timers) +- Test disposal by creating/destroying context 100+ times and monitoring memory + +**Warning signs:** +- MaxListenersExceededWarning after multiple context switches +- Memory usage climbs 50-100MB per switch without leveling off +- DevTools heap snapshot shows growing EventEmitter listener arrays + +**Example fix:** +```typescript +// Source: Verified pattern from FileWatcher.ts stop() method +dispose(): void { + // 1. Stop active operations + if (this.projectsWatcher) { + this.projectsWatcher.close(); + this.projectsWatcher = null; + } + + // 2. Clear timers/intervals + if (this.retryTimer) { + clearTimeout(this.retryTimer); + this.retryTimer = null; + } + + // 3. Clear data structures + this.debounceTimers.clear(); + this.lastProcessedLineCount.clear(); + + // 4. CRITICAL: Remove ALL listeners LAST + this.removeAllListeners(); +} +``` + +### Pitfall 2: File Watcher Cross-Context Pollution +**What goes wrong:** FileWatcher in background SSH context emits events that trigger IPC sends for wrong context, causing UI to show stale data. + +**Why it happens:** FileWatcher.on('file-change') forwards to renderer via mainWindow.webContents.send(). If watcher stays alive in background context, it keeps emitting for inactive context. + +**How to avoid:** +- Option A: Only start FileWatcher for active context, stop on switch +- Option B: Scope events with contextId, filter in renderer +- **Recommended:** Option A — simpler, no renderer filtering needed + +**Warning signs:** +- Switching from SSH to local shows SSH file change notifications +- Project list updates with data from inactive context +- Cache invalidation affects wrong context + +**Implementation:** +```typescript +// In ServiceContextRegistry.switch() +switch(newContextId: string): void { + const oldContext = this.getActive(); + + // Stop file watcher in old context + oldContext.fileWatcher.stop(); + + // Switch active + this.activeContextId = newContextId; + const newContext = this.getActive(); + + // Start file watcher in new context + newContext.fileWatcher.start(); +} +``` + +### Pitfall 3: IPC Handler Re-initialization Timing +**What goes wrong:** Switching contexts before re-initializing IPC handlers causes next IPC call to use old service instances from previous context. + +**Why it happens:** Module-level variables in ipc/sessions.ts etc. hold service references. Calling registry.switch() doesn't update those refs until reinitializeServiceHandlers() is called. + +**How to avoid:** +- Always call reinitializeServiceHandlers() IMMEDIATELY after registry.switch() +- Make switch() method handle re-init internally, don't rely on caller +- Verify with integration test: switch context, call IPC, check which projectsDir was scanned + +**Warning signs:** +- Switching to SSH shows local projects for first query +- Race condition where sometimes switch works, sometimes shows stale data +- Logs show scans hitting wrong directory path + +**Implementation:** +```typescript +// In ServiceContextRegistry +switch(newContextId: string): void { + this.activeContextId = newContextId; + const newContext = this.getActive(); + + // CRITICAL: Re-init IPC handlers with new context's services + reinitializeServiceHandlers( + newContext.projectScanner, + newContext.sessionParser, + newContext.subagentResolver, + newContext.chunkBuilder, + newContext.dataCache + ); +} +``` + +### Pitfall 4: SSH Connection Manager Lifecycle Confusion +**What goes wrong:** SshConnectionManager owns the SSH client and SftpWrapper, but ServiceContext also needs to dispose its SshFileSystemProvider. Double-dispose or leaked connections result. + +**Why it happens:** Ownership unclear — does SshConnectionManager own the connection, or does ServiceContext? + +**How to avoid:** +- **SshConnectionManager owns:** Client, SFTP channel, SshFileSystemProvider instance +- **ServiceContext receives:** Pre-created FileSystemProvider (interface, not lifecycle) +- **On context destroy:** ServiceContext calls fsProvider.dispose() to end SFTP, SshConnectionManager tracks this and cleans up Client +- **Clean separation:** Context disposal -> provider disposal -> connection manager cleanup chain + +**Warning signs:** +- SFTP channel stays open after context destroy +- Multiple SFTP channels open for same SSH connection +- "Channel already closed" errors on reconnect + +**Implementation:** +```typescript +// ServiceContext.dispose() +dispose(): void { + this.fileWatcher.stop(); + this.dataCache.clear(); + + // Dispose FileSystemProvider (closes SFTP if SSH) + if (this.fsProvider.type === 'ssh') { + this.fsProvider.dispose(); + } +} + +// SshConnectionManager watches for provider disposal +connect(config: SshConnectionConfig): Promise { + // Create SFTP + const sftp = await this.openSftp(); + const provider = new SshFileSystemProvider(sftp); + + // Track this provider + this.activeProviders.add(provider); + + return provider; +} + +// When provider.dispose() is called, it ends SFTP +// Connection manager can detect and clean up client +``` + +## Code Examples + +Verified patterns from existing codebase and research: + +### Service Bundle Creation +```typescript +// Source: Adapted from src/main/index.ts initializeServices() pattern +export class ServiceContext { + constructor(config: ServiceContextConfig) { + this.id = config.id; + this.type = config.type; + + // Chain dependencies: ProjectScanner -> SessionParser/SubagentResolver + this.projectScanner = new ProjectScanner( + config.projectsDir, + config.todosDir, + config.fsProvider + ); + this.sessionParser = new SessionParser(this.projectScanner); + this.subagentResolver = new SubagentResolver(this.projectScanner); + this.chunkBuilder = new ChunkBuilder(); + + // Isolated cache per context + this.dataCache = new DataCache( + MAX_CACHE_SESSIONS, + CACHE_TTL_MINUTES, + true // enabled + ); + + // FileWatcher with context-specific provider + this.fileWatcher = new FileWatcher( + this.dataCache, + config.projectsDir, + config.todosDir, + config.fsProvider + ); + } +} +``` + +### Registry Initialization in Main Process +```typescript +// Source: New pattern replacing src/main/index.ts service initialization +let contextRegistry: ServiceContextRegistry; + +function initializeServices(): void { + logger.info('Initializing service context registry...'); + + // Registry creates local context internally + contextRegistry = new ServiceContextRegistry(); + + // Initialize IPC with registry + initializeIpcHandlers(contextRegistry, sshConnectionManager); + + logger.info('Service context registry initialized'); +} +``` + +### IPC Handler with Context Routing +```typescript +// Source: Pattern for src/main/ipc/sessions.ts modification +let registry: ServiceContextRegistry; + +export function initializeSessionHandlers(reg: ServiceContextRegistry): void { + registry = reg; +} + +export function registerSessionHandlers(ipcMain: IpcMain): void { + ipcMain.handle('get-session-detail', async (event, projectId, sessionId, contextId?) => { + try { + const context = contextId ? registry.get(contextId) : registry.getActive(); + if (!context) { + return null; + } + + const sessionPath = context.projectScanner.getSessionPath(projectId, sessionId); + const messages = await parseJsonlFile( + sessionPath, + context.projectScanner.getFileSystemProvider() + ); + + // ... rest of handler using context services + } catch (error) { + logger.error('Error getting session detail:', error); + return null; + } + }); +} +``` + +### Context Creation Flow +```typescript +// Source: Integration of SshConnectionManager with ServiceContextRegistry +async function handleSshConnect(config: SshConnectionConfig): Promise { + // 1. Connect SSH (creates provider) + await sshConnectionManager.connect(config); + const provider = sshConnectionManager.getProvider(); + const projectsPath = sshConnectionManager.getRemoteProjectsPath(); + + // 2. Create context with SSH provider + const contextId = `ssh-${config.host}`; + const context = contextRegistry.createSshContext( + contextId, + provider, + projectsPath + ); + + // 3. Switch to new context + contextRegistry.switch(contextId); + + // 4. Notify renderer + if (mainWindow) { + mainWindow.webContents.send('context-changed', contextId); + } + + return contextId; +} +``` + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| Global service instances in main/index.ts | ServiceContext bundles in registry | Phase 2 (this phase) | Enables multiple contexts, proper isolation | +| Mode switching destroys/recreates all services | Local context always alive, SSH contexts independent | Phase 2 | Local data never lost, faster switching | +| Single FileSystemProvider swapped on mode change | Provider per context, passed to services at creation | Phase 1 (completed) | Foundation for multi-context | +| IPC handlers hold direct service refs | IPC handlers route via contextId to registry | Phase 2 | Enables context-aware routing | +| FileWatcher events global | FileWatcher scoped to active context | Phase 2 | Prevents cross-context pollution | + +**Deprecated/outdated:** +- **handleModeSwitch callback in main/index.ts:** Will be replaced by registry.switch() method +- **Global projectScanner/sessionParser variables:** Will be replaced by registry.getActive().projectScanner +- **reinitializeServiceHandlers() called manually:** Will be called automatically by registry.switch() + +## Open Questions + +1. **Should inactive SSH contexts keep FileWatcher running?** + - What we know: FileWatcher can run in background (SSH polling mode exists) + - What's unclear: Performance impact of N watchers polling simultaneously + - Recommendation: Start with "only active context watches" approach (simpler), measure performance, add background watching if users request it + +2. **How to handle context switching during active IPC request?** + - What we know: Node.js event loop serializes IPC handlers + - What's unclear: Can registry.switch() be called mid-request? + - Recommendation: Make switch() synchronous and immediate — in-flight requests complete with old context, next request uses new context. Document this behavior. + +3. **Should DataCache be shared across contexts or isolated?** + - What we know: Each SessionDetail is context-specific (local vs SSH paths differ) + - What's unclear: Could shared cache with composite keys work? + - Recommendation: Isolate caches — simpler, avoids key collision risks, allows per-context TTL tuning + +4. **How to persist context metadata across app restarts?** + - What we know: Need to restore SSH contexts on app restart + - What's unclear: Where to persist (ConfigManager? Separate state file?) + - Recommendation: Add sshContexts array to ConfigManager schema, store connection profiles + last active context ID + +## Sources + +### Primary (HIGH confidence) +- Codebase analysis: src/main/index.ts, src/main/services/infrastructure/FileWatcher.ts, src/main/ipc/handlers.ts +- FileSystemProvider abstraction (Phase 1): src/main/services/infrastructure/FileSystemProvider.ts +- Existing EventEmitter usage: FileWatcher, NotificationManager, SshConnectionManager + +### Secondary (MEDIUM confidence) +- [Process Model | Electron](https://www.electronjs.org/docs/latest/tutorial/process-model) — Electron main process lifecycle +- [Inter-Process Communication | Electron](https://www.electronjs.org/docs/latest/tutorial/ipc) — IPC patterns and channel management +- [How to fix possible EventEmitter memory leak detected](https://cri.dev/posts/2020-07-16-How-to-fix-possible-EventEmitter-memory-leak-detected/) — EventEmitter cleanup patterns +- [Dependency injection in ASP.NET Core | Microsoft Learn](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/dependency-injection) — Service lifetime and disposal patterns +- [Scaling 1M lines of TypeScript: Registries](https://puzzles.slash.com/blog/scaling-1m-lines-of-typescript-registries) — Registry pattern for large codebases + +### Tertiary (LOW confidence) +- [Advanced Electron.js architecture - LogRocket Blog](https://blog.logrocket.com/advanced-electron-js-architecture/) — General Electron architecture patterns +- [How to Profile Node.js Applications for Memory Leaks](https://oneuptime.com/blog/post/2026-01-26-nodejs-memory-leak-profiling/view) — Memory leak detection techniques + +## Metadata + +**Confidence breakdown:** +- Standard stack: HIGH - All components already in codebase (EventEmitter, Map, IPC) +- Architecture: HIGH - Patterns verified against existing services and IPC handlers +- Pitfalls: HIGH - Identified from EventEmitter research + codebase analysis of FileWatcher/SshConnectionManager + +**Research date:** 2026-02-12 +**Valid until:** 60 days (2026-04-12) — stable domain, established patterns diff --git a/.planning/phases/03-state-management/03-01-PLAN.md b/.planning/phases/03-state-management/03-01-PLAN.md new file mode 100644 index 00000000..d30cc466 --- /dev/null +++ b/.planning/phases/03-state-management/03-01-PLAN.md @@ -0,0 +1,476 @@ +--- +phase: 03-state-management +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - src/renderer/services/contextStorage.ts + - src/renderer/store/slices/contextSlice.ts + - src/renderer/store/types.ts + - src/renderer/store/index.ts + - src/renderer/hooks/useContextSwitch.ts + - src/renderer/components/common/ContextSwitchOverlay.tsx + - src/renderer/App.tsx +autonomous: true + +must_haves: + truths: + - "Switching from local to SSH and back restores exact tab state, selected project, and sidebar selections" + - "First-time switch to a new SSH context shows empty state with dashboard tab (not stale local data)" + - "Previously visited context restores instantly from snapshot without refetching" + - "Loading overlay covers entire app during context switch to prevent stale data flash" + - "Context snapshots survive app restart via IndexedDB persistence" + - "Expired snapshots (older than 5 minutes) are treated as missing and cleaned up" + artifacts: + - path: "src/renderer/services/contextStorage.ts" + provides: "IndexedDB persistence layer for context snapshots with TTL" + exports: ["contextStorage"] + - path: "src/renderer/store/slices/contextSlice.ts" + provides: "Zustand slice for context switching state and snapshot/restore orchestration" + exports: ["ContextSlice", "createContextSlice"] + - path: "src/renderer/hooks/useContextSwitch.ts" + provides: "React hook that exposes context switch action to components" + exports: ["useContextSwitch"] + - path: "src/renderer/components/common/ContextSwitchOverlay.tsx" + provides: "Full-screen loading overlay shown during context transitions" + exports: ["ContextSwitchOverlay"] + key_links: + - from: "src/renderer/store/slices/contextSlice.ts" + to: "src/renderer/services/contextStorage.ts" + via: "import contextStorage; called in captureSnapshot/restoreSnapshot actions" + pattern: "contextStorage\\.(saveSnapshot|loadSnapshot)" + - from: "src/renderer/store/slices/contextSlice.ts" + to: "src/renderer/store/index.ts" + via: "createContextSlice composed into useStore" + pattern: "createContextSlice" + - from: "src/renderer/hooks/useContextSwitch.ts" + to: "src/renderer/store/index.ts" + via: "useStore selector for switchContext action" + pattern: "useStore.*switchContext" + - from: "src/renderer/components/common/ContextSwitchOverlay.tsx" + to: "src/renderer/store/index.ts" + via: "useStore selector for isContextSwitching state" + pattern: "useStore.*isContextSwitching" + - from: "src/renderer/App.tsx" + to: "src/renderer/components/common/ContextSwitchOverlay.tsx" + via: "ContextSwitchOverlay rendered at root level" + pattern: " +Implement the complete context snapshot/restore system for instant workspace switching. + +Purpose: When users switch between local and SSH workspaces, their exact UI state (open tabs, selected project, sidebar selections, scroll positions) must be captured, persisted to IndexedDB, and restored instantly on switch-back. New contexts show clean empty state. A full-screen overlay prevents stale data flash during transitions. + +Output: contextSlice (Zustand), contextStorage (IndexedDB), useContextSwitch hook, ContextSwitchOverlay component, all wired into the store and App. + + + +@/home/bskim/.claude/get-shit-done/workflows/execute-plan.md +@/home/bskim/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/03-state-management/03-RESEARCH.md +@.planning/phases/02-service-infrastructure/02-03-SUMMARY.md + +# Key existing files to reference: +@src/renderer/store/index.ts +@src/renderer/store/types.ts +@src/renderer/store/slices/connectionSlice.ts +@src/renderer/store/utils/stateResetHelpers.ts +@src/renderer/App.tsx +@src/renderer/store/slices/projectSlice.ts +@src/renderer/store/slices/tabSlice.ts +@src/renderer/store/slices/paneSlice.ts +@src/renderer/store/slices/repositorySlice.ts +@src/renderer/store/slices/sessionSlice.ts +@src/renderer/store/slices/notificationSlice.ts +@src/renderer/store/slices/sessionDetailSlice.ts +@src/renderer/store/slices/conversationSlice.ts +@src/renderer/store/slices/uiSlice.ts + + + + + + Task 1: Create IndexedDB storage layer and contextSlice + + src/renderer/services/contextStorage.ts + src/renderer/store/slices/contextSlice.ts + + +**Install idb-keyval:** +```bash +pnpm add idb-keyval +``` + +**Create `src/renderer/services/contextStorage.ts`:** +IndexedDB persistence layer using `idb-keyval` (get, set, del, keys). Implements: +- `SNAPSHOT_TTL_MS = 5 * 60 * 1000` (5-minute TTL) +- `STORAGE_KEY_PREFIX = 'context-snapshot:'` +- `StoredSnapshot` interface: `{ snapshot: ContextSnapshot, timestamp: number, version: number }` +- `saveSnapshot(contextId, snapshot)` — wraps in StoredSnapshot with timestamp, saves via `set()` +- `loadSnapshot(contextId)` — loads via `get()`, checks TTL, returns null if expired (deletes expired entry) +- `deleteSnapshot(contextId)` — deletes via `del()` +- `cleanupExpired()` — iterates all keys with prefix, deletes expired entries +- `isAvailable()` — returns boolean indicating whether IndexedDB is accessible (try/catch around a test `set`/`del`) +- Export as `contextStorage` object (not class) +- All methods are async and handle errors gracefully (catch + console.error + return safe defaults) + +**Create `src/renderer/store/slices/contextSlice.ts`:** +Zustand slice managing context switching lifecycle. Interface: + +```typescript +export interface ContextSlice { + // State + activeContextId: string; // 'local' initially + isContextSwitching: boolean; // true during switch transition + targetContextId: string | null; // context being switched to + contextSnapshotsReady: boolean; // true after initial IndexedDB check + + // Actions + switchContext: (targetContextId: string) => Promise; + initializeContextSystem: () => Promise; +} +``` + +**`ContextSnapshot` type** — define within contextSlice.ts (not exported from types.ts to keep it internal): +```typescript +interface ContextSnapshot { + // Data state (persistable) + projects: Project[]; + selectedProjectId: string | null; + repositoryGroups: RepositoryGroup[]; + selectedRepositoryId: string | null; + selectedWorktreeId: string | null; + viewMode: 'flat' | 'grouped'; + sessions: Session[]; + selectedSessionId: string | null; + sessionsCursor: string | null; + sessionsHasMore: boolean; + sessionsTotalCount: number; + pinnedSessionIds: string[]; + notifications: DetectedError[]; + unreadCount: number; + + // Tab/pane state + openTabs: Tab[]; + activeTabId: string | null; + selectedTabIds: string[]; + activeProjectId: string | null; + paneLayout: PaneLayout; + + // UI state + sidebarCollapsed: boolean; + + // Metadata + _metadata: { + contextId: string; + capturedAt: number; + version: number; + }; +} +``` + +**IMPORTANT exclusions from snapshot** (transient state that must NOT be persisted): +- All `*Loading` flags (projectsLoading, sessionsLoading, etc.) +- All `*Error` flags (projectsError, sessionsError, etc.) +- sessionDetail, conversation, sessionClaudeMdStats, sessionContextStats, sessionPhaseInfo (per-session detail data — too large and stale) +- tabSessionData (per-tab cached data — will be re-fetched) +- tabUIStates (per-tab expansion state — Set/Map types not serializable) +- searchQuery, searchVisible, searchMatches, etc. (transient search state) +- commandPaletteOpen (transient UI) +- connectionMode, connectionState, connectedHost, etc. (connection state managed separately) +- configState (managed by ConfigManager, not per-context) +- update state (app-level, not per-context) +- conversationSlice expansion states (not serializable Maps/Sets) +- sessionsLoadingMore (transient) + +**`switchContext` action implementation:** +1. Early return if `targetContextId === activeContextId` +2. Set `isContextSwitching: true, targetContextId` +3. Capture current context's snapshot: extract persistable state from `get()`, create ContextSnapshot +4. Save snapshot to IndexedDB via `contextStorage.saveSnapshot(activeContextId, snapshot)` +5. Call `window.electronAPI.context.switch(targetContextId)` to switch main process context +6. Attempt to restore snapshot for target: `contextStorage.loadSnapshot(targetContextId)` +7. If snapshot exists: apply via `set()` with validated state (see validation in Task 3) +8. If no snapshot: apply empty context state via `getEmptyContextState()` helper +9. Fetch fresh data: `fetchProjects()`, `fetchRepositoryGroups()`, `fetchNotifications()` +10. Set `isContextSwitching: false, targetContextId: null, activeContextId: targetContextId` +11. Wrap in try/catch — on error, log, set isContextSwitching: false, do NOT leave in broken state + +**`getEmptyContextState()` helper** (internal function): +Returns Partial with empty arrays, null selections, single-pane dashboard layout. Pattern: same as `getFullResetState()` but also resets tabs to empty dashboard tab and single pane. + +**`initializeContextSystem` action:** +1. Check IndexedDB availability via `contextStorage.isAvailable()` +2. If available: run `contextStorage.cleanupExpired()` to purge stale snapshots +3. Set `contextSnapshotsReady: true` +4. Fetch active context from main process: `window.electronAPI.context.getActive()` and set `activeContextId` + +Follow existing slice patterns: use `StateCreator`, export `createContextSlice`. + + + pnpm typecheck passes. Both files exist with correct exports. idb-keyval in package.json dependencies. + + + contextStorage provides IndexedDB save/load/delete/cleanup with TTL. contextSlice provides switchContext and initializeContextSystem actions with proper snapshot capture/restore flow. Transient state (loading, errors, search, Maps/Sets) excluded from snapshots. + + + + + Task 2: Create overlay component, hook, and wire into store + + src/renderer/components/common/ContextSwitchOverlay.tsx + src/renderer/hooks/useContextSwitch.ts + src/renderer/store/types.ts + src/renderer/store/index.ts + + +**Create `src/renderer/components/common/ContextSwitchOverlay.tsx`:** +Full-screen loading overlay displayed during context switches. Uses theme CSS variables for consistency. + +```tsx +// Functional component, no props needed +// Reads isContextSwitching and targetContextId from useStore +// If !isContextSwitching, return null +// Render: fixed inset-0 div with bg-surface z-[9999], centered spinner + text +// Spinner: animate-spin h-8 w-8 border-4 border-text border-t-transparent rounded-full +// Text: "Switching to {contextLabel}..." where contextLabel is: +// - 'Local' if targetContextId === 'local' +// - targetContextId with 'ssh-' prefix stripped otherwise +// Use Tailwind classes from project theme (bg-surface, text-text, text-text-secondary) +``` + +**Create `src/renderer/hooks/useContextSwitch.ts`:** +Thin hook exposing context switch to components. + +```typescript +import { useCallback } from 'react'; +import { useStore } from '../store'; + +export function useContextSwitch() { + const switchContext = useStore(state => state.switchContext); + const isContextSwitching = useStore(state => state.isContextSwitching); + const activeContextId = useStore(state => state.activeContextId); + + const handleSwitch = useCallback(async (targetContextId: string) => { + await switchContext(targetContextId); + }, [switchContext]); + + return { + switchContext: handleSwitch, + isContextSwitching, + activeContextId, + }; +} +``` + +**Update `src/renderer/store/types.ts`:** +- Import `ContextSlice` from `./slices/contextSlice` +- Add `ContextSlice` to the `AppState` intersection type + +**Update `src/renderer/store/index.ts`:** +- Import `createContextSlice` from `./slices/contextSlice` +- Add `...createContextSlice(...args)` to the store creation (compose with other slices) +- Do NOT add it to `initializeNotificationListeners` yet (that's Task 3) + + + pnpm typecheck passes. ContextSwitchOverlay, useContextSwitch, updated types.ts and index.ts all compile without errors. useStore now includes ContextSlice properties. + + + ContextSwitchOverlay renders loading state during context switch. useContextSwitch hook exposes switchContext/isContextSwitching/activeContextId. Store types and creation updated to include contextSlice. + + + + + Task 3: Wire overlay into App, add context event listener, add snapshot validation + + src/renderer/App.tsx + src/renderer/store/index.ts + src/renderer/store/slices/contextSlice.ts + + +**Update `src/renderer/App.tsx`:** +- Import `ContextSwitchOverlay` from `./components/common/ContextSwitchOverlay` +- Import `useStore` from `./store` +- Add a `useEffect` that calls `useStore.getState().initializeContextSystem()` on mount (before notification listeners init) +- Render `` as first child inside ``, before `` + +**Update `src/renderer/store/index.ts` — add context:onChanged listener:** +In `initializeNotificationListeners()`, add a listener for context change events from main process: + +```typescript +// Listen for context changes from main process (e.g., SSH disconnect) +if (window.electronAPI.context?.onChanged) { + const cleanup = window.electronAPI.context.onChanged((_event: unknown, data: unknown) => { + const { contextId } = data as { contextId: string }; + const currentContextId = useStore.getState().activeContextId; + if (contextId !== currentContextId) { + // Main process switched context externally (e.g., SSH disconnect) + // Trigger renderer-side context switch to sync state + void useStore.getState().switchContext(contextId); + } + }); + if (typeof cleanup === 'function') { + cleanupFns.push(cleanup); + } +} +``` + +**Add snapshot validation to `src/renderer/store/slices/contextSlice.ts`:** +Add a `validateSnapshot` internal function called during `restoreSnapshot` step of `switchContext`: + +```typescript +function validateSnapshot( + snapshot: ContextSnapshot, + freshProjects: Project[], + freshRepoGroups: RepositoryGroup[] +): Partial { + const validProjectIds = new Set(freshProjects.map(p => p.id)); + const validWorktreeIds = new Set( + freshRepoGroups.flatMap(rg => rg.worktrees.map(w => w.id)) + ); + + // Validate selectedProjectId + const selectedProjectId = snapshot.selectedProjectId && validProjectIds.has(snapshot.selectedProjectId) + ? snapshot.selectedProjectId + : null; + + // Validate selectedRepositoryId and selectedWorktreeId + const selectedRepositoryId = snapshot.selectedRepositoryId; // repos may differ but allow graceful fallback + const selectedWorktreeId = snapshot.selectedWorktreeId && validWorktreeIds.has(snapshot.selectedWorktreeId) + ? snapshot.selectedWorktreeId + : null; + + // Validate tabs — filter out session tabs referencing invalid projects + const validTabs = snapshot.openTabs.filter(tab => { + if (tab.type === 'session' && tab.projectId) { + return validProjectIds.has(tab.projectId) || validWorktreeIds.has(tab.projectId); + } + return true; // Keep dashboard and non-session tabs + }); + + // Validate activeTabId + let activeTabId = snapshot.activeTabId; + if (activeTabId && !validTabs.find(t => t.id === activeTabId)) { + activeTabId = validTabs[0]?.id ?? null; + } + + // Validate pane layout tabs + const validatedPanes = snapshot.paneLayout.panes.map(pane => { + const paneTabs = pane.tabs.filter(tab => { + if (tab.type === 'session' && tab.projectId) { + return validProjectIds.has(tab.projectId) || validWorktreeIds.has(tab.projectId); + } + return true; + }); + const paneActiveId = paneTabs.find(t => t.id === pane.activeTabId) + ? pane.activeTabId + : paneTabs[0]?.id ?? null; + return { + ...pane, + tabs: paneTabs, + activeTabId: paneActiveId, + selectedTabIds: pane.selectedTabIds.filter(id => paneTabs.some(t => t.id === id)), + }; + }).filter(pane => pane.tabs.length > 0); // Remove empty panes + + // Ensure at least one pane exists + const finalPanes = validatedPanes.length > 0 + ? validatedPanes + : [{ + id: 'pane-default', + tabs: [], + activeTabId: null, + selectedTabIds: [], + widthFraction: 1, + }]; + + return { + // Restored from snapshot + projects: freshProjects, // Use fresh data, not snapshot's stale list + selectedProjectId, + repositoryGroups: freshRepoGroups, // Use fresh data + selectedRepositoryId, + selectedWorktreeId, + viewMode: snapshot.viewMode, + sessions: snapshot.sessions, + selectedSessionId: snapshot.selectedSessionId, + sessionsCursor: snapshot.sessionsCursor, + sessionsHasMore: snapshot.sessionsHasMore, + sessionsTotalCount: snapshot.sessionsTotalCount, + pinnedSessionIds: snapshot.pinnedSessionIds, + notifications: snapshot.notifications, + unreadCount: snapshot.unreadCount, + openTabs: validTabs, + activeTabId, + selectedTabIds: snapshot.selectedTabIds.filter(id => validTabs.some(t => t.id === id)), + activeProjectId: snapshot.activeProjectId && (validProjectIds.has(snapshot.activeProjectId) || validWorktreeIds.has(snapshot.activeProjectId)) + ? snapshot.activeProjectId + : selectedProjectId, + paneLayout: { + panes: finalPanes, + focusedPaneId: finalPanes.find(p => p.id === snapshot.paneLayout.focusedPaneId) + ? snapshot.paneLayout.focusedPaneId + : finalPanes[0].id, + }, + sidebarCollapsed: snapshot.sidebarCollapsed, + }; +} +``` + +**Update `switchContext` action flow** to use validation: +After loading snapshot and BEFORE applying it, fetch fresh projects and repoGroups: +1. Call `window.electronAPI.context.switch(targetContextId)` — switches main process +2. Fetch fresh data: `const [projects, repoGroups] = await Promise.all([window.electronAPI.getProjects(), window.electronAPI.getRepositoryGroups()])` +3. If snapshot exists: call `validateSnapshot(snapshot, projects, repoGroups)` to get validated state, apply via `set()` +4. If no snapshot: apply empty state, then set fresh projects/repoGroups +5. Also fetch notifications in background: `void get().fetchNotifications()` +6. Set `isContextSwitching: false, activeContextId: targetContextId, targetContextId: null` + +This ensures restored data references are validated against REAL data from the switched context. + + + pnpm typecheck passes. pnpm test passes (all existing tests). App.tsx renders ContextSwitchOverlay. Context change listener registered in initializeNotificationListeners. validateSnapshot filters invalid tabs/selections. + + + App renders overlay during context switches. Main process context change events trigger renderer-side state sync. Snapshot validation ensures restored tabs reference valid projects/worktrees in the target context. Empty panes are removed, at-least-one-pane invariant maintained. + + + + + + +1. `pnpm typecheck` — zero TypeScript errors +2. `pnpm test` — all existing tests pass (no regressions) +3. `pnpm build` — production build succeeds +4. Verify these files exist with correct exports: + - `src/renderer/services/contextStorage.ts` exports `contextStorage` + - `src/renderer/store/slices/contextSlice.ts` exports `ContextSlice`, `createContextSlice` + - `src/renderer/hooks/useContextSwitch.ts` exports `useContextSwitch` + - `src/renderer/components/common/ContextSwitchOverlay.tsx` exports `ContextSwitchOverlay` +5. Verify `useStore` includes context switching state (activeContextId, isContextSwitching) +6. Verify App.tsx renders `` inside ErrorBoundary +7. Verify `initializeNotificationListeners` includes context:onChanged listener + + + +- Context snapshot captures all user-facing data state (projects, sessions, tabs, pane layout, selections, notifications) while excluding transient state (loading flags, errors, search, non-serializable Maps/Sets) +- Snapshot is saved to IndexedDB on context exit and restored on context re-entry +- Expired snapshots (>5 min) are deleted and treated as missing +- New/never-visited contexts get clean empty state with dashboard tab +- Loading overlay prevents stale data flash during the switch transition +- Restored tabs are validated against fresh project/worktree data from the target context +- Main process context change events sync renderer state +- No regressions in existing tests or type checking + + + +After completion, create `.planning/phases/03-state-management/03-01-SUMMARY.md` + diff --git a/.planning/phases/03-state-management/03-RESEARCH.md b/.planning/phases/03-state-management/03-RESEARCH.md new file mode 100644 index 00000000..f954913b --- /dev/null +++ b/.planning/phases/03-state-management/03-RESEARCH.md @@ -0,0 +1,618 @@ +# Phase 3: State Management - Research + +**Researched:** 2026-02-12 +**Domain:** Frontend state persistence and snapshot/restore patterns for multi-context workspace switching +**Confidence:** HIGH + +## Summary + +Phase 3 implements snapshot-based state management to enable instant context switching between local and SSH workspaces. The core challenge is capturing complete Zustand state (12 slices totaling ~20+ state properties), persisting snapshots to IndexedDB, and restoring them without flickering or stale data flash. This requires: (1) a contextSlice managing the snapshot/restore lifecycle, (2) IndexedDB persistence with TTL-based expiration, (3) validation logic to ensure restored state references valid data (e.g., projectIds exist in current context), and (4) loading overlays to prevent UI flicker during transition. + +The research validates that Zustand's architecture supports manual snapshot/restore via `getState()`/`setState()`, IndexedDB provides the storage layer (with third-party TTL libraries for expiration), and React Suspense-style loading overlays prevent stale data flash. The key architectural pattern is **snapshot-on-exit + validate-on-restore**: capture full state when switching away from a context, persist to IndexedDB, then restore and validate when switching back. + +**Primary recommendation:** Use Zustand's native snapshot/restore with `idb-keyval` for IndexedDB storage, implement custom TTL tracking (simpler than external library), validate restored tabs/selections against current context data, and show full-screen loading overlay during context switch to prevent any visual artifacts. + +## Standard Stack + +### Core + +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| Zustand | 4.x (installed) | State management | Already used app-wide; `getState()`/`setState()` enable manual snapshot/restore | +| idb-keyval | Latest (5.x) | IndexedDB wrapper | Official recommendation from Zustand docs for async storage; minimal API (get/set/del) | +| React 18 | 18.x (installed) | UI framework | Suspense/transition APIs enable loading states without flicker | + +### Supporting + +| Library | Version | Purpose | When to Use | +|---------|---------|---------|-------------| +| zustand-indexeddb | 0.1.1 | IndexedDB integration via persist middleware | NOT RECOMMENDED - designed for persist() middleware which auto-hydrates; we need manual control for context switching | +| ttl-db | Latest | TTL support for IndexedDB | NOT NEEDED - manual TTL tracking is simpler for this use case (single timestamp per snapshot) | +| zustand/middleware persist | Built-in | Auto-persist state | NOT SUITABLE - auto-hydrates on app start; we need per-context snapshots with manual restore | + +### Alternatives Considered + +| Instead of | Could Use | Tradeoff | +|------------|-----------|----------| +| Manual snapshot/restore | Zustand persist middleware | Persist middleware auto-hydrates into a single global state; we need isolated per-context snapshots with manual restoration | +| idb-keyval | localForage | localForage is heavier (~5KB vs ~600B), adds async wrapper complexity we don't need | +| Custom TTL tracking | ttl-db library | ttl-db adds dependency for lazy expiration (checked on read); we need eager cleanup (background interval) to free IndexedDB space | + +**Installation:** +```bash +pnpm add idb-keyval +``` + +## Architecture Patterns + +### Recommended Project Structure + +``` +src/renderer/ +├── store/ +│ └── slices/ +│ └── contextSlice.ts # New: snapshot/restore orchestration +├── services/ +│ └── contextStorage.ts # New: IndexedDB persistence layer +├── hooks/ +│ └── useContextSwitch.ts # New: hook for triggering context switches +└── components/ + └── common/ + └── ContextSwitchOverlay.tsx # New: full-screen loading overlay +``` + +### Pattern 1: Manual Snapshot/Restore (NOT persist middleware) + +**What:** Use Zustand's `getState()` to capture state snapshot, store in IndexedDB, then restore via `setState()` on context switch. + +**When to use:** When you need isolated state snapshots per context with manual control over when to capture/restore. NOT when you want auto-hydration on app start (that's what persist middleware does). + +**Example:** +```typescript +// Capture snapshot +const captureSnapshot = (contextId: string) => { + const state = useStore.getState(); + + // Extract persistable slices (exclude transient state) + const snapshot = { + projects: state.projects, + selectedProjectId: state.selectedProjectId, + sessions: state.sessions, + selectedSessionId: state.selectedSessionId, + openTabs: state.openTabs, + activeTabId: state.activeTabId, + paneLayout: state.paneLayout, + notifications: state.notifications, + // ... all other slices + _metadata: { + contextId, + capturedAt: Date.now(), + version: 1 // for future migrations + } + }; + + await contextStorage.saveSnapshot(contextId, snapshot); +}; + +// Restore snapshot +const restoreSnapshot = async (contextId: string) => { + const snapshot = await contextStorage.loadSnapshot(contextId); + if (!snapshot) return false; // Never visited this context + + // Validate snapshot against current context data + const validated = validateSnapshot(snapshot); + + // Restore via setState + useStore.setState(validated); + return true; +}; +``` + +**Why manual over persist middleware:** +- Persist middleware auto-hydrates on app init (single global state) +- We need **per-context** snapshots with **manual** restore on switch +- Switching to "never-visited" context must show empty state, not auto-hydrate from IndexedDB + +### Pattern 2: IndexedDB Storage Layer with TTL + +**What:** Wrapper around `idb-keyval` that stores snapshots with timestamps and provides TTL-based cleanup. + +**When to use:** When persisting context snapshots with expiration to prevent unbounded IndexedDB growth. + +**Example:** +```typescript +// src/renderer/services/contextStorage.ts +import { get, set, del, keys } from 'idb-keyval'; + +interface StoredSnapshot { + snapshot: StateSnapshot; + timestamp: number; + version: number; +} + +const SNAPSHOT_TTL_MS = 5 * 60 * 1000; // 5 minutes (from phase notes) +const STORAGE_KEY_PREFIX = 'context-snapshot:'; + +export const contextStorage = { + async saveSnapshot(contextId: string, snapshot: StateSnapshot): Promise { + const stored: StoredSnapshot = { + snapshot, + timestamp: Date.now(), + version: 1 + }; + await set(`${STORAGE_KEY_PREFIX}${contextId}`, stored); + }, + + async loadSnapshot(contextId: string): Promise { + const stored = await get(`${STORAGE_KEY_PREFIX}${contextId}`); + if (!stored) return null; + + // Check TTL + const age = Date.now() - stored.timestamp; + if (age > SNAPSHOT_TTL_MS) { + await del(`${STORAGE_KEY_PREFIX}${contextId}`); // Expired, delete + return null; + } + + return stored.snapshot; + }, + + async deleteSnapshot(contextId: string): Promise { + await del(`${STORAGE_KEY_PREFIX}${contextId}`); + }, + + // Background cleanup - call periodically (e.g., on app init, every 5 min) + async cleanupExpired(): Promise { + const allKeys = await keys(); + const now = Date.now(); + + for (const key of allKeys) { + if (typeof key === 'string' && key.startsWith(STORAGE_KEY_PREFIX)) { + const stored = await get(key); + if (stored && (now - stored.timestamp) > SNAPSHOT_TTL_MS) { + await del(key); + } + } + } + } +}; +``` + +**Why custom TTL over ttl-db:** +- ttl-db uses lazy expiration (checked on read) - we need eager cleanup to free space +- Simple timestamp comparison is easier to reason about than external library +- Only storing one snapshot per context - not a large-scale key-value use case + +### Pattern 3: Snapshot Validation + +**What:** Validate restored snapshots to ensure references (projectIds, sessionIds, tabIds) exist in the current context. + +**When to use:** Always validate after restoring a snapshot to prevent rendering errors from stale references. + +**Example:** +```typescript +// Validate that restored tabs reference projects/sessions that exist in current context +const validateSnapshot = (snapshot: StateSnapshot): StateSnapshot => { + const currentProjects = useStore.getState().projects; // Freshly fetched for new context + const validProjectIds = new Set(currentProjects.map(p => p.id)); + + // Filter tabs to only those with valid projectIds + const validTabs = snapshot.openTabs.filter(tab => { + if (tab.type === 'session' && tab.projectId) { + return validProjectIds.has(tab.projectId); + } + return true; // Keep non-session tabs (e.g., dashboard) + }); + + // Reset activeTabId if it references invalid tab + let activeTabId = snapshot.activeTabId; + if (activeTabId && !validTabs.find(t => t.id === activeTabId)) { + activeTabId = validTabs[0]?.id ?? null; + } + + // Validate pane layout tabs + const validatedPanes = snapshot.paneLayout.panes.map(pane => ({ + ...pane, + tabs: pane.tabs.filter(tab => { + if (tab.type === 'session' && tab.projectId) { + return validProjectIds.has(tab.projectId); + } + return true; + }), + activeTabId: validTabs.find(t => t.id === pane.activeTabId) ? pane.activeTabId : null + })); + + return { + ...snapshot, + openTabs: validTabs, + activeTabId, + paneLayout: { + ...snapshot.paneLayout, + panes: validatedPanes + } + }; +}; +``` + +**Why validation is critical:** +- Switching to a different context means different projects/sessions exist +- Restored tabs may reference projectIds that don't exist in the new context +- Without validation: React will error trying to render non-existent data + +### Pattern 4: Loading Overlay During Context Switch + +**What:** Display full-screen overlay during context switch to prevent stale data flash. + +**When to use:** During context switches to mask the snapshot/restore + data refetch sequence. + +**Example:** +```typescript +// src/renderer/components/common/ContextSwitchOverlay.tsx +export const ContextSwitchOverlay: React.FC = () => { + const isSwitching = useStore(state => state.isContextSwitching); + const targetContext = useStore(state => state.targetContextId); + + if (!isSwitching) return null; + + return ( +
+
+
+

+ Switching to {targetContext === 'local' ? 'Local' : targetContext}... +

+
+
+ ); +}; + +// In App.tsx +return ( + <> + + {/* Rest of app */} + +); +``` + +**Based on Next.js loading.js pattern:** Show instant loading state while content switches, then remove overlay once restoration completes. See [Loading UI and Streaming - Next.js](https://nextjs.org/docs/app/building-your-application/routing/loading-ui-and-streaming). + +### Anti-Patterns to Avoid + +- **DON'T use Zustand persist middleware for context snapshots:** Persist middleware auto-hydrates on app init into a single global state. We need per-context snapshots with manual restore on switch. + +- **DON'T skip validation after restore:** Restored state may contain references to projects/sessions that don't exist in the new context. Always validate and filter invalid references. + +- **DON'T restore without showing loading state:** Users will see stale data flash as the old context's UI renders briefly before restoration completes. Always show full-screen overlay during transition. + +- **DON'T store derived/computed state:** Only persist base state. Derived values (e.g., filtered lists, computed counts) should be recomputed from restored base state. + +- **DON'T persist transient UI state:** Loading flags, error messages, and other transient state should NOT be persisted. Only persist user-facing data and selections. + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| IndexedDB wrapper | Custom promise-based IndexedDB code | `idb-keyval` | Handles browser quirks, transaction management, error recovery; battle-tested across browsers | +| State serialization | Custom JSON serialization with special cases | Native `JSON.stringify/parse` | Zustand state is already JSON-serializable (no Maps, Sets, Dates in store) | +| TTL expiration checking | Complex timestamp algebra | Simple `Date.now() - stored.timestamp > TTL_MS` | Single comparison is sufficient; no need for date libraries | +| Loading state coordination | Manual boolean flags and setTimeout | React 18 transitions (optional) | Built-in concurrent rendering prevents UI flicker | + +**Key insight:** State persistence is deceptively complex due to: +- **Browser compatibility:** IndexedDB behavior varies across browsers (quota limits, transaction lifetimes, error modes) +- **Race conditions:** Multiple tabs accessing same IndexedDB can cause conflicts +- **Quota limits:** Browsers impose storage limits; need cleanup strategy +- **Serialization edge cases:** Circular references, non-serializable types (functions, symbols) + +Using `idb-keyval` eliminates these concerns with a 600-byte library that's maintained by the Dexie.js team (IndexedDB experts). + +## Common Pitfalls + +### Pitfall 1: Auto-hydration with persist middleware + +**What goes wrong:** Using Zustand's persist middleware causes auto-hydration on app init, loading the last-saved snapshot into the global store before the user switches contexts. + +**Why it happens:** Persist middleware is designed for single-state persistence (e.g., saving user preferences). It auto-calls `rehydrate()` on store creation. + +**How to avoid:** Use manual snapshot/restore with `getState()`/`setState()`. Only call restore when the user explicitly switches to a context. + +**Warning signs:** +- UI shows data from a different context on app start +- "Hydration mismatch" errors in console +- State resets unexpectedly after reload + +### Pitfall 2: Stale Data Flash During Restore + +**What goes wrong:** When restoring a snapshot, the UI briefly shows the previous context's data before the new snapshot applies, causing visual flicker. + +**Why it happens:** React re-renders with old state before `setState()` completes. Without a loading overlay, users see the old context's projects/sessions for 50-100ms. + +**How to avoid:** +1. Set `isContextSwitching: true` BEFORE calling `setState()` +2. Show full-screen loading overlay while `isContextSwitching` is true +3. Complete restore sequence (snapshot + data refetch) +4. Set `isContextSwitching: false` to remove overlay + +**Warning signs:** +- Users report seeing "flickering" or "old data" during context switch +- Tabs briefly show wrong session titles before updating +- Sidebar jumps between different project lists + +### Pitfall 3: Invalid References After Restore + +**What goes wrong:** Restored tabs reference projectIds or sessionIds that don't exist in the new context, causing React to throw errors or render empty states. + +**Why it happens:** Contexts have different projects/sessions. Restoring a snapshot from Context A into Context B means tab.projectId may not exist in Context B's data. + +**How to avoid:** Always validate restored state against current context data: +```typescript +const validTabs = snapshot.openTabs.filter(tab => { + if (tab.type === 'session' && tab.projectId) { + return currentProjectIds.has(tab.projectId); + } + return true; +}); +``` + +**Warning signs:** +- Console errors: "Cannot read property 'name' of undefined" +- Tabs show blank content or "Session not found" +- Sidebar selections don't match visible tabs + +### Pitfall 4: IndexedDB Quota Exceeded + +**What goes wrong:** Snapshots accumulate in IndexedDB until browser quota is exceeded, causing storage failures. + +**Why it happens:** No cleanup strategy for expired snapshots or old SSH contexts that no longer exist. + +**How to avoid:** +1. Implement TTL-based expiration (5 minutes per phase notes) +2. Run cleanup on app init and periodically (every 5 minutes) +3. Delete snapshots when SSH context is destroyed +4. Store only essential state (exclude transient loading/error flags) + +**Warning signs:** +- "QuotaExceededError" in console +- Snapshots fail to save silently +- IndexedDB inspector shows hundreds of old snapshot entries + +### Pitfall 5: Restoring Transient State + +**What goes wrong:** Loading flags, error messages, and other transient UI state get persisted and restored, causing confusing behavior (e.g., "Loading..." shown on restore). + +**Why it happens:** Snapshot captures entire Zustand state including transient flags like `projectsLoading: true`. + +**How to avoid:** Use `partialize` pattern to exclude transient state: +```typescript +const snapshot = { + // Include + projects: state.projects, + selectedProjectId: state.selectedProjectId, + openTabs: state.openTabs, + // Exclude transient state + // projectsLoading: false, // NEVER persist loading flags + // projectsError: null, // NEVER persist errors +}; +``` + +**Warning signs:** +- Restored context shows loading spinners that never complete +- Error messages from previous context appear in new context +- UI is "stuck" in loading state after restore + +## Code Examples + +Verified patterns from official sources: + +### Zustand Manual State Capture/Restore + +```typescript +// Source: Zustand docs - https://zustand.docs.pmnd.rs/guides/how-to-reset-state +import { useStore } from './store'; + +// Capture current state +const currentState = useStore.getState(); + +// Restore state later +useStore.setState({ + projects: restoredProjects, + selectedProjectId: restoredSelectedProjectId, + // ... all other slices +}, true); // Second arg `true` = replace entire state (not merge) +``` + +**Note:** The `replace` parameter (second arg) controls whether to merge or replace. For context switching, use `replace: false` (default) to merge the snapshot with current state, preserving any runtime-only state. + +### IndexedDB with idb-keyval + +```typescript +// Source: Zustand persist docs - https://zustand.docs.pmnd.rs/integrations/persisting-store-data +import { get, set, del } from 'idb-keyval'; + +// Save +await set('context-snapshot:local', { + snapshot: { /* state */ }, + timestamp: Date.now() +}); + +// Load +const stored = await get('context-snapshot:local'); + +// Delete +await del('context-snapshot:local'); +``` + +### Context Switch Hook + +```typescript +// Pattern based on Zustand actions + async state transitions +export const useContextSwitch = () => { + const switchContext = useStore(state => state.switchContext); + + const handleSwitch = async (targetContextId: string) => { + // 1. Show loading overlay + useStore.setState({ isContextSwitching: true, targetContextId }); + + try { + // 2. Capture current context's snapshot + const currentContextId = await window.electronAPI.context.getActiveContextId(); + await captureSnapshot(currentContextId); + + // 3. Switch context in main process (updates ServiceContextRegistry) + await window.electronAPI.context.switch(targetContextId); + + // 4. Try to restore snapshot for target context + const restored = await restoreSnapshot(targetContextId); + + if (!restored) { + // Never visited this context - show empty state + useStore.setState(getEmptyContextState()); + } + + // 5. Fetch fresh data for target context + await Promise.all([ + useStore.getState().fetchProjects(), + useStore.getState().fetchRepositoryGroups(), + useStore.getState().fetchNotifications() + ]); + + // 6. Hide loading overlay + useStore.setState({ isContextSwitching: false, targetContextId: null }); + } catch (error) { + console.error('Context switch failed:', error); + useStore.setState({ + isContextSwitching: false, + targetContextId: null, + // Show error to user + }); + } + }; + + return { switchContext: handleSwitch }; +}; +``` + +### Empty State for New Context + +```typescript +// When switching to a never-visited context, reset to empty state +const getEmptyContextState = (): Partial => ({ + // Projects + projects: [], + selectedProjectId: null, + + // Sessions + sessions: [], + selectedSessionId: null, + sessionsPagination: { hasMore: false, currentPage: 0 }, + + // Tabs + openTabs: [{ type: 'dashboard', id: 'dashboard', label: 'Dashboard' }], + activeTabId: 'dashboard', + + // Pane layout - single pane with dashboard tab + paneLayout: { + panes: [{ + id: 'pane-default', + tabs: [{ type: 'dashboard', id: 'dashboard', label: 'Dashboard' }], + activeTabId: 'dashboard', + selectedTabIds: [], + widthFraction: 1 + }], + focusedPaneId: 'pane-default' + }, + + // Notifications + notifications: [], + unreadCount: 0, + + // Repository + repositoryGroups: [], + + // Session detail + sessionDetail: null, + sessionChunks: [], + sessionMetrics: null, + + // Conversation + conversationGroups: [], + + // Subagent + subagentDetail: null, + selectedSubagentId: null, +}); +``` + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| localStorage for state | IndexedDB for state | 2020+ | IndexedDB supports larger quotas (50MB+) and structured data; localStorage limited to 5-10MB strings | +| Redux persist middleware | Zustand manual snapshot | 2021+ | Zustand's simpler API enables custom snapshot/restore without middleware complexity | +| Manual TTL tracking | Libraries like ttl-db | 2023+ | For simple TTL use cases (single timestamp), manual tracking is simpler than library dependency | +| Class components with getDerivedStateFromProps | React 18 Suspense/Transitions | 2022+ | Concurrent rendering prevents UI flicker during async state changes | + +**Deprecated/outdated:** +- **localStorage for large state:** Replaced by IndexedDB for quota limits and structured data support +- **Zustand persist middleware for multi-context:** Designed for single-state persistence; manual snapshot/restore needed for per-context isolation +- **react-loading-overlay package:** React 18's Suspense provides built-in loading state coordination + +## Open Questions + +1. **Snapshot size optimization** + - What we know: Full Zustand state snapshot includes all 12 slices (projects, sessions, tabs, notifications, etc.) + - What's unclear: If snapshots exceed 1MB, should we compress (e.g., pako) or partialize more aggressively? + - Recommendation: Start without compression; monitor snapshot sizes in production. Add compression if snapshots exceed 500KB. + +2. **TTL tuning for different contexts** + - What we know: Phase notes suggest 5-minute TTL based on "typical user switching patterns" + - What's unclear: Should SSH contexts have longer TTL than local (users might stay in SSH for hours)? + - Recommendation: Start with uniform 5-minute TTL; add per-context TTL configuration if users report frequent "empty state on switch back" issues. + +3. **Graceful degradation when IndexedDB unavailable** + - What we know: Private browsing modes and some browsers disable IndexedDB + - What's unclear: Should we fall back to in-memory snapshots or disable persistence entirely? + - Recommendation: Detect IndexedDB availability on app init; if unavailable, log warning and use in-memory Map for current session only (no persistence across restarts). + +4. **Migration strategy for snapshot schema changes** + - What we know: Zustand persist middleware supports version migrations + - What's unclear: How to handle breaking changes to snapshot structure in future releases? + - Recommendation: Include `version` field in stored snapshot; implement migration function that transforms old versions to current schema on load. + +## Sources + +### Primary (HIGH confidence) + +- [Zustand Persist Middleware Documentation](https://zustand.docs.pmnd.rs/integrations/persisting-store-data) - Official docs covering persist middleware, custom storage engines, version migration, and sync vs async storage differences +- [GitHub: zustand-indexeddb](https://github.com/zustandjs/zustand-indexeddb) - Official IndexedDB integration library showing API and limitations +- [GitHub: How can I use zustand persist with indexeddb?](https://github.com/pmndrs/zustand/discussions/1721) - Official discussion confirming idb-keyval as recommended approach +- [Zustand Beginner TypeScript Guide](https://zustand.docs.pmnd.rs/guides/beginner-typescript) - TypeScript patterns for selectors and derived state + +### Secondary (MEDIUM confidence) + +- [Loading UI and Streaming - Next.js](https://nextjs.org/docs/app/building-your-application/routing/loading-ui-and-streaming) - Pattern for instant loading states during transitions +- [Data Grid - Overlays - MUI X](https://mui.com/x/react-data-grid/overlays/) - Loading overlay patterns for preventing stale data display +- [GitHub: ttl-db](https://github.com/jtsang4/ttl-db) - TTL implementation for IndexedDB showing API and expiration patterns + +### Tertiary (LOW confidence) + +- [Understanding Zustand: A Lightweight State Management Library](https://blog.msar.me/understanding-zustand-a-lightweight-state-management-library-for-react) - Third-party blog covering derived state patterns +- [Fixing React UI Updates: Stale Data and Caching Issues](https://www.techedubyte.com/react-ui-updates-stale-data-caching-fix/) - Community articles on preventing stale data flash (validate patterns against official docs) + +## Metadata + +**Confidence breakdown:** +- Standard stack: HIGH - Zustand + idb-keyval confirmed via official docs and recommendations +- Architecture: HIGH - Manual snapshot/restore pattern verified through Zustand docs and community discussions +- Pitfalls: MEDIUM - Common issues identified through GitHub issues and community discussions; validated against official docs + +**Research date:** 2026-02-12 +**Valid until:** 2026-03-12 (30 days - Zustand is stable, patterns unlikely to change) + +**Key findings:** +1. Zustand persist middleware is NOT suitable for per-context snapshots (auto-hydrates single global state) +2. Manual snapshot/restore via `getState()`/`setState()` is the correct pattern for multi-context isolation +3. IndexedDB via `idb-keyval` provides simple async storage with browser compatibility handled +4. Custom TTL tracking (timestamp comparison) is simpler than library dependency for single-snapshot use case +5. Snapshot validation is CRITICAL to prevent rendering errors from stale references +6. Loading overlay during context switch prevents stale data flash (Next.js loading.js pattern) diff --git a/.planning/phases/04-workspace-ui/04-01-SUMMARY.md b/.planning/phases/04-workspace-ui/04-01-SUMMARY.md new file mode 100644 index 00000000..6a581758 --- /dev/null +++ b/.planning/phases/04-workspace-ui/04-01-SUMMARY.md @@ -0,0 +1,97 @@ +--- +phase: 04-workspace-ui +plan: 01 +subsystem: ui +tags: [react, zustand, lucide-react, keyboard-shortcuts, dropdown] + +requires: + - phase: 03-state-management + provides: contextSlice with switchContext, activeContextId, isContextSwitching + - phase: 02-service-infrastructure + provides: context.list() IPC, context.switch() IPC, ssh.onStatus() listener +provides: + - ContextSwitcher dropdown component listing Local + SSH workspaces with status badges + - ConnectionStatusBadge icon component with 4 visual states + - Cmd+Shift+K keyboard shortcut for workspace cycling + - availableContexts state and fetchAvailableContexts action in contextSlice + - SSH status listener in App.tsx refreshing context list on changes +affects: [04-02-workspace-settings] + +tech-stack: + added: [] + patterns: [sidebar-header-dropdown-pattern, connection-status-icon-states] + +key-files: + created: + - src/renderer/components/common/ContextSwitcher.tsx + - src/renderer/components/common/ConnectionStatusBadge.tsx + modified: + - src/renderer/store/slices/contextSlice.ts + - src/renderer/components/layout/SidebarHeader.tsx + - src/renderer/hooks/useKeyboardShortcuts.ts + - src/renderer/App.tsx + +key-decisions: + - "ContextSwitcher placed first in SidebarHeader Row 1, before project name, with vertical separator" + - "Cmd+Shift+K check placed before Cmd+K to avoid shortcut shadowing" + - "SSH status listener refreshes available contexts automatically on connection changes" + +patterns-established: + - "ConnectionStatusBadge: 4-state icon rendering (Monitor/local, Wifi/green connected, Loader2/spinner connecting, WifiOff/muted disconnected, WifiOff/red error)" + - "Context switcher dropdown follows SidebarHeader dropdown pattern (useRef, outside click, escape key)" + +duration: 6min +completed: 2026-02-12 +--- + +# Plan 04-01: Context Switcher Summary + +**ContextSwitcher dropdown in SidebarHeader with ConnectionStatusBadge icons and Cmd+Shift+K workspace cycling** + +## Performance + +- **Duration:** 6 min +- **Completed:** 2026-02-12 +- **Tasks:** 2 +- **Files modified:** 6 + +## Accomplishments +- ContextSwitcher dropdown in sidebar header listing Local + SSH workspaces with connection status badges +- ConnectionStatusBadge component with 4 distinct visual states (local/connected/connecting/disconnected/error) +- Cmd+Shift+K keyboard shortcut for cycling through available workspaces +- Automatic context list refresh when SSH connection state changes + +## Task Commits + +1. **Task 1: Create ConnectionStatusBadge and ContextSwitcher components** - `ca60158` (feat) +2. **Task 2: Wire ContextSwitcher into SidebarHeader and add keyboard shortcut** - `58f4be0` (feat) + +## Files Created/Modified +- `src/renderer/components/common/ConnectionStatusBadge.tsx` - Icon component rendering 4 connection states +- `src/renderer/components/common/ContextSwitcher.tsx` - Dropdown listing local + SSH contexts with switch-on-click +- `src/renderer/store/slices/contextSlice.ts` - Added availableContexts state and fetchAvailableContexts action +- `src/renderer/components/layout/SidebarHeader.tsx` - Added ContextSwitcher to Row 1 with separator +- `src/renderer/hooks/useKeyboardShortcuts.ts` - Added Cmd+Shift+K before Cmd+K +- `src/renderer/App.tsx` - Added SSH status listener for context refresh + +## Decisions Made +- ContextSwitcher placed first in Row 1 (before project name) with vertical divider separator +- Cmd+Shift+K must come before Cmd+K in the handler to avoid shortcut shadowing +- Row 1 layout changed from justify-between to gap-2 with ml-auto on collapse button + +## Deviations from Plan +None - plan executed exactly as written + +## Issues Encountered +None + +## User Setup Required +None - no external service configuration required. + +## Next Phase Readiness +- Context switcher is functional and ready for 04-02 (workspace settings) to add SSH profile CRUD +- fetchAvailableContexts is called by WorkspaceSection after profile changes + +--- +*Phase: 04-workspace-ui* +*Completed: 2026-02-12* diff --git a/.planning/phases/04-workspace-ui/04-RESEARCH.md b/.planning/phases/04-workspace-ui/04-RESEARCH.md new file mode 100644 index 00000000..48ed954d --- /dev/null +++ b/.planning/phases/04-workspace-ui/04-RESEARCH.md @@ -0,0 +1,463 @@ +# Phase 4: Workspace UI - Research + +**Researched:** 2026-02-12 +**Domain:** React UI components, Electron desktop patterns, workspace/connection management UI +**Confidence:** HIGH + +## Summary + +Phase 4 delivers the final UI layer for workspace switching, building on Phases 1-3's infrastructure. The core challenge is creating an intuitive workspace switcher and persistent status indicators that integrate seamlessly with the existing sidebar-based layout without disrupting established patterns. + +**Key findings:** +- Existing codebase already has dropdown/selector patterns to follow (SidebarHeader project/worktree selectors, CommandPalette, SettingsSelect) +- VS Code model places workspace indicators on left side of status bar; this app lacks a traditional status bar but has SidebarHeader +- Keyboard shortcuts use Cmd/Ctrl+K pattern already (CommandPalette); context switching could use Cmd/Ctrl+Shift+K or similar to avoid collision +- Connection states need distinct visual treatment: connected (green), connecting (spinner), disconnected (neutral), error (red) +- SSH profiles already stored in ConfigManager; settings UI needs CRUD interface following existing NotificationTriggerSettings pattern + +**Primary recommendation:** Place context switcher in SidebarHeader Row 1 (alongside project name), add connection status badge next to switcher, implement settings section for SSH profile management, register Cmd/Ctrl+Shift+K shortcut for quick switching. + +## Standard Stack + +### Core +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| React 18.x | 18.x | UI framework | Already used throughout codebase | +| Zustand 4.x | 4.x | State management | Already used for contextSlice, connectionSlice | +| Tailwind CSS 3.x | 3.x | Styling | Theme-aware CSS variables already established | +| lucide-react | latest | Icons | Consistent with existing icon usage | + +### Supporting +| Library | Version | Purpose | When to Use | +|---------|---------|---------|-------------| +| date-fns | latest | Date formatting | Already used in CommandPalette for "last active" display | + +### Alternatives Considered +| Instead of | Could Use | Tradeoff | +|------------|-----------|----------| +| Custom dropdown | Headless UI Listbox | Headless UI adds dependency but provides accessibility features. Codebase already has working custom dropdowns (SidebarHeader, SettingsSelect) — stick with existing patterns for consistency. | +| Custom dropdown | Radix UI Dropdown Menu | Radix provides ARIA-compliant primitives with collision detection. Same tradeoff as Headless UI — codebase patterns work well, adding library introduces dependency for marginal benefit. | + +**Installation:** +```bash +# No new dependencies needed - use existing stack +``` + +## Architecture Patterns + +### Recommended Project Structure +``` +src/renderer/components/ +├── common/ +│ ├── ContextSwitcher.tsx # Main switcher component +│ ├── ContextSwitchOverlay.tsx # Already exists +│ └── ConnectionStatusBadge.tsx # Status indicator +├── layout/ +│ └── SidebarHeader.tsx # Modified to include switcher +├── settings/ +│ └── sections/ +│ └── WorkspaceSection.tsx # SSH profile management +``` + +### Pattern 1: Context Switcher Component +**What:** Dropdown component listing Local + all SSH contexts, with connection status indicators and keyboard navigation. +**When to use:** Embedded in SidebarHeader Row 1 alongside project name. +**Example:** +```typescript +// Based on existing SidebarHeader dropdown pattern +interface ContextSwitcherProps { + activeContextId: string; + onSwitch: (contextId: string) => void; +} + +export const ContextSwitcher: React.FC = ({ + activeContextId, + onSwitch +}) => { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + // Close on outside click (same pattern as SidebarHeader) + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { + setIsOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + // Close on escape (same pattern as SidebarHeader) + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') setIsOpen(false); + }; + document.addEventListener('keydown', handleEscape); + return () => document.removeEventListener('keydown', handleEscape); + }, []); + + const contexts = useStore((s) => s.availableContexts); // From contextSlice + + return ( +
+ + + {isOpen && ( +
+ {contexts.map(ctx => ( + + ))} +
+ )} +
+ ); +}; +``` + +### Pattern 2: Connection Status Badge +**What:** Visual indicator showing connection state with distinct colors/icons. +**When to use:** Always visible next to active context name. +**Example:** +```typescript +// Similar to OngoingIndicator pattern +type ConnectionState = 'connected' | 'connecting' | 'disconnected' | 'error'; + +export const ConnectionStatusBadge: React.FC<{ contextId: string }> = ({ + contextId +}) => { + const state = useStore((s) => + contextId === 'local' + ? 'connected' + : s.connectionState + ); + + if (contextId === 'local') { + return ; + } + + // SSH context + switch (state) { + case 'connected': + return ; + case 'connecting': + return ; + case 'disconnected': + return ; + case 'error': + return ; + } +}; +``` + +### Pattern 3: SSH Profile Management Settings Section +**What:** Settings section for creating, editing, deleting SSH connection profiles. +**When to use:** Settings view under new "Workspace" tab. +**Example:** +```typescript +// Follow NotificationTriggerSettings pattern +export const WorkspaceSection: React.FC = () => { + const [profiles, setProfiles] = useState([]); + const [editingProfile, setEditingProfile] = useState(null); + + // CRUD operations via ConfigManager IPC + const handleAddProfile = async (profile: Omit) => { + await window.electronAPI.config.update('ssh', { + profiles: [...profiles, { ...profile, id: generateId() }] + }); + await loadProfiles(); + }; + + const handleEditProfile = async (id: string, updates: Partial) => { + // Update via ConfigManager + }; + + const handleDeleteProfile = async (id: string) => { + // Delete via ConfigManager + }; + + return ( +
+ + + {profiles.map(profile => ( + + ))} + + +
+ ); +}; +``` + +### Pattern 4: Keyboard Shortcut Registration +**What:** Register Cmd/Ctrl+Shift+K for quick context switching. +**When to use:** Registered in useKeyboardShortcuts hook. +**Example:** +```typescript +// Add to useKeyboardShortcuts.ts +if (event.key === 'k' && event.shiftKey) { + event.preventDefault(); + // Open context switcher dropdown or cycle to next context + const currentIndex = contexts.findIndex(c => c.id === activeContextId); + const nextContext = contexts[(currentIndex + 1) % contexts.length]; + void switchContext(nextContext.id); + return; +} +``` + +### Anti-Patterns to Avoid +- **Don't add status bar component**: App uses sidebar-centric layout (not bottom status bar like VS Code). Place indicators in SidebarHeader instead. +- **Don't block UI during switch**: ContextSwitchOverlay already exists for loading state — use it, don't create inline spinners that block interaction. +- **Don't duplicate connection state**: connectionSlice and contextSlice both track state — contextSlice owns activeContextId, connectionSlice owns SSH connection state. Don't mix concerns. + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| Dropdown accessibility (focus trap, escape, arrow navigation) | Custom keyboard handler | Follow existing SidebarHeader pattern | Codebase already has working dropdown with keyboard support. Headless UI/Radix would add dependency for marginal benefit. | +| Context switch animation | Custom fade/slide | ContextSwitchOverlay (already exists) | Overlay prevents stale data flash during transition. Don't reinvent. | +| SSH config parsing | Custom parser | Use main process SshConnectionManager (already exists) | Main process already resolves SSH config hosts via `ssh.getConfigHosts()` IPC. | +| Profile persistence | IndexedDB or localStorage | ConfigManager (already exists) | SSH profiles already stored in config.json via ConfigManager. Don't create separate storage. | + +**Key insight:** This phase is primarily UI composition, not new infrastructure. Almost all backend logic exists from Phases 1-3. Focus on clean UI patterns that match existing components. + +## Common Pitfalls + +### Pitfall 1: Dropdown Positioning Conflict with macOS Traffic Lights +**What goes wrong:** Context switcher dropdown placed too close to window edge overlaps with macOS traffic lights (close/minimize/zoom buttons). +**Why it happens:** SidebarHeader Row 1 uses `--macos-traffic-light-padding-left` to avoid traffic lights, but dropdown menu anchoring doesn't account for this. +**How to avoid:** Use `inset-x-4` (same as SidebarHeader project dropdown) to ensure dropdown stays within safe area. See SidebarHeader.tsx line 381. +**Warning signs:** Dropdown menu appears behind or overlapping traffic lights on macOS. + +### Pitfall 2: Context Switch State Race Condition +**What goes wrong:** User rapidly clicks between contexts, causing stale state to be restored. +**Why it happens:** contextSlice.switchContext is async; second click can start before first completes. +**How to avoid:** Disable switcher UI while `isContextSwitching` is true. Add guard in switchContext to early-return if already switching. +**Warning signs:** Console errors about "Cannot read property of undefined" after rapid switching; snapshot restore fails validation. + +### Pitfall 3: SSH Connection Status Not Updating in UI +**What goes wrong:** Connection state changes in main process but UI shows stale "connecting" state. +**Why it happens:** IPC event listener not registered or cleaned up improperly. +**How to avoid:** Register `ssh.onStatus` listener in App.tsx alongside notification listeners. Update connectionSlice state on event. Clean up listener on unmount. +**Warning signs:** Status badge stuck on spinner; requires app restart to update. + +### Pitfall 4: Settings Section Doesn't Reflect Profile Changes +**What goes wrong:** User adds SSH profile in settings but it doesn't appear in context switcher. +**Why it happens:** Settings section modifies ConfigManager, but contextSlice doesn't refetch available contexts. +**How to avoid:** After profile save, call `context.list()` IPC to refresh available contexts. Or: add `config.onUpdated` listener in contextSlice to auto-refresh when ssh.profiles changes. +**Warning signs:** Profile appears in settings but not in switcher dropdown until app restart. + +### Pitfall 5: Keyboard Shortcut Collision with Existing Shortcuts +**What goes wrong:** Cmd+K already opens CommandPalette; using it for context switch breaks search. +**Why it happens:** useKeyboardShortcuts processes shortcuts in order; first match wins. +**How to avoid:** Use Cmd+Shift+K (or Cmd+Option+K) for context switching. Document in UI (tooltip, settings help text). +**Warning signs:** CommandPalette no longer opens on Cmd+K after adding context switch shortcut. + +## Code Examples + +Verified patterns from codebase: + +### Dropdown Component Pattern (from SidebarHeader.tsx) +```typescript +// Source: src/renderer/components/layout/SidebarHeader.tsx lines 236-295 +const [isDropdownOpen, setIsDropdownOpen] = useState(false); +const dropdownRef = useRef(null); + +// Close dropdowns on outside click +useEffect(() => { + function handleClickOutside(event: MouseEvent): void { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsDropdownOpen(false); + } + } + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); +}, []); + +// Close on escape +useEffect(() => { + function handleEscape(event: KeyboardEvent): void { + if (event.key === 'Escape') { + setIsDropdownOpen(false); + } + } + document.addEventListener('keydown', handleEscape); + return () => document.removeEventListener('keydown', handleEscape); +}, []); +``` + +### Connection Status Indicator Pattern (from ConnectionSection.tsx) +```typescript +// Source: src/renderer/components/settings/sections/ConnectionSection.tsx lines 150-178 +{isConnected && ( +
+ +
+

+ Connected to {connectedHost} +

+

+ Viewing remote sessions via SSH +

+
+
+)} +``` + +### Keyboard Shortcut Pattern (from useKeyboardShortcuts.ts) +```typescript +// Source: src/renderer/hooks/useKeyboardShortcuts.ts lines 68-97 +useEffect(() => { + function handleKeyDown(event: KeyboardEvent): void { + const isMod = event.metaKey || event.ctrlKey; + + if (!isMod) return; + + // Cmd+K: Open command palette + if (event.key === 'k') { + event.preventDefault(); + openCommandPalette(); + return; + } + + // Add context switcher shortcut here + // Cmd+Shift+K: Open context switcher or cycle contexts + if (event.key === 'k' && event.shiftKey) { + event.preventDefault(); + // Implementation here + return; + } + } + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); +}, [dependencies]); +``` + +### IPC Event Listener Pattern (from App.tsx and store initialization) +```typescript +// Source: src/renderer/App.tsx lines 22-31 +// Initialize IPC event listeners (notifications, file changes) +useEffect(() => { + const cleanup = initializeNotificationListeners(); + return cleanup; +}, []); + +// Add SSH status listener similarly +useEffect(() => { + const unsubscribe = window.electronAPI.ssh.onStatus((event, status) => { + useStore.getState().setConnectionStatus( + status.state, + status.host, + status.error + ); + }); + return unsubscribe; +}, []); +``` + +### Settings CRUD Pattern (from NotificationTriggerSettings) +```typescript +// Source: src/renderer/components/settings/NotificationTriggerSettings/index.tsx +const handleAddProfile = async (profile: Omit) => { + const newProfile = { ...profile, id: generateId() }; + await window.electronAPI.config.update('ssh', { + profiles: [...profiles, newProfile] + }); + await loadProfiles(); // Refetch from config +}; + +const handleDeleteProfile = async (id: string) => { + await window.electronAPI.config.update('ssh', { + profiles: profiles.filter(p => p.id !== id) + }); + await loadProfiles(); +}; +``` + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| Manual SSH connection in terminal, copy session files | In-app SSH connection with file watcher | Phase 2 (completed) | Users can now connect to remote machines without leaving app | +| No workspace concept, single active project | Multi-workspace with snapshot/restore | Phase 3 (completed) | Users can switch between local and remote without losing UI state | +| Status indicators only in settings view | Persistent status in sidebar/status bar | This phase (04) | Users always know which workspace is active without navigating to settings | + +**Deprecated/outdated:** +- N/A — This is a greenfield feature building on new infrastructure. + +## Open Questions + +1. **Context switcher placement in SidebarHeader Row 1** + - What we know: Row 1 has project name (left) and collapse button (right). Project name is clickable dropdown. + - What's unclear: Should context switcher be separate button left of project name, or integrated into project dropdown? + - Recommendation: Separate button left of project name (before traffic light padding). This keeps "where am I" (workspace) distinct from "what am I viewing" (project). VS Code model supports this (Remote indicator is separate from workspace name). + +2. **Keyboard shortcut choice** + - What we know: Cmd+K is CommandPalette. Requirements specify "Cmd/Ctrl+K or similar" for switching. + - What's unclear: Should Cmd+Shift+K open switcher dropdown, or directly cycle to next context? + - Recommendation: Cmd+Shift+K cycles to next context (faster for power users with 2-3 contexts). Cmd+Option+K opens dropdown (for users with many SSH profiles). Document both. + +3. **SSH profile quick-connect in switcher** + - What we know: SSH profiles stored in config can be saved/edited/deleted in settings. + - What's unclear: Should context switcher dropdown show saved profiles (allowing one-click connect), or only show currently active contexts? + - Recommendation: Show active contexts only (Local + currently connected SSH). Use settings section for profile management and initial connection. This keeps switcher simple (switch between established contexts) vs. connection manager (complex). + +4. **Connection failure handling in switcher** + - What we know: Switching to SSH context can fail (network error, auth failure). + - What's unclear: Should switcher show error inline, or defer to toast notification? + - Recommendation: Show inline error in dropdown (similar to ConnectionSection error display lines 180-184). User attempted action in switcher, error should appear there. Toast would be easy to miss. + +## Sources + +### Primary (HIGH confidence) +- Existing codebase patterns: + - `/home/bskim/claude-devtools/src/renderer/components/layout/SidebarHeader.tsx` - Dropdown pattern, macOS traffic light handling + - `/home/bskim/claude-devtools/src/renderer/components/settings/sections/ConnectionSection.tsx` - Connection status display, SSH config + - `/home/bskim/claude-devtools/src/renderer/hooks/useKeyboardShortcuts.ts` - Keyboard shortcut registration pattern + - `/home/bskim/claude-devtools/src/renderer/components/common/ContextSwitchOverlay.tsx` - Context switching overlay (already implemented) + - `/home/bskim/claude-devtools/src/renderer/store/slices/contextSlice.ts` - Context switching state management + - `/home/bskim/claude-devtools/src/renderer/store/slices/connectionSlice.ts` - SSH connection state management + +### Secondary (MEDIUM confidence) +- [VS Code Status Bar UX Guidelines](https://code.visualstudio.com/api/ux-guidelines/status-bar) - Official VS Code extension API docs specifying status bar item placement (workspace items on left) +- [Electron Keyboard Shortcuts Documentation](https://www.electronjs.org/docs/latest/tutorial/keyboard-shortcuts) - Official Electron docs for keyboard shortcut implementation +- [Headless UI Listbox Documentation](https://headlessui.com/react/listbox) - Keyboard navigation and accessibility patterns for dropdowns +- [Radix UI Dropdown Menu Documentation](https://www.radix-ui.com/primitives/docs/components/dropdown-menu) - WAI-ARIA compliant dropdown patterns +- [DoltHub: Building a Custom Title Bar in Electron](https://www.dolthub.com/blog/2025-02-11-building-a-custom-title-bar-in-electron/) - Recent (Feb 2025) article on Electron title bar patterns with dropdowns + +### Tertiary (LOW confidence) +- N/A — No unverified claims requiring low-confidence flagging. + +## Metadata + +**Confidence breakdown:** +- Standard stack: HIGH - All libraries already in use, verified in package.json and codebase +- Architecture patterns: HIGH - Patterns extracted directly from existing components (SidebarHeader, ConnectionSection, useKeyboardShortcuts) +- Don't hand-roll recommendations: HIGH - Based on existing infrastructure from Phases 1-3 +- Pitfalls: MEDIUM-HIGH - Based on common React/Electron patterns and analysis of existing code; actual pitfalls will emerge during implementation + +**Research date:** 2026-02-12 +**Valid until:** ~30 days (March 2026) - UI patterns are stable, but Electron/React ecosystem updates could introduce new best practices