Skip to content
Merged
9 changes: 8 additions & 1 deletion Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10327,7 +10327,14 @@ private extension NSWindow {
}
if String(describing: type(of: candidate)).contains("WindowBrowserSlotView"),
let portalWebView = cmuxUniqueBrowserWebView(in: candidate) {
return portalWebView
// Portal-hosted browser chrome (for example the Cmd+F overlay) is a
// sibling of the hosted WKWebView inside WindowBrowserSlotView, not a
// descendant of it. Treating every view in that slot as "web-owned"
// blocks legitimate first-responder changes to overlay text fields.
if view === portalWebView || view.isDescendant(of: portalWebView) {
return portalWebView
}
return nil
}
current = candidate.superview
}
Expand Down
119 changes: 117 additions & 2 deletions Sources/BrowserWindowPortal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import WebKit

private var cmuxWindowBrowserPortalKey: UInt8 = 0
private var cmuxWindowBrowserPortalCloseObserverKey: UInt8 = 0
private var cmuxBrowserSearchOverlayPanelIdAssociationKey: UInt8 = 0

#if DEBUG
private func browserPortalDebugToken(_ view: NSView?) -> String {
Expand All @@ -31,6 +32,17 @@ private extension NSObject {
}
}

private extension NSResponder {
var browserPortalOwningView: NSView? {
if let editor = self as? NSTextView,
editor.isFieldEditor,
let editedView = editor.delegate as? NSView {
return editedView
}
return self as? NSView
}
}

