Skip to content

Fix minimal-mode top-bar drag pass-through - #3121

Closed
austinywang wants to merge 2 commits into
mainfrom
issue-3119-minimal-drag-sidebar
Closed

austinywang wants to merge 2 commits into
mainfrom
issue-3119-minimal-drag-sidebar

Conversation

@austinywang

@austinywang austinywang commented Apr 22, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • add regression tests that assert portal hosts yield the minimal tab-strip drag region
  • route browser and terminal portal hit-testing through the underlying Bonsplit tab strip below the titlebar band
  • keep the native titlebar interaction band and empty minimal top strip available for window dragging again

Root Cause

In minimal mode, the browser and terminal portal hosts sat above the Bonsplit tab strip and continued to claim pointer hits just below the native titlebar interaction band. That prevented empty top-bar drags from reaching the minimal-mode window-drag handlers behind the portals.

Testing

  • built with ./scripts/reload.sh --tag issue-3119-minimal-drag-sidebar --launch
  • local tests not run per repo policy

Closes #3119


Note

Medium Risk
Adjusts AppKit hit-testing in window-level browser/terminal portal hosts; mistakes could regress click/drag routing around the titlebar/tab strip and split dividers, but scope is limited to pointer-event routing.

Overview
Restores minimal-mode top-bar drag/click behavior by teaching the browser and terminal portal host views to yield pointer hit-testing not only to the native/custom titlebar band, but also to the underlying Bonsplit minimal tab strip.

This introduces shared BonsplitTabBarPassThrough helpers (including a registry-based hit check with a view-tree fallback) and adds regression tests asserting the portal hosts return nil for hits in the tab strip area just below the titlebar interaction band.

Reviewed by Cursor Bugbot for commit bd54dad. Bugbot is set up for automated code reviews on this repo. Configure here.


Summary by cubic

Fixes minimal-mode top-bar dragging by letting browser and terminal portal hosts pass pointer events through to the native titlebar band and the underlying Bonsplit minimal tab strip. Restores window drag behavior just below the titlebar. Closes #3119.

  • Bug Fixes
    • Route portal hit-testing via shared BonsplitTabBarPassThrough helpers (registry check with view-tree fallback).
    • Respect the native/custom titlebar interaction band for window drags.
    • Add regression tests asserting portal hosts return nil hits in the minimal tab strip area for both browser and terminal.

Written for commit bd54dad. Summary will update on new commits.

Summary by CodeRabbit

  • Bug Fixes

    • Improved pointer event handling in minimal-mode pane tab bar regions to correctly pass through events to underlying UI elements.
    • Enhanced detection of tab bar interaction areas to prevent unintended event interception.
  • Tests

    • Added regression tests for pointer hit-testing behavior in tab bar areas.

@vercel

vercel Bot commented Apr 22, 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 22, 2026 9:35pm

@coderabbitai

coderabbitai Bot commented Apr 22, 2026 •

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Enhances hit-testing in browser and terminal window portals to defer pointer events to underlying tab bars and titlebar regions in minimal mode. Adds shared pass-through logic via BonsplitTabBarPassThrough registry consultation and background view recursion, with early-return nil paths in host views to unblock the native draggable regions.

Changes

Cohort / File(s) Summary
Portal Hit-Testing Pass-Through Logic
Sources/BrowserWindowPortal.swift, Sources/TerminalWindowPortal.swift
Implements centralized pass-through filtering for pointer events in minimal-mode Bonsplit pane tab bars. BrowserWindowPortal computes titlebar interaction bands, consults BonsplitTabBarHitRegionRegistry, and recursively detects TabBarBackgroundNSView; TerminalWindowPortal checks titlebar band and delegates tab bar pass-through decisions. Both integrate early-return nil paths into hitTest to unblock underlying tab bar interactions.
Hit-Testing Regression Tests
cmuxTests/BrowserPanelTests.swift, cmuxTests/TerminalAndGhosttyTests.swift
Introduces test NSView subclasses modeling hittable tab-strip backgrounds and validates that both portal host views defer pointer handling at points below the native titlebar interaction band, ensuring portal does not intercept hits intended for the underlying draggable regions.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • #1094 — Modifies BrowserWindowPortal.swift pass-through/hit-testing logic for gating tab/sidebar interactions that bypass the portal.
  • #1230 — Changes hit-testing logic in BrowserWindowPortal.swift, specifically WindowBrowserHostView.hitTest and related divider hit paths.
  • #712 — Modifies WindowBrowserHostView hitTest implementation for divider hit/drag handling alongside portal pass-through logic.

