Skip to content

Fix browser pane flicker during multi-split resize - #2574

Merged
austinywang merged 8 commits into
mainfrom
issue-2571-browser-flicker-multi-split
Apr 6, 2026
Merged

austinywang merged 8 commits into
mainfrom
issue-2571-browser-flicker-multi-split

Conversation

@austinywang

@austinywang austinywang commented Apr 4, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #2571

Summary

  • fix bonsplit divider drag tracking so multi-split resizes latch reliably at divider boundaries
  • update bonsplit divider position synchronously to avoid stale ratio replay and pane snap-back during fast drags
  • use the same expanded divider hit rect in the browser portal drag detector so browser sync suppression matches split drag behavior
  • restore initial workspace git metadata probes for inherited directories, new splits, and nested terminal surfaces
  • restore self-healing tracked git metadata refreshes and regression coverage for the probe path
  • keep the tagged Debug build compiling by using the Ghostty clipboard callback shim that works across importer variants

Verification

  • ./scripts/reload.sh --tag fix-git-probe-regression --launch
  • ./scripts/reload.sh --tag fix-git-probe-regression

Notes

  • no local tests run, per repo policy

Summary by CodeRabbit

  • Bug Fixes

    • Improved detection and handling of split divider drag interactions for more responsive split pane resizing.
  • Chores

    • Optimized git metadata refresh scheduling during workspace initialization.
    • Updated bundled dependencies.

@vercel

vercel Bot commented Apr 4, 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 Apr 6, 2026 0:40am

@coderabbitai

coderabbitai Bot commented Apr 4, 2026 •

Copy link
Copy Markdown

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: fdac7b6a-8163-4f8b-a33d-29a9334bd4ee

📥 Commits

Reviewing files that changed from the base of the PR and between a9e58cf and b98a7ce.

📒 Files selected for processing (4)
  • Sources/BrowserWindowPortal.swift
  • Sources/TabManager.swift
  • cmuxTests/TabManagerUnitTests.swift
  • vendor/bonsplit
✅ Files skipped from review due to trivial changes (2)
  • vendor/bonsplit
  • Sources/TabManager.swift
🚧 Files skipped from review as they are similar to previous changes (1)
  • Sources/BrowserWindowPortal.swift

📝 Walkthrough

Walkthrough

This PR enhances interactive split divider drag detection in BrowserWindowPortal with a new per-window flag tracking mechanism to suppress unwanted geometry synchronization during divider drags, adds a dedicated helper in TabManager for initial workspace git metadata refresh scheduling, updates corresponding test API calls, and updates the bonsplit vendor dependency.

Changes

Cohort / File(s) Summary
Interactive Split Divider Drag Detection
Sources/BrowserWindowPortal.swift
Added per-NSWindow associated state browserPortalHasInteractiveSplitDividerDrag to track active divider drags. Introduced noteInteractiveSplitDividerDragIfNeeded(...) to detect .leftMouseDown/.leftMouseDragged events near split dividers via hit-rect validation. Modified isInteractiveSplitDividerDrag(in:) to prioritize the new window-level flag. Extended installGeometryObservers(for:) to observe willResizeSubviewsNotification and trigger drag detection.
Initial Git Metadata Refresh
Sources/TabManager.swift
Added helper method scheduleInitialWorkspaceGitMetadataRefresh(workspaceId:panelId:directory:) that forwards to existing scheduling machinery with initial probe delays and a fixed reason string.
Test API Migration
cmuxTests/TabManagerUnitTests.swift
Updated test setup calls from workspace.updatePanel* to manager.updateSurface* APIs, passing workspace.id as tabId and panelId as surfaceId.
Vendor Dependency
vendor/bonsplit
Updated submodule pointer to newer commit b2788b1e77d43f0c114dcf189aa59cae8abb47de.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~40 minutes

Possibly related PRs

Poem

