Skip to content

Fix split zoom visibility sync to prevent black browser pane - #929

Closed
lawrencecchen wants to merge 6 commits into
mainfrom
task-browser-cmd-shift-enter-black-view
Closed

lawrencecchen wants to merge 6 commits into
mainfrom
task-browser-cmd-shift-enter-black-view

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Mar 5, 2026 •

Copy link
Copy Markdown
Contributor

Summary

After toggling split zoom (Cmd+Shift+Enter), terminal portal visibility could drift from the active layout during selection churn, leaving the browser pane effectively covered and appearing black.

This change makes zoom and unzoom transitions reconcile terminal visibility from layout state instead of relying on incidental callback ordering.

What changed

  • Added a layout-driven visibility synchronizer in Workspace.
  • Run synchronizer when toggling split zoom and when clearing split zoom.
  • Keep TerminalWindowPortalRegistry visibility in lockstep with setVisibleInUI.
  • Added a transient guard to avoid hiding all terminals during temporary empty tab selection churn.
  • Added a regression test that verifies visible panel set and terminal visibility collapse to the zoomed pane, then restore on unzoom.

Verification

  • xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination 'platform=macOS' build
  • xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux-unit -configuration Debug -destination 'platform=macOS' -only-testing:cmuxTests/WorkspacePanelGitBranchTests/testToggleSplitZoomCollapsesVisiblePanelSnapshotToZoomedPaneAndRestoresOnUnzoom test
  • codex review --base origin/main (clean)

Summary by cubic

Fixes split zoom (Cmd+Shift+Enter) so terminal visibility and browser portals stay aligned with the layout, preventing the browser pane from appearing black. Hardens the portal lifecycle to survive SwiftUI host churn, stale dismantle calls, and transient recovery windows.

  • Bug Fixes

    • Layout-driven synchronizer updates terminal visibility from zoom state and focus; runs on toggle/clear; ignores transient empty selections.
    • Browser portal lifecycle: skip detach/visibility changes during transient dismantle; rebind missing entries on geometry changes; avoid pruning while visible; guarded detach with expectedAnchorId; defer hiding while transiently recovering; explicitly detach on panel close; new helpers to check binding and entry presence.
  • Tests

    • Added coverage for zoom visibility sync, stale dismantle with stale coordinator hosts, anchor-binding reporting, registry entry checks, and a Python regression guard ensuring dismantle remains transient.

Written for commit 98d1948. Summary will update on new commits.

Summary by CodeRabbit

  • New Features

    • Improved diagnostic visibility allowing better inspection of panel/web view visibility for debugging.
  • Bug Fixes

    • Terminal portals reliably sync with layout zooming, preventing visibility mismatches after zoom/unzoom.
    • Web views remain attached and stable across host/window and geometry changes, reducing detaches, visual glitches, and flicker.
    • Panel close now deterministically detaches hosted web views to avoid stale state.
  • Tests

    • Added regression and unit tests covering split-pane zoom visibility and web view dismantle/detach scenarios.

@vercel

vercel Bot commented Mar 5, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Mar 6, 2026 0:12am

@coderabbitai

coderabbitai Bot commented Mar 5, 2026 •

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Synchronizes terminal portal visibility with workspace zoom/unzoom, exposes small debug visibility accessors for terminal views, and tightens browser webview portal detach semantics to validate expected anchor identity and return success indicators. Tests and panel close/dismantle paths adjusted to match new lifecycle behavior.

Changes