Poem

🐰 A rabbit hops through minimal panes,
Where titlebar bands meet dragging chains—
Pass-through logic, registry deep,
Now windows move where clicks can creep! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 3.70% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Fix minimal-mode top-bar drag pass-through' accurately and concisely summarizes the main change—restoring window-drag functionality in minimal mode by preventing portal hosts from intercepting pointer hits in the tab-strip region.
Description check ✅ Passed The description provides a clear summary of changes, root cause analysis, and testing methodology. It covers what changed (regression tests and pass-through routing), why (pointer hit interception blocked drags), and how it was tested.
Linked Issues check ✅ Passed The changes directly address issue #3119 by routing portal hit-testing through the underlying Bonsplit tab strip and restoring the native titlebar interaction band for window drags, resolving the minimal-mode drag-blocking problem.
Out of Scope Changes check ✅ Passed All changes are directly scoped to the linked issue #3119: portal hit-testing logic in BrowserWindowPortal.swift and TerminalWindowPortal.swift, plus regression tests in the test files validating the fix.

✏️ 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-3119-minimal-drag-sidebar

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.

@greptile-apps

greptile-apps Bot commented Apr 22, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR routes pointer hit-testing for the browser and terminal portal hosts through the underlying Bonsplit tab strip in minimal mode, restoring window-drag behaviour in the empty top bar. The shared BonsplitTabBarPassThrough helper is cleanly extracted and the production code looks correct, but both new regression tests will fail when run because they call hitTest without a current NSEvent — isPassThroughPointerEvent(nil) returns false, preventing the tab-bar pass-through from firing and causing XCTAssertNil to fail.

  • BrowserPanelTests testHostViewPassesThroughUnderlyingTabStripBelowTitlebarBand — NSApp.currentEvent is nil, so passThroughDecision returns nil and the child view is returned instead of nil.
  • TerminalAndGhosttyTests mirror test — same issue: isPointerEvent = false skips the entire guard block and the child view is returned.

Confidence Score: 4/5

Production logic is correct; both regression tests are structurally broken and will fail when run.

The hit-test routing changes in the two portal source files look correct and well-structured. Score is 4 rather than 5 because both new tests have a definite failure mode (nil event type bypasses all tab-bar pass-through logic). The fragile class-name fallback is P2 only.

cmuxTests/BrowserPanelTests.swift and cmuxTests/TerminalAndGhosttyTests.swift — both new tests need a synthesized NSEvent before calling hitTest.

Important Files Changed

Filename Overview
Sources/BrowserWindowPortal.swift Adds shared BonsplitTabBarPassThrough enum with registry-based + class-name-fallback hit detection; wires shouldPassThroughToPaneTabBar into WindowBrowserHostView.hitTest correctly.
Sources/TerminalWindowPortal.swift Adds shouldPassThroughToTitlebar and shouldPassThroughToPaneTabBar checks inside the existing isPointerEvent guard, consistent with the broader hit-test routing pattern.
cmuxTests/BrowserPanelTests.swift New test XCTAssertNil will fail: NSApp.currentEvent is nil during direct hitTest calls, so isPassThroughPointerEvent(nil) returns false and the child view is returned.
cmuxTests/TerminalAndGhosttyTests.swift Mirror terminal test has the same nil-event problem: isPointerEvent is false, the tab-bar pass-through block is skipped, and the assertion fails.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["hitTest(point) called"] --> B{Is pointer event?}
    B -- No --> Z["super.hitTest → return child or nil"]
    B -- Yes --> C{In titlebar band?}
    C -- Yes --> D["return nil (window drag)"]
    C -- No --> E{BonsplitTabBarPassThrough shouldPassThrough?}
    E -- "Registry hit (primary)" --> F["return nil (tab strip)"]
    E -- "Class-name walk (fallback)" --> F
    E -- No --> G{In sidebar resizer?}
    G -- Yes --> H["return nil (resizer)"]
    G -- No --> I{Split divider?}
    I -- Yes --> J["return nil (divider)"]
    I -- No --> K["return portal child view"]
