Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
---
id: TASK-240
title: Terminal panes render blank after switching back to a workspace until resized
status: Done
assignee:
- '@mpmisha'
created_date: '2026-06-21 07:18'
updated_date: '2026-06-21 08:49'
labels:
- bug
dependencies: []
---

## Description

<!-- SECTION:DESCRIPTION:BEGIN -->
When switching away from a workspace and back, the workspace's terminal pane(s) appear empty. Dragging a split border (any resize) forces a re-render and the content reappears. Reproduces even with a single pane.

Root cause: App renders a single <TilingLayout/> bound to the active workspace's layout.tilingRoot. Switching workspaces swaps the layout, unmounting the previous workspace's TerminalPanels and remounting them on return. The only thing preserving content across that remount is the renderer-side buffer cache (terminal-buffer-cache.ts), which auto-expires entries after 10s. If the user spends >10s in another workspace, the cached buffer expires and the pane remounts with an empty xterm buffer. Resizing fires resizePty (SIGWINCH), making the running program redraw - which is why content 'comes back' on resize.

The 10s TTL assumes remount happens within one React render cycle (true for split/float/grid reshapes) but is wrong for workspace switching, where remount only happens when the user returns - arbitrarily later.
<!-- SECTION:DESCRIPTION:END -->

## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Switching away from a workspace for more than 10s and back restores all terminal pane content without needing a manual resize
- [x] #2 Reproduces/fixed for both single-pane and multi-pane workspaces
- [x] #3 Existing split / float-dock / grid-rebuild / refresh-pane buffer restore behavior is unaffected
- [x] #4 Output produced while a workspace is hidden is present on switch-back (panes stay mounted/live, not just restored from a pre-switch snapshot)
<!-- AC:END -->

## Implementation Plan

<!-- SECTION:PLAN:BEGIN -->
1. terminal-buffer-cache.ts: remove the aggressive 10s TTL (the actual bug). Add dropTerminalBuffer(id) for explicit eviction; keep setBufferCacheExpiry test hook but default expiry to disabled.
2. TerminalPanel.tsx unmount cleanup: only saveTerminalBuffer when the terminal still exists in the store (alive=hide -> save; gone=closed -> skip + drop). Prevents orphaned entries now that there is no TTL.
3. terminal-store.ts: call dropTerminalBuffer(id) in closeTerminal and closeWorkspace so buffers for terminals killed while not mounted are evicted.
4. Build (npm run build / tsc) + run a targeted check.
<!-- SECTION:PLAN:END -->

## Implementation Notes

<!-- SECTION:NOTES:BEGIN -->
Implemented Option 1 (targeted cache fix), confirmed root cause live in dev build:
- pty.resize() only delivers SIGWINCH on an actual size change, so a remount at unchanged dims never makes the program redraw; with the 10s snapshot TTL expired, the pane stayed blank until a manual border-drag changed the size.

Changes:
- terminal-buffer-cache.ts: default expiry disabled (0 = no TTL); snapshots persist until popped or explicitly dropped. Added dropTerminalBuffer(). setBufferCacheExpiry retained for tests.
- TerminalPanel.tsx: unmount cleanup only saves a snapshot when the terminal is still in the store (hide/reparent), and drops it when the terminal is gone (genuine close) - prevents orphans now that there is no TTL.
- terminal-store.ts: dropTerminalBuffer(id) in closeTerminal and closeWorkspace to evict snapshots for terminals killed while not mounted.

Cleanup pass (post-verification):
- Reverted intermediate Option-1 buffer-cache changes (terminal-buffer-cache.ts, TerminalPanel.tsx, terminal-store.ts) - unnecessary once panes no longer unmount on workspace switch.
- Tightened comments; raised IPC listener cap (setMaxListeners 2000) since all panes now mount at once.
- Preserved TASK-117 no-empty-state-flash: active empty layer shows SessionLoading while restoring, EmptyState otherwise.
- Added e2e regression test (inactive workspace panes stay mounted across a switch).
- e2e suite is Windows-only (fixture hardcodes tmax-win32-x64/tmax.exe); validated new test + changed sources parse via esbuild; existing single-workspace specs use >= N panel-count assertions + class selectors so the extra layer wrapper does not affect them.
<!-- SECTION:NOTES:END -->

## Final Summary

<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Fix workspace panes rendering blank/stale after switching back.

