Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -1126,6 +1126,13 @@ enum CmuxEmbeddedConfigSchema {
"descriptionKey": "schemaDescriptions.notifications.suppressOnlyFocusedSurface",
"description": "When enabled, a notification banner is auto-withdrawn only when its surface is the exact focused surface. A banner delivered for a non-focused surface in the currently visible workspace stays up until you focus that surface (or click/dismiss it), instead of being retracted when the workspace becomes visible. Off preserves the legacy workspace-visibility withdraw."
},
"suppressWhenAppFocused": {
"x-cmux-scopes": ["global"],
"type": "boolean",
"default": false,
"descriptionKey": "schemaDescriptions.notifications.suppressWhenAppFocused",
"description": "When enabled, cmux skips the desktop banner for every notification while cmux is the active app, not only for the focused pane. Notifications still appear in the sidebar, the sound and custom command still run, and phone forwarding is unchanged. Off keeps showing banners for other workspaces and panes while cmux is focused."
},
"agentPermissionPrompt": {
"x-cmux-scopes": ["global"],
"type": "boolean",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,18 @@ public struct NotificationsCatalogSection: SettingCatalogSection {
userDefaultsKey: "notificationsSuppressOnlyFocusedSurface"
)

/// When enabled, cmux skips the desktop banner for every notification while
/// cmux is the active app, not only for the focused surface. The
/// notification is still recorded, the sound and custom command still run,
/// and phone forwarding keeps the focused-surface gate. Off keeps
/// delivering banners for other workspaces and panes while cmux is
/// focused. See issue #3126.
public let suppressWhenAppFocused = DefaultsKey<Bool>(
id: "notifications.suppressWhenAppFocused",
defaultValue: false,
userDefaultsKey: "notificationsSuppressWhenAppFocused"
)

/// Notify when an agent (e.g. Claude Code) is blocked waiting for the user's
/// permission to run a tool. On by default: this is the one alert the user
/// must act on to unblock the agent.
Expand Down
1 change: 1 addition & 0 deletions Sources/CmuxSettingsFileStore+SupportedPaths.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ extension CmuxSettingsFileStore {
"notifications.hooks",
"notifications.hooksMode",
"notifications.suppressOnlyFocusedSurface",
"notifications.suppressWhenAppFocused",
"notifications.agentPermissionPrompt",
"notifications.agentTurnComplete",
"notifications.agentIdleReminder",
Expand Down
4 changes: 4 additions & 0 deletions Sources/CmuxSettingsJSONPathSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,10 @@ enum NotificationSettingsFileMapping {
jsonKey: "suppressOnlyFocusedSurface",
defaultsKey: notifications.suppressOnlyFocusedSurface.userDefaultsKey
),
.init(
jsonKey: "suppressWhenAppFocused",
defaultsKey: notifications.suppressWhenAppFocused.userDefaultsKey
),
.init(
jsonKey: "agentPermissionPrompt",
defaultsKey: notifications.agentPermissionPrompt.userDefaultsKey
Expand Down
7 changes: 5 additions & 2 deletions Sources/Feed/FeedCoordinator+NotificationDelivery.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ extension FeedCoordinator {
}
let ownerID = target.ownerID
let surfaceID = target.surfaceID
let suppressWhenAppFocused = TerminalNotificationStore.isSuppressWhenAppFocusedEnabled()

if let dock = appDelegate.existingWindowDock(forWindowId: ownerID) {
let context = appDelegate.mainWindowContexts.values.first {
Expand All @@ -60,7 +61,8 @@ extension FeedCoordinator {
isFocusedSurface: isFocusedSurface,
isMuted: false,
soundWhenFocused: soundWhenFocused,
effects: effects
effects: effects,
suppressWhenAppFocused: suppressWhenAppFocused
)
}

Expand All @@ -80,7 +82,8 @@ extension FeedCoordinator {
isFocusedSurface: isFocusedSurface,
isMuted: isMuted,
soundWhenFocused: soundWhenFocused,
effects: effects
effects: effects,
suppressWhenAppFocused: suppressWhenAppFocused
)
}

Expand Down
4 changes: 0 additions & 4 deletions Sources/TerminalNotificationArrivalDisposition.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,4 @@ enum TerminalNotificationArrivalDisposition: Equatable, Sendable {
case externalDelivery
case focusedInline
case muted

var suppressesPhoneForward: Bool {
self != .externalDelivery
}
}
11 changes: 10 additions & 1 deletion Sources/TerminalNotificationDeliveryDecision.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ struct TerminalNotificationDeliveryDecision: Equatable, Sendable {
isFocusedSurface: Bool,
isMuted: Bool,
soundWhenFocused: Bool,
effects: TerminalNotificationPolicyEffects
effects: TerminalNotificationPolicyEffects,
suppressWhenAppFocused: Bool = false
) -> Self {
if isMuted {
// A workspace mute drops every effect before history, badges,
Expand All @@ -18,6 +19,14 @@ struct TerminalNotificationDeliveryDecision: Equatable, Sendable {
}

guard isAppFocused, isActiveTab, isFocusedSurface else {
if isAppFocused, suppressWhenAppFocused {
// `notifications.suppressWhenAppFocused`: cmux is the active
// app, so skip the banner but keep the sound, pane flash,
// and custom command for this arrival.
var appFocusedEffects = effects
appFocusedEffects.desktop = false
return Self(disposition: .focusedInline, effects: appFocusedEffects)
}
return Self(disposition: .externalDelivery, effects: effects)
}

Expand Down
27 changes: 27 additions & 0 deletions Sources/TerminalNotificationStore+FocusState.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,33 @@
import CmuxSettings
import Foundation

extension TerminalNotificationStore.NotificationFocusState {
/// The notification targets the exact surface the user is looking at.
var isFocusedSurfaceArrival: Bool {
isAppFocused && isActiveTab && isFocusedSurface
}
}

extension TerminalNotificationStore {
private static let notificationsSettings = NotificationsCatalogSection()

/// Opt-in `notifications.suppressWhenAppFocused` (issue #3126): when on,
/// any notification that arrives while cmux is the active app skips the
/// desktop banner, not only one for the focused surface.
static func isSuppressWhenAppFocusedEnabled(defaults: UserDefaults = .standard) -> Bool {
notificationsSettings.suppressWhenAppFocused.value(in: defaults)
}

static func shouldSuppressExternalDelivery(
_ focusState: NotificationFocusState,
suppressWhenAppFocused: Bool
) -> Bool {
if suppressWhenAppFocused {
return focusState.isAppFocused
}
return focusState.isFocusedSurfaceArrival
}

func notificationFocusState(
tabId: UUID,
surfaceId: UUID?
Expand Down
43 changes: 27 additions & 16 deletions Sources/TerminalNotificationStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1510,12 +1510,17 @@ final class TerminalNotificationStore: ObservableObject {
restoreCooldownReservation(cooldownReservation)
return false
}
let shouldSuppressExternalDelivery = shouldSuppressExternalDelivery(
tabId: request.tabId,
surfaceId: request.surfaceId
let focusState = notificationFocusState(tabId: request.tabId, surfaceId: request.surfaceId)
let shouldSuppressExternalDelivery = Self.shouldSuppressExternalDelivery(
focusState,
suppressWhenAppFocused: Self.isSuppressWhenAppFocusedEnabled()
)
let isFocusedSurfaceArrival = focusState.isFocusedSurfaceArrival
// Only the exact focused pane holds the workspace in place;
// `suppressWhenAppFocused` withholds the banner without changing
// sidebar ordering, matching Feed's delivery decision.
let effects = effects.keepingFocusedWorkspaceInPlace(
isFocusedPane: shouldSuppressExternalDelivery
isFocusedPane: isFocusedSurfaceArrival
)
let notification = TerminalNotification(
id: notificationID,
Expand All @@ -1539,6 +1544,7 @@ final class TerminalNotificationStore: ObservableObject {
if effects.record {
recordNotification(
notification,
isFocusedSurfaceArrival: isFocusedSurfaceArrival,
shouldSuppressExternalDelivery: shouldSuppressExternalDelivery,
effects: effects,
now: now,
Expand All @@ -1562,13 +1568,15 @@ final class TerminalNotificationStore: ObservableObject {
}
deliverNotificationSideEffects(
notification,
isFocusedSurfaceArrival: isFocusedSurfaceArrival,
shouldSuppressExternalDelivery: shouldSuppressExternalDelivery,
effects: effects
)
return false
}
private func recordNotification(
_ notification: TerminalNotification,
isFocusedSurfaceArrival: Bool,
shouldSuppressExternalDelivery: Bool,
effects: TerminalNotificationPolicyEffects,
now: Date,
Expand All @@ -1594,7 +1602,7 @@ final class TerminalNotificationStore: ObservableObject {
focusedReadIndicatorByTabId.removeValue(forKey: notification.tabId)
}

if shouldSuppressExternalDelivery, effects.markUnread {
if isFocusedSurfaceArrival, effects.markUnread {
setFocusedReadIndicator(forTabId: notification.tabId, surfaceId: notification.surfaceId)
}

Expand Down Expand Up @@ -1641,20 +1649,15 @@ final class TerminalNotificationStore: ObservableObject {
}
deliverNotificationSideEffects(
notification,
isFocusedSurfaceArrival: isFocusedSurfaceArrival,
shouldSuppressExternalDelivery: shouldSuppressExternalDelivery,
effects: effects
)
}

private func shouldSuppressExternalDelivery(tabId: UUID, surfaceId: UUID?) -> Bool {
let focusState = notificationFocusState(tabId: tabId, surfaceId: surfaceId)
return focusState.isAppFocused
&& focusState.isActiveTab
&& focusState.isFocusedSurface
}

private func deliverNotificationSideEffects(
_ notification: TerminalNotification,
isFocusedSurfaceArrival: Bool,
shouldSuppressExternalDelivery: Bool,
effects: TerminalNotificationPolicyEffects
) {
Expand All @@ -1665,12 +1668,17 @@ final class TerminalNotificationStore: ObservableObject {
#endif
if effects.desktop || effects.sound || effects.command {
if shouldSuppressExternalDelivery {
// Only the pane the user is looking at goes quiet;
// `suppressWhenAppFocused` withholds just the banner for
// other panes, matching Feed's delivery decision.
suppressedNotificationFeedbackHandler(
self,
notification,
effects.keepingFocusedPaneQuiet(
soundWhenFocused: NotificationSoundSettings.soundWhenFocused()
)
isFocusedSurfaceArrival
? effects.keepingFocusedPaneQuiet(
soundWhenFocused: NotificationSoundSettings.soundWhenFocused()
)
: effects
)
} else {
notificationDeliveryHandler(self, notification, effects)
Expand All @@ -1681,7 +1689,10 @@ final class TerminalNotificationStore: ObservableObject {
tabId: notification.tabId,
surfaceId: notification.surfaceId
)
let shouldAttemptPhone = !shouldSuppressExternalDelivery
// `suppressWhenAppFocused` only withholds the desktop banner: the Mac
// may be frontmost with nobody at it, so phone forwarding keeps the
// exact focused-surface gate.
let shouldAttemptPhone = !isFocusedSurfaceArrival
&& Self.shouldAttemptPhoneForward(
effects: effects,
phoneForwardingEnabled: PhonePushClient.shared
Expand Down
4 changes: 4 additions & 0 deletions cmux.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -2421,6 +2421,7 @@
D7769000000000000000000F /* NotificationsPopoverMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77690000000000000000010 /* NotificationsPopoverMetrics.swift */; };
D77690000000000000000007 /* NotificationsPopoverResizeHandle.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77690000000000000000008 /* NotificationsPopoverResizeHandle.swift */; };
D77690000000000000000009 /* NotificationsPopoverResizeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7769000000000000000000A /* NotificationsPopoverResizeView.swift */; };
2C9949001245BCD676E5ACA6 /* NotificationSuppressWhenAppFocusedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D45948E0E0E282796BE8936 /* NotificationSuppressWhenAppFocusedTests.swift */; };
CF5D844BBCB1482280ABA319 /* NSImageView+SidebarWorkspaceAccessory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21111C055F1D46288E928B8C /* NSImageView+SidebarWorkspaceAccessory.swift */; };
F12748000000000000000005 /* NSTextView+FilePreviewLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = F12748000000000000000006 /* NSTextView+FilePreviewLayout.swift */; };
C01E00080000000000000001 /* NSTextView+FilePreviewTabWidth.swift in Sources */ = {isa = PBXBuildFile; fileRef = C01E00080000000000000002 /* NSTextView+FilePreviewTabWidth.swift */; };
Expand Down Expand Up @@ -6466,6 +6467,7 @@
D77690000000000000000010 /* NotificationsPopoverMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/NotificationsPopoverMetrics.swift; sourceTree = "<group>"; };
D77690000000000000000008 /* NotificationsPopoverResizeHandle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/NotificationsPopoverResizeHandle.swift; sourceTree = "<group>"; };
D7769000000000000000000A /* NotificationsPopoverResizeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/NotificationsPopoverResizeView.swift; sourceTree = "<group>"; };
5D45948E0E0E282796BE8936 /* NotificationSuppressWhenAppFocusedTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationSuppressWhenAppFocusedTests.swift"; sourceTree = "<group>"; };
21111C055F1D46288E928B8C /* NSImageView+SidebarWorkspaceAccessory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSImageView+SidebarWorkspaceAccessory.swift"; sourceTree = "<group>"; };
F12748000000000000000006 /* NSTextView+FilePreviewLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Panels/NSTextView+FilePreviewLayout.swift"; sourceTree = "<group>"; };
C01E00080000000000000002 /* NSTextView+FilePreviewTabWidth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Panels/NSTextView+FilePreviewTabWidth.swift"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -12340,6 +12342,7 @@
23E35F95320CBED308AD98CF /* CloudTreeTogglePerformanceTests.swift */,
0C37EA14DE74095FB1BB23E1 /* CloudTreeDisclosureIntentTests.swift */,
6F27EA9972F21B98FB4A7312 /* SurfaceCatalogObservationTests.swift */,
5D45948E0E0E282796BE8936 /* NotificationSuppressWhenAppFocusedTests.swift */,
4772A4FA42EB29D54779B2F4 /* GhosttyConfigDiagnosticsNoticePlacementTests.swift */,
);
path = cmuxTests;
Expand Down Expand Up @@ -16584,6 +16587,7 @@
C79010000000000000000003 /* NotificationScrollRestoreRowSpaceTests.swift in Sources */,
C79010000000000000000005 /* NotificationScrollRestoreTestSupport.swift in Sources */,
4E5F60720000000000000001 /* NotificationSoundSettingsTests.swift in Sources */,
2C9949001245BCD676E5ACA6 /* NotificationSuppressWhenAppFocusedTests.swift in Sources */,
645645000000000000000002 /* NumberedShortcutSwapTests.swift in Sources */,
0A1107110000000000000001 /* OllamaAgentDetectionTests.swift in Sources */,
4378399A7C0245EF8186F306 /* OmnibarAndToolsTests.swift in Sources */,
Expand Down
22 changes: 21 additions & 1 deletion cmuxTests/AgentNotificationMoveRaceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,19 @@ struct AgentNotificationRegressionTests {
}
}

/// Waits until a matching notification is recorded. Assertions after
/// this call still decide the outcome; the deadline only bounds the
/// failure path.
func waitForNotifications(
in store: TerminalNotificationStore,
matching predicate: (TerminalNotification) -> Bool
) async {
let deadline = ContinuousClock.now + .seconds(15)
while !store.notifications.contains(where: predicate), ContinuousClock.now < deadline {
try? await Task.sleep(for: .milliseconds(10))
}
}

private func firstPolicyCompletion(
from stream: AsyncStream<Void>,
within timeout: Duration
Expand Down Expand Up @@ -579,7 +592,10 @@ struct AgentNotificationRegressionTests {
fixture.store.clearNotifications(forTabId: fixture.destination.id)

#expect(await waitForFile(at: completionURL))
for _ in 0..<100 { await Task.yield() }
// The hook touches the marker before it exits and its output is
// applied, so wait for the delivery itself rather than a fixed
// number of yields.
await waitForNotifications(in: fixture.store) { $0.title == "Relay" }
let recorded = fixture.store.notifications.filter { $0.title == "Relay" }
#expect(recorded.map(\.tabId) == [fixture.source.id])
#expect(!recorded.contains { $0.tabId == fixture.destination.id })
Expand Down Expand Up @@ -623,6 +639,10 @@ struct AgentNotificationRegressionTests {
fixture.store.clearNotifications(forTabId: fixture.source.id, surfaceId: fixture.panelId)

#expect(await waitForFile(at: completionURL))
await waitForNotifications(in: fixture.store) { $0.title == "Relay live" }
// Both hooks touch the same marker, so the stale "Relay" delivery may
// still be applying; give it the settling window the negative
// assertion below relied on before.
for _ in 0..<100 { await Task.yield() }
let recorded = fixture.store.notifications.filter { $0.title.hasPrefix("Relay") }
#expect(recorded.map(\.tabId) == [fixture.destination.id])
Expand Down
Loading
Loading