From 644b20c19a97cbb8eab0140b0e46d1432b2baaeb Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 19 Oct 2026 02:51:53 +0000 Subject: [PATCH 1/4] cmux-next: a Dock bounce stops once its alert is read elsewhere; no bounce without a banner Follow-ups from the #21735 review: - Until You Switch to cmux bounced on after the notification was read on the phone, from the CLI or by Mark All Read, since nothing cancelled the request. DockAttention (CmuxNextSettings) keeps the running request and cancels it when the unread count reaches zero. - `notify --desktop false` asks for no banner, so it gets no bounce either: the bounce follows the banner decision, not the alert decision. CmuxNextApp source shrinks by 2 lines (the bounce helper moved out). Co-Authored-By: Claude Opus 5.5 --- .../NotificationCenterService+Lookup.swift | 8 +---- .../NotificationCenterService.swift | 6 ++-- .../CmuxNextSettings/DockAttention.swift | 30 +++++++++++++++++++ .../CmuxNextSettings/NotificationPolicy.swift | 3 +- .../DockBouncePolicyTests.swift | 6 ++++ 5 files changed, 43 insertions(+), 10 deletions(-) create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextSettings/DockAttention.swift diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/NotificationCenterService+Lookup.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/NotificationCenterService+Lookup.swift index 7ccbfbb3169f..afd2239b8a9e 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/NotificationCenterService+Lookup.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/NotificationCenterService+Lookup.swift @@ -92,19 +92,13 @@ extension NotificationCenterService { /// Sets the Dock tile's unread count. Compares with the label it set /// last: reading `dockTile.badgeLabel` asks the Dock and can block. func updateDockBadge(_ count: Int) { + dockAttention.unreadChanged(count) let label = preferences.dockBadge && count > 0 ? String(count) : nil guard label != dockBadgeLabel else { return } dockBadgeLabel = label NSApp.dockTile.badgeLabel = label } - /// Bounces the Dock icon for an alert that arrived while cmux was inactive - /// (`notifications.dockBounce`): once, or until the user switches to cmux. - func bounceDock(_ mode: DockBounceMode?) { - guard let mode, !NSApp.isActive else { return } - NSApp.requestUserAttention(mode == .untilActive ? .criticalRequest : .informationalRequest) - } - /// The handoff driver over the local daemon and this Mac's install /// principal (`feed.adopt` is an install-only owner op). func makeFeedDriver(_ services: AppServices, principal: FeedInstallPrincipal) -> FeedHandoffDriver { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/NotificationCenterService.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/NotificationCenterService.swift index 53bfa52dbc54..54edbd84a145 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/NotificationCenterService.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/NotificationCenterService.swift @@ -47,6 +47,8 @@ final class NotificationCenterService { @ObservationIgnored private var lastSeen: UInt64 = 0 /// The Dock badge this service set last (nil: none). @ObservationIgnored var dockBadgeLabel: String? + /// `notifications.dockBounce`: the running bounce, ended when nothing is unread. + @ObservationIgnored let dockAttention = DockAttention() /// Hands the daemon's local feed items to the cloud owner (feed.md 9.1). @ObservationIgnored var feedDriver: FeedHandoffDriver? /// Agent permission prompts to the person's feed, while @@ -283,7 +285,7 @@ final class NotificationCenterService { feedDriver?.run() if decision.desktop { post(notification, tab: nil, workspace: nil, sound: decision.sound) } if !notification.desktop, let sound = decision.sound { NotificationSounds.playWithoutBanner(sound) } - bounceDock(decision.bounce) + dockAttention.bounce(decision.bounce) return } if decision.acknowledge { @@ -345,7 +347,7 @@ final class NotificationCenterService { notification.desktop ? NotificationSounds.play(sound) : NotificationSounds.playWithoutBanner(sound) } if let seconds = decision.timeout { scheduleTimeout(seconds, tabID: located.tab.id) } - bounceDock(decision.bounce) + dockAttention.bounce(decision.bounce) } private func post(_ notification: DaemonNotification, tab: TabModel?, workspace: String?, sound: String?, diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/DockAttention.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/DockAttention.swift new file mode 100644 index 000000000000..49039d1fdef9 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/DockAttention.swift @@ -0,0 +1,30 @@ +public import AppKit + +/// The Dock icon's bounce for `notifications.dockBounce`, one request at a +/// time. A bounce that lasts until the user switches to cmux also ends once +/// nothing is unread any more (read on the phone, from the CLI, or by Mark All +/// Read), so an alert that was dealt with elsewhere never keeps bouncing. +@MainActor +public final class DockAttention { + private var request: Int? + + public init() {} + + /// Bounces for an alert that arrived while cmux was inactive: once, or until the user switches to cmux. + public func bounce(_ mode: DockBounceMode?) { + guard let mode, !NSApp.isActive else { return } + cancel() + let id = NSApp.requestUserAttention(mode == .untilActive ? .criticalRequest : .informationalRequest) + request = mode == .untilActive ? id : nil + } + + /// The unread count changed; at zero a bounce still running ends. + public func unreadChanged(_ count: Int) { + if count == 0 { cancel() } + } + + private func cancel() { + if let request { NSApp.cancelUserAttentionRequest(request) } + request = nil + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/NotificationPolicy.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/NotificationPolicy.swift index 7908fd55634d..30ff7fc0405a 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/NotificationPolicy.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/NotificationPolicy.swift @@ -108,7 +108,8 @@ public nonisolated struct NotificationPolicy: Sendable { decision.sound = sound } if mode == .timeout { decision.timeout = prefs.timeoutSeconds } - if decision.alerts, !arrival.appActive, prefs.dockBounce != .off { decision.bounce = prefs.dockBounce } + // A poster asking for no banner (`notify --desktop false`) gets no bounce either. + if decision.desktop, !arrival.appActive, prefs.dockBounce != .off { decision.bounce = prefs.dockBounce } return decision } } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/DockBouncePolicyTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/DockBouncePolicyTests.swift index 11468cd261ee..f6b1a7bda2dd 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/DockBouncePolicyTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/DockBouncePolicyTests.swift @@ -39,6 +39,12 @@ struct DockBouncePolicyTests { #expect(Self.decide(desktop: .never).bounce == nil) } + @Test func aPosterAskingForNoBannerGetsNoBounce() { + var arrival = NotificationPolicy.Arrival(source: .cli) + arrival.bannerAllowed = false + #expect(NotificationPolicy(prefs: NotificationPreferences()).decide(arrival).bounce == nil) + } + @Test func quietHoursDoNotBounce() { var prefs = NotificationPreferences() prefs.dockBounce = .untilActive From 4786f0dfb5d350a099f464fb04b16034b81d3b52 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 19 Oct 2026 02:56:04 +0000 Subject: [PATCH 2/4] cmux-next: settings export stamp for the dock bounce follow-ups Generated only: fleet export step at 644b20c1. Co-Authored-By: Claude Opus 5.5 --- schemas/settings-export/settings-schema.sources.sha256 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/schemas/settings-export/settings-schema.sources.sha256 b/schemas/settings-export/settings-schema.sources.sha256 index e384961c84cf..3045fe21b2ac 100644 --- a/schemas/settings-export/settings-schema.sources.sha256 +++ b/schemas/settings-export/settings-schema.sources.sha256 @@ -1 +1 @@ -0ab5166eb5f7863fe72ff244c6483356a9001252eb21d04b538cd86c17f4b805 +4c47eab73c1403541c2c770bdb755b60d1c87832f07ef0f14210ab457ecbe210 From a3c950794415ac8a7f8d4d673c3322fcae8e8522 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 19 Oct 2026 03:19:17 +0000 Subject: [PATCH 3/4] cmux-next: DockAttention lives in CmuxNextDesign; the App maps the setting Review of #21883: a settings module should not call app-wide AppKit. DockAttention moves to CmuxNextDesign with a bounce(untilActive:) API; NotificationCenterService maps notifications.dockBounce to it. Co-Authored-By: Claude Opus 5.5 --- .../Notifications/NotificationCenterService.swift | 5 +++-- .../DockAttention.swift | 10 +++++----- 2 files changed, 8 insertions(+), 7 deletions(-) rename Packages/macOS/CmuxNext/Sources/{CmuxNextSettings => CmuxNextDesign}/DockAttention.swift (69%) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/NotificationCenterService.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/NotificationCenterService.swift index 54edbd84a145..0695acf50ae0 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/NotificationCenterService.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/NotificationCenterService.swift @@ -3,6 +3,7 @@ import CmuxNextAgentPane import CmuxNextBridge import CmuxNextCompat import CmuxNextDaemon +import CmuxNextDesign import CmuxNextSettings import CmuxNextTerminal import CmuxNextWakeups @@ -285,7 +286,7 @@ final class NotificationCenterService { feedDriver?.run() if decision.desktop { post(notification, tab: nil, workspace: nil, sound: decision.sound) } if !notification.desktop, let sound = decision.sound { NotificationSounds.playWithoutBanner(sound) } - dockAttention.bounce(decision.bounce) + if let mode = decision.bounce { dockAttention.bounce(untilActive: mode == .untilActive) } return } if decision.acknowledge { @@ -347,7 +348,7 @@ final class NotificationCenterService { notification.desktop ? NotificationSounds.play(sound) : NotificationSounds.playWithoutBanner(sound) } if let seconds = decision.timeout { scheduleTimeout(seconds, tabID: located.tab.id) } - dockAttention.bounce(decision.bounce) + if let mode = decision.bounce { dockAttention.bounce(untilActive: mode == .untilActive) } } private func post(_ notification: DaemonNotification, tab: TabModel?, workspace: String?, sound: String?, diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/DockAttention.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/DockAttention.swift similarity index 69% rename from Packages/macOS/CmuxNext/Sources/CmuxNextSettings/DockAttention.swift rename to Packages/macOS/CmuxNext/Sources/CmuxNextDesign/DockAttention.swift index 49039d1fdef9..0775cdabe3e0 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/DockAttention.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/DockAttention.swift @@ -1,6 +1,6 @@ public import AppKit -/// The Dock icon's bounce for `notifications.dockBounce`, one request at a +/// The Dock icon's bounce (`notifications.dockBounce`, mapped by the App), one request at a /// time. A bounce that lasts until the user switches to cmux also ends once /// nothing is unread any more (read on the phone, from the CLI, or by Mark All /// Read), so an alert that was dealt with elsewhere never keeps bouncing. @@ -11,11 +11,11 @@ public final class DockAttention { public init() {} /// Bounces for an alert that arrived while cmux was inactive: once, or until the user switches to cmux. - public func bounce(_ mode: DockBounceMode?) { - guard let mode, !NSApp.isActive else { return } + public func bounce(untilActive: Bool) { + guard !NSApp.isActive else { return } cancel() - let id = NSApp.requestUserAttention(mode == .untilActive ? .criticalRequest : .informationalRequest) - request = mode == .untilActive ? id : nil + let id = NSApp.requestUserAttention(untilActive ? .criticalRequest : .informationalRequest) + request = untilActive ? id : nil } /// The unread count changed; at zero a bounce still running ends. From 8a1a7e3eea985e26216005007fa53ee52993a5ef Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 19 Oct 2026 03:24:35 +0000 Subject: [PATCH 4/4] cmux-next: settings export stamp after moving DockAttention Generated only: fleet export step at 05a097df. Co-Authored-By: Claude Opus 5.5 --- schemas/settings-export/settings-schema.sources.sha256 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/schemas/settings-export/settings-schema.sources.sha256 b/schemas/settings-export/settings-schema.sources.sha256 index 2bd86df2f5bb..09600d4474e2 100644 --- a/schemas/settings-export/settings-schema.sources.sha256 +++ b/schemas/settings-export/settings-schema.sources.sha256 @@ -1 +1 @@ -f98f0183248257e005cb1cb431cebc8f5f02c6243890c5b38d6bd77d0e8a6f9c +383309d0b9be0d3a960db70d205bc7649ac655a914e2df225ef1b181eb455e88