diff --git a/docs/components/notifications.md b/docs/components/notifications.md index f90d3ccf..323e75f9 100644 --- a/docs/components/notifications.md +++ b/docs/components/notifications.md @@ -67,6 +67,10 @@ top of its section. **Jump to Latest Unread** (`⌘⌥U`) takes you straight to `hero`, or `supacodeClassic` = Prowl Classic, the default). Selecting a sound previews it. Replaces the legacy `notificationSoundEnabled` toggle (`true` → `supacodeClassic`, `false` → `never` on migration). +- `muteNotificationsForActiveSurface` (default on) — skip the banner, sound, and + dock bounce when the notification comes from the pane you're already looking at + (selected worktree, focused pane, key + visible window). Sidebar reordering + still happens. - `moveNotifiedWorktreeToTop` — float notified worktree to top. - `commandFinishedNotificationEnabled` + `commandFinishedNotificationThreshold` — long-command notifications and their minimum duration. diff --git a/docs/reference/settings-fields.md b/docs/reference/settings-fields.md index 97163667..71cd7b33 100644 --- a/docs/reference/settings-fields.md +++ b/docs/reference/settings-fields.md @@ -32,6 +32,7 @@ JSON is pretty-printed with sorted keys. Legacy `~/.supacode` is migrated to | `inAppNotificationsEnabled` | Bool | `true` | In-app alerts / bell indicators. | | `notificationSound` | enum (`never` / system sound raw values like `hero` / `supacodeClassic`) | `supacodeClassic` | Sound played for notifications when system banners are off; `never` disables it. Migrates the legacy `notificationSoundEnabled` Bool (`true` → `supacodeClassic`, `false` → `never`); unknown raw values fall back to the default. | | `systemNotificationsEnabled` | Bool | `false` | macOS system banners. | +| `muteNotificationsForActiveSurface` | Bool | `true` | Suppress the banner, sound, and dock bounce when the notification's pane is the one you're actively viewing (selected worktree, focused pane, key + visible window). | | `moveNotifiedWorktreeToTop` | Bool | `true` | Float a notified worktree to top. | | `commandFinishedNotificationEnabled` | Bool | `true` | Notify when a long command finishes. | | `commandFinishedNotificationThreshold` | Int (seconds) | `10` | Minimum duration before that notification fires. | diff --git a/supacode/Clients/Terminal/TerminalClient.swift b/supacode/Clients/Terminal/TerminalClient.swift index 7def5e4a..ba895d7a 100644 --- a/supacode/Clients/Terminal/TerminalClient.swift +++ b/supacode/Clients/Terminal/TerminalClient.swift @@ -60,7 +60,7 @@ struct TerminalClient { enum Event: Equatable { case customCommandSucceeded(worktreeID: Worktree.ID, name: String, durationMs: Int) - case notificationReceived(worktreeID: Worktree.ID, surfaceID: UUID, title: String, body: String) + case notificationReceived(worktreeID: Worktree.ID, surfaceID: UUID, title: String, body: String, isViewed: Bool) case notificationIndicatorChanged(count: Int) case tabCreated(worktreeID: Worktree.ID) case tabClosed(worktreeID: Worktree.ID, remainingTabs: Int) diff --git a/supacode/Features/App/Reducer/AppFeature+TerminalEvents.swift b/supacode/Features/App/Reducer/AppFeature+TerminalEvents.swift index fb1cd390..462f1399 100644 --- a/supacode/Features/App/Reducer/AppFeature+TerminalEvents.swift +++ b/supacode/Features/App/Reducer/AppFeature+TerminalEvents.swift @@ -32,12 +32,15 @@ extension AppFeature { state: inout State ) -> Effect? { switch event { - case .notificationReceived(let worktreeID, let surfaceID, let title, let body): + case .notificationReceived(let worktreeID, let surfaceID, let title, let body, let isViewed): return terminalNotificationReceivedEffect( - worktreeID: worktreeID, - surfaceID: surfaceID, - title: title, - body: body, + TerminalNotificationPayload( + worktreeID: worktreeID, + surfaceID: surfaceID, + title: title, + body: body, + isViewed: isViewed + ), state: state ) @@ -157,20 +160,38 @@ extension AppFeature { } } + struct TerminalNotificationPayload { + let worktreeID: Worktree.ID + let surfaceID: UUID + let title: String + let body: String + /// The surface is the one the user is actively looking at, so the mute + /// setting can suppress the redundant banner/sound/bounce for it. + let isViewed: Bool + } + func terminalNotificationReceivedEffect( - worktreeID: Worktree.ID, - surfaceID: UUID, - title: String, - body: String, + _ notification: TerminalNotificationPayload, state: State ) -> Effect { + // Reordering the sidebar is organizational, not attention-grabbing, so it + // always runs. The banner / sound / dock bounce are suppressed when the user + // is already looking at the originating surface and the mute setting is on. var effects: [Effect] = [ - .send(.repositories(.worktreeOrdering(.worktreeNotificationReceived(worktreeID)))) + .send(.repositories(.worktreeOrdering(.worktreeNotificationReceived(notification.worktreeID)))) ] + if state.settings.muteNotificationsForActiveSurface && notification.isViewed { + return .merge(effects) + } if state.settings.systemNotificationsEnabled { effects.append( .run { _ in - await systemNotificationClient.send(title, body, worktreeID, surfaceID) + await systemNotificationClient.send( + notification.title, + notification.body, + notification.worktreeID, + notification.surfaceID + ) } ) } diff --git a/supacode/Features/Settings/Models/GlobalSettings.swift b/supacode/Features/Settings/Models/GlobalSettings.swift index ac0d7eb1..a07a899e 100644 --- a/supacode/Features/Settings/Models/GlobalSettings.swift +++ b/supacode/Features/Settings/Models/GlobalSettings.swift @@ -8,6 +8,7 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { var inAppNotificationsEnabled: Bool var notificationSound: NotificationSound var systemNotificationsEnabled: Bool + var muteNotificationsForActiveSurface: Bool var moveNotifiedWorktreeToTop: Bool var commandFinishedNotificationEnabled: Bool var commandFinishedNotificationThreshold: Int @@ -53,6 +54,7 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { inAppNotificationsEnabled: true, notificationSound: .supacodeClassic, systemNotificationsEnabled: false, + muteNotificationsForActiveSurface: true, moveNotifiedWorktreeToTop: true, commandFinishedNotificationEnabled: true, commandFinishedNotificationThreshold: 10, @@ -97,6 +99,7 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { inAppNotificationsEnabled: Bool, notificationSound: NotificationSound = .supacodeClassic, systemNotificationsEnabled: Bool = false, + muteNotificationsForActiveSurface: Bool = true, moveNotifiedWorktreeToTop: Bool, commandFinishedNotificationEnabled: Bool = true, commandFinishedNotificationThreshold: Int = 10, @@ -139,6 +142,7 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { self.inAppNotificationsEnabled = inAppNotificationsEnabled self.notificationSound = notificationSound self.systemNotificationsEnabled = systemNotificationsEnabled + self.muteNotificationsForActiveSurface = muteNotificationsForActiveSurface self.moveNotifiedWorktreeToTop = moveNotifiedWorktreeToTop self.commandFinishedNotificationEnabled = commandFinishedNotificationEnabled self.commandFinishedNotificationThreshold = commandFinishedNotificationThreshold @@ -184,6 +188,7 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { try container.encode(inAppNotificationsEnabled, forKey: .inAppNotificationsEnabled) try container.encode(notificationSound, forKey: .notificationSound) try container.encode(systemNotificationsEnabled, forKey: .systemNotificationsEnabled) + try container.encode(muteNotificationsForActiveSurface, forKey: .muteNotificationsForActiveSurface) try container.encode(moveNotifiedWorktreeToTop, forKey: .moveNotifiedWorktreeToTop) try container.encode(commandFinishedNotificationEnabled, forKey: .commandFinishedNotificationEnabled) try container.encode(commandFinishedNotificationThreshold, forKey: .commandFinishedNotificationThreshold) @@ -230,6 +235,7 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { case inAppNotificationsEnabled case notificationSound case systemNotificationsEnabled + case muteNotificationsForActiveSurface case moveNotifiedWorktreeToTop case commandFinishedNotificationEnabled case commandFinishedNotificationThreshold @@ -290,6 +296,9 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { systemNotificationsEnabled = try container.decodeIfPresent(Bool.self, forKey: .systemNotificationsEnabled) ?? Self.default.systemNotificationsEnabled + muteNotificationsForActiveSurface = + try container.decodeIfPresent(Bool.self, forKey: .muteNotificationsForActiveSurface) + ?? Self.default.muteNotificationsForActiveSurface moveNotifiedWorktreeToTop = try container.decodeIfPresent(Bool.self, forKey: .moveNotifiedWorktreeToTop) ?? Self.default.moveNotifiedWorktreeToTop diff --git a/supacode/Features/Settings/Reducer/SettingsFeature.swift b/supacode/Features/Settings/Reducer/SettingsFeature.swift index 6d09ffe2..73fda7de 100644 --- a/supacode/Features/Settings/Reducer/SettingsFeature.swift +++ b/supacode/Features/Settings/Reducer/SettingsFeature.swift @@ -15,6 +15,7 @@ struct SettingsFeature { var inAppNotificationsEnabled: Bool var notificationSound: NotificationSound var systemNotificationsEnabled: Bool + var muteNotificationsForActiveSurface: Bool var moveNotifiedWorktreeToTop: Bool var commandFinishedNotificationEnabled: Bool var commandFinishedNotificationThreshold: Int @@ -73,6 +74,7 @@ struct SettingsFeature { inAppNotificationsEnabled = settings.inAppNotificationsEnabled notificationSound = settings.notificationSound systemNotificationsEnabled = settings.systemNotificationsEnabled + muteNotificationsForActiveSurface = settings.muteNotificationsForActiveSurface moveNotifiedWorktreeToTop = settings.moveNotifiedWorktreeToTop commandFinishedNotificationEnabled = settings.commandFinishedNotificationEnabled commandFinishedNotificationThreshold = settings.commandFinishedNotificationThreshold @@ -121,6 +123,7 @@ struct SettingsFeature { inAppNotificationsEnabled: inAppNotificationsEnabled, notificationSound: notificationSound, systemNotificationsEnabled: systemNotificationsEnabled, + muteNotificationsForActiveSurface: muteNotificationsForActiveSurface, moveNotifiedWorktreeToTop: moveNotifiedWorktreeToTop, commandFinishedNotificationEnabled: commandFinishedNotificationEnabled, commandFinishedNotificationThreshold: commandFinishedNotificationThreshold, @@ -242,6 +245,7 @@ struct SettingsFeature { state.inAppNotificationsEnabled = normalizedSettings.inAppNotificationsEnabled state.notificationSound = normalizedSettings.notificationSound state.systemNotificationsEnabled = normalizedSettings.systemNotificationsEnabled + state.muteNotificationsForActiveSurface = normalizedSettings.muteNotificationsForActiveSurface state.moveNotifiedWorktreeToTop = normalizedSettings.moveNotifiedWorktreeToTop state.commandFinishedNotificationEnabled = normalizedSettings.commandFinishedNotificationEnabled state.commandFinishedNotificationThreshold = normalizedSettings.commandFinishedNotificationThreshold diff --git a/supacode/Features/Settings/Views/NotificationsSettingsView.swift b/supacode/Features/Settings/Views/NotificationsSettingsView.swift index 91d4c07a..654a66de 100644 --- a/supacode/Features/Settings/Views/NotificationsSettingsView.swift +++ b/supacode/Features/Settings/Views/NotificationsSettingsView.swift @@ -33,6 +33,14 @@ struct NotificationsSettingsView: View { } .help("Choose the sound played when a notification is received") .disabled(store.systemNotificationsEnabled) + Toggle( + "Mute notifications for the active pane", + isOn: $store.muteNotificationsForActiveSurface + ) + .help( + "Skip the banner, sound, and dock bounce when the notification comes from " + + "the pane you're already looking at." + ) Toggle( "Move notified worktree to top", isOn: $store.moveNotifiedWorktreeToTop diff --git a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift index b10f1ec6..72f26b04 100644 --- a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift +++ b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift @@ -245,8 +245,16 @@ final class WorktreeTerminalManager { state.isSelected = { [weak self] in self?.selectedWorktreeID == worktree.id } - state.onNotificationReceived = { [weak self] surfaceID, title, body in - self?.emit(.notificationReceived(worktreeID: worktree.id, surfaceID: surfaceID, title: title, body: body)) + state.onNotificationReceived = { [weak self] surfaceID, title, body, isViewed in + self?.emit( + .notificationReceived( + worktreeID: worktree.id, + surfaceID: surfaceID, + title: title, + body: body, + isViewed: isViewed + ) + ) } state.onNotificationIndicatorChanged = { [weak self] in self?.emitNotificationIndicatorCountIfNeeded() diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState+Notifications.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState+Notifications.swift index d70dfd27..a099c19e 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState+Notifications.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState+Notifications.swift @@ -128,7 +128,7 @@ extension WorktreeTerminalState { ) emitNotificationIndicatorIfNeeded(previousHasUnseen: previousHasUnseen) } - onNotificationReceived?(surfaceId, trimmedTitle, trimmedBody) + onNotificationReceived?(surfaceId, trimmedTitle, trimmedBody, isViewedSurface(surfaceId)) } static func formatDuration(_ seconds: Int) -> String { diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift index 3d477311..a3d96b48 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift @@ -649,6 +649,15 @@ extension WorktreeTerminalState { return focusedSurfaceIdByTab[selectedTabId] == surfaceId } + /// Whether the user is actively looking at `surfaceId` right now: its worktree + /// is selected, it is the focused pane of the selected tab (`isFocusedSurface` + /// already implies both), and the app window is key and visible. Unknown window + /// state (`nil`) is treated as not-viewed so a notification is never silently + /// dropped. + func isViewedSurface(_ surfaceId: UUID) -> Bool { + isSelected() && isFocusedSurface(surfaceId) && lastWindowIsKey == true && lastWindowIsVisible == true + } + func updateRunningState(for tabId: TerminalTabID) { guard let tree = trees[tabId] else { return } let now = Date() diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 36d89f95..7c12ae22 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -158,7 +158,10 @@ final class WorktreeTerminalState { } var isSelected: () -> Bool = { false } - var onNotificationReceived: ((UUID, String, String) -> Void)? + /// `isViewed` is true when the notification's surface is the one the user is + /// actively looking at (selected worktree, focused pane, key + visible window), + /// so the reducer can suppress a redundant banner/sound for it. + var onNotificationReceived: ((_ surfaceId: UUID, _ title: String, _ body: String, _ isViewed: Bool) -> Void)? var onNotificationIndicatorChanged: (() -> Void)? var onTabCreated: (() -> Void)? var onTabClosed: (() -> Void)? diff --git a/supacodeTests/AppFeatureDockTests.swift b/supacodeTests/AppFeatureDockTests.swift index 49e7c493..76b16ab9 100644 --- a/supacodeTests/AppFeatureDockTests.swift +++ b/supacodeTests/AppFeatureDockTests.swift @@ -13,7 +13,8 @@ struct AppFeatureDockTests { worktreeID: "/tmp/repo/wt-1", surfaceID: UUID(), title: "Done", - body: "Build succeeded" + body: "Build succeeded", + isViewed: false ) ) } diff --git a/supacodeTests/AppFeatureSystemNotificationTests.swift b/supacodeTests/AppFeatureSystemNotificationTests.swift index b38c7604..6738a853 100644 --- a/supacodeTests/AppFeatureSystemNotificationTests.swift +++ b/supacodeTests/AppFeatureSystemNotificationTests.swift @@ -155,7 +155,8 @@ struct AppFeatureSystemNotificationTests { worktreeID: "/tmp/repo/wt-1", surfaceID: surfaceID, title: "Done", - body: "Build succeeded" + body: "Build succeeded", + isViewed: false ) ) ) @@ -168,6 +169,80 @@ struct AppFeatureSystemNotificationTests { #expect(sends.value.first?.surfaceID == surfaceID) } + @Test(.dependencies) func notificationMutedForViewedSurfaceSkipsBannerSoundAndBounce() async { + var globalSettings = GlobalSettings.default + globalSettings.muteNotificationsForActiveSurface = true + globalSettings.systemNotificationsEnabled = true + globalSettings.notificationSound = .funk + globalSettings.dockBounceMode = .once + let sends = LockIsolated(0) + let plays = LockIsolated(0) + let bounces = LockIsolated(0) + let store = TestStore( + initialState: AppFeature.State( + settings: SettingsFeature.State(settings: globalSettings) + ) + ) { + AppFeature() + } withDependencies: { + $0.systemNotificationClient.send = { _, _, _, _ in sends.withValue { $0 += 1 } } + $0.notificationSoundClient.play = { _ in plays.withValue { $0 += 1 } } + $0.dockClient.bounce = { _ in bounces.withValue { $0 += 1 } } + } + store.exhaustivity = .off + + await store.send( + .terminalEvent( + .notificationReceived( + worktreeID: "/tmp/repo/wt-1", + surfaceID: UUID(), + title: "Done", + body: "Build succeeded", + isViewed: true + ) + ) + ) + await store.finish() + + // The user is already looking at the pane, so no attention-grabbing channel fires. + #expect(sends.value == 0) + #expect(plays.value == 0) + #expect(bounces.value == 0) + } + + @Test(.dependencies) func notificationForViewedSurfaceStillNotifiesWhenMuteDisabled() async { + var globalSettings = GlobalSettings.default + globalSettings.muteNotificationsForActiveSurface = false + globalSettings.systemNotificationsEnabled = true + let sends = LockIsolated(0) + let store = TestStore( + initialState: AppFeature.State( + settings: SettingsFeature.State(settings: globalSettings) + ) + ) { + AppFeature() + } withDependencies: { + $0.systemNotificationClient.send = { _, _, _, _ in sends.withValue { $0 += 1 } } + } + store.exhaustivity = .off + + await store.send( + .terminalEvent( + .notificationReceived( + worktreeID: "/tmp/repo/wt-1", + surfaceID: UUID(), + title: "Done", + body: "Build succeeded", + isViewed: true + ) + ) + ) + await store.finish() + + // Mute is off, so a viewed surface still posts the banner. + #expect(sends.value == 1) + } + @Test(.dependencies) func notificationReceivedSkipsLocalSoundWhenSystemNotificationsEnabled() async { var globalSettings = GlobalSettings.default globalSettings.systemNotificationsEnabled = true @@ -191,7 +266,8 @@ struct AppFeatureSystemNotificationTests { worktreeID: "/tmp/repo/wt-1", surfaceID: UUID(), title: "Done", - body: "Build succeeded" + body: "Build succeeded", + isViewed: false ) ) ) @@ -228,7 +304,8 @@ struct AppFeatureSystemNotificationTests { worktreeID: "/tmp/repo/wt-1", surfaceID: UUID(), title: "Done", - body: "Build succeeded" + body: "Build succeeded", + isViewed: false ) ) ) @@ -267,7 +344,8 @@ struct AppFeatureSystemNotificationTests { worktreeID: "/tmp/repo/wt-1", surfaceID: UUID(), title: "Done", - body: "Build succeeded" + body: "Build succeeded", + isViewed: false ) ) ) diff --git a/supacodeTests/TerminalEventCoalescerTests.swift b/supacodeTests/TerminalEventCoalescerTests.swift index 2f21422b..01a0f4f0 100644 --- a/supacodeTests/TerminalEventCoalescerTests.swift +++ b/supacodeTests/TerminalEventCoalescerTests.swift @@ -40,7 +40,8 @@ struct TerminalEventCoalescerTests { worktreeID: "w1", surfaceID: UUID(), title: "Build", - body: "done" + body: "done", + isViewed: false ) let first = coalescer.shouldEmit(event) // Two identical notifications are two distinct user-facing events.