Skip to content

fix(desktop): keep v2 terminals and browsers stable across workspace switches#3687

Merged
Kitenite merged 6 commits into
mainfrom
please-debug-our-terminal-is-now-switching-and-reattaching-when-switching-workspace-instead-of-being
Apr 24, 2026
Merged

fix(desktop): keep v2 terminals and browsers stable across workspace switches#3687
Kitenite merged 6 commits into
mainfrom
please-debug-our-terminal-is-now-switching-and-reattaching-when-switching-workspace-instead-of-being

Conversation

@Kitenite
Copy link
Copy Markdown
Collaborator

@Kitenite Kitenite commented Apr 23, 2026

Summary

Both v2 terminals and browsers were visibly rebuilt on every workspace switch. Root cause is a shared lifecycle hazard — the v2 layout's WorkspaceTrpcProvider key remount + the way useLiveQuery updates can propagate — but the fixes are different for each runtime.

Terminal (commit e56992653)

  • Park the xterm wrapper in a hidden body-level #v2-terminal-parking div on detach instead of wrapper.remove(), so xterm stays in the document and the next mount is a DOM move back.
  • Split registry.attach into mount (synchronous DOM) and connect (called only after ensureSession). Matches VSCode TerminalInstance.attachToElement + _createProcess and Tabby's XTermFrontend.attach + setSession. Eliminates the "Session not found" race on cold mount and the tRPC-gated delay on warm return.
  • Narrow TerminalPane's attach effect deps to [terminalId]; workspaceId / websocketUrl go through refs. websocketUrl changes route through registry.reconnect(), which hard-guards on the transport already being live.

Browser (commit 3b88c0207)

  • onRemoved: (pane) => browserRuntimeRegistry.destroy(pane.id) in usePaneRegistry was firing during workspace switches when the provider key didn't remount promptly: the existing WorkspaceContent stayed mounted, useV2WorkspacePaneLayout called store.replaceState(WS-B panes) on the same store, and the Panes library's diff correctly observed "WS-A's browser is gone now" → destroyed the webview. Return trip ran the cold createEntry path, losing state.
  • Fix: new useGlobalBrowserLifecycle sweeper, mirrors useGlobalTerminalLifecycle — diffs browser pane ids across every workspace's persisted paneLayout and destroys with a 500 ms grace delay. The onRemoved wiring is gone.

Reference

apps/desktop/plans/20260423-1226-v2-pane-persistence-across-workspace-switch.md captures full root-cause analysis for both runtimes, the shared "register state outside the remounting subtree" pattern, and a note on the code editor (same class of risk, not reported yet).

Test plan

  • Two workspaces each with a terminal — switch back and forth, output and cursor preserved, no flicker, no "Session not found"
  • New terminal in a new tab — xterm frame appears instantly, prompt streams in
  • Two workspaces each with a browser pane — switch back and forth, page stays loaded (URL, scroll, history preserved)
  • Close a terminal pane — actually disposed after grace
  • Close a browser pane — actually disposed after grace
  • #v2-terminal-parking and #browser-runtime-root visible on document.body in DevTools as expected

Summary by CodeRabbit

  • Bug Fixes

    • Removed terminal flicker during pane remounts by preserving terminal UI between React remounts.
    • Prevented premature browser/webview teardown during workspace switches.
  • Improvements

    • Mounts terminal UI immediately and starts socket transport separately for faster, steadier terminal display.
    • Transport updates now reuse existing connections when endpoints change to avoid pane recreation.
  • New Features

    • Global browser lifecycle manager that defers and debounces webview destruction.
  • Documentation

    • Added notes on pane persistence and lifecycle strategies across workspace switches.

Problem: every v2-workspace switch yanked the xterm wrapper out of the DOM and
re-opened a WebSocket, producing a visible "switching and reattaching" flash
instead of VSCode-style hide/show. The v2-workspace layout's WorkspaceTrpcProvider
has a load-bearing key that unmounts the whole subtree on every switch, so the
React component for TerminalPane goes away — but the xterm instance, wrapper
div, and transport in terminalRuntimeRegistry should survive, and previously the
DOM node didn't.

Three entangled issues were fixed together:

