From 1e9098194721d0a8682f7b63dca66e0603889fe4 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 13 Mar 2026 15:44:11 -0700 Subject: [PATCH 1/3] Add unread jump split zoom regression test --- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index c01ae047..da61d8b9 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -5490,6 +5490,33 @@ final class TabManagerCloseCurrentPanelTests: XCTestCase { } } +@MainActor +final class TabManagerNotificationFocusTests: XCTestCase { + func testFocusTabFromNotificationClearsSplitZoomBeforeFocusingTargetPanel() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let leftPanelId = workspace.focusedPanelId, + let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { + XCTFail("Expected split setup to succeed") + return + } + + workspace.focusPanel(leftPanelId) + XCTAssertTrue(workspace.toggleSplitZoom(panelId: leftPanelId), "Expected split zoom to enable") + XCTAssertTrue(workspace.bonsplitController.isSplitZoomed, "Expected workspace to start zoomed") + + manager.focusTabFromNotification(workspace.id, surfaceId: rightPanel.id) + drainMainQueue() + drainMainQueue() + + XCTAssertFalse( + workspace.bonsplitController.isSplitZoomed, + "Expected notification focus to exit split zoom so the target pane becomes visible" + ) + XCTAssertEqual(workspace.focusedPanelId, rightPanel.id, "Expected notification target panel to be focused") + } +} + @MainActor final class TabManagerPendingUnfocusPolicyTests: XCTestCase { func testDoesNotUnfocusWhenPendingTabIsCurrentlySelected() { From 02854441e90350d76cb2db9765a984e18f75e7b6 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 13 Mar 2026 16:03:07 -0700 Subject: [PATCH 2/3] Clear split zoom when jumping to unread --- Sources/TabManager.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index fc3b596c..2556f229 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -2205,12 +2205,16 @@ class TabManager: ObservableObject { func focusTabFromNotification(_ tabId: UUID, surfaceId: UUID? = nil) { let wasSelected = selectedTabId == tabId - let desiredPanelId = surfaceId ?? tabs.first(where: { $0.id == tabId })?.focusedPanelId + guard let tab = tabs.first(where: { $0.id == tabId }) else { return } + let desiredPanelId = surfaceId ?? tab.focusedPanelId #if DEBUG if let desiredPanelId { AppDelegate.shared?.armJumpUnreadFocusRecord(tabId: tabId, surfaceId: desiredPanelId) } #endif + // Jump-to-unread should reveal the destination pane instead of keeping an old split-zoom + // state active around it. + tab.clearSplitZoom() suppressFocusFlash = true focusTab(tabId, surfaceId: desiredPanelId, suppressFlash: true) if wasSelected { From 09a98c911d76fdd3cf060f0659e0ae5b7731d22d Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 13 Mar 2026 17:21:40 -0700 Subject: [PATCH 3/3] Guard notification focus failures --- Sources/AppDelegate.swift | 27 +++++++++++++++++-- Sources/TabManager.swift | 20 ++++++++++++-- Sources/TerminalController.swift | 4 ++- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 12 ++++++++- 4 files changed, 57 insertions(+), 6 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 7d72cb08..5db4367f 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -9968,7 +9968,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent context.sidebarSelectionState.selection = .tabs bringToFront(window) - context.tabManager.focusTabFromNotification(tabId, surfaceId: surfaceId) + guard context.tabManager.focusTabFromNotification(tabId, surfaceId: surfaceId) else { +#if DEBUG + recordMultiWindowNotificationOpenFailureIfNeeded( + tabId: tabId, + surfaceId: surfaceId, + notificationId: notificationId, + reason: "focus_failed" + ) + if ProcessInfo.processInfo.environment["CMUX_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { + writeJumpUnreadTestData(["jumpUnreadOpenResult": "0"]) + } +#endif + return false + } #if DEBUG // UI test support: Jump-to-unread asserts that the correct workspace/panel is focused. @@ -10033,7 +10046,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent sidebarSelectionState?.selection = .tabs bringToFront(window) - tabManager.focusTabFromNotification(tabId, surfaceId: surfaceId) + guard tabManager.focusTabFromNotification(tabId, surfaceId: surfaceId) else { +#if DEBUG + if ProcessInfo.processInfo.environment["CMUX_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { + writeJumpUnreadTestData([ + "jumpUnreadFallbackFail": "focus_failed", + "jumpUnreadOpenResult": "0", + ]) + } +#endif + return false + } #if DEBUG recordJumpUnreadFocusFromModelIfNeeded( diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 2556f229..ceb99add 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -2203,9 +2203,24 @@ class TabManager: ObservableObject { } } - func focusTabFromNotification(_ tabId: UUID, surfaceId: UUID? = nil) { + @discardableResult + func focusTabFromNotification(_ tabId: UUID, surfaceId: UUID? = nil) -> Bool { let wasSelected = selectedTabId == tabId - guard let tab = tabs.first(where: { $0.id == tabId }) else { return } + guard let tab = tabs.first(where: { $0.id == tabId }) else { +#if DEBUG + dlog("notification.focus.fail tab=\(tabId.uuidString.prefix(5)) reason=missingTab") +#endif + return false + } + if let surfaceId, tab.panels[surfaceId] == nil { +#if DEBUG + dlog( + "notification.focus.fail tab=\(tabId.uuidString.prefix(5)) " + + "panel=\(surfaceId.uuidString.prefix(5)) reason=missingPanel" + ) +#endif + return false + } let desiredPanelId = surfaceId ?? tab.focusedPanelId #if DEBUG if let desiredPanelId { @@ -2232,6 +2247,7 @@ class TabManager: ObservableObject { tab.triggerNotificationFocusFlash(panelId: targetPanelId, requiresSplit: false, shouldFocus: true) notificationStore.markRead(forTabId: tabId, surfaceId: targetPanelId) } + return true } func focusSurface(tabId: UUID, surfaceId: UUID) { diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index bf4862c6..70fe4a0f 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -11099,7 +11099,9 @@ class TerminalController { result = "ERROR: Surface not found" return } - tabManager.focusTabFromNotification(tab.id, surfaceId: surfaceId) + if !tabManager.focusTabFromNotification(tab.id, surfaceId: surfaceId) { + result = "ERROR: Focus failed" + } } return result } diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index da61d8b9..3713877a 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -5505,7 +5505,7 @@ final class TabManagerNotificationFocusTests: XCTestCase { XCTAssertTrue(workspace.toggleSplitZoom(panelId: leftPanelId), "Expected split zoom to enable") XCTAssertTrue(workspace.bonsplitController.isSplitZoomed, "Expected workspace to start zoomed") - manager.focusTabFromNotification(workspace.id, surfaceId: rightPanel.id) + XCTAssertTrue(manager.focusTabFromNotification(workspace.id, surfaceId: rightPanel.id)) drainMainQueue() drainMainQueue() @@ -5515,6 +5515,16 @@ final class TabManagerNotificationFocusTests: XCTestCase { ) XCTAssertEqual(workspace.focusedPanelId, rightPanel.id, "Expected notification target panel to be focused") } + + func testFocusTabFromNotificationReturnsFalseForMissingPanel() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace else { + XCTFail("Expected selected workspace") + return + } + + XCTAssertFalse(manager.focusTabFromNotification(workspace.id, surfaceId: UUID())) + } } @MainActor