Cohort / File(s) Summary
Terminal View Debugging
Sources/GhosttyTerminalView.swift
Added debugIsVisibleInUI() helpers on GhosttyNSView and GhosttySurfaceScrollView to expose internal visibility state for debugging/instrumentation.
Workspace Visibility Synchronization
Sources/Workspace.swift
clearSplitZoom() and toggleSplitZoom() now call synchronizeTerminalPortalVisibilityForCurrentLayout(). Added private helpers: visiblePanelIdsForCurrentLayout(), synchronizeTerminalPortalVisibilityForCurrentLayout(), and debugVisiblePanelIdsForCurrentLayout() to compute and apply terminal portal visibility based on layout/zoom/focus.
Browser Portal Registry & API
Sources/BrowserWindowPortal.swift, Sources/.../BrowserWindowPortalRegistry
Detach APIs updated to accept optional expectedAnchorId and return Bool; registry detach now validates anchor identity, conditionally prunes mappings, and exposes query helpers (isWebView(_:boundTo:), hasPortalEntry(for:)). Call sites updated accordingly.
Browser Panel Bind/Dismantle Call Sites
Sources/Panels/BrowserPanelView.swift, Sources/Panels/BrowserPanel.swift
Reworked WebViewRepresentable binding/dismantle/update logic to avoid unconditional detach and to rebind when missing. BrowserPanel.close() now explicitly detaches the webView from the window portal on close.
Tests
cmuxTests/CmuxWebViewKeyEquivalentTests.swift, tests/test_browser_transient_dismantle_lifecycle_regression.py
Added/updated tests covering stale-anchor dismantle behavior, split-zoom visibility collapse/restore, registry binding checks, and a new regression script validating dismantle/close side-effect constraints.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant Workspace
    participant LayoutEngine
    participant VisibilityCompute
    participant TerminalPortalRegistry
    participant TerminalPanel

    User->>Workspace: toggleSplitZoom(panelId)
    activate Workspace
    Workspace->>LayoutEngine: apply zoom/unzoom
    LayoutEngine-->>Workspace: layout updated

    Workspace->>VisibilityCompute: synchronizeTerminalPortalVisibilityForCurrentLayout()
    activate VisibilityCompute
    VisibilityCompute->>VisibilityCompute: visiblePanelIdsForCurrentLayout()
    VisibilityCompute->>TerminalPortalRegistry: set visibility for panels (visible/hidden)
    activate TerminalPortalRegistry
    TerminalPortalRegistry->>TerminalPanel: apply visibility change
    TerminalPanel-->>TerminalPortalRegistry: ack
    TerminalPortalRegistry-->>VisibilityCompute: done
    VisibilityCompute-->>Workspace: sync complete
    deactivate VisibilityCompute

    Workspace-->>User: zoom toggled (visibility updated)
    deactivate Workspace
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐇
I twitch in the margins, nose to the pane,
I peek through the portals to see who remains.
A zoom, a nudge — I mark who’s in view,
Little debug eyes to know what is true. ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 5.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: fixing split zoom visibility synchronization to prevent the browser pane from appearing black due to visibility drift.
Description check ✅ Passed The description includes summary section with what changed and why, comprehensive details of modifications, and verification steps, though it lacks explicit testing checklist completion and demo video.

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

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch task-browser-cmd-shift-enter-black-view

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

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No issues found across 3 files

@greptile-apps

greptile-apps Bot commented Mar 5, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a visibility-state drift issue in split zoom by introducing synchronizeTerminalPortalVisibilityForCurrentLayout() in Workspace, which derives the correct visible-panel set from BonsplitController's layout state and applies it to terminal portal visibility immediately after zoom/unzoom transitions, replacing fragile callback-ordering assumptions. The test validates the primary zoom/unzoom path well. One code-quality concern: debug accessor methods lack #if DEBUG guards and expose what should be file-private internal state at module-visible internal access.

Confidence Score: 4/5

  • The core fix is sound and well-tested. One minor encapsulation issue with debug accessors should be addressed before merge.
  • The layout-driven visibility synchronizer correctly addresses the split-zoom visibility-drift race by deriving the ground-truth visible set from BonsplitController state and immediately applying it to both setVisibleInUI() and TerminalWindowPortalRegistry. The regression test validates zoom/unzoom cycles. The single concern is that debugIsVisibleInUI(), debugVisiblePanelIdsForCurrentLayout() methods lack #if DEBUG guards and expose internals at unintended internal visibility—a straightforward fix.
  • Sources/GhosttyTerminalView.swift and Sources/Workspace.swift — add #if DEBUG guards to all debug accessors.