private extension WKWebView {
func browserPortalNotifyHidden(reason: String) {
let firedSelectors = ["viewDidHide", "_exitInWindow"].filter {
Expand Down Expand Up @@ -978,9 +990,12 @@ private final class BrowserDropZoneOverlayView: NSView {
struct BrowserPortalSearchOverlayConfiguration {
let panelId: UUID
let searchState: BrowserSearchState
let focusRequestGeneration: UInt64
let canApplyFocusRequest: (UInt64) -> Bool
let onNext: () -> Void
let onPrevious: () -> Void
let onClose: () -> Void
let onFieldDidFocus: () -> Void
}

struct BrowserPaneDropContext: Equatable {
Expand Down Expand Up @@ -1420,23 +1435,63 @@ final class WindowBrowserSlotView: NSView {
applyResolvedDropZoneOverlay()
}

private func logSearchOverlayEvent(_ action: String, panelId: UUID?) {
#if DEBUG
let firstResponderSummary: String = {
guard let firstResponder = window?.firstResponder else { return "nil" }
if let editor = firstResponder as? NSTextView, editor.isFieldEditor {
let delegateSummary = editor.delegate.map { String(describing: type(of: $0)) } ?? "nil"

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 Stop reading field-editor delegate in debug logging

NSTextView.delegate is an unsafe-unretained AppKit property, so reading it during responder teardown can crash with an unowned-reference trap; this helper now does that on every browser find-overlay log path. Since overlay mount/remove frequently coincides with first-responder churn, this reintroduces the exact crash pattern already called out elsewhere in the codebase (see the guard comment in AppDelegate around field-editor delegate access).

Useful? React with 👍 / 👎.

return "fieldEditor(delegate=\(delegateSummary))"
}
return String(describing: type(of: firstResponder))
}()
dlog(
"browser.findbar.portal action=\(action) " +
"panel=\(panelId?.uuidString.prefix(5) ?? "nil") " +
"window=\(window?.windowNumber ?? -1) " +
"firstResponder=\(firstResponderSummary) " +
"hasOverlay=\(searchOverlayHostingView != nil ? 1 : 0)"
)
#endif
}

func setSearchOverlay(_ configuration: BrowserPortalSearchOverlayConfiguration?) {
guard let configuration else {
logSearchOverlayEvent("remove", panelId: nil)
if let overlay = searchOverlayHostingView {
objc_setAssociatedObject(
overlay,
&cmuxBrowserSearchOverlayPanelIdAssociationKey,
nil,
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
)
}
searchOverlayHostingView?.removeFromSuperview()
searchOverlayHostingView = nil
return
}

logSearchOverlayEvent("set", panelId: configuration.panelId)
let rootView = BrowserSearchOverlay(
panelId: configuration.panelId,
searchState: configuration.searchState,
focusRequestGeneration: configuration.focusRequestGeneration,
canApplyFocusRequest: configuration.canApplyFocusRequest,
onNext: configuration.onNext,
onPrevious: configuration.onPrevious,
onClose: configuration.onClose
onClose: configuration.onClose,
onFieldDidFocus: configuration.onFieldDidFocus
)

if let overlay = searchOverlayHostingView {
logSearchOverlayEvent("updateExisting", panelId: configuration.panelId)
overlay.rootView = rootView
objc_setAssociatedObject(
overlay,
&cmuxBrowserSearchOverlayPanelIdAssociationKey,
configuration.panelId,
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
)
if overlay.superview !== self {
overlay.removeFromSuperview()
addSubview(overlay)
Expand All @@ -1452,6 +1507,12 @@ final class WindowBrowserSlotView: NSView {

let overlay = NSHostingView(rootView: rootView)
overlay.translatesAutoresizingMaskIntoConstraints = false
objc_setAssociatedObject(
overlay,
&cmuxBrowserSearchOverlayPanelIdAssociationKey,
configuration.panelId,
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
)
addSubview(overlay)
NSLayoutConstraint.activate([
overlay.topAnchor.constraint(equalTo: topAnchor),
Expand All @@ -1460,6 +1521,25 @@ final class WindowBrowserSlotView: NSView {
overlay.trailingAnchor.constraint(equalTo: trailingAnchor),
])
searchOverlayHostingView = overlay
logSearchOverlayEvent("create", panelId: configuration.panelId)
}

func searchOverlayPanelId(for responder: NSResponder) -> UUID? {
guard let overlay = searchOverlayHostingView,
let view = responder.browserPortalOwningView,
view.isDescendant(of: overlay) else {
return nil
}
return objc_getAssociatedObject(overlay, &cmuxBrowserSearchOverlayPanelIdAssociationKey) as? UUID
}

@discardableResult
func yieldSearchOverlayFocusIfOwned(by panelId: UUID, in window: NSWindow) -> Bool {
guard let firstResponder = window.firstResponder,
searchOverlayPanelId(for: firstResponder) == panelId else {
return false
}
return window.makeFirstResponder(nil)
}

func pinHostedWebView(_ webView: WKWebView) {
Expand Down Expand Up @@ -1872,7 +1952,9 @@ final class WindowBrowserPortal: NSObject {
case (nil, nil):
return true
case let (lhs?, rhs?):
return lhs.panelId == rhs.panelId && lhs.searchState === rhs.searchState
return lhs.panelId == rhs.panelId &&
lhs.searchState === rhs.searchState &&
lhs.focusRequestGeneration == rhs.focusRequestGeneration
default:
return false
}
Expand Down Expand Up @@ -2144,6 +2226,26 @@ final class WindowBrowserPortal: NSObject {
entry.containerView?.setSearchOverlay(configuration)
}

func searchOverlayPanelId(for responder: NSResponder) -> UUID? {
for entry in entriesByWebViewId.values {
if let panelId = entry.containerView?.searchOverlayPanelId(for: responder) {
return panelId
}
}
return nil
}

@discardableResult
func yieldSearchOverlayFocusIfOwned(by panelId: UUID) -> Bool {
guard let window else { return false }
for entry in entriesByWebViewId.values {
if entry.containerView?.yieldSearchOverlayFocusIfOwned(by: panelId, in: window) == true {
return true
}
}
return false
}

func updatePaneTopChromeHeight(forWebViewId webViewId: ObjectIdentifier, height: CGFloat) {
guard var entry = entriesByWebViewId[webViewId] else { return }
let resolvedHeight = max(0, height)
Expand Down Expand Up @@ -3031,6 +3133,19 @@ enum BrowserWindowPortalRegistry {
portal.updateSearchOverlay(forWebViewId: webViewId, configuration: configuration)
}

static func searchOverlayPanelId(for responder: NSResponder, in window: NSWindow) -> UUID? {
let windowId = ObjectIdentifier(window)
guard let portal = portalsByWindowId[windowId] else { return nil }
return portal.searchOverlayPanelId(for: responder)
}

@discardableResult
static func yieldSearchOverlayFocusIfOwned(by panelId: UUID, in window: NSWindow) -> Bool {
let windowId = ObjectIdentifier(window)
guard let portal = portalsByWindowId[windowId] else { return false }
return portal.yieldSearchOverlayFocusIfOwned(by: panelId)
}

static func updatePaneTopChromeHeight(for webView: WKWebView, height: CGFloat) {
let webViewId = ObjectIdentifier(webView)
guard let windowId = webViewToWindowId[webViewId],
Expand Down
95 changes: 53 additions & 42 deletions Sources/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1398,15 +1398,10 @@ struct ContentView: View {
}
}

private enum CommandPaletteRestoreFocusIntent {
case panel
case browserAddressBar
}

private struct CommandPaletteRestoreFocusTarget {
let workspaceId: UUID
let panelId: UUID
let intent: CommandPaletteRestoreFocusIntent
let intent: PanelFocusIntent
}

private enum CommandPaletteInputFocusTarget {
Expand Down Expand Up @@ -5337,7 +5332,7 @@ struct ContentView: View {
static func shouldRestoreBrowserAddressBarAfterCommandPaletteDismiss(
focusedPanelIsBrowser: Bool,
focusedBrowserAddressBarPanelId: UUID?,
focusedPanelId: UUID
focusedPanelId: UUID?
) -> Bool {
focusedPanelIsBrowser && focusedBrowserAddressBarPanelId == focusedPanelId

@cubic-dev-ai cubic-dev-ai Bot Mar 11, 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: When both optional UUIDs are nil, nil == nil is true in Swift, so this can return true even when no address bar was focused. Guard against this by requiring focusedBrowserAddressBarPanelId to be non-nil.

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

<comment>When both optional UUIDs are `nil`, `nil == nil` is `true` in Swift, so this can return `true` even when no address bar was focused. Guard against this by requiring `focusedBrowserAddressBarPanelId` to be non-nil.</comment>

<file context>
@@ -5329,6 +5329,14 @@ struct ContentView: View {
+        focusedBrowserAddressBarPanelId: UUID?,
+        focusedPanelId: UUID?
+    ) -> Bool {
+        focusedPanelIsBrowser && focusedBrowserAddressBarPanelId == focusedPanelId
+    }
+
</file context>
Suggested change
focusedPanelIsBrowser && focusedBrowserAddressBarPanelId == focusedPanelId
focusedPanelIsBrowser && focusedBrowserAddressBarPanelId != nil && focusedBrowserAddressBarPanelId == focusedPanelId
Fix with Cubic

}
Expand Down Expand Up @@ -5383,15 +5378,10 @@ struct ContentView: View {

private func presentCommandPalette(initialQuery: String) {
if let panelContext = focusedPanelContext {
let shouldRestoreBrowserAddressBar = Self.shouldRestoreBrowserAddressBarAfterCommandPaletteDismiss(
focusedPanelIsBrowser: panelContext.panel.panelType == .browser,
focusedBrowserAddressBarPanelId: AppDelegate.shared?.focusedBrowserAddressBarPanelId(),
focusedPanelId: panelContext.panelId
)
commandPaletteRestoreFocusTarget = CommandPaletteRestoreFocusTarget(
workspaceId: panelContext.workspace.id,
panelId: panelContext.panelId,
intent: shouldRestoreBrowserAddressBar ? .browserAddressBar : .panel
intent: panelContext.panel.captureFocusIntent(in: observedWindow)
)
} else {
commandPaletteRestoreFocusTarget = nil
Expand Down Expand Up @@ -5468,7 +5458,7 @@ struct ContentView: View {
if let clickedFocusTarget {
dlog(
"palette.dismiss.backdrop focusTarget panel=\(clickedFocusTarget.panelId.uuidString.prefix(5)) " +
"workspace=\(clickedFocusTarget.workspaceId.uuidString.prefix(5)) intent=\(clickedFocusTarget.intent == .browserAddressBar ? "addressBar" : "panel")"
"workspace=\(clickedFocusTarget.workspaceId.uuidString.prefix(5)) intent=\(debugCommandPaletteFocusIntent(clickedFocusTarget.intent))"
)
} else {
dlog("palette.dismiss.backdrop focusTarget=nil")
Expand Down Expand Up @@ -5507,10 +5497,11 @@ struct ContentView: View {
let workspaceId = terminalView.tabId,
let panelId = terminalView.terminalSurface?.id,
tabManager.tabs.contains(where: { $0.id == workspaceId }) {
return CommandPaletteRestoreFocusTarget(
return commandPaletteRestoreFocusTarget(
workspaceId: workspaceId,
panelId: panelId,
intent: .panel
fallbackIntent: .terminal(.surface),
in: window
)
}

Expand All @@ -5522,10 +5513,11 @@ struct ContentView: View {
let workspaceId = terminalView.tabId,
let panelId = terminalView.terminalSurface?.id,
tabManager.tabs.contains(where: { $0.id == workspaceId }) {
return CommandPaletteRestoreFocusTarget(
return commandPaletteRestoreFocusTarget(
workspaceId: workspaceId,
panelId: panelId,
intent: .panel
fallbackIntent: .terminal(.surface),
in: observedWindow
)
}

Expand Down Expand Up @@ -5563,16 +5555,35 @@ struct ContentView: View {
continue
}

return CommandPaletteRestoreFocusTarget(
return commandPaletteRestoreFocusTarget(
workspaceId: workspace.id,
panelId: panelId,
intent: .panel
fallbackIntent: .browser(.webView),
in: observedWindow
)
}

return nil
}

private func commandPaletteRestoreFocusTarget(
workspaceId: UUID,
panelId: UUID,
fallbackIntent: PanelFocusIntent,
in window: NSWindow?
) -> CommandPaletteRestoreFocusTarget {
let intent = tabManager.tabs
.first(where: { $0.id == workspaceId })?
.panels[panelId]?
.captureFocusIntent(in: window) ?? fallbackIntent

return CommandPaletteRestoreFocusTarget(
workspaceId: workspaceId,
panelId: panelId,
intent: intent
)
}

private func restoreCommandPaletteFocus(
target: CommandPaletteRestoreFocusTarget,
attemptsRemaining: Int
Expand All @@ -5588,8 +5599,9 @@ struct ContentView: View {
if let context = focusedPanelContext,
context.workspace.id == target.workspaceId,
context.panelId == target.panelId {
restoreCommandPaletteInputFocusIfNeeded(target: target, attemptsRemaining: 6)
return
if context.panel.restoreFocusIntent(target.intent) {
return
}
}

guard attemptsRemaining > 0 else { return }
Expand All @@ -5598,33 +5610,32 @@ struct ContentView: View {
if let context = focusedPanelContext,
context.workspace.id == target.workspaceId,
context.panelId == target.panelId {
restoreCommandPaletteInputFocusIfNeeded(target: target, attemptsRemaining: 6)
return
if context.panel.restoreFocusIntent(target.intent) {
return
}
}
restoreCommandPaletteFocus(target: target, attemptsRemaining: attemptsRemaining - 1)
}
}

private func restoreCommandPaletteInputFocusIfNeeded(
target: CommandPaletteRestoreFocusTarget,
attemptsRemaining: Int
) {
guard !isCommandPalettePresented else { return }
guard target.intent == .browserAddressBar else { return }
guard attemptsRemaining > 0 else { return }
guard let appDelegate = AppDelegate.shared else { return }

if appDelegate.requestBrowserAddressBarFocus(panelId: target.panelId) {
return
}

DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) {
restoreCommandPaletteInputFocusIfNeeded(
target: target,
attemptsRemaining: attemptsRemaining - 1
)
#if DEBUG
private func debugCommandPaletteFocusIntent(_ intent: PanelFocusIntent) -> String {
switch intent {
case .panel:
return "panel"
case .terminal(.surface):
return "terminal.surface"
case .terminal(.findField):
return "terminal.findField"
case .browser(.webView):
return "browser.webView"
case .browser(.addressBar):
return "browser.addressBar"
case .browser(.findField):
return "browser.findField"
}
}
#endif

private func resetCommandPaletteSearchFocus() {
applyCommandPaletteInputFocusPolicy(.search)
Expand Down
Loading
Loading