Skip to content

refactor: WebContentsView-per-project switching#4672

Merged
gregpriday merged 19 commits intodevelopfrom
feature/webcontent-view-project-switching
Mar 31, 2026
Merged

refactor: WebContentsView-per-project switching#4672
gregpriday merged 19 commits intodevelopfrom
feature/webcontent-view-project-switching

Conversation

@gregpriday
Copy link
Copy Markdown
Collaborator

@gregpriday gregpriday commented Mar 31, 2026

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

  • Created webContentsRegistry.ts — maps webContents.id → parent BrowserWindow
  • Exported getWindowForWebContents() with fallback lookup
  • Replaced 28 BrowserWindow.fromWebContents(event.sender) calls across 13 files
  • Registered BrowserWindow's own webContents at creation time

Phase 2: WebContentsView Content Host

  • Moved React app from BrowserWindow's webContents into a WebContentsView
  • Updated outbound IPC helpers to target the app view's webContents
  • Routed all direct win.webContents.send() calls through the registry
  • Added resize handling, keyboard shortcuts, and crash recovery on the view

Phase 3: Multi-View Project Switching

  • Created new WebContentsView per project instead of resetting stores
  • ProjectViewManager manages view creation, switching, and LRU eviction (2 cached views)
  • Switch time: <16ms (GPU composite) vs previous 500-1500ms
  • Throttled background view creation to avoid startup overhead
  • Session partitions per project for complete isolation

Phase 4: MessagePort Direct Routing

  • Direct MessagePort channels between each view and its workspace host
  • Kept IPC relay alongside direct port for reliability
  • Eliminates main-process relay bottleneck for worktree events

Additional Refinements

  • Session partitions, focus management, and view tracking
  • Menu directory-open routed through ProjectViewManager
  • Background view throttling and reduced cache size for memory efficiency

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

  • All existing IPC handlers resolve the correct BrowserWindow via the registry
  • getWindowForWebContents() fallback works for WebContentsView senders
  • Run full E2E suite — fixing test failures in progress
  • Cross-platform testing (macOS, Windows, Linux) for window/view behavior

🤖 Generated with Claude Code

- 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
@gregpriday gregpriday force-pushed the feature/webcontent-view-project-switching branch from 455df51 to d592d9d Compare March 31, 2026 09:25
- 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
gregpriday and others added 8 commits March 31, 2026 11:51
- 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
@gregpriday gregpriday merged commit 7578747 into develop Mar 31, 2026
4 checks passed
@gregpriday gregpriday deleted the feature/webcontent-view-project-switching branch March 31, 2026 12:00
@gregpriday
Copy link
Copy Markdown
Collaborator Author

gregpriday commented Apr 7, 2026

This architectural refactor introduced state management gaps across project switching. The view eviction/destruction lifecycle caused race conditions in 8 different subsystems:

Core issue: destroying renderer views before state could be captured.

Regression audit for training data.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant