diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/QuitConfirmationStore.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/QuitConfirmationStore.swift index 1f34c1827030..c6dcd5e984c2 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/QuitConfirmationStore.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/QuitConfirmationStore.swift @@ -48,10 +48,11 @@ public struct QuitConfirmationStore: Sendable { /// Whether the quit flow should show the confirmation dialog. /// - /// Semantics are kept verbatim from the legacy `QuitWarningSettings` - /// namespace: a prior in-session confirmation always skips the dialog, - /// dev builds never warn, and otherwise ``confirmQuitMode`` decides - /// (`dirtyOnly` consults `hasDirtyWorkspaces`). + /// A logout, restart, or shutdown never shows the dialog: it would block + /// the whole session change. Otherwise semantics are kept verbatim from + /// the legacy `QuitWarningSettings` namespace: a prior in-session + /// confirmation always skips the dialog, dev builds never warn, and + /// ``confirmQuitMode`` decides (`dirtyOnly` consults `hasDirtyWorkspaces`). /// /// - Parameters: /// - isQuitWarningConfirmed: Whether the user already confirmed the @@ -59,11 +60,14 @@ public struct QuitConfirmationStore: Sendable { /// - hasDirtyWorkspaces: Whether any workspace has unsaved/dirty state. /// - isDevBuild: Whether this is a dev-flavor build (the app resolves /// its build flavor; dev builds skip the warning entirely). + /// - quitReason: Why the app is quitting; see ``QuitRequestReason``. public func shouldShowConfirmation( isQuitWarningConfirmed: Bool, hasDirtyWorkspaces: Bool, - isDevBuild: Bool + isDevBuild: Bool, + quitReason: QuitRequestReason = .user ) -> Bool { + guard quitReason != .sessionEnd else { return false } guard !isQuitWarningConfirmed else { return false } guard !isDevBuild else { return false } diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/QuitRequestReason.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/QuitRequestReason.swift new file mode 100644 index 000000000000..73cc34f150d0 --- /dev/null +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/QuitRequestReason.swift @@ -0,0 +1,26 @@ +import CoreServices + +/// Why the app was asked to quit, read from the quit Apple Event. +/// +/// macOS sends logout, restart, and shutdown as a quit event whose +/// `kAEQuitReason` attribute names the session change. A confirmation dialog +/// shown then blocks the whole session change, so those quits skip it. +public enum QuitRequestReason: Sendable, Equatable { + /// Cmd+Q, the menu, the Dock, or any quit without a session-change reason. + case user + /// Logout, restart, or shutdown started by the system. + case sessionEnd + + /// Maps the quit event's `kAEQuitReason` code. A missing or unknown code + /// is an ordinary quit. + public init(appleEventQuitReason code: OSType?) { + switch code { + case kAELogOut, kAEReallyLogOut, + kAEShowRestartDialog, kAERestart, + kAEShowShutdownDialog, kAEShutDown: + self = .sessionEnd + default: + self = .user + } + } +} diff --git a/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/DomainSettingsStoreTests.swift b/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/DomainSettingsStoreTests.swift index 19573358321e..40ccd272fde8 100644 --- a/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/DomainSettingsStoreTests.swift +++ b/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/DomainSettingsStoreTests.swift @@ -1,3 +1,4 @@ +import CoreServices import Foundation import Testing @testable import CmuxSettings @@ -477,6 +478,38 @@ struct QuitConfirmationPolicyTests { )) } + /// A confirmation dialog during logout, restart, or shutdown blocks the + /// whole session change, so those quits skip it in every mode. + @Test(arguments: ["always", "dirty-only"]) + func sessionEndNeverWarns(mode: String) { + let defaults = makeScratchDefaults() + defaults.set(mode, forKey: "confirmQuit") + let store = QuitConfirmationStore(defaults: defaults) + + #expect(!store.shouldShowConfirmation( + isQuitWarningConfirmed: false, hasDirtyWorkspaces: true, isDevBuild: false, + quitReason: .sessionEnd + )) + #expect(store.shouldShowConfirmation( + isQuitWarningConfirmed: false, hasDirtyWorkspaces: true, isDevBuild: false, + quitReason: .user + )) + } + + @Test func quitEventReasonMapsSessionChanges() { + let sessionCodes: [OSType] = [ + kAELogOut, kAEReallyLogOut, + kAEShowRestartDialog, kAERestart, + kAEShowShutdownDialog, kAEShutDown, + ] + for code in sessionCodes { + #expect(QuitRequestReason(appleEventQuitReason: code) == .sessionEnd) + } + #expect(QuitRequestReason(appleEventQuitReason: nil) == .user) + #expect(QuitRequestReason(appleEventQuitReason: 0) == .user) + #expect(QuitRequestReason(appleEventQuitReason: kAEQuitApplication) == .user) + } + @Test func neverModeNeverWarns() { let defaults = makeScratchDefaults() defaults.set("never", forKey: "confirmQuit") diff --git a/Sources/App/ComputerUseOnboardingCoordinator.swift b/Sources/App/ComputerUseOnboardingCoordinator.swift index bdb84fd02d74..fee1d19431f9 100644 --- a/Sources/App/ComputerUseOnboardingCoordinator.swift +++ b/Sources/App/ComputerUseOnboardingCoordinator.swift @@ -8,7 +8,8 @@ import CmuxComputerUse @MainActor final class ComputerUseOnboardingCoordinator { typealias StartingPoint = ComputerUseOnboardingWindowController.StartingPoint - typealias Presenter = @MainActor (StartingPoint) -> Void + typealias Origin = ComputerUseOnboardingWindowController.PresentationOrigin + typealias Presenter = @MainActor (StartingPoint, Origin) -> Void private let runtimeService: ComputerUseRuntimeService private let presenter: Presenter @@ -24,17 +25,19 @@ final class ComputerUseOnboardingCoordinator { @discardableResult func requestFromSettings(startingAt startingPoint: StartingPoint) -> Bool { runtimeService.onboardingWasPresented() - presenter(startingPoint) + presenter(startingPoint, .userAction) return true } /// Claims and presents first-use setup atomically on the main actor. A ready /// helper or an already claimed flow stays quiet, including after dismissal. /// Call only for authenticated, locally owned functional CUA requests. + /// An agent's tool call is not a user action, so the window must not take + /// focus from the app the user is working in. @discardableResult func requestFromToolInvocation() -> Bool { guard runtimeService.requestAutomaticOnboarding() else { return false } - presenter(.overview) + presenter(.overview, .toolInvocation) return true } } diff --git a/Sources/App/ComputerUseOnboardingWindowController.swift b/Sources/App/ComputerUseOnboardingWindowController.swift index 76861952e48a..0354ca83845a 100644 --- a/Sources/App/ComputerUseOnboardingWindowController.swift +++ b/Sources/App/ComputerUseOnboardingWindowController.swift @@ -98,6 +98,36 @@ final class ComputerUseOnboardingWindowController: NSObject, NSWindowDelegate { } } + /// Who asked for the window. Only a deliberate user action may take focus + /// away from whatever app the user is working in. + enum PresentationOrigin: Sendable, Equatable { + case userAction + case toolInvocation + } + + enum ActivationPlan: Sendable, Equatable { + /// Activate cmux and make the window key. + case activateAndFocus + /// cmux is already active: show the window without taking key focus + /// from the terminal the user is typing in. + case orderFrontWithoutFocus + /// Another app is active: keep the window behind it, bounce the Dock + /// icon, and bring the window forward once the user switches to cmux. + case waitForAppActivation + } + + nonisolated static func activationPlan( + origin: PresentationOrigin, + isAppActive: Bool + ) -> ActivationPlan { + switch origin { + case .userAction: + return .activateAndFocus + case .toolInvocation: + return isAppActive ? .orderFrontWithoutFocus : .waitForAppActivation + } + } + static let seenDefaultsKey = "cmux.computerUse.onboarding.seen" static let directCaptureReadyDefaultsKey = ComputerUseOnboardingStore.legacyCompletionKey nonisolated static let completionDismissDelay: Duration = .seconds(2.4) @@ -122,6 +152,7 @@ final class ComputerUseOnboardingWindowController: NSObject, NSWindowDelegate { private var pendingPermissionStep: ComputerUseOnboardingStep? private var presentationState: ComputerUseOnboardingPresentationState? private var completionDismissTask: Task? + private var appActivationObserver: NSObjectProtocol? init( runtimeService: ComputerUseRuntimeService, @@ -156,7 +187,11 @@ final class ComputerUseOnboardingWindowController: NSObject, NSWindowDelegate { (window?.isVisible ?? false) || (permissionCompanionWindow?.isVisible ?? false) } - func present(startingAt startingPoint: StartingPoint = .overview) { + func present( + startingAt startingPoint: StartingPoint = .overview, + origin: PresentationOrigin = .userAction + ) { + stopWaitingForAppActivation() stopSystemSettingsObservation() completionDismissTask?.cancel() completionDismissTask = nil @@ -168,9 +203,40 @@ final class ComputerUseOnboardingWindowController: NSObject, NSWindowDelegate { window.level = .normal window.collectionBehavior = [.managed] window.hidesOnDeactivate = false - NSApp.activate(ignoringOtherApps: true) - window.makeKeyAndOrderFront(nil) - window.orderFrontRegardless() + show(window, plan: Self.activationPlan(origin: origin, isAppActive: NSApp.isActive)) + } + + private func show(_ window: NSWindow, plan: ActivationPlan) { + switch plan { + case .activateAndFocus: + NSApp.activate(ignoringOtherApps: true) + window.makeKeyAndOrderFront(nil) + window.orderFrontRegardless() + case .orderFrontWithoutFocus: + window.orderFront(nil) + case .waitForAppActivation: + window.orderBack(nil) + NSApp.requestUserAttention(.informationalRequest) + appActivationObserver = NotificationCenter.default.addObserver( + forName: NSApplication.didBecomeActiveNotification, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + guard let self else { return } + self.stopWaitingForAppActivation() + guard let window = self.window, window.isVisible else { return } + window.orderFront(nil) + } + } + } + } + + private func stopWaitingForAppActivation() { + if let appActivationObserver { + NotificationCenter.default.removeObserver(appActivationObserver) + } + appActivationObserver = nil } func makeWindow(startingAt startingPoint: StartingPoint = .overview) -> ComputerUseOnboardingWindow { @@ -224,6 +290,7 @@ final class ComputerUseOnboardingWindowController: NSObject, NSWindowDelegate { } func dismiss() { + stopWaitingForAppActivation() stopSystemSettingsObservation() completionDismissTask?.cancel() completionDismissTask = nil @@ -236,6 +303,7 @@ final class ComputerUseOnboardingWindowController: NSObject, NSWindowDelegate { guard let closingWindow = notification.object as? NSWindow, closingWindow === window else { return } + stopWaitingForAppActivation() stopSystemSettingsObservation() dismissPermissionCompanion() closingWindow.delegate = nil diff --git a/Sources/App/ComputerUseUXCoordinator.swift b/Sources/App/ComputerUseUXCoordinator.swift index 83890ac4f4b6..74839d73845d 100644 --- a/Sources/App/ComputerUseUXCoordinator.swift +++ b/Sources/App/ComputerUseUXCoordinator.swift @@ -308,14 +308,15 @@ final class ComputerUseUXCoordinator { } private func presentOnboardingWindow( - startingAt startingPoint: ComputerUseOnboardingWindowController.StartingPoint + startingAt startingPoint: ComputerUseOnboardingWindowController.StartingPoint, + origin: ComputerUseOnboardingWindowController.PresentationOrigin ) { userDefaults.set(true, forKey: ComputerUseOnboardingWindowController.seenDefaultsKey) let controller = onboardingWindowController ?? ComputerUseOnboardingWindowController( runtimeService: runtimeService ) onboardingWindowController = controller - controller.present(startingAt: startingPoint) + controller.present(startingAt: startingPoint, origin: origin) } private func ensureOnboardingCoordinator() -> ComputerUseOnboardingCoordinator { @@ -324,8 +325,8 @@ final class ComputerUseUXCoordinator { } let coordinator = ComputerUseOnboardingCoordinator( runtimeService: runtimeService, - presenter: { [weak self] startingPoint in - self?.presentOnboardingWindow(startingAt: startingPoint) + presenter: { [weak self] startingPoint, origin in + self?.presentOnboardingWindow(startingAt: startingPoint, origin: origin) } ) onboardingCoordinator = coordinator diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 9c4953327ff3..318689773ac8 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2313,6 +2313,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let quitConfirmationStore = QuitConfirmationStore(defaults: .standard) let hasDirtyWorkspaces = hasQuitConfirmationDirtyWorkspaces() let confirmQuitMode = quitConfirmationStore.confirmQuitMode + let quitReason = Self.currentQuitRequestReason() StartupBreadcrumbLog.append( "appDelegate.shouldTerminate.begin", @@ -2320,6 +2321,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent "buildFlavor": buildFlavor.rawValue, "confirmQuitMode": confirmQuitMode.rawValue, "hasDirtyWorkspaces": hasDirtyWorkspaces ? "1" : "0", + "quitReason": quitReason == .sessionEnd ? "sessionEnd" : "user", "quitWarningConfirmed": isQuitWarningConfirmed ? "1" : "0", "quitWarningEnabled": quitConfirmationStore.isEnabled ? "1" : "0" ] @@ -2330,12 +2332,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if !quitConfirmationStore.shouldShowConfirmation( isQuitWarningConfirmed: isQuitWarningConfirmed, hasDirtyWorkspaces: hasDirtyWorkspaces, - isDevBuild: buildFlavor == .dev + isDevBuild: buildFlavor == .dev, + quitReason: quitReason ) { prepareForConfirmedAppTermination() closeAllWebInspectorsBeforeAppTeardown() let reason: String - if isQuitWarningConfirmed { + if quitReason == .sessionEnd { + reason = "sessionEnd" + } else if isQuitWarningConfirmed { reason = "confirmed" } else if buildFlavor == .dev { reason = "devBuild" @@ -2364,6 +2369,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return .terminateLater } + /// Reads `kAEQuitReason` from the quit Apple Event AppKit is handling. + /// Logout, restart, and shutdown set it; Cmd+Q and the menu do not. + private static func currentQuitRequestReason() -> QuitRequestReason { + guard let descriptor = NSAppleEventManager.shared().currentAppleEvent? + .attributeDescriptor(forKeyword: AEKeyword(kAEQuitReason)) else { + return .user + } + // loginwindow sends the reason as typeType; accept typeEnumerated too. + let code = descriptor.typeCodeValue != 0 ? descriptor.typeCodeValue : descriptor.enumCodeValue + return QuitRequestReason(appleEventQuitReason: code) + } + @discardableResult private func closeAllWebInspectorsBeforeAppTeardown() -> Int { WebViewInspectorTeardown.closeAllInspectors(in: NSApp.windows) diff --git a/Sources/TerminalNotificationDeliveryDecision.swift b/Sources/TerminalNotificationDeliveryDecision.swift index 69384a20fcd3..432027021d43 100644 --- a/Sources/TerminalNotificationDeliveryDecision.swift +++ b/Sources/TerminalNotificationDeliveryDecision.swift @@ -20,7 +20,7 @@ struct TerminalNotificationDeliveryDecision: Equatable, Sendable { return Self(disposition: .externalDelivery, effects: effects) } - var focusedEffects = effects + var focusedEffects = effects.keepingFocusedWorkspaceInPlace(isFocusedPane: true) // The active surface is already visible. Preserve history/unread and // the custom automation hook while suppressing external feedback. focusedEffects.desktop = false @@ -29,3 +29,15 @@ struct TerminalNotificationDeliveryDecision: Equatable, Sendable { return Self(disposition: .focusedInline, effects: focusedEffects) } } + +extension TerminalNotificationPolicyEffects { + /// A notification for the pane the user is looking at must not move the + /// workspace they are typing in. Both the terminal notification store and + /// Feed's delivery lane apply this before sidebar ordering. + func keepingFocusedWorkspaceInPlace(isFocusedPane: Bool) -> Self { + guard isFocusedPane else { return self } + var effects = self + effects.reorderWorkspace = false + return effects + } +} diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index b0a591600076..8e55349476f9 100644 --- a/Sources/TerminalNotificationStore.swift +++ b/Sources/TerminalNotificationStore.swift @@ -1514,6 +1514,9 @@ final class TerminalNotificationStore: ObservableObject { tabId: request.tabId, surfaceId: request.surfaceId ) + let effects = effects.keepingFocusedWorkspaceInPlace( + isFocusedPane: shouldSuppressExternalDelivery + ) let notification = TerminalNotification( id: notificationID, tabId: request.tabId, diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 6a9effc1b3d9..e36af47ca9eb 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -3494,6 +3494,7 @@ 468110000000000000000007 /* TerminalNotificationClickAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 468110000000000000000008 /* TerminalNotificationClickAction.swift */; }; A5C0B1E00000000000000003 /* TerminalNotificationClipboardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5C0B1E00000000000000004 /* TerminalNotificationClipboardTests.swift */; }; B7F00008 /* TerminalNotificationDeliveryDecision.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7F00007 /* TerminalNotificationDeliveryDecision.swift */; }; + 48281F9D00BDE0C6E70192B9 /* TerminalNotificationDeliveryDecisionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 434B58A3AC2CF995D960D057 /* TerminalNotificationDeliveryDecisionTests.swift */; }; 78F5315DE5C21218B5F9109F /* TerminalNotificationDirectInteractionTests+Visibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = F31EBB65E7FAFD31F21EAF0C /* TerminalNotificationDirectInteractionTests+Visibility.swift */; }; A5A5A505A1B2C3D4E5F60718 /* TerminalNotificationDirectInteractionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A5A506A1B2C3D4E5F60718 /* TerminalNotificationDirectInteractionTests.swift */; }; 7939000DAA11BB22CC33DD0D /* TerminalNotificationLiveRetargetDelivery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7939000EAA11BB22CC33DD0E /* TerminalNotificationLiveRetargetDelivery.swift */; }; @@ -7463,6 +7464,7 @@ 468110000000000000000008 /* TerminalNotificationClickAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotificationClickAction.swift; sourceTree = ""; }; A5C0B1E00000000000000004 /* TerminalNotificationClipboardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotificationClipboardTests.swift; sourceTree = ""; }; B7F00007 /* TerminalNotificationDeliveryDecision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotificationDeliveryDecision.swift; sourceTree = ""; }; + 434B58A3AC2CF995D960D057 /* TerminalNotificationDeliveryDecisionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalNotificationDeliveryDecisionTests.swift"; sourceTree = ""; }; F31EBB65E7FAFD31F21EAF0C /* TerminalNotificationDirectInteractionTests+Visibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalNotificationDirectInteractionTests+Visibility.swift"; sourceTree = ""; }; A5A5A506A1B2C3D4E5F60718 /* TerminalNotificationDirectInteractionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotificationDirectInteractionTests.swift; sourceTree = ""; }; 7939000EAA11BB22CC33DD0E /* TerminalNotificationLiveRetargetDelivery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotificationLiveRetargetDelivery.swift; sourceTree = ""; }; @@ -11045,6 +11047,7 @@ F1000003A1B2C3D4E5F60718 /* cmuxTests */ = { isa = PBXGroup; children = ( + 434B58A3AC2CF995D960D057 /* TerminalNotificationDeliveryDecisionTests.swift */, DEC1CE00000000000000003F /* DevicePresenceWireTests.swift */, DEC1CE000000000000000043 /* DeviceRouteSelectorTests.swift */, DEC1CE00000000000000004D /* DeviceTerminalMirrorTests.swift */, @@ -16643,6 +16646,7 @@ A5C41103A1B2C3D4E5F60718 /* TerminalNotificationCallerTests.swift in Sources */, A5A5A507A1B2C3D4E5F60718 /* TerminalNotificationClearAllTests.swift in Sources */, A5C0B1E00000000000000003 /* TerminalNotificationClipboardTests.swift in Sources */, + 48281F9D00BDE0C6E70192B9 /* TerminalNotificationDeliveryDecisionTests.swift in Sources */, 78F5315DE5C21218B5F9109F /* TerminalNotificationDirectInteractionTests+Visibility.swift in Sources */, A5A5A505A1B2C3D4E5F60718 /* TerminalNotificationDirectInteractionTests.swift in Sources */, A5E380710000000000000001 /* TerminalNotificationOpenPanelFallbackTests.swift in Sources */, diff --git a/cmuxTests/ComputerUseOnboardingIntentTests.swift b/cmuxTests/ComputerUseOnboardingIntentTests.swift index 56f927ee33e7..7d5dafc836dd 100644 --- a/cmuxTests/ComputerUseOnboardingIntentTests.swift +++ b/cmuxTests/ComputerUseOnboardingIntentTests.swift @@ -140,7 +140,7 @@ struct ComputerUseOnboardingIntentTests { var presentations: [ComputerUseOnboardingWindowController.StartingPoint] = [] let coordinator = ComputerUseOnboardingCoordinator( runtimeService: fixture.runtime, - presenter: { presentations.append($0) } + presenter: { point, _ in presentations.append(point) } ) #expect(coordinator.requestFromToolInvocation()) @@ -162,6 +162,29 @@ struct ComputerUseOnboardingIntentTests { #expect(!coordinator.requestFromToolInvocation()) } + /// An agent's tool call must not pull cmux over the app the user is typing + /// in; only a Settings action may activate cmux and take key focus. + @Test func toolInvocationPresentsWithoutTakingFocus() async throws { + let fixture = try ComputerUseToolOnboardingFixture() + defer { fixture.remove() } + try await fixture.enable() + var origins: [ComputerUseOnboardingWindowController.PresentationOrigin] = [] + let coordinator = ComputerUseOnboardingCoordinator( + runtimeService: fixture.runtime, + presenter: { _, origin in origins.append(origin) } + ) + + #expect(coordinator.requestFromToolInvocation()) + #expect(coordinator.requestFromSettings(startingAt: .overview)) + #expect(origins == [.toolInvocation, .userAction]) + + typealias Controller = ComputerUseOnboardingWindowController + #expect(Controller.activationPlan(origin: .toolInvocation, isAppActive: false) == .waitForAppActivation) + #expect(Controller.activationPlan(origin: .toolInvocation, isAppActive: true) == .orderFrontWithoutFocus) + #expect(Controller.activationPlan(origin: .userAction, isAppActive: false) == .activateAndFocus) + #expect(Controller.activationPlan(origin: .userAction, isAppActive: true) == .activateAndFocus) + } + private func settingsActions(_ fixture: ComputerUseToolOnboardingFixture) -> HostSettingsActions { HostSettingsActions( configFileURL: fixture.persistence.root.appendingPathComponent("cmux.json"), diff --git a/cmuxTests/ComputerUseToolOnboardingFixture.swift b/cmuxTests/ComputerUseToolOnboardingFixture.swift index 2d239e5b8da6..cec0424cda9f 100644 --- a/cmuxTests/ComputerUseToolOnboardingFixture.swift +++ b/cmuxTests/ComputerUseToolOnboardingFixture.swift @@ -42,7 +42,7 @@ final class ComputerUseToolOnboardingFixture { featureEnabled: { [weak self] in self?.featureEnabled == true }, onboardingCoordinator: usesProductionPresenter ? nil : ComputerUseOnboardingCoordinator( runtimeService: runtime, - presenter: { [weak self] in self?.presentations.append($0) } + presenter: { [weak self] point, _ in self?.presentations.append(point) } ), ownsSurface: { [weak self] surfaceID, workspaceID in surfaceID == self?.surfaceID && workspaceID == self?.workspaceID diff --git a/cmuxTests/ComputerUseUXTests.swift b/cmuxTests/ComputerUseUXTests.swift index 2b1eeded52e0..dbf4ea83b81e 100644 --- a/cmuxTests/ComputerUseUXTests.swift +++ b/cmuxTests/ComputerUseUXTests.swift @@ -331,7 +331,7 @@ struct ComputerUseUXTests { let runtimeService = ComputerUseRuntimeService(paths: paths) let presentationCoordinator = ComputerUseOnboardingCoordinator( runtimeService: runtimeService, - presenter: { startingPoint in + presenter: { startingPoint, _ in presentations.append(startingPoint) } ) diff --git a/cmuxTests/TerminalNotificationDeliveryDecisionTests.swift b/cmuxTests/TerminalNotificationDeliveryDecisionTests.swift new file mode 100644 index 000000000000..3cb83412f3a5 --- /dev/null +++ b/cmuxTests/TerminalNotificationDeliveryDecisionTests.swift @@ -0,0 +1,56 @@ +import Testing +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@Suite("Terminal notification delivery decision") +struct TerminalNotificationDeliveryDecisionTests { + /// The workspace the user is typing in must not jump in the sidebar when + /// a notification arrives for the pane they are already looking at. + @Test func focusedPaneKeepsWorkspaceInPlace() { + let decision = TerminalNotificationDeliveryDecision.resolve( + isAppFocused: true, + isActiveTab: true, + isFocusedSurface: true, + isMuted: false, + effects: TerminalNotificationPolicyEffects() + ) + #expect(decision.disposition == .focusedInline) + #expect(!decision.effects.reorderWorkspace) + #expect(!decision.effects.desktop) + #expect(!decision.effects.sound) + #expect(!decision.effects.paneFlash) + #expect(decision.effects.record) + #expect(decision.effects.markUnread) + #expect(decision.effects.command) + } + + @Test(arguments: [ + (false, true, true), + (true, false, true), + (true, true, false), + ]) + func unfocusedPaneStillReorders(appFocused: Bool, activeTab: Bool, focusedSurface: Bool) { + let decision = TerminalNotificationDeliveryDecision.resolve( + isAppFocused: appFocused, + isActiveTab: activeTab, + isFocusedSurface: focusedSurface, + isMuted: false, + effects: TerminalNotificationPolicyEffects() + ) + #expect(decision.disposition == .externalDelivery) + #expect(decision.effects.reorderWorkspace) + } + + /// The terminal notification store applies the same rule once it knows the + /// target pane is focused; other effects pass through unchanged. + @Test func storeOrderingEffectsDropReorderOnlyForFocusedPane() { + let effects = TerminalNotificationPolicyEffects() + #expect(effects.keepingFocusedWorkspaceInPlace(isFocusedPane: false) == effects) + var expected = effects + expected.reorderWorkspace = false + #expect(effects.keepingFocusedWorkspaceInPlace(isFocusedPane: true) == expected) + } +}