Problem: App rendered a single <TilingLayout/> bound to the active workspace, so switching workspaces unmounted the previous workspace's TerminalPanels and disposed their xterm instances. PTY output produced while hidden was dropped, and the pane came back blank/stale until a manual resize fired SIGWINCH and the running app (e.g. Copilot CLI) redrew.

Fix (Option B - keep panes alive): render every workspace's tiling tree as a stacked, absolutely-positioned layer in .layout-area (active visible, inactive visibility:hidden but still sized & rendering) and mount portals for ALL terminals via the existing TASK-158 host system. Each xterm keeps consuming its PTY while hidden, so switching back is instant and current - no resize, nothing lost.

Changes:
- TilingLayout.tsx: per-workspace stacked layers; portals + host GC over all terminals; active-empty layer shows SessionLoading (restoring) / EmptyState.
- global.css: .tiling-ws-layer (+ .inactive).
- preload.ts: ipcRenderer.setMaxListeners(2000) - per-terminal IPC listeners now scale with total terminal count.
- tests/e2e/workspaces.spec.ts: regression test asserting inactive-workspace panes stay mounted across a switch.

Verified live by the user. Follow-up filed for floating panels in inactive workspaces (still unmount on switch; Option B scoped to tiled panes).
<!-- SECTION:FINAL_SUMMARY:END -->
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
id: TASK-241
title: Floating panels in inactive workspaces lose content on workspace switch
status: To Do
assignee: []
created_date: '2026-06-21 08:49'
labels:
- bug
dependencies: []
---

## Description

<!-- SECTION:DESCRIPTION:BEGIN -->
Follow-up to TASK-240. That fix keeps TILED workspace panes mounted/live across workspace switches (stacked layers in TilingLayout). Floating panels are rendered separately by FloatingLayer, which only reads the ACTIVE workspace's layout.floatingPanels, so a floating panel living in a non-active workspace still unmounts when you switch away and loses output produced while hidden (same class of bug TASK-240 fixed for tiled panes). Decide whether to keep all workspaces' floating panels mounted (hidden) the same way, or accept the limitation.
<!-- SECTION:DESCRIPTION:END -->

## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 A floating panel in workspace A keeps receiving and retaining PTY output while workspace B is active
- [ ] #2 Switching back to A shows the floating panel's current content without a manual resize
- [ ] #3 No regression to active-workspace floating panel behavior (drag, focus, dock)
<!-- AC:END -->
7 changes: 7 additions & 0 deletions src/preload/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ import type { DiffMode, DiffResult, AnnotatedFile } from '../shared/diff-types';
import type { RepoWorktrees } from '../shared/worktree-types';
import type { BacklogTask } from '../shared/backlog-types';

// TASK-240: every workspace's terminal panes now stay mounted at once, so the
// per-terminal IPC listeners (pty:data / pty:exit) scale with the total number
// of terminals across all workspaces - well past Node's default cap of 10,
// which would otherwise trip a misleading MaxListenersExceededWarning. Listeners
// are still removed on unmount, so a real leak would surface far below this.
ipcRenderer.setMaxListeners(2000);