🐰 When split dividers dance and drag about,
Our rabbit sets the flicker doubts to rout,
With window flags that track each drag so near,
Smooth resizes bloom without a fear! ✨

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Out of Scope Changes check ❓ Inconclusive Most changes align with issue #2571, but TabManager git metadata refresh helper and bonsplit submodule update appear tangential to the primary browser flicker fix. Clarify whether TabManager git metadata and bonsplit updates are necessary prerequisites or if they address a related regression mentioned in the summary.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Fix browser pane flicker during multi-split resize' directly and clearly describes the main change, accurately reflecting the PR's primary objective.
Description check ✅ Passed The description covers all required template sections: Summary with clear what/why statements, Testing with verification commands, and Checklist acknowledgment.
Linked Issues check ✅ Passed Code changes comprehensively address linked issue #2571: window-level drag detection prevents nested resize callbacks from triggering geometry sync, eliminating flicker in multi-split layouts.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issue-2571-browser-flicker-multi-split

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.

@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 `@Sources/BrowserWindowPortal.swift`:
- Around line 2170-2198: The current detection only checks the first
arrangedSubviews pair and misses later dividers; update the logic in the
hit-test block that uses splitView, arrangedSubviews and dividerThickness to
iterate adjacent subview pairs (0/1, 1/2, …) compute each dividerRect
(respecting splitView.isVertical vs horizontal and using first.maxX/first.maxY
and second frames) and test location against each dividerRect.insetBy(dx: -4,
dy: -4); if any contains(location) set
window.browserPortalHasInteractiveSplitDividerDrag = true (and return/exit
early) so drags on any divider latch correctly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1a007cce-406c-4105-aad5-cc18244d41de

📥 Commits

Reviewing files that changed from the base of the PR and between f484673 and 50e5f45.

📒 Files selected for processing (2)
  • Sources/BrowserWindowPortal.swift
  • Sources/GhosttyTerminalView.swift

Comment on lines +2170 to +2198
guard splitView.arrangedSubviews.count >= 2 else { return }

let location = splitView.convert(event.locationInWindow, from: nil)
let first = splitView.arrangedSubviews[0].frame
let second = splitView.arrangedSubviews[1].frame
let thickness = splitView.dividerThickness
let dividerRect: NSRect

if splitView.isVertical {
guard first.width > 1, second.width > 1 else { return }
dividerRect = NSRect(
x: max(0, first.maxX),
y: 0,
width: thickness,
height: splitView.bounds.height
)
} else {
guard first.height > 1, second.height > 1 else { return }
dividerRect = NSRect(
x: 0,
y: max(0, first.maxY),
width: splitView.bounds.width,
height: thickness
)
}

if dividerRect.insetBy(dx: -4, dy: -4).contains(location) {
window.browserPortalHasInteractiveSplitDividerDrag = true
}

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

Divider latch detection only checks the first divider pair.

Line 2173–2174 only inspects arrangedSubviews[0]/[1], so drags on later dividers in a 3+ pane NSSplitView won’t set the latch flag. That leaves a path for redundant external geometry sync during active drag.

Suggested fix
-        guard splitView.arrangedSubviews.count >= 2 else { return }
-
-        let location = splitView.convert(event.locationInWindow, from: nil)
-        let first = splitView.arrangedSubviews[0].frame
-        let second = splitView.arrangedSubviews[1].frame
-        let thickness = splitView.dividerThickness
-        let dividerRect: NSRect
-
-        if splitView.isVertical {
-            guard first.width > 1, second.width > 1 else { return }
-            dividerRect = NSRect(
-                x: max(0, first.maxX),
-                y: 0,
-                width: thickness,
-                height: splitView.bounds.height
-            )
-        } else {
-            guard first.height > 1, second.height > 1 else { return }
-            dividerRect = NSRect(
-                x: 0,
-                y: max(0, first.maxY),
-                width: splitView.bounds.width,
-                height: thickness
-            )
-        }
-
-        if dividerRect.insetBy(dx: -4, dy: -4).contains(location) {
-            window.browserPortalHasInteractiveSplitDividerDrag = true
-        }
+        let location = splitView.convert(event.locationInWindow, from: nil)
+        let thickness = splitView.dividerThickness
+        let dividerCount = max(0, splitView.arrangedSubviews.count - 1)
+        guard dividerCount > 0 else { return }
+
+        for dividerIndex in 0..<dividerCount {
+            let first = splitView.arrangedSubviews[dividerIndex].frame
+            let second = splitView.arrangedSubviews[dividerIndex + 1].frame
+            let dividerRect: NSRect
+
+            if splitView.isVertical {
+                guard first.width > 1 || second.width > 1 else { continue }
+                dividerRect = NSRect(
+                    x: max(0, first.maxX),
+                    y: 0,
+                    width: thickness,
+                    height: splitView.bounds.height
+                )
+            } else {
+                guard first.height > 1 || second.height > 1 else { continue }
+                dividerRect = NSRect(
+                    x: 0,
+                    y: max(0, first.maxY),
+                    width: splitView.bounds.width,
+                    height: thickness
+                )
+            }
+
+            if dividerRect.insetBy(dx: -4, dy: -4).contains(location) {
+                window.browserPortalHasInteractiveSplitDividerDrag = true
+                return
+            }
+        }
📝 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
guard splitView.arrangedSubviews.count >= 2 else { return }
let location = splitView.convert(event.locationInWindow, from: nil)
let first = splitView.arrangedSubviews[0].frame
let second = splitView.arrangedSubviews[1].frame
let thickness = splitView.dividerThickness
let dividerRect: NSRect
if splitView.isVertical {
guard first.width > 1, second.width > 1 else { return }
dividerRect = NSRect(
x: max(0, first.maxX),
y: 0,
width: thickness,
height: splitView.bounds.height
)
} else {
guard first.height > 1, second.height > 1 else { return }
dividerRect = NSRect(
x: 0,
y: max(0, first.maxY),
width: splitView.bounds.width,
height: thickness
)
}
if dividerRect.insetBy(dx: -4, dy: -4).contains(location) {
window.browserPortalHasInteractiveSplitDividerDrag = true
}
let location = splitView.convert(event.locationInWindow, from: nil)
let thickness = splitView.dividerThickness
let dividerCount = max(0, splitView.arrangedSubviews.count - 1)
guard dividerCount > 0 else { return }
for dividerIndex in 0..<dividerCount {
let first = splitView.arrangedSubviews[dividerIndex].frame
let second = splitView.arrangedSubviews[dividerIndex + 1].frame
let dividerRect: NSRect
if splitView.isVertical {
guard first.width > 1 || second.width > 1 else { continue }
dividerRect = NSRect(
x: max(0, first.maxX),
y: 0,
width: thickness,
height: splitView.bounds.height
)
} else {
guard first.height > 1 || second.height > 1 else { continue }
dividerRect = NSRect(
x: 0,
y: max(0, first.maxY),
width: splitView.bounds.width,
height: thickness
)
}
if dividerRect.insetBy(dx: -4, dy: -4).contains(location) {
window.browserPortalHasInteractiveSplitDividerDrag = true
return
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/BrowserWindowPortal.swift` around lines 2170 - 2198, The current
detection only checks the first arrangedSubviews pair and misses later dividers;
update the logic in the hit-test block that uses splitView, arrangedSubviews and
dividerThickness to iterate adjacent subview pairs (0/1, 1/2, …) compute each
dividerRect (respecting splitView.isVertical vs horizontal and using
first.maxX/first.maxY and second frames) and test location against each
dividerRect.insetBy(dx: -4, dy: -4); if any contains(location) set
window.browserPortalHasInteractiveSplitDividerDrag = true (and return/exit
early) so drags on any divider latch correctly.

@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 2 files

@greptile-apps

greptile-apps Bot commented Apr 4, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR eliminates browser pane flicker during multi-split divider drags by latching interactive drag state on NSWindow (via associated objects) at NSSplitView.willResizeSubviewsNotification time, then suppressing the portal-wide geometry sync in didResizeSubviewsNotification while that latch is active. A secondary fix addresses Ghostty callback return-value build blockers in GhosttyTerminalView. The suppression logic is well-guarded: the latch auto-clears when the mouse button is released, and a NSWindow.didEndLiveResizeNotification observer still fires a final sync after window-level resizes.

Confidence Score: 5/5

Safe to merge — no P0/P1 issues; latch logic is correct and well-guarded against stale state.

All findings are P2 or lower. The latch uses a well-established associated-object pattern, auto-clears on mouse-up via the getter, and falls back to raw event-state detection when the divider hit test can't confirm the drag. The final portal sync after drag end is preserved via the post-mouse-up didResizeSubviewsNotification path. No data loss, no security surface, no broken contracts.

No files require special attention.

Important Files Changed

Filename Overview
Sources/BrowserWindowPortal.swift Adds NSWindow-level interactive split-divider drag latch (associated object) and a willResizeSubviewsNotification observer to set it; suppresses portal-wide geometry sync while the latch is active. Logic is well-guarded and the latch auto-clears on mouse-up.
Sources/GhosttyTerminalView.swift Fixes Ghostty clipboard callback return-value handling that was causing reload build blockers; no logic changes to clipboard flow itself.

Sequence Diagram

sequenceDiagram
    participant User
    participant AppKit
    participant BrowserWindowPortal
    participant NSWindow
    participant GeometrySync

    User->>AppKit: Drag split divider (leftMouseDragged)
    AppKit->>BrowserWindowPortal: willResizeSubviewsNotification
    BrowserWindowPortal->>BrowserWindowPortal: noteInteractiveSplitDividerDragIfNeeded()
    Note over BrowserWindowPortal: Checks: mouse button down,<br/>recent event, divider hit test
    BrowserWindowPortal->>NSWindow: browserPortalHasInteractiveSplitDividerDrag = true

    AppKit->>BrowserWindowPortal: didResizeSubviewsNotification
    BrowserWindowPortal->>BrowserWindowPortal: shouldTreatSplitResizeAsExternalGeometry()
    BrowserWindowPortal->>NSWindow: browserPortalHasInteractiveSplitDividerDrag (get)
    NSWindow-->>BrowserWindowPortal: true (latch active + mouse still down)
    BrowserWindowPortal->>GeometrySync: suppressed (no flicker)

    User->>AppKit: Release mouse button
    AppKit->>BrowserWindowPortal: didResizeSubviewsNotification (final)
    BrowserWindowPortal->>NSWindow: browserPortalHasInteractiveSplitDividerDrag (get)
    NSWindow-->>BrowserWindowPortal: false (auto-cleared, mouse released)
    BrowserWindowPortal->>GeometrySync: scheduleExternalGeometrySynchronize()
    GeometrySync->>GeometrySync: synchronizeAllEntriesFromExternalGeometryChange()
Loading

Reviews (1): Last reviewed commit: "Fix ghostty callback return values" | Re-trigger Greptile

@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 `@Sources/GhosttyTerminalView.swift`:
- Around line 1233-1252: The callbacks for insertText and onFailure currently
call MainActor.assumeIsolated directly but may be invoked off the main thread;
update those closures to mirror the defensive pattern used in
TerminalImageTransferPlanner.execute/executeImageTransferPlan by checking
Thread.isMainThread and calling the UI updates
(callbackContext.terminalSurface?.hostedView.endImageTransferIndicator(for:
operation) and NSSound.beep() in onFailure) immediately if on main thread,
otherwise dispatch them with DispatchQueue.main.async; keep calls to
completeClipboardRequest(with:) as-is but ensure the UI-ending calls run on the
main thread and preserve any weak captures (e.g., callbackContext) used in the
original closures.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9e659150-ce71-445a-bdd8-3b49c3bc638f

📥 Commits

Reviewing files that changed from the base of the PR and between 50e5f45 and a9e58cf.

📒 Files selected for processing (1)
  • Sources/GhosttyTerminalView.swift

Comment thread Sources/GhosttyTerminalView.swift Outdated
Comment on lines +1233 to +1252
insertText: { text in
MainActor.assumeIsolated {
callbackContext.terminalSurface?.hostedView.endImageTransferIndicator(
for: operation
)
}
completeClipboardRequest(with: text)
},
onFailure: { _ in
MainActor.assumeIsolated {
callbackContext.terminalSurface?.hostedView.endImageTransferIndicator(
for: operation
)
}
NSSound.beep()
#if DEBUG
dlog("terminal.remotePasteUpload.failed surface=\(callbackContext.surfaceId.uuidString.prefix(5))")
#endif
completeClipboardRequest(with: "")
}

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

planner_file=$(rg -n --type swift 'struct TerminalImageTransferPlanner|enum TerminalImageTransferPlanner|class TerminalImageTransferPlanner' | head -n1 | cut -d: -f1)

if [[ -z "${planner_file:-}" ]]; then
  echo "TerminalImageTransferPlanner definition not found"
  exit 1
fi

printf '=== %s ===\n' "$planner_file"
rg -n -C6 'static func execute\(|insertText:|onFailure:|DispatchQueue\.main|MainActor\.assumeIsolated' "$planner_file"

Repository: manaflow-ai/cmux

Length of output: 3245


🏁 Script executed:

#!/bin/bash
# Get complete finishUpload function
rg -n -A30 'private static func finishUpload' Sources/TerminalImageTransfer.swift

Repository: manaflow-ai/cmux

Length of output: 1283


🏁 Script executed:

#!/bin/bash
# Find executeImageTransferPlan in GhosttyTerminalView.swift to see the "defensive hop"
rg -n -B5 -A15 'executeImageTransferPlan' Sources/GhosttyTerminalView.swift

Repository: manaflow-ai/cmux

Length of output: 1663


🏁 Script executed:

#!/bin/bash
# Check how uploadWorkspaceRemote/uploadDetectedSSH handlers are passed and whether they hop to main
rg -n -B3 -A10 'uploadWorkspaceRemote:|uploadDetectedSSH:' Sources/GhosttyTerminalView.swift | head -100

Repository: manaflow-ai/cmux

Length of output: 3881


🏁 Script executed:

#!/bin/bash
# Get the complete insertText and onFailure callbacks in executeImageTransferPlan
sed -n '7265,7300p' Sources/GhosttyTerminalView.swift

Repository: manaflow-ai/cmux

Length of output: 1512


Mirror the defensive main-thread dispatch pattern used in executeImageTransferPlan.

The insertText and onFailure callbacks at lines 1233–1252 use MainActor.assumeIsolated directly without checking thread context, but TerminalImageTransferPlanner.execute invokes these callbacks from async completion handlers in finishUpload (lines 326, 328) with no main-thread guarantee. The executeImageTransferPlan path (lines 7265–7290) correctly handles this with explicit checks and dispatch:

insertText: { [weak self] text in
    if Thread.isMainThread {
        send()
    } else {
        DispatchQueue.main.async(execute: send)
    }
},
onFailure: { [weak self] _ in
    DispatchQueue.main.async {
        NSSound.beep()
        // ...
    }
}

Update lines 1233–1252 to match this defensive threading pattern for consistency and safety.

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

In `@Sources/GhosttyTerminalView.swift` around lines 1233 - 1252, The callbacks
for insertText and onFailure currently call MainActor.assumeIsolated directly
but may be invoked off the main thread; update those closures to mirror the
defensive pattern used in
TerminalImageTransferPlanner.execute/executeImageTransferPlan by checking
Thread.isMainThread and calling the UI updates
(callbackContext.terminalSurface?.hostedView.endImageTransferIndicator(for:
operation) and NSSound.beep() in onFailure) immediately if on main thread,
otherwise dispatch them with DispatchQueue.main.async; keep calls to
completeClipboardRequest(with:) as-is but ensure the UI-ending calls run on the
main thread and preserve any weak captures (e.g., callbackContext) used in the
original closures.

@austinywang
austinywang merged commit 1363acf into main Apr 6, 2026
16 checks passed
@austinywang
austinywang deleted the issue-2571-browser-flicker-multi-split branch April 6, 2026 00:49
@austinywang austinywang mentioned this pull request Apr 6, 2026
3 tasks
@coderabbitai coderabbitai Bot mentioned this pull request May 13, 2026
3 of 4 tasks

This branch was successfully deployed

1 active deployment
Preview — b98a7cec Deployed Apr 6, 2026 by vercel[bot]
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.

Browser pane flicker during split resize with 2+ terminal panes

1 participant