1. Parking container. On detach, the wrapper used to be wrapper.remove()'d,
   taking the rendered canvases with it. Now it's appended to a hidden
   body-level div (#v2-terminal-parking) so xterm stays attached to the
   document. Re-mount in the new workspace is a DOM move back from parking
   to the live container — mirrors the existing v1 persistent-webview pattern
   at usePersistentWebview.ts:14-27 and VSCode's TerminalInstance setVisible
   model.

2. DOM vs transport split. The previous single registry.attach() both
   mounted DOM and opened the WebSocket. That forced TerminalPane to gate
   attach on ensureSession, which made warm returns wait on a tRPC round-trip
   (visible delay) and made cold mounts race — opening a WS before the
   server session existed produced "Session not found. Call terminal.
   ensureSession first.". Split into mount() (synchronous, DOM-only, safe
   on every mount) and connect() (called only after ensureSession resolves).
   Matches VSCode's TerminalInstance.attachToElement + _createProcess and
   Tabby's XTermFrontend.attach + setSession.

3. Effect dep narrowing. TerminalPane's attach effect used to depend on
   [terminalId, websocketUrl, initialThemeType, workspaceId]; prop churn
   during the provider key remount (workspaceId flipping while pane data
   caught up) forced repeated detach/attach cycles. Narrowed to [terminalId]
   with the others read through refs. websocketUrl changes now go through
   registry.reconnect(), which is hard-gated on transport already being
   live so it never opens a WS before ensureSession has resolved.

Log walkthrough on a warm workspace switch:
  pane:effect-cleanup → registry:detach → runtime:detach (wrapper parks)
  pane:effect-mount   → registry:mount  → runtime:attach wasParked:true
  registry:reconnect-skip same-url
  pane:ensureSession-ok → registry:connect → transport:connect-skip idempotent

Instrumentation stays in this commit for the rollout check; a follow-up
strips the termLog calls once this is confirmed in production.
Removes the temporary termLog tracing added to diagnose the workspace-switch
reattach bug. The behavior fix (parking + mount/connect split) landed in the
previous commit and is confirmed stable in logs; no functional change here.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 23, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4e29b48f-60d6-493e-904a-182a2d751e6c

📥 Commits

Reviewing files that changed from the base of the PR and between a0e72f6 and 78dd18e.

📒 Files selected for processing (1)
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx

📝 Walkthrough

Walkthrough

Decouples terminal DOM mounting from transport: replaces attach() with mount(), adds connect() and reconnect() to control WebSocket transport, and parks terminal DOM in a hidden body-level container to survive React remounts. Adds a global browser lifecycle hook that debounces webview destruction across workspace switches.

Changes

Cohort / File(s) Summary
Terminal Registry
apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts
Replaces attach() with mount() (no wsUrl), adds connect(terminalId, wsUrl) and reconnect(terminalId, wsUrl). Updates JSDoc and pending-link handler comments; adjusts detach() semantics.
Terminal Runtime DOM
apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts
Adds a single hidden body-level parking container. detachFromContainer() moves runtime.wrapper into parking instead of removing it; attachToContainer() is idempotent and avoids redundant observer/resize/focus work.
Terminal Pane integration
apps/desktop/src/renderer/routes/.../TerminalPane/TerminalPane.tsx
Shifts to DOM-first lifecycle: calls terminalRuntimeRegistry.mount(...) when container exists, runs ensureSession, then calls connect(...) (in finally). Stores workspace/websocket values in refs, memoizes store subscription callbacks, narrows effect deps to [terminalId], and calls reconnect(...) when websocket URL changes.
Browser pane registry
apps/desktop/src/renderer/routes/_authenticated/_dashboard/.../usePaneRegistry/usePaneRegistry.tsx
Removes immediate browserRuntimeRegistry.destroy on pane removal; delegates destruction to global lifecycle manager and removes browserRuntimeRegistry import.
Global Browser Lifecycle
apps/desktop/src/renderer/routes/_authenticated/components/GlobalBrowserLifecycle/...
.../useGlobalBrowserLifecycle/useGlobalBrowserLifecycle.ts, index.ts, GlobalBrowserLifecycle.tsx, index.ts
Adds GlobalBrowserLifecycle component and useGlobalBrowserLifecycle hook. Hook observes all persisted v2 workspace state, computes browser pane ids across workspaces, schedules delayed destruction with cancellation if id reappears, re-checks state before destroying via browserRuntimeRegistry.destroy. Adds re-exports.
Layout inclusion
apps/desktop/src/renderer/routes/_authenticated/layout.tsx
Mounts GlobalBrowserLifecycle in the authenticated layout alongside existing global lifecycle components.
Design note / plan
apps/desktop/plans/20260423-1226-v2-pane-persistence-across-workspace-switch.md
Adds design notes describing root cause and fixes for v2 pane persistence across workspace switches: terminal parking, mount/connect split, browser lifecycle debounce, and planned editor persistence approach.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant Component as TerminalPane (React)
  participant Registry as TerminalRuntimeRegistry
  participant Runtime as TerminalRuntime (DOM)
  participant Transport as Transport / WebSocket

  Component->>Registry: mount(container, terminalId, options)
  Registry->>Runtime: create or reuse runtime.wrapper
  Runtime->>Runtime: attach wrapper to container (or move to parking on detach)
  Component-->>Registry: ensureSession() (async)
  Component->>Registry: connect(terminalId, wsUrl)  -- after session attempt
  Registry->>Transport: open or reuse transport for terminalId with wsUrl
  Transport->>Transport: WebSocket connect / handshake
  Transport-->>Registry: connected / error
  Registry-->>Component: connection state/events
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Poem

🐇
I mount the shell before the wire,
I park it safe when layouts tire,
I wait a beat if panes depart,
Then reconnect with steady heart,
No flicker — just a hopping art.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 9.09% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The PR title clearly and concisely summarizes the main change: fixing v2 terminals and browsers to remain stable across workspace switches, which is the core objective of the changeset.
Description check ✅ Passed The PR description is comprehensive with clear sections for Summary, Terminal/Browser fixes, Reference documentation, and Test plan. It follows the template structure with Description, Related Issues, Type of Change (Bug fix), and Testing sections properly filled.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch please-debug-our-terminal-is-now-switching-and-reattaching-when-switching-workspace-instead-of-being

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Apr 23, 2026

Greptile Summary

This PR re-architects the v2 terminal lifecycle to eliminate workspace-switch flicker and the "Session not found" race: on detach the xterm wrapper is parked in a hidden body-level container (instead of being removed), and the old single attach call is split into a synchronous mount (DOM only) followed by an async connect (WebSocket, after ensureSession resolves). The main effect's deps are narrowed to [terminalId] to prevent re-runs from provider key churn, with other values read through refs.

Confidence Score: 4/5

Safe to merge; two P2 findings worth addressing before shipping — one accessibility gap and one transport-state edge case — but neither blocks the primary fix.

The parking + DOM-first lifecycle strategy is well-reasoned and the cancelled flag / ref pattern correctly handles the async race. Both remaining comments are P2: missing inert on the parking container is a straightforward accessibility improvement, and the reconnect guard for "connecting" state depends on transport-level idempotency not visible in this diff.

terminal-runtime.ts (parking container accessibility) and terminal-runtime-registry.ts (reconnect connecting-state guard).

Important Files Changed

Filename Overview
apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts Adds getParkingContainer() (body-level hidden div) and changes detachFromContainer to move the xterm wrapper there instead of removing it; adds idempotency guard to attachToContainer. Minor concern: parking container lacks inert, leaving parked xterm textareas keyboard-reachable.
apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts Splits attach into mount (synchronous DOM), connect (WebSocket), and new reconnect (URL-change-only swap). Logic is sound but reconnect doesn't guard the "connecting" state, which could trigger a double-connect if a URL change races an in-flight WebSocket handshake.
apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx Main lifecycle effect narrowed to [terminalId] with prop values read through refs; mount called synchronously before async ensureSessionconnect chain; separate reconnect effect handles URL changes. The cancelled flag correctly prevents stale connects on cleanup.

Sequence Diagram

