Skip to content

Conversation

@stevenvo
Copy link
Contributor

@stevenvo stevenvo commented Dec 25, 2025

Summary

Fixes issue where TUI applications (vim, htop, opencode, etc.) would lose terminal state when switching between workspaces, causing inability to scroll and display corruption.

Problem

When switching workspaces, all tab views were being destroyed and recreated. This caused:

  • Loss of alternate screen buffer mode state
  • Terminal display corruption with TUI apps
  • Inability to scroll in terminals after returning to workspace
  • Running TUI applications to appear broken

Root Cause

The switchworkspace operation in emain/emain-window.ts called removeAllChildViews() which destroyed all tab views including their xterm.js terminal instances. When recreated from cache, the terminal state was not properly restored.

Solution

Cache tab views across workspace switches instead of destroying them:

  • Tab views are positioned off-screen but kept alive
  • Terminal state fully preserved (buffer modes, scrollback, cursor position)
  • Views only destroyed when tab explicitly closed or window closes
  • Memory-efficient: ~1-5MB per cached terminal

Changes

  • Added allTabViewsCache map to preserve tab views during workspace switches
  • Modified workspace switch logic to cache instead of destroy
  • Added cache cleanup when tabs are explicitly closed
  • Cache cleared when window closes to prevent memory leaks

Testing

  1. Run a TUI app (vim, htop, opencode) in workspace A
  2. Switch to workspace B
  3. Switch back to workspace A (even after hours)
  4. Terminal state is preserved, scrolling works correctly

Behavior Match

Now matches macOS Terminal, iTerm2, and other terminal emulators where terminals remain active across tab/workspace switches.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Dec 25, 2025

Note

Other AI code review bot(s) detected

CodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review.

Walkthrough

A tab-view caching mechanism was added to WaveBrowserWindow in emain/emain-window.ts: a new public field allTabViewsCache: Map<string, WaveTabView> stores tab views across workspace switches. During a workspace switch, active tab views are moved into the cache and positioned off-screen instead of being destroyed. Tab selection now attempts to reuse a cached view (removing it from the cache) or creates a new one if absent. Removal and teardown paths were updated to check and clear the cache, ensuring cached views are destroyed on window teardown and when explicitly closed.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main fix: preventing terminal state loss during workspace switches.
Description check ✅ Passed The description thoroughly explains the problem, root cause, solution, and testing approach, all directly related to the changeset.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

📜 Recent review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d625a37 and 887aa79.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (1)
  • emain/emain-window.ts
🔇 Additional comments (5)
emain/emain-window.ts (5)

142-142: LGTM on cache initialization.

The cache field is well-documented and properly initialized. Consistent with the existing allLoadedTabViews pattern.

Also applies to: 222-222


357-361: LGTM on cache cleanup.

Proper cleanup of cached views during window teardown. This prevents memory leaks when windows are closed.


586-610: LGTM on workspace switch caching.

The caching logic correctly handles the edge case of rapid workspace switching by destroying stale cached views before caching new ones (lines 594-598). The inline comments clearly document the cleanup strategy.

One consideration: cached views remain attached to contentView (positioned off-screen). For users with many workspaces and tabs, the cumulative memory footprint could grow. The current cleanup strategy (on tab close or window close) is reasonable, but you might consider adding a configurable cache size limit in a future iteration if memory becomes a concern.


615-623: LGTM on cache reuse logic.

The logic correctly prioritizes cached views over creating new ones, and properly removes entries from the cache upon reuse to prevent double-use scenarios.


679-685: getWaveWindowByTabId doesn't check the cache, creating an inconsistency.

While removeTabView correctly handles both allLoadedTabViews and allTabViewsCache (lines 655-658), getWaveWindowByTabId only searches active tabs. This creates a potential issue: if cached tabs' webContents emit IPC events, the call chain getWaveWindowByWebContentsIdgetWaveWindowByTabId (lines 687-692) would fail to find the parent window.

Either cached tabs shouldn't emit events while cached, or getWaveWindowByTabId should also search allTabViewsCache for consistency with the dual-map pattern used elsewhere in the class.


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.

Copy link
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

📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 90011a7 and 2745ac1.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (1)
  • emain/emain-window.ts
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-10-15T03:18:52.647Z
Learnt from: sawka
Repo: wavetermdev/waveterm PR: 2433
File: emain/emain-window.ts:811-828
Timestamp: 2025-10-15T03:18:52.647Z
Learning: In emain/emain-window.ts, within the relaunchBrowserWindows function, ClientService.GetClientData() is guaranteed to return a valid client object and never null/undefined. The backend ensures a client is initialized before startup, so no null-guard is needed when accessing clientData.windowids.

Applied to files:

  • emain/emain-window.ts