Loading

Reviews (1): Last reviewed commit: "Pass portal hits through minimal tab str..." | Re-trigger Greptile

Comment on lines +511 to +514
XCTAssertNil(
host.hitTest(pointInHost),
"Browser portal should defer to the minimal tab strip even just below the native titlebar interaction band"
)

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.

P1 Test asserts nil but will always fail without a current event

passThroughDecision(at:in:eventType:) starts with guard isPassThroughPointerEvent(eventType) else { return nil }. When the test calls host.hitTest(pointInHost) directly — with no synthesized NSEvent — NSApp.currentEvent is nil, so isPassThroughPointerEvent(nil) falls to the default branch and returns false. passThroughDecision returns nil, shouldPassThroughToPaneTabBar returns false, the child CapturingView is hit, and XCTAssertNil fails.

The same pattern breaks the mirror test in TerminalAndGhosttyTests.swift. Existing divider and titlebar pass-through tests succeed because those guards are geometry-only and don't consult NSApp.currentEvent. To fix, synthesize a .leftMouseDown event before calling hitTest.

Comment on lines +2157 to +2161
let pointInHost = host.convert(pointInWindow, from: nil)

XCTAssertNil(
host.hitTest(pointInHost),
"Terminal portal should defer to the minimal tab strip even just below the native titlebar interaction band"

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.

P1 Same nil-event failure as the browser portal test

shouldPassThroughToPaneTabBar is only reachable inside the if isPointerEvent { } block. With no current event (NSApp.currentEvent == nil), isPointerEvent is false, the entire block is skipped, super.hitTest returns the child CapturingView, and XCTAssertNil fails. A synthesized .leftMouseDown event is needed (same fix as the browser portal test).

Comment on lines +74 to +88
}

for subview in view.subviews.reversed() {
if hasBonsplitTabBarBackground(at: windowPoint, in: subview) {
return true
}
}
return false
}

static func hasUnderlyingBonsplitTabBarBackground(
at windowPoint: NSPoint,
below portalHost: NSView
) -> Bool {
if let container = portalHost.superview,

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.

P2 Fragile private-API class-name match in fallback hit detection

hasBonsplitTabBarBackground identifies the tab strip by checking NSStringFromClass(type(of: view)).contains("TabBarBackgroundNSView"). This is a string match against an internal Bonsplit class name with no public API contract. If Bonsplit renames that view, the fallback silently stops working with no compile-time signal. The primary BonsplitTabBarHitRegionRegistry path is robust; consider whether the fallback is worth keeping, or add a comment explaining the known fragility.

@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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Sources/TerminalWindowPortal.swift (1)

3-5: ⚠️ Potential issue | 🔴 Critical

Remove #if DEBUG guards from import Bonsplit.

Lines 215 and 222 reference BonsplitTabBarPassThrough in production code, but the import is currently wrapped in #if DEBUG. Release builds will fail to compile.

Proposed fix
 import AppKit
 import ObjectiveC
-#if DEBUG
 import Bonsplit
-#endif
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TerminalWindowPortal.swift` around lines 3 - 5, Remove the
conditional compile guard around importing Bonsplit so the module is available
in release builds: remove the surrounding `#if` DEBUG / `#endif` and keep a plain
import Bonsplit at the top of TerminalWindowPortal.swift so references to
BonsplitTabBarPassThrough (and any other Bonsplit symbols used at lines
referencing BonsplitTabBarPassThrough) compile in production.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@cmuxTests/TerminalAndGhosttyTests.swift`:
- Around line 2114-2163: The test
testHostViewPassesThroughUnderlyingTabStripBelowTitlebarBand is falsely passing
because WindowTerminalHostView.hitTest only follows the titlebar pass-through
path when the current event is a pointer; add a synthetic mouse pointer event
before calling host.hitTest(pointInHost) so isPointerEvent is true and the
shouldPassThroughToTitlebar() branch is exercised. Create an NSEvent via
NSEvent.mouseEvent(...) positioned at pointInWindow (or converted appropriately)
and deliver it with NSApp.sendEvent(...) (or set NSApp.currentEvent) immediately
prior to the hitTest invocation in the test to ensure the titlebar routing logic
in WindowTerminalHostView.hitTest is actually tested.

---

Outside diff comments:
In `@Sources/TerminalWindowPortal.swift`:
- Around line 3-5: Remove the conditional compile guard around importing
Bonsplit so the module is available in release builds: remove the surrounding
`#if` DEBUG / `#endif` and keep a plain import Bonsplit at the top of
TerminalWindowPortal.swift so references to BonsplitTabBarPassThrough (and any
other Bonsplit symbols used at lines referencing BonsplitTabBarPassThrough)
compile in production.
🪄 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: b42f57ba-d6a2-4a15-af19-856f223565e7

📥 Commits

Reviewing files that changed from the base of the PR and between 03a0c14 and bd54dad.

📒 Files selected for processing (4)
  • Sources/BrowserWindowPortal.swift
  • Sources/TerminalWindowPortal.swift
  • cmuxTests/BrowserPanelTests.swift
  • cmuxTests/TerminalAndGhosttyTests.swift

Comment on lines +2114 to +2163
func testHostViewPassesThroughUnderlyingTabStripBelowTitlebarBand() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
defer { window.orderOut(nil) }
guard let contentView = window.contentView else {
XCTFail("Expected content view")
return
}
guard let container = contentView.superview else {
XCTFail("Expected content container")
return
}

let tabStripHeight: CGFloat = 44
let tabStrip = FakeTabBarBackgroundNSView(
frame: NSRect(
x: 0,
y: contentView.bounds.maxY - tabStripHeight,
width: contentView.bounds.width,
height: tabStripHeight
)
)
tabStrip.autoresizingMask = [.width, .minYMargin]
contentView.addSubview(tabStrip)

let hostFrame = container.convert(contentView.bounds, from: contentView)
let host = WindowTerminalHostView(frame: hostFrame)
host.autoresizingMask = [.width, .height]
let child = CapturingView(frame: host.bounds)
child.autoresizingMask = [.width, .height]
host.addSubview(child)
container.addSubview(host, positioned: .above, relativeTo: contentView)

let titlebarBandHeight = max(28, min(72, window.frame.height - window.contentLayoutRect.height))
let pointInContent = NSPoint(
x: contentView.bounds.midX,
y: contentView.bounds.maxY - titlebarBandHeight - 8
)
let pointInWindow = contentView.convert(pointInContent, to: nil)
let pointInHost = host.convert(pointInWindow, from: nil)

XCTAssertNil(
host.hitTest(pointInHost),
"Terminal portal should defer to the minimal tab strip even just below the native titlebar interaction band"
)
}

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
# Verify the regression test calls hitTest without arranging a pointer currentEvent,
# while WindowTerminalHostView gates the pass-through path on NSApp.currentEvent.
rg -n -C5 'testHostViewPassesThroughUnderlyingTabStripBelowTitlebarBand|host\.hitTest|NSApp\.currentEvent|let isPointerEvent|NSEvent\.mouseEvent|sendEvent' \
  cmuxTests/TerminalAndGhosttyTests.swift Sources/TerminalWindowPortal.swift

Repository: manaflow-ai/cmux

Length of output: 10461


🏁 Script executed:

#!/bin/bash
# Get the complete hitTest method implementation
sed -n '134,200p' Sources/TerminalWindowPortal.swift

Repository: manaflow-ai/cmux

Length of output: 2530


🏁 Script executed:

#!/bin/bash
# Get the rest of the hitTest method to see non-pointer path
sed -n '134,220p' Sources/TerminalWindowPortal.swift

Repository: manaflow-ai/cmux

Length of output: 3221


🏁 Script executed:

#!/bin/bash
# Check other hitTest calls in the same test class to see if they all have the same issue
rg -n 'func test.*\(\)' cmuxTests/TerminalAndGhosttyTests.swift | grep -A1 "hitTest" | head -20

Repository: manaflow-ai/cmux

Length of output: 42


🏁 Script executed:

#!/bin/bash
# Find all test methods that call hitTest and check if they set up events
rg -B20 'host\.hitTest|splitView\.hitTest' cmuxTests/TerminalAndGhosttyTests.swift | grep -E '(func test|NSEvent\.mouseEvent|NSApp\.sendEvent|host\.hitTest)'

Repository: manaflow-ai/cmux

Length of output: 587


Set NSApp.currentEvent to a pointer event before calling hitTest.

The test expects host.hitTest(pointInHost) to return nil via the shouldPassThroughToTitlebar() logic, but WindowTerminalHostView.hitTest() only executes that routing path when isPointerEvent is true (lines 150–193 of TerminalWindowPortal.swift). Without a pointer event in NSApp.currentEvent, the method skips directly to the non-pointer fallback (lines 195–197), which also happens to return nil—so the test passes for the wrong reason and doesn't actually verify the titlebar pass-through behavior.

Use NSEvent.mouseEvent() to create a pointer event and NSApp.sendEvent() to establish it as the current event, as shown in the proposed diff.

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

In `@cmuxTests/TerminalAndGhosttyTests.swift` around lines 2114 - 2163, The test
testHostViewPassesThroughUnderlyingTabStripBelowTitlebarBand is falsely passing
because WindowTerminalHostView.hitTest only follows the titlebar pass-through
path when the current event is a pointer; add a synthetic mouse pointer event
before calling host.hitTest(pointInHost) so isPointerEvent is true and the
shouldPassThroughToTitlebar() branch is exercised. Create an NSEvent via
NSEvent.mouseEvent(...) positioned at pointInWindow (or converted appropriately)
and deliver it with NSApp.sendEvent(...) (or set NSApp.currentEvent) immediately
prior to the hitTest invocation in the test to ensure the titlebar routing logic
in WindowTerminalHostView.hitTest is actually tested.

@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.

1 issue found across 4 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="cmuxTests/BrowserPanelTests.swift">

<violation number="1" location="cmuxTests/BrowserPanelTests.swift:512">
P2: This test invokes `hitTest` without a current mouse event, so pane-tab-bar pass-through is never evaluated and the child capture view may be returned. Set up a synthetic pointer event before the `hitTest` call so the assertion validates the real routing path.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

let pointInHost = host.convert(pointInWindow, from: nil)

XCTAssertNil(
host.hitTest(pointInHost),

@cubic-dev-ai cubic-dev-ai Bot Apr 22, 2026 •

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: This test invokes hitTest without a current mouse event, so pane-tab-bar pass-through is never evaluated and the child capture view may be returned. Set up a synthetic pointer event before the hitTest call so the assertion validates the real routing path.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At cmuxTests/BrowserPanelTests.swift, line 512:

<comment>This test invokes `hitTest` without a current mouse event, so pane-tab-bar pass-through is never evaluated and the child capture view may be returned. Set up a synthetic pointer event before the `hitTest` call so the assertion validates the real routing path.</comment>

<file context>
@@ -457,6 +463,57 @@ final class WindowBrowserHostViewTests: XCTestCase {
+        let pointInHost = host.convert(pointInWindow, from: nil)
+
+        XCTAssertNil(
+            host.hitTest(pointInHost),
+            "Browser portal should defer to the minimal tab strip even just below the native titlebar interaction band"
+        )
</file context>
Fix with Cubic

@lawrencecchen lawrencecchen added the stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening. label Sep 23, 2026
@github-project-automation github-project-automation Bot moved this from Todo to Done in cmux backlog Sep 23, 2026

This branch was successfully deployed

1 active deployment
Preview — bd54dad8 Deployed Apr 22, 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.

Minimal mode: sidebar / top bar is not draggable to move the window

3 participants