sequenceDiagram
    participant React as TerminalPane
    participant Reg as Registry
    participant RT as TerminalRuntime
    participant Park as ParkingContainer
    participant WS as WebSocket

    Note over React: Cold mount
    React->>Reg: mount(id, container, appearance)
    Reg->>RT: createRuntime() / attachToContainer()
    RT-->>React: xterm visible (blank cursor)
    React->>Reg: ensureSession → connect(id, wsUrl)
    Reg->>WS: open WebSocket

    Note over React: Workspace switch (unmount)
    React->>Reg: detach(id)
    Reg->>RT: detachFromContainer()
    RT->>Park: appendChild(wrapper)
    Note over WS: Transport stays alive

    Note over React: Warm return (remount)
    React->>Reg: mount(id, container, appearance)
    Reg->>RT: attachToContainer() — reparents wrapper
    Park-->>RT: wrapper moved back
    RT-->>React: xterm visible (buffer preserved)
    React->>Reg: ensureSession → connect(id, wsUrl)
    Reg->>WS: no-op (already connected)

    Note over React: URL change (token refresh)
    React->>Reg: reconnect(id, newUrl)
    alt transport disconnected
        Reg-->>React: no-op (ensureSession path handles it)
    else transport live
        Reg->>WS: reconnect to new URL
    end
Loading
Prompt To Fix All With AI
This is a comment left during a code review.
Path: apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts
Line: 133-147

Comment:
**Parked terminals remain keyboard-focusable**

The parking container hides terminals visually and disables mouse interaction via `pointerEvents: none`, but xterm's internal `<textarea>` elements stay in the tab order. Users cycling through focusable elements with Tab can land inside an off-screen, inactive terminal.

Setting `inert` on the container prevents both keyboard focus and accessibility-tree exposure without touching individual child nodes:

```suggestion
	el.id = PARKING_CONTAINER_ID;
	el.setAttribute("inert", "");
	el.style.position = "fixed";
	el.style.left = "-9999px";
	el.style.top = "-9999px";
	el.style.width = "100vw";
	el.style.height = "100vh";
	el.style.overflow = "hidden";
	el.style.pointerEvents = "none";
```

`inert` is broadly supported and is the platform-idiomatic way to make a subtree non-interactive.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts
Line: 109-115

Comment:
**`reconnect` fires during `"connecting"` state on URL change**

The guard skips reconnection only when `connectionState === "disconnected"`. If the underlying WebSocket is still opening (`"connecting"`) and the URL changes — e.g. a fast token refresh that races the initial `ensureSession` resolution — `reconnect` will call `connect` with the new URL while the first socket is mid-handshake. If `connect` does not abort the in-flight socket first, the terminal could end up with two live transports briefly. Consider extending the guard:

```ts
if (
    entry.transport.connectionState === "disconnected" ||
    entry.transport.connectionState === "connecting"
) return;
```

or confirming that `connect` in `terminal-ws-transport` always cancels any in-progress socket before opening a new one.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "chore(desktop): strip v2 terminal lifecy..." | Re-trigger Greptile

Comment thread apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts
Comment thread apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts (1)

132-148: Consider making the parking container inert to hide it from focus and AT.

pointer-events: none blocks mouse events, but xterm's underlying <textarea> inside the parked wrapper is still reachable via Tab and announced by screen readers. A keyboard user tabbing through the app could land in a hidden terminal, and typed input would silently go to the parked instance. Adding inert (or at minimum aria-hidden="true" + tabindex="-1" management) would remove the subtree from focus and the accessibility tree while parked.

♻️ Suggested tweak
 	const el = document.createElement("div");
 	el.id = PARKING_CONTAINER_ID;
+	el.inert = true;
+	el.setAttribute("aria-hidden", "true");
 	el.style.position = "fixed";
 	el.style.left = "-9999px";
 	el.style.top = "-9999px";
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts` around lines 132
- 148, The parked terminal container created by getParkingContainer
(PARKING_CONTAINER_ID) is hidden visually but still reachable by keyboard/screen
readers; update getParkingContainer to mark the container and its subtree as
non-interactive and removed from the accessibility tree when parked by adding
the inert attribute (preferred) or, if inert isn't available, set
aria-hidden="true" and ensure tabindex="-1" is managed for the container; ensure
these attributes are applied before appending to document.body and removed when
the container is reused so the terminal can become interactive again.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts`:
- Around line 109-115: Update the JSDoc for the reconnect(terminalId: string,
wsUrl: string) method to state that the guard only prevents reconnect when the
transport is in the initial "disconnected" state; clarify that reconnect is
intentionally allowed for "connecting", "open", and "closed" states (where
"closed" represents a previously live transport eligible for URL-based
reconnect), and reference entry.transport.connectionState and the
connect(entry.transport, entry.runtime.terminal, wsUrl) call so readers know
which symbols govern the behavior.

---

Nitpick comments:
In `@apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts`:
- Around line 132-148: The parked terminal container created by
getParkingContainer (PARKING_CONTAINER_ID) is hidden visually but still
reachable by keyboard/screen readers; update getParkingContainer to mark the
container and its subtree as non-interactive and removed from the accessibility
tree when parked by adding the inert attribute (preferred) or, if inert isn't
available, set aria-hidden="true" and ensure tabindex="-1" is managed for the
container; ensure these attributes are applied before appending to document.body
and removed when the container is reused so the terminal can become interactive
again.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 29eae077-a0d0-49b0-ad6b-6ed8bf08d905

📥 Commits

Reviewing files that changed from the base of the PR and between 64a36f0 and 3ec2260.

📒 Files selected for processing (3)
  • apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts
  • apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx

Comment thread apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 3 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts">

<violation number="1" location="apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts:145">
P2: The parking container hides terminals visually and disables pointer events, but xterm's internal `<textarea>` elements retain `tabindex="0"`. Keyboard users pressing Tab can focus into an off-screen, inactive terminal. Add `el.setAttribute("inert", "")` to remove the subtree from the tab order and accessibility tree — this is well-supported and is the platform-idiomatic way to make a hidden subtree fully non-interactive.</violation>

<violation number="2" location="apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts:242">
P1: Blur focus before parking the wrapper; otherwise a hidden parked terminal can remain focused and capture keystrokes after detach.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts
Comment thread apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts
Browser webviews were being destroyed on workspace switch, discarding
guest-page state (URL, scroll, history) even though the registry was
designed to persist them.

Root cause: usePaneRegistry wired browser destruction through the Panes
library's onRemoved hook:

    onRemoved: (pane) => browserRuntimeRegistry.destroy(pane.id)

Under ideal conditions the v2 layout's `key={`${workspace.id}:${hostUrl}`}`
remounts the WorkspaceTrpcProvider subtree on every switch, so each
workspace gets its own Workspace component whose previous-panes diff
never observes a cross-workspace "removal". But the remount isn't
always prompt — layout.tsx's useLiveQuery can return stale WS-A data
for a tick while page.tsx's already flipped to WS-B. During that tick
the existing WorkspaceContent stays mounted, useV2WorkspacePaneLayout
calls store.replaceState(WS-B panes) on the same store instance, and
the Panes diff correctly sees "WS-A's browser is gone" → fires
onRemoved → destroys the webview. By the time the user returns,
attach() runs the cold createEntry path and the guest page is lost.

Terminals don't hit this because destruction goes through
useGlobalTerminalLifecycle, a global sweep against every workspace's
persisted paneLayout — cross-workspace "removal" isn't a removal from
the sweep's perspective.

Fix: mirror the terminal pattern exactly.
- Added useGlobalBrowserLifecycle under
  _authenticated/components/GlobalBrowserLifecycle/, following the
  same shape as useGlobalTerminalLifecycle (extract pane.ids from all
  workspace layouts, diff against previous, 500 ms grace delay
  destroy to tolerate cross-workspace pane moves).
- Mounted <GlobalBrowserLifecycle /> alongside <GlobalTerminalLifecycle />
  in _authenticated/layout.tsx.
- Removed the onRemoved wiring from usePaneRegistry.tsx — the sweep
  replaces it.

Verified with instrumentation that a workspace switch on a live browser
pane no longer reaches browserRuntimeRegistry.destroy, and that closing
a browser pane still destroys after the 500 ms grace.

Followup to PR #3687 (terminal-side fix landed earlier on this branch).
Plan doc at apps/desktop/plans/20260423-1226-v2-pane-persistence-across-workspace-switch.md
captures the full root-cause analysis for both runtimes as a reference
for future pane persistence work.

Also removes a now-redundant biome-ignore comment in TerminalPane
that biome flagged as having no effect after the dep narrowing in the
earlier commit.
@Kitenite Kitenite changed the title fix(desktop): keep v2 terminals stable across workspace switches fix(desktop): keep v2 terminals and browsers stable across workspace switches Apr 23, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
apps/desktop/src/renderer/routes/_authenticated/components/GlobalBrowserLifecycle/hooks/useGlobalBrowserLifecycle/useGlobalBrowserLifecycle.ts (2)

13-27: Minor: the as WorkspaceState<unknown> cast hides the shape contract.

row.paneLayout is typed unknown, and the cast is taken on faith. The current code is safe at runtime because if (!layout?.tabs) continue and Object.values(tab.panes) both tolerate missing shape, and any entry missing kind simply fails the === "browser" check. Still, a narrow type guard would be nicer-to-have and would surface schema drift if the persisted paneLayout representation ever changes (e.g., gets stored as a JSON string instead of a parsed object).

♻️ Sketch
-function extractBrowserPaneIds(rows: { paneLayout: unknown }[]): Set<string> {
-	const ids = new Set<string>();
-	for (const row of rows) {
-		const layout = row.paneLayout as WorkspaceState<unknown> | undefined;
-		if (!layout?.tabs) continue;
+function isWorkspaceState(
+	value: unknown,
+): value is WorkspaceState<unknown> {
+	return (
+		typeof value === "object" &&
+		value !== null &&
+		Array.isArray((value as { tabs?: unknown }).tabs)
+	);
+}
+
+function extractBrowserPaneIds(rows: { paneLayout: unknown }[]): Set<string> {
+	const ids = new Set<string>();
+	for (const row of rows) {
+		if (!isWorkspaceState(row.paneLayout)) continue;
+		const layout = row.paneLayout;
 		for (const tab of layout.tabs) {
 			for (const pane of Object.values(tab.panes)) {
 				if (pane.kind === "browser") {
 					ids.add(pane.id);
 				}
 			}
 		}
 	}
 	return ids;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/desktop/src/renderer/routes/_authenticated/components/GlobalBrowserLifecycle/hooks/useGlobalBrowserLifecycle/useGlobalBrowserLifecycle.ts`
around lines 13 - 27, The function extractBrowserPaneIds currently casts
row.paneLayout to WorkspaceState<unknown>; replace that unsafe cast with a
proper runtime type guard (e.g., isWorkspaceState(value): value is
WorkspaceState<unknown>) and use it to early-return/continue when row.paneLayout
isn't the expected shape; ensure the guard verifies the presence and types of
tabs and pane containers so the subsequent loops over layout.tabs and
Object.values(tab.panes) are safe and will surface schema drift instead of
silently trusting the cast.

7-11: Optional: share DESTROY_DELAY_MS with the terminal lifecycle.

The JSDoc explicitly calls out that this "matches the terminal-side timing so the two runtimes behave consistently," but the two constants are currently declared independently in each hook. Extracting a single shared constant (e.g., alongside the pane-persistence plan or in a small shared module) would prevent the two from silently drifting if one is later tuned.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/desktop/src/renderer/routes/_authenticated/components/GlobalBrowserLifecycle/hooks/useGlobalBrowserLifecycle/useGlobalBrowserLifecycle.ts`
around lines 7 - 11, The local DESTROY_DELAY_MS in useGlobalBrowserLifecycle
should be extracted to a single shared constant so it can't drift from the
terminal-side value; create a small shared module (e.g., paneLifecycleConstants
or alongside the pane-persistence plan) that exports DESTROY_DELAY_MS, replace
the local const in useGlobalBrowserLifecycle with an import of that shared
constant, and update the terminal-side lifecycle to import the same shared
constant as well so both runtimes use the identical value.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In
`@apps/desktop/src/renderer/routes/_authenticated/components/GlobalBrowserLifecycle/hooks/useGlobalBrowserLifecycle/useGlobalBrowserLifecycle.ts`:
- Around line 13-27: The function extractBrowserPaneIds currently casts
row.paneLayout to WorkspaceState<unknown>; replace that unsafe cast with a
proper runtime type guard (e.g., isWorkspaceState(value): value is
WorkspaceState<unknown>) and use it to early-return/continue when row.paneLayout
isn't the expected shape; ensure the guard verifies the presence and types of
tabs and pane containers so the subsequent loops over layout.tabs and
Object.values(tab.panes) are safe and will surface schema drift instead of
silently trusting the cast.
- Around line 7-11: The local DESTROY_DELAY_MS in useGlobalBrowserLifecycle
should be extracted to a single shared constant so it can't drift from the
terminal-side value; create a small shared module (e.g., paneLifecycleConstants
or alongside the pane-persistence plan) that exports DESTROY_DELAY_MS, replace
the local const in useGlobalBrowserLifecycle with an import of that shared
constant, and update the terminal-side lifecycle to import the same shared
constant as well so both runtimes use the identical value.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0186cccd-a828-46b8-8458-90cc33b610ec

📥 Commits

Reviewing files that changed from the base of the PR and between 3ec2260 and 3b88c02.

📒 Files selected for processing (8)
  • apps/desktop/plans/20260423-1226-v2-pane-persistence-across-workspace-switch.md
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx
  • apps/desktop/src/renderer/routes/_authenticated/components/GlobalBrowserLifecycle/GlobalBrowserLifecycle.tsx
  • apps/desktop/src/renderer/routes/_authenticated/components/GlobalBrowserLifecycle/hooks/useGlobalBrowserLifecycle/index.ts
  • apps/desktop/src/renderer/routes/_authenticated/components/GlobalBrowserLifecycle/hooks/useGlobalBrowserLifecycle/useGlobalBrowserLifecycle.ts
  • apps/desktop/src/renderer/routes/_authenticated/components/GlobalBrowserLifecycle/index.ts
  • apps/desktop/src/renderer/routes/_authenticated/layout.tsx
✅ Files skipped from review due to trivial changes (3)
  • apps/desktop/src/renderer/routes/_authenticated/components/GlobalBrowserLifecycle/index.ts
  • apps/desktop/src/renderer/routes/_authenticated/components/GlobalBrowserLifecycle/hooks/useGlobalBrowserLifecycle/index.ts
  • apps/desktop/plans/20260423-1226-v2-pane-persistence-across-workspace-switch.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx

… doc

Two follow-ups from PR #3687 review:

- `inert` on #v2-terminal-parking. Parked terminals' internal <textarea>
  still had `tabindex=0`, so a keyboard user tabbing through the app
  could land in an off-screen terminal and have keystrokes silently go
  to the wrong pane. `inert` removes the subtree from the tab order
  and the accessibility tree, and moves focus out automatically, which
  also handles the "blur before park" concern for free. aria-hidden
  added for belt-and-suspenders on older engines.

- `reconnect` JSDoc. Clarify that the guard only skips `"disconnected"`
  (never-opened transport, caller should use ensureSession + connect
  path). `"connecting"`, `"open"`, `"closed"` are all intentionally
  allowed through — `connect()` aborts any in-flight or stale socket
  before opening the new one.
- Collapse TerminalPane's .then()/.catch() into .catch()/.finally():
  one connect() call site instead of two identical guarded calls. Same
  semantics (connect after ensureSession settles, even on rejection),
  cancellation check consolidated.
- Merge the three connect-related comments into one block explaining
  "connect regardless of outcome" + idempotency. Drop the redundant
  "// DOM first" inline that the block comment above already covered.
- Trim detachFromContainer's 5-line comment down to 2 lines pointing
  at getParkingContainer — the helper's docstring already explains
  the parking rationale in full.
The module-level `subscribeToState(terminalId)` built a fresh closure on
every render, so `useSyncExternalStore` saw a new subscribe function each
time and re-subscribed to `terminalRuntimeRegistry.onStateChange` on every
TerminalPane render — this is the anti-pattern React's useSyncExternalStore
docs explicitly warn about ("If you don't memoize the subscribe function,
React will resubscribe to your store every time your component re-renders").

Inline the helpers and wrap both subscribe + getSnapshot in useCallback
keyed on [terminalId]. Re-subscribe now only fires when the pane's
terminalId actually changes (cold create / destroy), not on every
keystroke-triggered re-render.
@Kitenite Kitenite merged commit 6f12f77 into main Apr 24, 2026
7 checks passed
@Kitenite Kitenite deleted the please-debug-our-terminal-is-now-switching-and-reattaching-when-switching-workspace-instead-of-being branch April 24, 2026 00:45
@github-actions
Copy link
Copy Markdown
Contributor

🧹 Preview Cleanup Complete

The following preview resources have been cleaned up:

  • ✅ Neon database branch

Thank you for your contribution! 🎉

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