Last reviewed commit: ddd996a

Comment on lines +2977 to +2979
func debugIsVisibleInUI() -> Bool {
visibleInUI
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

debugIsVisibleInUI() bypasses fileprivate access control by exposing visibleInUI at default internal visibility. Since this method only supports unit-test assertions, it should be absent from production builds.

Apply #if DEBUG guards to debugIsVisibleInUI() here and on GhosttySurfaceScrollView (line 5706), and to debugVisiblePanelIdsForCurrentLayout() in Workspace.swift (line 3253):

Suggested change
func debugIsVisibleInUI() -> Bool {
visibleInUI
}
#if DEBUG
func debugIsVisibleInUI() -> Bool {
visibleInUI
}
#endif

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

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)
Sources/BrowserWindowPortal.swift (1)

1245-1250: Align hasPortalEntry with the stale-mapping fallback used by isWebView(boundTo:).

hasPortalEntry(for:) currently returns false when webViewToWindowId is stale, even if another portal still has the entry. Matching the fallback behavior avoids false negatives during churn.

Suggested refactor
     static func hasPortalEntry(for webView: WKWebView) -> Bool {
         let webViewId = ObjectIdentifier(webView)
-        guard let windowId = webViewToWindowId[webViewId],
-              let portal = portalsByWindowId[windowId] else { return false }
-        return portal.hasWebViewEntry(withId: webViewId)
+        if let windowId = webViewToWindowId[webViewId],
+           let portal = portalsByWindowId[windowId],
+           portal.hasWebViewEntry(withId: webViewId) {
+            return true
+        }
+        for portal in portalsByWindowId.values {
+            if portal.hasWebViewEntry(withId: webViewId) {
+                return true
+            }
+        }
+        return false
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/BrowserWindowPortal.swift` around lines 1245 - 1250,
hasPortalEntry(for:) currently relies solely on webViewToWindowId and returns
false if that mapping is stale; change it to mirror isWebView(boundTo:) by
falling back to scanning all portals when the webViewToWindowId lookup fails.
Specifically, in static func hasPortalEntry(for webView: WKWebView) compute let
webViewId = ObjectIdentifier(webView), attempt the existing webViewToWindowId
then portalsByWindowId lookup, and if that returns nil iterate over
portalsByWindowId.values and return true if any portal.hasWebViewEntry(withId:
webViewId) is true; otherwise return false. Ensure you reference
webViewToWindowId, portalsByWindowId, portal.hasWebViewEntry(withId:), and
hasPortalEntry(for:) in your change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/Panels/BrowserPanelView.swift`:
- Around line 3228-3249: The guard currently only rebinds when both
!isBoundToHost and !hasPortalEntry, which skips the case where a portal entry
exists but is bound to the wrong host; change the logic to rebind whenever
!isBoundToHost OR the existing portal entry is associated with a different host
(i.e., detect entryHost mismatch), then call
BrowserWindowPortalRegistry.bind(webView:to:visibleInUI:zPriority:) and update
coordinator.lastPortalHostId accordingly (and if necessary unbind or overwrite
the stale entry before binding). Ensure you reference isBoundToHost,
hasPortalEntry, BrowserWindowPortalRegistry.bind, coordinator.lastPortalHostId,
coordinator.desiredPortalVisibleInUI, and coordinator.desiredPortalZPriority
when implementing the fix.

---

Nitpick comments:
In `@Sources/BrowserWindowPortal.swift`:
- Around line 1245-1250: hasPortalEntry(for:) currently relies solely on
webViewToWindowId and returns false if that mapping is stale; change it to
mirror isWebView(boundTo:) by falling back to scanning all portals when the
webViewToWindowId lookup fails. Specifically, in static func hasPortalEntry(for
webView: WKWebView) compute let webViewId = ObjectIdentifier(webView), attempt
the existing webViewToWindowId then portalsByWindowId lookup, and if that
returns nil iterate over portalsByWindowId.values and return true if any
portal.hasWebViewEntry(withId: webViewId) is true; otherwise return false.
Ensure you reference webViewToWindowId, portalsByWindowId,
portal.hasWebViewEntry(withId:), and hasPortalEntry(for:) in your change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: bfa4c26e-14ab-484f-87af-84124943760f

📥 Commits

Reviewing files that changed from the base of the PR and between 2c2de2c and a14ee31.

📒 Files selected for processing (3)
  • Sources/BrowserWindowPortal.swift
  • Sources/Panels/BrowserPanelView.swift
  • cmuxTests/CmuxWebViewKeyEquivalentTests.swift
🚧 Files skipped from review as they are similar to previous changes (1)
  • cmuxTests/CmuxWebViewKeyEquivalentTests.swift

Comment on lines +3228 to +3249
if host.window != nil,
!isBoundToHost,
!hasPortalEntry {
#if DEBUG
if let panel = coordinator.panel {
Self.logDevToolsState(
panel,
event: "portal.rebindOnGeometry",
generation: coordinator.attachGeneration,
retryCount: 0,
details: Self.attachContext(webView: webView, host: host) + " reason=portalEntryMissing"
)
}
#endif
BrowserWindowPortalRegistry.bind(
webView: webView,
to: host,
visibleInUI: coordinator.desiredPortalVisibleInUI,
zPriority: coordinator.desiredPortalZPriority
)
coordinator.lastPortalHostId = ObjectIdentifier(host)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Rebind guard misses the “entry exists but bound to wrong host” case.

At Line 3228, rebind only happens when !isBoundToHost && !hasPortalEntry. If a stale entry exists on a different anchor, hasPortalEntry is true and this path skips rebind, which can leave this host unsynchronized.

Suggested fix
-            if host.window != nil,
-               !isBoundToHost,
-               !hasPortalEntry {
+            if host.window != nil,
+               !isBoundToHost {
 `#if` DEBUG
                 if let panel = coordinator.panel {
+                    let rebindReason = hasPortalEntry ? "wrongAnchor" : "portalEntryMissing"
                     Self.logDevToolsState(
                         panel,
                         event: "portal.rebindOnGeometry",
                         generation: coordinator.attachGeneration,
                         retryCount: 0,
-                        details: Self.attachContext(webView: webView, host: host) + " reason=portalEntryMissing"
+                        details: Self.attachContext(webView: webView, host: host) + " reason=\(rebindReason)"
                     )
                 }
 `#endif`
                 BrowserWindowPortalRegistry.bind(
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Panels/BrowserPanelView.swift` around lines 3228 - 3249, The guard
currently only rebinds when both !isBoundToHost and !hasPortalEntry, which skips
the case where a portal entry exists but is bound to the wrong host; change the
logic to rebind whenever !isBoundToHost OR the existing portal entry is
associated with a different host (i.e., detect entryHost mismatch), then call
BrowserWindowPortalRegistry.bind(webView:to:visibleInUI:zPriority:) and update
coordinator.lastPortalHostId accordingly (and if necessary unbind or overwrite
the stale entry before binding). Ensure you reference isBoundToHost,
hasPortalEntry, BrowserWindowPortalRegistry.bind, coordinator.lastPortalHostId,
coordinator.desiredPortalVisibleInUI, and coordinator.desiredPortalZPriority
when implementing the fix.

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

Copy link
Copy Markdown

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.

Reviewed commit: a14ee31ee3

ℹ️ 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 +1260 to +1262
let didDetach = portal.detachWebView(withId: webViewId, expectedAnchorId: expectedAnchorId)
guard didDetach else { return false }
webViewToWindowId.removeValue(forKey: webViewId)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Remove stale webview mapping even when detach misses entry

BrowserWindowPortalRegistry.detach now returns early when portal.detachWebView is false, but that can happen after WindowBrowserPortal.pruneDeadEntries has already removed the portal entry (it detaches internally without updating webViewToWindowId). In that case the mapping is never cleared, so closed/replaced web views leave stale webViewToWindowId entries behind, and later registry lookups (hasPortalEntry, repeated detach) keep targeting a non-existent entry instead of cleaning up.

Useful? React with 👍 / 👎.

…t-enter-black-view

# Conflicts:
#	Sources/BrowserWindowPortal.swift
#	Sources/Panels/BrowserPanelView.swift
#	cmuxTests/CmuxWebViewKeyEquivalentTests.swift

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tests/test_browser_transient_dismantle_lifecycle_regression.py`:
- Around line 16-24: The repo_root() function calls "git" via PATH lookup;
replace that with a resolved absolute git executable using shutil.which("git")
(call once at function start or module-scope) and pass that absolute path to
subprocess.run([...]) instead of the bare "git"; if shutil.which returns None,
fall back to the current behavior (i.e., run with "git" or return the parent
path as the existing code does) so tests still work when git isn't on PATH;
update references in repo_root() only.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 844d0415-da35-4b56-9844-6e20534d942c

📥 Commits

Reviewing files that changed from the base of the PR and between a14ee31 and 98d1948.

📒 Files selected for processing (6)
  • Sources/BrowserWindowPortal.swift
  • Sources/Panels/BrowserPanel.swift
  • Sources/Panels/BrowserPanelView.swift
  • Sources/Workspace.swift
  • cmuxTests/CmuxWebViewKeyEquivalentTests.swift
  • tests/test_browser_transient_dismantle_lifecycle_regression.py

Comment on lines +16 to +24
def repo_root() -> Path:
result = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
capture_output=True,
text=True,
)
if result.returncode == 0:
return Path(result.stdout.strip())
return Path(__file__).resolve().parents[1]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cd /repo && find . -name "test_browser_transient_dismantle_lifecycle_regression.py" -type f

Repository: manaflow-ai/cmux

Length of output: 115


🏁 Script executed:

head -30 tests/test_browser_transient_dismantle_lifecycle_regression.py

Repository: manaflow-ai/cmux

Length of output: 1009


🏁 Script executed:

# Check if there are any other calls to subprocess with git in the codebase
rg "subprocess.*git|git.*subprocess" --type py -A 2 -B 2

Repository: manaflow-ai/cmux

Length of output: 368


🏁 Script executed:

# Look for any existing use of shutil.which in the codebase
rg "shutil.which|shutil\.which" --type py

Repository: manaflow-ai/cmux

Length of output: 571


Use an absolute git path in repo_root() to avoid PATH-hijack risk in runners.

Line 18 executes git via PATH lookup. This pattern is already established elsewhere in the codebase (see tests/test_browser_portal_lifecycle_architecture.py and tests/regression_helpers.py). Resolve once with shutil.which("git") and pass the absolute executable path to subprocess.run(). Handle the case where git is not found in PATH by falling back to the current logic.

🔧 Proposed fix
+import shutil
 import subprocess
 from pathlib import Path
@@
 def repo_root() -> Path:
+    git = shutil.which("git")
+    if git is None:
+        return Path(__file__).resolve().parents[1]
     result = subprocess.run(
-        ["git", "rev-parse", "--show-toplevel"],
+        [git, "rev-parse", "--show-toplevel"],
         capture_output=True,
         text=True,
     )
🧰 Tools
🪛 Ruff (0.15.2)

[error] 18-18: Starting a process with a partial executable path

(S607)

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

In `@tests/test_browser_transient_dismantle_lifecycle_regression.py` around lines
16 - 24, The repo_root() function calls "git" via PATH lookup; replace that with
a resolved absolute git executable using shutil.which("git") (call once at
function start or module-scope) and pass that absolute path to
subprocess.run([...]) instead of the bare "git"; if shutil.which returns None,
fall back to the current behavior (i.e., run with "git" or return the parent
path as the existing code does) so tests still work when git isn't on PATH;
update references in repo_root() only.

This branch was successfully deployed

1 active deployment
Preview — 98d19487 Deployed Mar 6, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants