refactor: WebContentsView-per-project switching#4672
Merged
gregpriday merged 19 commits intodevelopfrom Mar 31, 2026
Merged
Conversation
- Add webContentsRegistry.ts mapping webContents.id to parent BrowserWindow - Replace 28 BrowserWindow.fromWebContents() calls across 13 IPC handler files - Register BrowserWindow's own webContents at creation time - Prepares inbound IPC path for Phase 2 where app runs in WebContentsView - Zero behavioral change: registry delegates to native lookup first
- Replace all direct win.webContents.send() calls with getAppWebContents(win) across 13 files - Update broadcastToRenderer and typedBroadcast to use getAllAppWebContents() instead of iterating BrowserWindow.getAllWindows() - Add sendToApp helper in PortalManager and sendToEntryWindows update in WorkspaceClient - Expand webContentsRegistry with app view tracking, registerAppView, and getAllAppWebContents - Update createWindow to return appView and register it in WindowRegistry
- Add ProjectViewManager to manage one WebContentsView per project with LRU eviction, serialized switching, and per-view crash recovery - Update project switch IPC to swap views instead of resetting stores, eliminating all 8 cross-project contamination guards - Remove resetStores, projectSwitchRendererCache, snapshot cache, scope tracking, store generation counters, and switch epoch guards (~2900 lines deleted) - Simplify WorkspaceClient by removing blue-green swap race protection (per-view isolation makes generation counters unnecessary)
- handleDirectoryOpen now uses ProjectViewManager.switchTo() instead of ProjectSwitchService.switchProject(), ensuring menu-driven project switching (Open Directory, Open Recent) uses the multi-view path
…round views - Lower MAX_CACHED_VIEWS from 3 to 2 (1 active + 1 cached) to save ~400-500MB per evicted view - Enable backgroundThrottling on deactivated views to reduce CPU - Disable backgroundThrottling when reactivating cached views
- Create MessageChannelMain between workspace host and renderer views - Workspace host sends spontaneous events (worktree/PR/issue) via direct port - Main-process relay skipped when direct ports active, events.emit preserved - Preload translates port messages to ipcRenderer events transparently - Ports re-established automatically on workspace host restart
- Remove hasDirectPorts guard that suppressed IPC relay per-project - Direct port is now purely additive; stores dedup via equality checks - Refresh workspace port on did-finish-load to survive renderer reloads - Prevents event black-holing from stale port state or partial coverage
…tracking
- Add per-project session partitions (persist:project-{id}) for crash isolation
- Add explicit webContents.focus() on view activation and cold-start load
- Add view-to-project tracking in webContentsRegistry for scoped IPC routing
- Remove duplicate resize handlers between createWindow.ts and ProjectViewManager
- Add onViewEvicted callback to clean up WorkspaceClient directPortViews on eviction
455df51 to
d592d9d
Compare
- Register app:// and canopy-file:// handlers on each project session partition - Apply trusted permission lockdown to persist:project-* sessions - Add "project" type to classifyPartition for session routing - Fix resize gap by falling back to first child view before registration - Extract protocol handlers into reusable functions for multi-session use
This was referenced Mar 31, 2026
- Add readLastActiveProjectIdSync() to read projectId from SQLite before window creation
- Pass initialProjectId through to setupBrowserWindow for session partition assignment
- Initial view now uses persist:project-{id} session on returning-user boot
- Falls back to default session on first launch (no DB or no project)
- Registers app:// and canopy-file:// protocols on the initial view session
- Fix IPC utils tests to mock webContentsRegistry and windowRef modules - Fix WorkspaceClient resilience tests for current blue-green swap behavior - Fix worktree rate limit tests with missing mock dependencies - Replace gutted ProjectSwitchOverlay tests with no-op verification - Fix worktreeDataStore tests with vi.resetModules() for module-level state - Replace gutted useProjectSwitchRehydration tests with no-op verification - Fix stateHydration test for scrollbackRestoreState guard mechanism
…d view switch When switching back to a cached WebContentsView, loadProject was not called (guarded by `if (isNew)`), leaving windowToProject pointing at the previous project. sendToEntryWindows then routed the old project's IPC events to the active view via getAppWebContents, which always returns the currently-visible view regardless of project ownership. Two fixes: 1. Always call loadProject on project switch so the window→project mapping stays correct and the old entry's windowIds is cleaned up. 2. Change sendToEntryWindows to target entry.directPortViews (the project's own webContents) instead of getAppWebContents(win), eliminating the race window between registerAppView and loadProject. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Always re-attach direct MessagePort on switch (not just for new views), so a host recreated after CLEANUP_GRACE_MS expiry still has a relay target. - Fix setActiveWorktree no-windowId broadcast to use sendToEntryWindows instead of getAppWebContents (same contamination vector). - Remove dead BrowserWindow/getAppWebContents imports from WorkspaceClient. - Update stale "IPC relay always runs" comment. - Add dedicated A→B→A cached reactivation regression test. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Extract distributePortsToView into portDistribution.ts to break circular ESM dependency - Distribute fresh PTY MessagePort on every project switch and reopen - Add onViewReady callback to ProjectViewManager for reload/crash recovery - Gate onViewReady to active view only, preventing cached views from stealing ports - Fix blank agent terminals caused by new views never receiving MessagePorts
- Lock worktreeDataStore to first received scopeId, reject updates from other scopes - Reset scope lock on getAll/refresh since those are always server-side scoped - Prevents worktrees from wrong project appearing in sidebar during view switches - scopeId was already delivered in onUpdate callback but was being ignored
- Replace minimal startup skeleton with detailed layout matching app structure (toolbar, sidebar cards, main area with centered Canopy logo) - Add GPU-accelerated shimmer animation via translateX pseudo-elements and gentle logo breathing effect - Defer skeleton removal until hydration completes instead of removing on React mount - Use color-mix() with CSS custom properties for automatic theme adaptation - Support prefers-reduced-motion and add aria-live/aria-busy accessibility
- Add skeletonCss utility to inject theme tokens, sidebar width, and focus mode as CSS variables before HTML loads - Wire CSS injection into both createWindow (initial load) and ProjectViewManager (project switch) - Replace hardcoded 350px sidebar with var(--skeleton-sidebar-width) to match persisted user width - Add recipe grid skeleton (3-col with icon+name cards) and project pulse skeleton (heatmap + stats) - Scale Canopy logo to 112px matching actual empty state, add ghost title and tagline text
- Add EPIPE error listeners on stdout/stderr in main, pty-host, and workspace-host processes to prevent crashes when parent terminal closes - Add vitest globalSetup to auto-rebuild better-sqlite3 for system Node when ABI mismatches Electron's build - Wire globalSetup into vitest.config.ts so tests pass regardless of invocation method
- Use named import for PtyClient (no default export) - Remove unused BrowserWindow import in WorkspaceClient test
Collaborator
Author
This was referenced Apr 7, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Migrate project switching from single-renderer state swapping to WebContentsView-per-project isolation. This eliminates cross-project worktree contamination by giving each project its own V8 context instead of relying on fragile client-side guards.
All four phases are complete. Net change: +1,382 / −3,153 across 54 files — the migration deletes significantly more code than it adds.
Phase 1: WebContents Registry
webContentsRegistry.ts— mapswebContents.id→ parentBrowserWindowgetWindowForWebContents()with fallback lookupBrowserWindow.fromWebContents(event.sender)calls across 13 filesPhase 2: WebContentsView Content Host
win.webContents.send()calls through the registryPhase 3: Multi-View Project Switching
ProjectViewManagermanages view creation, switching, and LRU eviction (2 cached views)Phase 4: MessagePort Direct Routing
Additional Refinements
Motivation
The root cause of persistent worktree contamination (#4642, #4670) is architectural: a shared renderer cannot reliably isolate per-project state. Client-side filtering of shared IPC channels is fundamentally fragile. This migration achieves isolation by construction — each project gets its own V8 context with no shared state to contaminate.
Test plan
getWindowForWebContents()fallback works for WebContentsView senders🤖 Generated with Claude Code