From 797345fe2a75a48079787d83514067499b28c3fc Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 6 Apr 2026 23:22:58 -0700 Subject: [PATCH 1/3] Fix terminal blank when last pane closes (#2665) When closing panes down to the last remaining one, the surviving terminal went blank because the SwiftUI split-to-single-pane rebuild transiently detached the terminal surface, causing the first responder to be lost. The one-shot async focus reconcile could fire before the view was reattached, leaving no further retry. Include the surviving panel in the layout follow-up as terminalFocusPanelId so the existing retry loop keeps calling ensureFocus until the surface view successfully becomes first responder after reattachment. Closes #2665 Co-Authored-By: Claude Opus 4.6 --- Sources/Workspace.swift | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 87ebe6372a2a..4eafd082d0bd 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -11811,7 +11811,23 @@ extension Workspace: BonsplitDelegate { if bonsplitController.allPaneIds.contains(pane) { normalizePinnedTabs(in: pane) } - scheduleTerminalGeometryReconcile() + // When a pane was auto-closed (e.g. N→1 panes), the SwiftUI split view rebuilds + // asynchronously, transiently detaching the surviving terminal surface. The synchronous + // ensureFocus from applyTabSelection may succeed on the old layout but the first responder + // is lost when SwiftUI tears down the split and recreates a single-pane wrapper. Include + // the surviving panel in the layout follow-up so the retry loop re-applies focus once the + // view is reattached (#2665). + if !isDetaching && !bonsplitController.allPaneIds.contains(pane), + let survivingPanelId = focusedPanelId, + terminalPanel(for: survivingPanelId) != nil { + beginEventDrivenLayoutFollowUp( + reason: "workspace.paneCollapse", + terminalFocusPanelId: survivingPanelId, + includeGeometry: true + ) + } else { + scheduleTerminalGeometryReconcile() + } if !isDetaching { scheduleFocusReconcile() } @@ -11931,7 +11947,19 @@ extension Workspace: BonsplitDelegate { } } - scheduleTerminalGeometryReconcile() + // Same pane-collapse focus fix as didCloseTab (#2665): the SwiftUI split view + // rebuild can transiently detach the surviving terminal, losing first responder. + if shouldScheduleFocusReconcile, + let survivingPanelId = focusedPanelId, + terminalPanel(for: survivingPanelId) != nil { + beginEventDrivenLayoutFollowUp( + reason: "workspace.paneCollapse", + terminalFocusPanelId: survivingPanelId, + includeGeometry: true + ) + } else { + scheduleTerminalGeometryReconcile() + } if shouldScheduleFocusReconcile { scheduleFocusReconcile() } From 9c7cb55d3e24d9676d0daf849adeb3b8bdb4f04a Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 7 Apr 2026 01:59:52 -0700 Subject: [PATCH 2/3] Fix blank terminal on Cmd+T workspace creation The focusPanel layout follow-up with terminalFocusPanelId was gated to .terminalFirstResponder trigger only. When creating a new workspace via Cmd+T, the trigger is .standard, so no focus retry loop was set up. The new terminal surface goes through portal attach/detach cycles and the one-shot ensureFocus fires before the view is ready. Extend the follow-up to fire for any trigger when the terminal is not yet first responder, so the retry loop handles portal churn on workspace creation the same way it handles split-close churn. Co-Authored-By: Claude Opus 4.6 --- Sources/Workspace.swift | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 4eafd082d0bd..1ac77b3bad67 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -10067,12 +10067,19 @@ final class Workspace: Identifiable, ObservableObject { maybeAutoFocusBrowserAddressBarOnPanelFocus(browserPanel, trigger: trigger) } - if trigger == .terminalFirstResponder, - panels[panelId] is TerminalPanel { - beginEventDrivenLayoutFollowUp( - reason: "workspace.focusPanel.terminal", - terminalFocusPanelId: panelId - ) + if let terminalPanel = panels[panelId] as? TerminalPanel { + // Always set up a focus follow-up when the terminal is not yet first responder. + // The .terminalFirstResponder trigger always needs it (reentrant focus assertion). + // The .standard trigger needs it when the surface view isn't ready yet (e.g. + // new workspace via Cmd+T where the portal hasn't attached to a window yet). + let needsFocusFollowUp = trigger == .terminalFirstResponder + || !terminalPanel.hostedView.isSurfaceViewFirstResponder() + if needsFocusFollowUp { + beginEventDrivenLayoutFollowUp( + reason: "workspace.focusPanel.terminal", + terminalFocusPanelId: panelId + ) + } } } From f8e480abf93752f5c54d5ff30d2f6c6b1f367e02 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 9 Apr 2026 18:30:38 -0700 Subject: [PATCH 3/3] Preserve terminal find focus in follow-up --- Sources/Workspace.swift | 35 +++++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 1ac77b3bad67..c47befa8eb44 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -10068,12 +10068,11 @@ final class Workspace: Identifiable, ObservableObject { } if let terminalPanel = panels[panelId] as? TerminalPanel { - // Always set up a focus follow-up when the terminal is not yet first responder. - // The .terminalFirstResponder trigger always needs it (reentrant focus assertion). - // The .standard trigger needs it when the surface view isn't ready yet (e.g. - // new workspace via Cmd+T where the portal hasn't attached to a window yet). + // Always set up a focus follow-up when the terminal's preferred keyboard target + // is not yet active. That target can be either the surface view or the terminal + // find field, so surface-first-responder alone is too strict here. let needsFocusFollowUp = trigger == .terminalFirstResponder - || !terminalPanel.hostedView.isSurfaceViewFirstResponder() + || terminalPanelKeyboardFocusNeedsFollowUp(panelId: panelId, terminalPanel: terminalPanel) if needsFocusFollowUp { beginEventDrivenLayoutFollowUp( reason: "workspace.focusPanel.terminal", @@ -10596,7 +10595,19 @@ final class Workspace: Identifiable, ObservableObject { let terminalPanel = terminalPanel(for: panelId) else { return false } - return focusedPanelId != panelId || !terminalPanel.hostedView.isSurfaceViewFirstResponder() + return terminalPanelKeyboardFocusNeedsFollowUp(panelId: panelId, terminalPanel: terminalPanel) + } + + private func terminalPanelKeyboardFocusNeedsFollowUp( + panelId: UUID, + terminalPanel: TerminalPanel + ) -> Bool { + guard focusedPanelId == panelId else { return true } + guard let window = terminalPanel.hostedView.window, + let firstResponder = window.firstResponder else { + return true + } + return !terminalPanel.hostedView.responderMatchesPreferredKeyboardFocus(firstResponder) } private func browserPanelNeedsFollowUp() -> Bool { @@ -10628,8 +10639,16 @@ final class Workspace: Identifiable, ObservableObject { if let terminalFocusPanelId = layoutFollowUpTerminalFocusPanelId { if let terminalPanel = terminalPanel(for: terminalFocusPanelId), focusedPanelId == terminalFocusPanelId { - terminalPanel.hostedView.ensureFocus(for: id, surfaceId: terminalFocusPanelId) - if terminalPanel.hostedView.isSurfaceViewFirstResponder() { + if terminalPanelKeyboardFocusNeedsFollowUp( + panelId: terminalFocusPanelId, + terminalPanel: terminalPanel + ) { + terminalPanel.hostedView.ensureFocus(for: id, surfaceId: terminalFocusPanelId) + } + if !terminalPanelKeyboardFocusNeedsFollowUp( + panelId: terminalFocusPanelId, + terminalPanel: terminalPanel + ) { layoutFollowUpTerminalFocusPanelId = nil } } else if terminalPanel(for: terminalFocusPanelId) == nil {