Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 118 additions & 4 deletions Sources/BrowserWindowPortal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,100 @@ private var cmuxBrowserSearchOverlayPanelIdAssociationKey: UInt8 = 0
private var cmuxBrowserPortalNeedsRenderingStateReattachKey: UInt8 = 0
private var cmuxWindowInteractiveSplitDividerDragKey: UInt8 = 0

/// Shared helpers for portal hosts that must defer to the minimal-mode
/// Bonsplit tab strip rendered underneath them.
enum BonsplitTabBarPassThrough {
static func isPassThroughPointerEvent(_ eventType: NSEvent.EventType?) -> Bool {
switch eventType {
case .leftMouseDown, .leftMouseUp,
.rightMouseDown, .rightMouseUp,
.otherMouseDown, .otherMouseUp,
.mouseMoved, .mouseEntered,
.mouseExited, .cursorUpdate:
return true
default:
return false
}
}

static func titlebarInteractionBandMinY(in window: NSWindow) -> CGFloat {
let nativeTitlebarHeight = window.frame.height - window.contentLayoutRect.height
let customTitlebarBandHeight = max(28, min(72, nativeTitlebarHeight))
return window.contentLayoutRect.maxY - customTitlebarBandHeight - 0.5
}

static func shouldPassThroughToPaneTabBar(
windowPoint: NSPoint,
below portalHost: NSView
) -> (result: Bool, registryHit: Bool) {
let registryHit = portalHost.window.map {
BonsplitTabBarHitRegionRegistry.containsWindowPoint(windowPoint, in: $0)
} ?? false
if registryHit {
return (true, true)
}
let fallbackHit = hasUnderlyingBonsplitTabBarBackground(
at: windowPoint,
below: portalHost
)
return (fallbackHit, false)
}

static func passThroughDecision(
at point: NSPoint,
in portalHost: NSView,
eventType: NSEvent.EventType?
) -> (windowPoint: NSPoint, result: Bool, registryHit: Bool)? {
guard isPassThroughPointerEvent(eventType) else { return nil }
let windowPoint = portalHost.convert(point, to: nil)
let decision = shouldPassThroughToPaneTabBar(windowPoint: windowPoint, below: portalHost)
return (windowPoint, decision.result, decision.registryHit)
}

static func hasBonsplitTabBarBackground(at windowPoint: NSPoint, in view: NSView) -> Bool {
guard !view.isHidden, view.alphaValue > 0 else { return false }

// NSView subviews are not clipped to parent bounds by default, and the
// minimal tab strip can render outside its immediate container.
let className = NSStringFromClass(type(of: view))
if className.contains("TabBarBackgroundNSView") {
let pointInView = view.convert(windowPoint, from: nil)
if view.bounds.contains(pointInView) {
return true
}
}

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,
Comment on lines +74 to +88

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.

let hostIndex = container.subviews.firstIndex(of: portalHost) {
for sibling in container.subviews[..<hostIndex].reversed() {
guard !sibling.isHidden, sibling.alphaValue > 0 else { continue }
if hasBonsplitTabBarBackground(at: windowPoint, in: sibling) {
return true
}
}
return false
}

guard let window = portalHost.window,
let rootView = window.contentView else {
return false
}
return hasBonsplitTabBarBackground(at: windowPoint, in: rootView)
}
}

#if DEBUG
private func browserPortalDebugToken(_ view: NSView?) -> String {
guard let view else { return "nil" }
Expand Down Expand Up @@ -493,6 +587,9 @@ final class WindowBrowserHostView: NSView {
#endif
return nil
}
if shouldPassThroughToPaneTabBar(at: point, eventType: NSApp.currentEvent?.type) {
return nil
}
if sidebarPassThrough {
#if DEBUG
debugLogPointerRouting(
Expand Down Expand Up @@ -689,10 +786,27 @@ final class WindowBrowserHostView: NSView {
// hits that land in native titlebar space or the custom titlebar strip
// we reserve directly under it for window drag/double-click behaviors.
let windowPoint = convert(point, to: nil)
let nativeTitlebarHeight = window.frame.height - window.contentLayoutRect.height
let customTitlebarBandHeight = max(28, min(72, nativeTitlebarHeight))
let interactionBandMinY = window.contentLayoutRect.maxY - customTitlebarBandHeight - 0.5
return windowPoint.y >= interactionBandMinY
return windowPoint.y >= BonsplitTabBarPassThrough.titlebarInteractionBandMinY(in: window)
}

private func shouldPassThroughToPaneTabBar(
at point: NSPoint,
eventType: NSEvent.EventType?
) -> Bool {
guard let decision = BonsplitTabBarPassThrough.passThroughDecision(
at: point,
in: self,
eventType: eventType
) else { return false }
#if DEBUG
if eventType == .leftMouseDown {
dlog(
"portal.brwsr.passThroughTabBar wp=\(Int(decision.windowPoint.x)),\(Int(decision.windowPoint.y)) " +
"registry=\(decision.registryHit ? 1 : 0) result=\(decision.result ? 1 : 0)"
)
}
#endif
return decision.result
}

private func shouldPassThroughToSidebarResizer(at point: NSPoint) -> Bool {
Expand Down
36 changes: 36 additions & 0 deletions Sources/TerminalWindowPortal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,16 @@ final class WindowTerminalHostView: NSView {
}

if isPointerEvent {
if shouldPassThroughToTitlebar(at: point) {
clearActiveDividerCursor(restoreArrow: false)
return nil
}

if shouldPassThroughToPaneTabBar(at: point, eventType: currentEvent?.type) {
clearActiveDividerCursor(restoreArrow: false)
return nil
}

if shouldPassThroughToSidebarResizer(at: point) {
clearActiveDividerCursor(restoreArrow: false)
return nil
Expand Down Expand Up @@ -199,6 +209,32 @@ final class WindowTerminalHostView: NSView {
return hitView === self ? nil : hitView
}

private func shouldPassThroughToTitlebar(at point: NSPoint) -> Bool {
guard let window else { return false }
let windowPoint = convert(point, to: nil)
return windowPoint.y >= BonsplitTabBarPassThrough.titlebarInteractionBandMinY(in: window)
}

private func shouldPassThroughToPaneTabBar(
at point: NSPoint,
eventType: NSEvent.EventType?
) -> Bool {
guard let decision = BonsplitTabBarPassThrough.passThroughDecision(
at: point,
in: self,
eventType: eventType
) else { return false }
#if DEBUG
if eventType == .leftMouseDown {
dlog(
"portal.term.passThroughTabBar wp=\(Int(decision.windowPoint.x)),\(Int(decision.windowPoint.y)) " +
"registry=\(decision.registryHit ? 1 : 0) result=\(decision.result ? 1 : 0)"
)
}
#endif
return decision.result
}

private func shouldPassThroughToSidebarResizer(at point: NSPoint) -> Bool {
// The sidebar resizer handle is implemented in SwiftUI. When terminals
// are portal-hosted, this AppKit host can otherwise sit above the handle
Expand Down
57 changes: 57 additions & 0 deletions cmuxTests/BrowserPanelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,12 @@ final class WindowBrowserHostViewTests: XCTestCase {
}
}

private final class FakeTabBarBackgroundNSView: NSView {
override func hitTest(_ point: NSPoint) -> NSView? {
bounds.contains(point) ? self : nil
}
}

private final class PrimaryPageProbeView: NSView {
override func hitTest(_ point: NSPoint) -> NSView? {
bounds.contains(point) ? self : nil
Expand Down Expand Up @@ -457,6 +463,57 @@ final class WindowBrowserHostViewTests: XCTestCase {
return inspectorView.isDescendant(of: hit) && !(pageView === hit || pageView.isDescendant(of: hit))
}

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 = WindowBrowserHostView(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),

@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

"Browser portal should defer to the minimal tab strip even just below the native titlebar interaction band"
)
Comment on lines +511 to +514

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.

}

func testHostViewPassesThroughDividerWhenAdjacentPaneIsCollapsed() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 300, height: 180),
Expand Down
57 changes: 57 additions & 0 deletions cmuxTests/TerminalAndGhosttyTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2075,6 +2075,12 @@ final class WindowTerminalHostViewTests: XCTestCase {
}
}

private final class FakeTabBarBackgroundNSView: NSView {
override func hitTest(_ point: NSPoint) -> NSView? {
bounds.contains(point) ? self : nil
}
}

private final class BonsplitMockSplitDelegate: NSObject, NSSplitViewDelegate {}

private func makeHostedTerminalView(frame: NSRect) -> GhosttySurfaceScrollView {
Expand Down Expand Up @@ -2105,6 +2111,57 @@ final class WindowTerminalHostViewTests: XCTestCase {
)
}

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"
Comment on lines +2157 to +2161

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 +2114 to +2163

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.


func testHostViewPassesThroughWhenNoTerminalSubviewIsHit() {
let host = WindowTerminalHostView(frame: NSRect(x: 0, y: 0, width: 200, height: 120))

Expand Down
Loading