From 02c5086d660eedd95917a00ae487702d9857946b Mon Sep 17 00:00:00 2001 From: Steven Vo <875426+stevenvo@users.noreply.github.com> Date: Thu, 25 Dec 2025 23:11:37 +0700 Subject: [PATCH 1/4] Fix terminal state loss when switching workspaces Fixes issue where TUI applications (vim, htop, opencode, etc.) would lose terminal state when switching between workspaces. This caused inability to scroll and display corruption. Root cause: Workspace switching was destroying all tab views including terminals, then recreating them from cache. This destroyed xterm.js instances and lost their state. Solution: Cache tab views across workspace switches instead of destroying them. Tab views are positioned off-screen but kept alive, preserving: - Terminal buffer state (normal and alternate screen modes) - Scrollback history and scrolling capability - Running processes and their output - Cursor position and all terminal modes Memory management: Cached views kept alive until tab closed or window closed. Note: This PR includes the StreamCancelFn type fix from #2716 to ensure the branch builds correctly. --- emain/emain-window.ts | 61 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 52 insertions(+), 9 deletions(-) diff --git a/emain/emain-window.ts b/emain/emain-window.ts index d3b7f4849e..b9e94260b2 100644 --- a/emain/emain-window.ts +++ b/emain/emain-window.ts @@ -138,6 +138,7 @@ export class WaveBrowserWindow extends BaseWindow { waveWindowId: string; workspaceId: string; allLoadedTabViews: Map; + allTabViewsCache: Map; // Cache for preserving tab views across workspace switches activeTabView: WaveTabView; private canClose: boolean; private deleteAllowed: boolean; @@ -217,6 +218,7 @@ export class WaveBrowserWindow extends BaseWindow { this.waveWindowId = waveWindow.oid; this.workspaceId = waveWindow.workspaceid; this.allLoadedTabViews = new Map(); + this.allTabViewsCache = new Map(); const winBoundsPoller = setInterval(() => { if (this.isDestroyed()) { clearInterval(winBoundsPoller); @@ -351,6 +353,11 @@ export class WaveBrowserWindow extends BaseWindow { } tabView?.destroy(); } + // Also destroy any cached views + for (const tabView of this.allTabViewsCache.values()) { + tabView?.destroy(); + } + this.allTabViewsCache.clear(); } async switchWorkspace(workspaceId: string) { @@ -575,8 +582,27 @@ export class WaveBrowserWindow extends BaseWindow { return; } console.log("processActionQueue switchworkspace newWs", newWs); - this.removeAllChildViews(); - console.log("destroyed all tabs", this.waveWindowId); + // Move current tab views to cache instead of destroying them + // This preserves terminal state (including alternate screen mode) across workspace switches + for (const [tabId, tabView] of this.allLoadedTabViews.entries()) { + // Position off-screen but don't destroy + if (!this.isDestroyed()) { + const bounds = this.getContentBounds(); + tabView.positionTabOffScreen(bounds); + } + // If tabId already in cache (edge case with rapid workspace switching), destroy the old cached view first + const existingCachedView = this.allTabViewsCache.get(tabId); + if (existingCachedView) { + existingCachedView.destroy(); + } + this.allTabViewsCache.set(tabId, tabView); + } + console.log("cached", this.allLoadedTabViews.size, "tabs for workspace", this.workspaceId, this.waveWindowId); + // Note: Cached views are kept alive indefinitely and only destroyed when: + // 1. The tab is explicitly closed by the user + // 2. The window is closed (via removeAllChildViews) + // This matches how traditional terminal apps work and prevents terminal state loss + this.workspaceId = entry.workspaceId; this.allLoadedTabViews = new Map(); tabId = newWs.activetabid; @@ -585,7 +611,15 @@ export class WaveBrowserWindow extends BaseWindow { if (tabId == null) { return; } - const [tabView, tabInitialized] = await getOrCreateWebViewForTab(this.waveWindowId, tabId); + // Check cache first to reuse existing tab views across workspace switches + let tabView = this.allTabViewsCache.get(tabId); + let tabInitialized = true; + if (tabView) { + console.log("reusing cached tab view", tabId, this.waveWindowId); + this.allTabViewsCache.delete(tabId); + } else { + [tabView, tabInitialized] = await getOrCreateWebViewForTab(this.waveWindowId, tabId); + } const primaryStartupTabFlag = entry.op === "switchtab" ? (entry.primaryStartupTab ?? false) : false; await this.setTabViewIntoWindow(tabView, tabInitialized, primaryStartupTabFlag); } catch (e) { @@ -617,14 +651,23 @@ export class WaveBrowserWindow extends BaseWindow { console.log("cannot remove active tab", tabId, this.waveWindowId); return; } - const tabView = this.allLoadedTabViews.get(tabId); + let tabView = this.allLoadedTabViews.get(tabId); if (tabView == null) { - console.log("removeTabView -- tabView not found", tabId, this.waveWindowId); - // the tab was never loaded, so just return - return; + // Check cache - tab might be from a different workspace + tabView = this.allTabViewsCache.get(tabId); + if (tabView == null) { + console.log("removeTabView -- tabView not found in loaded or cache", tabId, this.waveWindowId); + return; + } + console.log("removeTabView -- removing from cache", tabId, this.waveWindowId); + this.allTabViewsCache.delete(tabId); + } else { + this.allLoadedTabViews.delete(tabId); + } + // Remove from contentView (cached views are still children, just positioned off-screen) + if (!this.isDestroyed()) { + this.contentView.removeChildView(tabView); } - this.contentView.removeChildView(tabView); - this.allLoadedTabViews.delete(tabId); tabView.destroy(); } From 2c21871947474854fce364983eec423d2c1b5076 Mon Sep 17 00:00:00 2001 From: Steven Vo <875426+stevenvo@users.noreply.github.com> Date: Sat, 3 Jan 2026 02:38:52 -0800 Subject: [PATCH 2/4] Add defensive check to ensure cached tabs are in contentView --- emain/emain-window.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/emain/emain-window.ts b/emain/emain-window.ts index b9e94260b2..5f8b17e18f 100644 --- a/emain/emain-window.ts +++ b/emain/emain-window.ts @@ -617,6 +617,15 @@ export class WaveBrowserWindow extends BaseWindow { if (tabView) { console.log("reusing cached tab view", tabId, this.waveWindowId); this.allTabViewsCache.delete(tabId); + // Ensure cached view is in contentView (it should be, but add defensively) + if (!this.isDestroyed()) { + // Check if already a child before adding + const isChild = this.contentView.children.includes(tabView); + if (!isChild) { + console.log("cached tab was not a child, re-adding", tabId); + this.contentView.addChildView(tabView); + } + } } else { [tabView, tabInitialized] = await getOrCreateWebViewForTab(this.waveWindowId, tabId); } From 36cf97be288733af8c83119d5956717cd272fd1a Mon Sep 17 00:00:00 2001 From: Steven Vo <875426+stevenvo@users.noreply.github.com> Date: Sat, 3 Jan 2026 12:08:53 -0800 Subject: [PATCH 3/4] Fix gibberish screen: remove cached views from DOM Cached tab views were left as children of contentView (positioned off-screen), causing multiple terminals to render on top of each other. Changes: - Remove cached views from contentView during workspace switch - Re-add when reusing from cache - Simplify removal logic (cached vs loaded) --- emain/emain-window.ts | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/emain/emain-window.ts b/emain/emain-window.ts index 5f8b17e18f..b9c1ad180d 100644 --- a/emain/emain-window.ts +++ b/emain/emain-window.ts @@ -585,10 +585,8 @@ export class WaveBrowserWindow extends BaseWindow { // Move current tab views to cache instead of destroying them // This preserves terminal state (including alternate screen mode) across workspace switches for (const [tabId, tabView] of this.allLoadedTabViews.entries()) { - // Position off-screen but don't destroy if (!this.isDestroyed()) { - const bounds = this.getContentBounds(); - tabView.positionTabOffScreen(bounds); + this.contentView.removeChildView(tabView); } // If tabId already in cache (edge case with rapid workspace switching), destroy the old cached view first const existingCachedView = this.allTabViewsCache.get(tabId); @@ -597,7 +595,13 @@ export class WaveBrowserWindow extends BaseWindow { } this.allTabViewsCache.set(tabId, tabView); } - console.log("cached", this.allLoadedTabViews.size, "tabs for workspace", this.workspaceId, this.waveWindowId); + console.log( + "cached", + this.allLoadedTabViews.size, + "tabs for workspace", + this.workspaceId, + this.waveWindowId + ); // Note: Cached views are kept alive indefinitely and only destroyed when: // 1. The tab is explicitly closed by the user // 2. The window is closed (via removeAllChildViews) @@ -617,14 +621,8 @@ export class WaveBrowserWindow extends BaseWindow { if (tabView) { console.log("reusing cached tab view", tabId, this.waveWindowId); this.allTabViewsCache.delete(tabId); - // Ensure cached view is in contentView (it should be, but add defensively) if (!this.isDestroyed()) { - // Check if already a child before adding - const isChild = this.contentView.children.includes(tabView); - if (!isChild) { - console.log("cached tab was not a child, re-adding", tabId); - this.contentView.addChildView(tabView); - } + this.contentView.addChildView(tabView); } } else { [tabView, tabInitialized] = await getOrCreateWebViewForTab(this.waveWindowId, tabId); @@ -671,11 +669,8 @@ export class WaveBrowserWindow extends BaseWindow { console.log("removeTabView -- removing from cache", tabId, this.waveWindowId); this.allTabViewsCache.delete(tabId); } else { - this.allLoadedTabViews.delete(tabId); - } - // Remove from contentView (cached views are still children, just positioned off-screen) - if (!this.isDestroyed()) { this.contentView.removeChildView(tabView); + this.allLoadedTabViews.delete(tabId); } tabView.destroy(); } From ec88c71ce6a73698fbbf3c00dd0b9357a5895cdf Mon Sep 17 00:00:00 2001 From: Steven Vo <875426+stevenvo@users.noreply.github.com> Date: Sat, 3 Jan 2026 12:16:12 -0800 Subject: [PATCH 4/4] Fix getWaveWindowByTabId to search cached tabs When cached tabs send IPC events, lookup was failing because getWaveWindowByTabId only searched allLoadedTabViews. Now also checks allTabViewsCache to handle cached tabs. --- emain/emain-window.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/emain/emain-window.ts b/emain/emain-window.ts index b9c1ad180d..e846c9b005 100644 --- a/emain/emain-window.ts +++ b/emain/emain-window.ts @@ -684,7 +684,7 @@ export class WaveBrowserWindow extends BaseWindow { export function getWaveWindowByTabId(tabId: string): WaveBrowserWindow { for (const ww of waveWindowMap.values()) { - if (ww.allLoadedTabViews.has(tabId)) { + if (ww.allLoadedTabViews.has(tabId) || ww.allTabViewsCache.has(tabId)) { return ww; } }