-
-
-
- {viewMode === 'focus' && (
-
- )}
+
+ {workspaceTrees.map(({ id, tree }) => {
+ const isActive = id === activeWorkspaceId;
+ // Only the active layer gets the focus-mode wrapper; inactive layers
+ // stay in normal mode so their visibility:hidden is never overridden
+ // by the focus-mode :has(.focused) rule.
+ const wrapperClass =
+ isActive && viewMode === 'focus' ? 'tiling-focus-mode' : 'tiling-normal-mode';
+ return (
+
+
+ {tree ? (
+
+ ) : isActive ? (
+ isRestoring ? :
+ ) : null}
+ {isActive && }
+ {isActive && viewMode === 'focus' && (
+
+ )}
+
+
+ );
+ })}
+
);
diff --git a/src/renderer/styles/global.css b/src/renderer/styles/global.css
index 6342e7b..1f7c794 100644
--- a/src/renderer/styles/global.css
+++ b/src/renderer/styles/global.css
@@ -1388,6 +1388,23 @@ html.transparency-active .input-dialog {
}
/* ===== Focus Mode / Normal Mode wrappers ===== */
+/* TASK-240: one stacked layer per workspace. Every workspace's tiling tree is
+ rendered and kept mounted so its xterm instances stay alive and keep
+ consuming PTY output even while hidden; switching only flips which layer is
+ visible (no remount, no blank/stale panes). Layers anchor to .layout-area via
+ the display:contents .tiling-root wrapper. Inactive layers stay SIZED (so
+ xterm measures correctly) but are hidden and non-interactive. */
+.tiling-ws-layer {
+ position: absolute;
+ inset: 0;
+ overflow: hidden;
+}
+
+.tiling-ws-layer.inactive {
+ visibility: hidden;
+ pointer-events: none;
+}
+
/* display:contents makes the normal-mode wrapper transparent to layout —
children participate as if the div were not there. */
.tiling-normal-mode {
diff --git a/tests/e2e/workspaces.spec.ts b/tests/e2e/workspaces.spec.ts
index 3ae28ed..efb2cb8 100644
--- a/tests/e2e/workspaces.spec.ts
+++ b/tests/e2e/workspaces.spec.ts
@@ -121,6 +121,69 @@ test('terminals created in a workspace stay in that workspace when switching', a
}
});
+test('TASK-240: panes in an inactive workspace stay mounted (alive) across a switch', async () => {
+ // Regression for the "switch back to a workspace and the terminal is blank
+ // until you resize" bug. The fix renders every workspace's tiling tree as a
+ // stacked layer and keeps ALL terminals mounted, so a hidden workspace's
+ // xterm keeps consuming its PTY instead of being torn down. The structural
+ // guard here is that the previous workspace's pane host is still in the DOM
+ // (inside an inactive layer) after switching away, not removed.
+ const { window, close } = await launchTmax();
+ try {
+ await window.waitForSelector('.terminal-panel', { timeout: 15_000 });
+ await window.waitForTimeout(500);
+
+ const wsA = await window.evaluate(() => (window as any).__terminalStore.getState().activeWorkspaceId);
+ const termA = await window.evaluate(() => (window as any).__terminalStore.getState().focusedTerminalId);
+
+ // New workspace B with its own terminal; creating it switches active to B.
+ await createWorkspace(window);
+ await window.evaluate(() => (window as any).__terminalStore.getState().createTerminal());
+ await window.waitForTimeout(400);
+ const termB = await window.evaluate(() => (window as any).__terminalStore.getState().focusedTerminalId);
+
+ // Active is B, but A's pane host must still be mounted - that is what keeps
+ // A's PTY output flowing while hidden. A sits in an inactive layer, B in the
+ // active one.
+ const onB = await window.evaluate(({ a, b }) => {
+ const hosts = [...document.querySelectorAll('[data-pane-host]')].map((e) => (e as HTMLElement).dataset.paneHost);
+ const hostA = document.querySelector(`[data-pane-host="${a}"]`);
+ const hostB = document.querySelector(`[data-pane-host="${b}"]`);
+ return {
+ hasA: hosts.includes(a),
+ hasB: hosts.includes(b),
+ aInInactive: !!hostA?.closest('.tiling-ws-layer.inactive'),
+ bInActive: !!hostB?.closest('.tiling-ws-layer.active'),
+ };
+ }, { a: termA, b: termB });
+ expect(onB.hasA).toBe(true);
+ expect(onB.hasB).toBe(true);
+ expect(onB.aInInactive).toBe(true);
+ expect(onB.bInActive).toBe(true);
+
+ // Switching back to A keeps both mounted and flips which layer is active.
+ await window.evaluate((id) => (window as any).__terminalStore.getState().setActiveWorkspace(id), wsA);
+ await window.waitForTimeout(300);
+ const onA = await window.evaluate(({ a, b }) => {
+ const hosts = [...document.querySelectorAll('[data-pane-host]')].map((e) => (e as HTMLElement).dataset.paneHost);
+ const hostA = document.querySelector(`[data-pane-host="${a}"]`);
+ const hostB = document.querySelector(`[data-pane-host="${b}"]`);
+ return {
+ hasA: hosts.includes(a),
+ hasB: hosts.includes(b),
+ aInActive: !!hostA?.closest('.tiling-ws-layer.active'),
+ bInInactive: !!hostB?.closest('.tiling-ws-layer.inactive'),
+ };
+ }, { a: termA, b: termB });
+ expect(onA.hasA).toBe(true);
+ expect(onA.hasB).toBe(true);
+ expect(onA.aInActive).toBe(true);
+ expect(onA.bInInactive).toBe(true);
+ } finally {
+ await close();
+ }
+});
+
test('closeWorkspace removes its terminals and switches to a successor', async () => {
const { window, close } = await launchTmax();
try {