🧬 Code graph analysis (1)
emain/emain-window.ts (1)
emain/emain-tabview.ts (2)
  • WaveTabView (106-225)
  • getOrCreateWebViewForTab (295-349)
🔇 Additional comments (4)
emain/emain-window.ts (4)

142-142: LGTM: Cache field properly declared and initialized.

The allTabViewsCache field mirrors the pattern of allLoadedTabViews and is correctly initialized in the constructor.

Also applies to: 222-222


357-361: LGTM: Proper cache cleanup on window teardown.

Cached views are correctly destroyed and cleared when the window closes, preventing memory leaks.


610-618: LGTM: Cache-first lookup enables seamless tab reuse.

The logic correctly:

  1. Checks the cache first for existing tab views
  2. Sets tabInitialized=true for cached views (already initialized)
  3. Removes from cache when reused (will be added to allLoadedTabViews in setTabViewIntoWindow)
  4. Falls back to getOrCreateWebViewForTab for uncached tabs

Since cached views remain children of contentView (only positioned off-screen), they're correctly repositioned when reused.


586-604: Well-designed caching strategy for workspace switches.

The approach of caching views off-screen rather than destroying them correctly preserves terminal state. The comments clearly document the lifecycle expectations.

Tab IDs are globally unique (generated as UUIDs independent of workspace context), so the cache key design is safe from collisions across workspaces.

Comment on lines +650 to 669
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.contentView.removeChildView(tabView);
this.allLoadedTabViews.delete(tabId);
}
this.contentView.removeChildView(tabView);
this.allLoadedTabViews.delete(tabId);
tabView.destroy();
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Potential issue: Missing contentView.removeChildView for cached tabs.

When removing a cached tab (lines 652-659), contentView.removeChildView(tabView) is not called before destroy(). However, cached tabs were never removed from contentView during the workspace switch—they were only positioned off-screen.

This could potentially cause issues when destroying a view that's still attached to its parent. Consider adding the removal:

🔎 Proposed fix
         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);
+        if (!this.isDestroyed()) {
+            this.contentView.removeChildView(tabView);
+        }
         this.allTabViewsCache.delete(tabId);
     } else {
         this.contentView.removeChildView(tabView);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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.contentView.removeChildView(tabView);
this.allLoadedTabViews.delete(tabId);
}
this.contentView.removeChildView(tabView);
this.allLoadedTabViews.delete(tabId);
tabView.destroy();
let tabView = this.allLoadedTabViews.get(tabId);
if (tabView == null) {
// 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);
if (!this.isDestroyed()) {
this.contentView.removeChildView(tabView);
}
this.allTabViewsCache.delete(tabId);
} else {
this.contentView.removeChildView(tabView);
this.allLoadedTabViews.delete(tabId);
}
tabView.destroy();
🤖 Prompt for AI Agents
In emain/emain-window.ts around lines 650 to 664, when a tabView is found in the
cache you delete it from allTabViewsCache and immediately call
tabView.destroy(), but you never remove it from contentView; before destroying a
cached tab you should call this.contentView.removeChildView(tabView) (and then
delete it from this.allTabViewsCache) so the view is detached from its parent
prior to destroy to avoid destroying an attached view.

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines 89 to 93
"devDependencies": {
"@eslint/js": "^8.57.0",
"@rollup/plugin-node-resolve": "^16.0.3",
"@tailwindcss/vite": "^4.1.18",
"@tailwindcss/vite": "^4.1.17",
"@types/color": "^4.2.0",

Choose a reason for hiding this comment

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

P1 Badge Lockfile downgrades Tailwind deps below package.json

The root package-lock now pins the Tailwind build tooling to 4.1.17 (@tailwindcss/vite and tailwindcss), while package.json still requires ^4.1.18. Because npm verifies that the lockfile satisfies the declared ranges, npm ci/install will now fail with an out-of-sync lockfile error until the lock is regenerated with the 4.1.18 range expected by package.json (and the tsunami/frontend workspace, which also still requests 4.1.18).

Useful? React with 👍 / 👎.

@stevenvo stevenvo force-pushed the fix/preserve-terminal-state-workspace-switch branch from 2745ac1 to d625a37 Compare December 25, 2025 16:18
Fixes issue where TUI applications (vim, htop, 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.

Solution: Cache tab views across workspace switches instead of destroying them. Tab views are positioned off-screen but kept alive, preserving all terminal state.
@stevenvo stevenvo force-pushed the fix/preserve-terminal-state-workspace-switch branch from d625a37 to 887aa79 Compare December 25, 2025 16:27
@stevenvo
Copy link
Contributor Author

Closing temporarily for additional testing. Will reopen after validation.

@stevenvo stevenvo closed this Dec 25, 2025
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