export interface PtyDiag {
pid: number;
writeCount: number;
Expand Down
116 changes: 83 additions & 33 deletions src/renderer/components/TilingLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,18 @@ const SessionLoading: React.FC = () => (
);

const TilingLayout: React.FC = () => {
const tilingRoot = useTerminalStore((s) => s.layout.tilingRoot);
// TASK-240: every workspace's panes stay mounted & live, not just the active
// one. Switching workspaces used to swap s.layout, unmounting the previous
// workspace's TerminalPanels and disposing their xterm instances - so any PTY
// output produced while that workspace was hidden was dropped, and the pane
// came back blank/stale until a manual resize forced the running app to
// redraw. We now render one tiling tree per workspace as a stacked layer
// (active visible, others visibility:hidden but still sized & rendering) and
// mount portals for ALL terminals, so every xterm keeps consuming its PTY
// and switching back is instant and current.
const activeTilingRoot = useTerminalStore((s) => s.layout.tilingRoot);
const workspaces = useTerminalStore((s) => s.workspaces);
const activeWorkspaceId = useTerminalStore((s) => s.activeWorkspaceId);
const viewMode = useTerminalStore((s) => s.viewMode);
const focusedTerminalId = useTerminalStore((s) => s.focusedTerminalId);
const isRestoring = useTerminalStore((s) => s.isRestoring);
Expand All @@ -220,48 +231,87 @@ const TilingLayout: React.FC = () => {
return host;
}, []);

const leafIds = useMemo(() => (tilingRoot ? collectLeafIds(tilingRoot) : []), [tilingRoot]);
// Per-workspace tiling tree. The active workspace uses the live s.layout tree
// (the workspaces-map entry is only re-snapshotted on switch-away, so it can
// be stale); every other workspace uses its stored tree.
const workspaceTrees = useMemo(
() =>
[...workspaces.values()].map((ws) => ({
id: ws.id,
tree: ws.id === activeWorkspaceId ? activeTilingRoot : ws.layout.tilingRoot,
})),
[workspaces, activeWorkspaceId, activeTilingRoot],
);

// Every terminal across every workspace, so each xterm stays mounted & live.
const allLeafIds = useMemo(() => {
const ids: string[] = [];
const seen = new Set<string>();
for (const { tree } of workspaceTrees) {
if (!tree) continue;
for (const id of collectLeafIds(tree)) {
if (!seen.has(id)) {
seen.add(id);
ids.push(id);
}
}
}
return ids;
}, [workspaceTrees]);

// Drop host nodes for terminals that no longer have a tiled leaf (closed,
// Drop host nodes for terminals that no longer exist in ANY workspace (closed
// or moved to a floating panel) so they can be garbage-collected.
useEffect(() => {
const map = hostsRef.current;
const live = new Set(leafIds);
const live = new Set(allLeafIds);
for (const id of Array.from(map.keys())) {
if (!live.has(id)) map.delete(id);
}
}, [leafIds]);
}, [allLeafIds]);

if (!tilingRoot) {
// TASK-117: while session restore is in flight, render a neutral
// loading indicator instead of the empty-state hero. The hero showing
// mid-restore made users think their panes were lost. Once restore
// completes - either by attaching panes (which sets tilingRoot) or
// by confirming nothing to restore (which clears isRestoring) - we
// fall through to <EmptyState />.
if (isRestoring) {
return <SessionLoading />;
}
return <EmptyState />;
}

// Always render a stable wrapper div so React never unmounts the TilingNode tree
// on mode changes. Changing the root element type (div vs TilingNode) would cause
// a full remount, destroying xterm instances and losing input focus.
// In focus mode the CSS class hides non-focused panes via visibility tricks.
// In normal mode display:contents makes the wrapper transparent to layout.
// Render one stacked layer per workspace. The wrapper is display:contents so
// the absolutely-positioned layers anchor to .layout-area (its positioned
// ancestor). Each terminal lives in exactly one workspace tree, so its host
// attaches to exactly one layer's slot. Layers are keyed by workspace id and
// stable across switches - only the active/inactive class flips - so no
// TilingNode remount and no xterm churn. The active workspace always has a
// layer (the store always holds at least the default workspace), so the empty
// workspace falls back to the loading indicator while restoring (TASK-117: no
// empty-state flash) or the empty-state hero otherwise.
return (
<PaneHostContext.Provider value={getHost}>
<div className={viewMode === 'focus' ? 'tiling-focus-mode' : 'tiling-normal-mode'}>
<TilingNode node={tilingRoot} />
<PanePortals leafIds={leafIds} getHost={getHost} />
<RootDropZones />
{viewMode === 'focus' && (
<FocusModePaneIndicator
leafIds={leafIds}
focusedId={focusedTerminalId}
/>
)}
<div className="tiling-root" style={{ display: 'contents' }}>
{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 (
<div
key={id}
className={`tiling-ws-layer ${isActive ? 'active' : 'inactive'}`}
aria-hidden={isActive ? undefined : true}
>
<div className={wrapperClass}>
{tree ? (
<TilingNode node={tree} />
) : isActive ? (
isRestoring ? <SessionLoading /> : <EmptyState />
) : null}
{isActive && <RootDropZones />}
{isActive && viewMode === 'focus' && (
<FocusModePaneIndicator
leafIds={tree ? collectLeafIds(tree) : []}
focusedId={focusedTerminalId}
/>
)}
</div>
</div>
);
})}
<PanePortals leafIds={allLeafIds} getHost={getHost} />
</div>
</PaneHostContext.Provider>
);
Expand Down
17 changes: 17 additions & 0 deletions src/renderer/styles/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
63 changes: 63 additions & 0 deletions tests/e2e/workspaces.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading