From a11a809499ac83d92a0acd7c05276c4c1589b029 Mon Sep 17 00:00:00 2001 From: Stefano Bertagno Date: Sat, 22 Aug 2026 15:35:10 +0200 Subject: [PATCH] Redesign the worktree notification inspector Replace the per-worktree sections with one flat, reverse-chronological list (newest first). Each row shows the repository and worktree it came from, dimmed when it is not the selected worktree, and clicking the row navigates to that surface. Add filters persisted in settings and bound directly from the filter menu: an All / Current Worktree scope, an Unread Only toggle, and a Group into Worktrees toggle that restores the sectioned layout on demand. Right-clicking a row copies the notification, marks it as read, or dismisses it; the overflow menu marks all as read or dismisses all, respecting the active filter. Collapse unread that the retention cap evicted into a single non-interactive summary row, and preserve the inspector's scope, grouping, and unread-only preferences across unrelated Settings-window writes. --- .../Reducer/SettingsFeature.swift | 14 + .../Models/GlobalSettings.swift | 33 + .../BusinessLogic/SidebarStructure.swift | 7 +- .../Models/ToolbarNotificationGroup.swift | 163 ++++- .../Reducer/RepositoriesFeature.swift | 3 + .../Views/WorktreeDetailView.swift | 13 +- .../Views/WorktreeStatusInspector.swift | 555 +++++++++++++---- .../BusinessLogic/WorktreeContentHost.swift | 8 + .../WorktreeTerminalManager.swift | 5 + supacodeTests/SettingsFeatureTests.swift | 23 + .../ToolbarNotificationGroupingTests.swift | 565 +++++++++++++++++- 11 files changed, 1235 insertions(+), 154 deletions(-) diff --git a/SupacodeSettingsFeature/Reducer/SettingsFeature.swift b/SupacodeSettingsFeature/Reducer/SettingsFeature.swift index 8d65aab69..2d1e31c16 100644 --- a/SupacodeSettingsFeature/Reducer/SettingsFeature.swift +++ b/SupacodeSettingsFeature/Reducer/SettingsFeature.swift @@ -58,6 +58,11 @@ public struct SettingsFeature { public var muteNotificationsForActiveSurface: Bool public var moveNotifiedWorktreeToTop: Bool public var notificationRetentionLimit: NotificationRetentionLimit + // Inspector-owned notification view prefs; the Settings window doesn't edit + // them, it only carries them through so a settings write can't reset them. + public var notificationScope: NotificationScope + public var notificationsGroupedByWorktree: Bool + public var notificationsUnreadOnly: Bool public var analyticsEnabled: Bool public var crashReportsEnabled: Bool public var githubIntegrationEnabled: Bool @@ -150,6 +155,9 @@ public struct SettingsFeature { muteNotificationsForActiveSurface = settings.muteNotificationsForActiveSurface moveNotifiedWorktreeToTop = settings.moveNotifiedWorktreeToTop notificationRetentionLimit = settings.notificationRetentionLimit + notificationScope = settings.notificationScope + notificationsGroupedByWorktree = settings.notificationsGroupedByWorktree + notificationsUnreadOnly = settings.notificationsUnreadOnly analyticsEnabled = settings.analyticsEnabled crashReportsEnabled = settings.crashReportsEnabled githubIntegrationEnabled = settings.githubIntegrationEnabled @@ -198,6 +206,9 @@ public struct SettingsFeature { muteNotificationsForActiveSurface: muteNotificationsForActiveSurface, moveNotifiedWorktreeToTop: moveNotifiedWorktreeToTop, notificationRetentionLimit: notificationRetentionLimit, + notificationScope: notificationScope, + notificationsGroupedByWorktree: notificationsGroupedByWorktree, + notificationsUnreadOnly: notificationsUnreadOnly, analyticsEnabled: analyticsEnabled, crashReportsEnabled: crashReportsEnabled, githubIntegrationEnabled: githubIntegrationEnabled, @@ -364,6 +375,9 @@ public struct SettingsFeature { state.muteNotificationsForActiveSurface = normalizedSettings.muteNotificationsForActiveSurface state.moveNotifiedWorktreeToTop = normalizedSettings.moveNotifiedWorktreeToTop state.notificationRetentionLimit = normalizedSettings.notificationRetentionLimit + state.notificationScope = normalizedSettings.notificationScope + state.notificationsGroupedByWorktree = normalizedSettings.notificationsGroupedByWorktree + state.notificationsUnreadOnly = normalizedSettings.notificationsUnreadOnly state.analyticsEnabled = normalizedSettings.analyticsEnabled state.crashReportsEnabled = normalizedSettings.crashReportsEnabled state.githubIntegrationEnabled = normalizedSettings.githubIntegrationEnabled diff --git a/SupacodeSettingsShared/Models/GlobalSettings.swift b/SupacodeSettingsShared/Models/GlobalSettings.swift index 3f39162f8..c7fd1541f 100644 --- a/SupacodeSettingsShared/Models/GlobalSettings.swift +++ b/SupacodeSettingsShared/Models/GlobalSettings.swift @@ -57,6 +57,14 @@ public nonisolated enum NotificationRetentionLimit: Int, Codable, CaseIterable, } } +/// Which worktrees the notification inspector lists. Persisted across sessions. +public nonisolated enum NotificationScope: String, Codable, CaseIterable, Sendable { + case all + case currentWorktree + + public static let defaultValue: NotificationScope = .all +} + /// How Supacode combines the user's own Ghostty config with the optional /// Supacode-specific config at `~/.supacode/ghostty.config`. public nonisolated enum GhosttyUserConfigMode: String, Codable, CaseIterable, Sendable { @@ -111,6 +119,11 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable { public var muteNotificationsForActiveSurface: Bool public var moveNotifiedWorktreeToTop: Bool public var notificationRetentionLimit: NotificationRetentionLimit + public var notificationScope: NotificationScope + /// Whether the notification inspector groups its list into worktree sections. + public var notificationsGroupedByWorktree: Bool + /// Whether the notification inspector hides read notifications. + public var notificationsUnreadOnly: Bool public var analyticsEnabled: Bool public var crashReportsEnabled: Bool public var githubIntegrationEnabled: Bool @@ -182,6 +195,9 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable { muteNotificationsForActiveSurface: true, moveNotifiedWorktreeToTop: false, notificationRetentionLimit: .defaultValue, + notificationScope: .defaultValue, + notificationsGroupedByWorktree: false, + notificationsUnreadOnly: false, analyticsEnabled: true, crashReportsEnabled: true, githubIntegrationEnabled: true, @@ -224,6 +240,9 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable { muteNotificationsForActiveSurface: Bool = true, moveNotifiedWorktreeToTop: Bool, notificationRetentionLimit: NotificationRetentionLimit = .defaultValue, + notificationScope: NotificationScope = .defaultValue, + notificationsGroupedByWorktree: Bool = false, + notificationsUnreadOnly: Bool = false, analyticsEnabled: Bool, crashReportsEnabled: Bool, githubIntegrationEnabled: Bool, @@ -268,6 +287,9 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable { self.muteNotificationsForActiveSurface = muteNotificationsForActiveSurface self.moveNotifiedWorktreeToTop = moveNotifiedWorktreeToTop self.notificationRetentionLimit = notificationRetentionLimit + self.notificationScope = notificationScope + self.notificationsGroupedByWorktree = notificationsGroupedByWorktree + self.notificationsUnreadOnly = notificationsUnreadOnly self.analyticsEnabled = analyticsEnabled self.crashReportsEnabled = crashReportsEnabled self.githubIntegrationEnabled = githubIntegrationEnabled @@ -353,6 +375,17 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable { (try container.decodeIfPresent(Int.self, forKey: .notificationRetentionLimit)) .flatMap(NotificationRetentionLimit.init(rawValue:)) ?? Self.default.notificationRetentionLimit + // Fall back instead of throwing, which would reset the whole file. + notificationScope = + ((try? container.decodeIfPresent(String.self, forKey: .notificationScope)) ?? nil) + .flatMap(NotificationScope.init(rawValue:)) + ?? Self.default.notificationScope + notificationsGroupedByWorktree = + try container.decodeIfPresent(Bool.self, forKey: .notificationsGroupedByWorktree) + ?? Self.default.notificationsGroupedByWorktree + notificationsUnreadOnly = + try container.decodeIfPresent(Bool.self, forKey: .notificationsUnreadOnly) + ?? Self.default.notificationsUnreadOnly analyticsEnabled = try container.decodeIfPresent(Bool.self, forKey: .analyticsEnabled) ?? Self.default.analyticsEnabled diff --git a/supacode/Features/Repositories/BusinessLogic/SidebarStructure.swift b/supacode/Features/Repositories/BusinessLogic/SidebarStructure.swift index dac8c7b37..d66dc0caa 100644 --- a/supacode/Features/Repositories/BusinessLogic/SidebarStructure.swift +++ b/supacode/Features/Repositories/BusinessLogic/SidebarStructure.swift @@ -364,9 +364,10 @@ extension RepositoriesFeature.State { /// projection actually changes. mutating func recomputeToolbarNotificationGroupsIfChanged() { let new = computeToolbarNotificationGroups() - if new != toolbarNotificationGroupsCache { - toolbarNotificationGroupsCache = new - } + guard new != toolbarNotificationGroupsCache else { return } + toolbarNotificationGroupsCache = new + // Pure function of the groups, so rebuild it in the same guarded step. + toolbarNotificationItemsCache = NotificationInspectorList.flatten(new) } /// Equatable-diffs the menu bar sections against the cache so the status menu diff --git a/supacode/Features/Repositories/Models/ToolbarNotificationGroup.swift b/supacode/Features/Repositories/Models/ToolbarNotificationGroup.swift index a0718f0e7..e09f05686 100644 --- a/supacode/Features/Repositories/Models/ToolbarNotificationGroup.swift +++ b/supacode/Features/Repositories/Models/ToolbarNotificationGroup.swift @@ -37,12 +37,165 @@ struct ToolbarNotificationWorktreeGroup: Identifiable, Equatable { var unseenNotificationCount: Int { unseenSurfaces.reduce(0) { $0 + $1.count } } +} + +/// A notification flattened out of the groups, carrying its source for the +/// inspector's single reverse-chronological list. +struct FlatNotificationItem: Identifiable, Equatable, Sendable { + // A notification is never surfaced under two worktrees, so its id is unique here. + var id: UUID { notification.id } + let notification: WorktreeTerminalNotification + let worktreeID: Worktree.ID + let repositoryName: String + let repositoryColor: RepositoryColor? + let worktreeName: String + /// A folder's synthetic worktree repeats the repo name; drives dropping the suffix. + let isFolder: Bool +} + +/// A worktree's notifications, for the inspector's optional grouped layout. +struct GroupedNotifications: Identifiable, Equatable { + var id: Worktree.ID { worktreeID } + let worktreeID: Worktree.ID + let repositoryName: String + let repositoryColor: RepositoryColor? + let worktreeName: String + let isFolder: Bool + let items: [WorktreeTerminalNotification] +} + +/// Pure derivations for the flat notification inspector. +enum NotificationInspectorList { + /// Flattens the grouped cache into one list, newest first, stable on id. + static func flatten(_ groups: [ToolbarNotificationRepositoryGroup]) -> [FlatNotificationItem] { + var items: [FlatNotificationItem] = [] + for repository in groups { + for worktree in repository.worktrees { + for notification in worktree.notifications { + items.append( + FlatNotificationItem( + notification: notification, + worktreeID: worktree.id, + repositoryName: repository.name, + repositoryColor: repository.color, + worktreeName: worktree.name, + isFolder: repository.isFolder + ) + ) + } + } + } + items.sort { lhs, rhs in + guard lhs.notification.createdAt == rhs.notification.createdAt else { + return lhs.notification.createdAt > rhs.notification.createdAt + } + return lhs.id.uuidString > rhs.id.uuidString + } + return items + } + + /// Filters the flat list to the active scope and read state. + /// `.currentWorktree` with no selection lists nothing, not everything. + static func visibleItems( + _ items: [FlatNotificationItem], + scope: NotificationScope, + selectedWorktreeID: Worktree.ID?, + unreadOnly: Bool + ) -> [FlatNotificationItem] { + var filtered: [FlatNotificationItem] = [] + for item in items { + guard worktreeInScope(item.worktreeID, scope: scope, selectedWorktreeID: selectedWorktreeID) else { + continue + } + guard !unreadOnly || !item.notification.isRead else { continue } + filtered.append(item) + } + return filtered + } - /// Surfaces whose unread notifications were all pruned from the visible log; - /// the inspector renders one "go to the surface" row per entry. - var prunedUnseenSurfaces: [WorktreeUnseenSurface] { - let visibleSurfaceIDs = Set(notifications.map(\.surfaceID)) - return unseenSurfaces.filter { !visibleSurfaceIDs.contains($0.id) } + /// Worktree sections for the grouped layout, read from the already-grouped + /// cache in sidebar order and filtered to the active scope and read state. + static func visibleGroups( + _ groups: [ToolbarNotificationRepositoryGroup], + scope: NotificationScope, + selectedWorktreeID: Worktree.ID?, + unreadOnly: Bool + ) -> [GroupedNotifications] { + var result: [GroupedNotifications] = [] + for repository in groups { + for worktree in repository.worktrees + where worktreeInScope(worktree.id, scope: scope, selectedWorktreeID: selectedWorktreeID) { + var items: [WorktreeTerminalNotification] = [] + for notification in worktree.notifications where !unreadOnly || !notification.isRead { + items.append(notification) + } + guard !items.isEmpty else { continue } + result.append( + GroupedNotifications( + worktreeID: worktree.id, + repositoryName: repository.name, + repositoryColor: repository.color, + worktreeName: worktree.name, + isFolder: repository.isFolder, + items: items + ) + ) + } + } + return result + } + + /// Unread the retention cap evicted, clamped per surface so a drifted counter can't borrow slack from a sibling. + static func prunedUnreadCount( + groups: [ToolbarNotificationRepositoryGroup], + scope: NotificationScope, + selectedWorktreeID: Worktree.ID? + ) -> Int { + var total = 0 + for repository in groups { + for worktree in repository.worktrees { + guard worktreeInScope(worktree.id, scope: scope, selectedWorktreeID: selectedWorktreeID) else { + continue + } + var visibleUnreadBySurface: [UUID: Int] = [:] + for notification in worktree.notifications where !notification.isRead { + visibleUnreadBySurface[notification.surfaceID, default: 0] += 1 + } + for surface in worktree.unseenSurfaces { + total += max(0, surface.count - (visibleUnreadBySurface[surface.id] ?? 0)) + } + } + } + return total + } + + /// Worktrees a bulk action targets, from the groups so a pruned-only worktree counts. + static func actionableWorktreeIDs( + groups: [ToolbarNotificationRepositoryGroup], + scope: NotificationScope, + selectedWorktreeID: Worktree.ID? + ) -> [Worktree.ID] { + var ids: [Worktree.ID] = [] + for repository in groups { + for worktree in repository.worktrees + where worktreeInScope(worktree.id, scope: scope, selectedWorktreeID: selectedWorktreeID) { + ids.append(worktree.id) + } + } + return ids + } + + private static func worktreeInScope( + _ worktreeID: Worktree.ID, + scope: NotificationScope, + selectedWorktreeID: Worktree.ID? + ) -> Bool { + switch scope { + case .all: + return true + case .currentWorktree: + return worktreeID == selectedWorktreeID + } } } diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 7085af43a..6350e5471 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -280,6 +280,9 @@ struct RepositoriesFeature { /// mutation across all worktrees). Recomputed via /// `recomputeToolbarNotificationGroupsIfChanged()`. var toolbarNotificationGroupsCache: [ToolbarNotificationRepositoryGroup] = [] + /// Notifications flattened into one reverse-chron list, rebuilt with the + /// groups cache. The scope filter stays a view predicate, never cached. + var toolbarNotificationItemsCache: [FlatNotificationItem] = [] /// Cached menu bar sections. The `MenuBarExtra` scene reads this instead of /// `sidebarItems`, which would subscribe the status menu to every per-row /// notification and agent tick. Recomputed via diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index db3ff0bcd..0c3357b9c 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -122,7 +122,6 @@ struct WorktreeDetailView: View { fileOpenActions: state.installedOpenActions.filter(\.canOpenFiles), resolvedOpenAction: resolvedSelection, onSelectNotification: selectToolbarNotification, - onSelectSurface: selectToolbarSurface, onPullRequestAction: { sendPullRequestAction($0, worktree: selectedWorktree) }, onOpenFile: { store.send(.openFile($0, with: $1)) }, onActivateFile: { store.send(.openFileFromExplorer($0)) } @@ -412,21 +411,17 @@ struct WorktreeDetailView: View { } } + /// Selects the worktree and focuses the notification's surface, which marks it read. private func selectToolbarNotification( _ worktreeID: Worktree.ID, _ notification: WorktreeTerminalNotification ) { - selectToolbarSurface(worktreeID, notification.surfaceID) - } - - /// Focuses a surface directly, used by the inspector's pruned-unread row where - /// no notification object survives to carry the surface ID. - private func selectToolbarSurface(_ worktreeID: Worktree.ID, _ surfaceID: UUID) { store.send(.repositories(.selectWorktree(worktreeID))) if let host = terminalManager.hostIfExists(for: worktreeID), - !host.focusSurface(id: surfaceID) + !host.focusSurface(id: notification.surfaceID) { - SupaLogger("Terminal").warning("Failed to focus surface \(surfaceID) for worktree \(worktreeID).") + SupaLogger("Terminal").warning( + "Failed to focus surface \(notification.surfaceID) for worktree \(worktreeID).") } } diff --git a/supacode/Features/Repositories/Views/WorktreeStatusInspector.swift b/supacode/Features/Repositories/Views/WorktreeStatusInspector.swift index 8d3efa9c5..8d74d610d 100644 --- a/supacode/Features/Repositories/Views/WorktreeStatusInspector.swift +++ b/supacode/Features/Repositories/Views/WorktreeStatusInspector.swift @@ -15,7 +15,6 @@ struct WorktreeStatusInspectorContainer: View { let fileOpenActions: [OpenWorktreeAction] let resolvedOpenAction: OpenWorktreeAction? let onSelectNotification: (Worktree.ID, WorktreeTerminalNotification) -> Void - let onSelectSurface: (Worktree.ID, UUID) -> Void let onPullRequestAction: (RepositoriesFeature.PullRequestAction) -> Void let onOpenFile: (URL, OpenWorktreeAction?) -> Void let onActivateFile: (URL) -> Void @@ -45,8 +44,7 @@ struct WorktreeStatusInspectorContainer: View { WorktreeNotificationsInspectorView( repositoriesStore: repositoriesStore, terminalManager: terminalManager, - onSelectNotification: onSelectNotification, - onSelectSurface: onSelectSurface + onSelectNotification: onSelectNotification ) } } @@ -476,26 +474,75 @@ private struct PullRequestMergeQueueRow: View { // MARK: - Notifications pane -/// Inspector pane for worktree notifications. Reads the notification cache in -/// its own body so notification churn invalidates only this pane, mirroring the -/// toolbar bell host. +/// Inspector pane for worktree notifications. Reads the caches in its own body +/// so churn invalidates only this pane; applies the scope and read filter here. struct WorktreeNotificationsInspectorView: View { let repositoriesStore: StoreOf let terminalManager: WorktreeTerminalManager let onSelectNotification: (Worktree.ID, WorktreeTerminalNotification) -> Void - let onSelectSurface: (Worktree.ID, UUID) -> Void + + @Shared(.settingsFile) private var settingsFile var body: some View { + let scope = settingsFile.global.notificationScope + let isGrouped = settingsFile.global.notificationsGroupedByWorktree + let unreadOnly = settingsFile.global.notificationsUnreadOnly + let selectedWorktreeID = repositoriesStore.selectedWorktreeID let groups = repositoriesStore.toolbarNotificationGroupsCache + let allItems = repositoriesStore.toolbarNotificationItemsCache + let items = NotificationInspectorList.visibleItems( + allItems, scope: scope, selectedWorktreeID: selectedWorktreeID, unreadOnly: unreadOnly + ) + let groupedItems = + isGrouped + ? NotificationInspectorList.visibleGroups( + groups, scope: scope, selectedWorktreeID: selectedWorktreeID, unreadOnly: unreadOnly) + : [] + let prunedCount = NotificationInspectorList.prunedUnreadCount( + groups: groups, scope: scope, selectedWorktreeID: selectedWorktreeID + ) + let hasNotificationsAnywhere = + !allItems.isEmpty + || NotificationInspectorList.prunedUnreadCount(groups: groups, scope: .all, selectedWorktreeID: nil) > 0 + let actionableWorktreeIDs = NotificationInspectorList.actionableWorktreeIDs( + groups: groups, scope: scope, selectedWorktreeID: selectedWorktreeID + ) + NotificationsInspectorContent( - groups: groups, + items: items, + groupedItems: groupedItems, + isGrouped: isGrouped, + scope: scope, + unreadOnly: unreadOnly, + selectedWorktreeID: selectedWorktreeID, + prunedCount: prunedCount, + hasNotificationsAnywhere: hasNotificationsAnywhere, + onClearFilters: { + $settingsFile.withLock { + $0.global.notificationScope = .all + $0.global.notificationsUnreadOnly = false + } + }, onSelectNotification: onSelectNotification, - onSelectSurface: onSelectSurface, + onMarkRead: { worktreeID, notificationID in + terminalManager.markNotificationRead(worktreeID: worktreeID, notificationID: notificationID) + }, + onDismiss: { worktreeID, notificationID in + terminalManager.dismissNotification(worktreeID: worktreeID, notificationID: notificationID) + }, + onMarkAllRead: { + for worktreeID in actionableWorktreeIDs { + terminalManager.hostIfExists(for: worktreeID)?.markAllNotificationsRead() + } + }, onDismissAll: { - for repositoryGroup in groups { - for worktreeGroup in repositoryGroup.worktrees { - terminalManager.hostIfExists(for: worktreeGroup.id)? - .dismissAllNotifications() + for worktreeID in actionableWorktreeIDs { + let host = terminalManager.hostIfExists(for: worktreeID) + // Unread-only view dismisses only what it shows; read entries stay. + if unreadOnly { + host?.dismissUnreadNotifications() + } else { + host?.dismissAllNotifications() } } } @@ -504,40 +551,60 @@ struct WorktreeNotificationsInspectorView: View { } private struct NotificationsInspectorContent: View { - let groups: [ToolbarNotificationRepositoryGroup] + let items: [FlatNotificationItem] + let groupedItems: [GroupedNotifications] + let isGrouped: Bool + let scope: NotificationScope + let unreadOnly: Bool + let selectedWorktreeID: Worktree.ID? + let prunedCount: Int + let hasNotificationsAnywhere: Bool + let onClearFilters: () -> Void let onSelectNotification: (Worktree.ID, WorktreeTerminalNotification) -> Void - let onSelectSurface: (Worktree.ID, UUID) -> Void + let onMarkRead: (Worktree.ID, UUID) -> Void + let onDismiss: (Worktree.ID, UUID) -> Void + let onMarkAllRead: () -> Void let onDismissAll: () -> Void + @State private var confirmingDismissAll = false + var body: some View { - let count = groups.reduce(0) { $0 + $1.notificationCount } - let unseenCount = groups.flatMap(\.worktrees).reduce(0) { $0 + $1.unseenNotificationCount } + let isEmpty = items.isEmpty && prunedCount == 0 // `List` virtualizes rows (NSTableView), so a large backlog builds only the // on-screen rows on open, never the whole log or its markdown bodies. List { - ForEach(groups) { repository in - ForEach(repository.worktrees) { worktree in + if isGrouped { + ForEach(groupedItems) { group in Section { - ForEach(worktree.notifications) { notification in + ForEach(group.items) { notification in NotificationRow( notification: notification, - worktreeID: worktree.id, - onSelect: onSelectNotification - ) - } - // A surface whose unread notifications were all pruned by the cap - // still needs a way back, so synthesize one row per orphaned surface. - ForEach(worktree.prunedUnseenSurfaces) { surface in - PrunedNotificationRow( - surface: surface, - worktreeID: worktree.id, - onSelect: onSelectSurface + worktreeID: group.worktreeID, + source: nil, + onSelect: onSelectNotification, + onMarkRead: onMarkRead, + onDismiss: onDismiss ) } } header: { - NotificationWorktreeHeader(repository: repository, worktree: worktree) + NotificationGroupHeader(group: group) } } + } else { + ForEach(items) { item in + NotificationRow( + notification: item.notification, + worktreeID: item.worktreeID, + source: scope == .all ? Self.rowSource(item, selectedWorktreeID: selectedWorktreeID) : nil, + onSelect: onSelectNotification, + onMarkRead: onMarkRead, + onDismiss: onDismiss + ) + } + } + // One aggregate for evicted unread; timeless, so it sits at the bottom. + if prunedCount > 0 { + PrunedNotificationSummaryRow(count: prunedCount) } } .listStyle(.inset) @@ -547,107 +614,262 @@ private struct NotificationsInspectorContent: View { // The header bar sits only over the list, so it scrolls under it for the // native top blur; the empty state reserves no bar. .safeAreaBar(edge: .top) { - if !groups.isEmpty { - HStack { - Text("Notifications") - .appFont(.headline) - Spacer() - Button("Dismiss All", action: onDismissAll) - .buttonStyle(.borderless) - .disabled(count == 0 && unseenCount == 0) - .help("Dismiss all notifications.") - } - .padding(.horizontal) - .padding(.vertical) + if hasNotificationsAnywhere { + NotificationsInspectorHeader( + canAct: !isEmpty, + onMarkAllRead: onMarkAllRead, + onRequestDismissAll: { confirmingDismissAll = true } + ) } } - // Empty state as a background so it fills the whole pane (past the safe area) - // instead of being offset by the reserved bar. - .background { - if groups.isEmpty { - ContentUnavailableView( - "No Notifications", - systemImage: "bell.slash", - description: Text("Agent and terminal notifications appear here.") + // Overlay, not background, so the Show All button stays hittable above the list. + .overlay { + if isEmpty { + NotificationsEmptyState( + scope: scope, + unreadOnly: unreadOnly, + hasNotificationsElsewhere: hasNotificationsAnywhere, + onClearFilters: onClearFilters ) } } + .confirmationDialog( + // Dismiss All also clears pruned unread, so the count includes it. + Self.dismissAllTitle(scope: scope, unreadOnly: unreadOnly, count: items.count + prunedCount), + isPresented: $confirmingDismissAll, + titleVisibility: .visible + ) { + Button("Dismiss All", role: .destructive, action: onDismissAll) + Button("Cancel", role: .cancel) {} + } + } + + private static func dismissAllTitle(scope: NotificationScope, unreadOnly: Bool, count: Int) -> String { + let suffix = scope == .currentWorktree ? " in this worktree" : "" + let adjective = unreadOnly ? "unread " : "" + guard count > 0 else { return "Dismiss all \(adjective)notifications\(suffix)?" } + let noun = count == 1 ? "notification" : "notifications" + return "Dismiss all \(count) \(adjective)\(noun)\(suffix)?" + } + + private static func rowSource( + _ item: FlatNotificationItem, selectedWorktreeID: Worktree.ID? + ) -> NotificationRowSource { + NotificationRowSource( + repositoryName: item.repositoryName, + repositoryColor: item.repositoryColor, + worktreeName: item.worktreeName, + isFolder: item.isFolder, + isSelectedWorktree: item.worktreeID == selectedWorktreeID + ) } } -/// Section header mirroring the sidebar's row identity: colored repo name, -/// folder glyph for folder repos, and the worktree's sidebar title. -private struct NotificationWorktreeHeader: View { - let repository: ToolbarNotificationRepositoryGroup - let worktree: ToolbarNotificationWorktreeGroup +private struct NotificationsInspectorHeader: View { + let canAct: Bool + let onMarkAllRead: () -> Void + let onRequestDismissAll: () -> Void var body: some View { - HStack(spacing: 5) { - if repository.isFolder { - Image(systemName: "folder") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 14, height: 14) - .foregroundStyle(.secondary) - .accessibilityHidden(true) - } else { - Image(worktree.pullRequestIcon.assetName) - .renderingMode(.template) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 14, height: 14) - .foregroundStyle(worktree.pullRequestIcon.color) - .help(worktree.pullRequestIcon.statusDescription) - .accessibilityLabel(worktree.pullRequestIcon.statusDescription) + HStack { + NotificationFilterMenu() + Spacer() + NotificationOverflowMenu( + onMarkAllRead: onMarkAllRead, + onRequestDismissAll: onRequestDismissAll + ) + .disabled(!canAct) + } + .padding(.horizontal) + .padding(.vertical) + } +} + +/// Scope filter (its label doubles as the pane title) plus the unread-only and +/// grouping toggles. Binds settings directly for stable bindings, not closures. +private struct NotificationFilterMenu: View { + @Shared(.settingsFile) private var settingsFile + + var body: some View { + let scope = settingsFile.global.notificationScope + let unreadOnly = settingsFile.global.notificationsUnreadOnly + Menu { + Picker("Show Notifications From", selection: Binding($settingsFile.global.notificationScope)) { + ForEach(NotificationScope.allCases, id: \.self) { option in + Text(option.inspectorTitle).tag(option) + } } - // Repo keeps layout priority so the colored tag doesn't truncate first, - // mirroring the sidebar highlight subtitle. - Text(repository.name) - .foregroundStyle(repositoryStyle) - .layoutPriority(1) - // A folder's synthetic worktree repeats the repo name; skip the trail. - if !repository.isFolder { - Text(verbatim: "·") - .foregroundStyle(.tertiary) - Text(worktree.name) + .pickerStyle(.inline) + .labelsHidden() + Divider() + Toggle("Unread Only", isOn: Binding($settingsFile.global.notificationsUnreadOnly)) + Toggle("Group into Worktrees", isOn: Binding($settingsFile.global.notificationsGroupedByWorktree)) + } label: { + HStack(spacing: 3) { + Text(scope.inspectorTitle) + .appFont(.headline) + .foregroundStyle(.primary) + // Unread-only is easy to forget once set; flag it with the row dot. + if unreadOnly { + Circle() + .fill(.orange) + .frame(width: 6, height: 6) + .padding(.trailing, 4) + .accessibilityLabel("Unread only") + } + // A plain button menu draws no indicator, so supply the chevron here. + Image(systemName: "chevron.down") + .appFont(.caption2) .foregroundStyle(.secondary) + .accessibilityHidden(true) } } + // Plain button menu renders the label faithfully; borderless overrides its weight. + .menuStyle(.button) + .buttonStyle(.plain) + .fixedSize() + .help("Filter which worktrees' notifications are shown.") + } +} + +/// Section header for the grouped layout. +private struct NotificationGroupHeader: View { + let group: GroupedNotifications + + var body: some View { + NotificationSourceTag( + repositoryName: group.repositoryName, + repositoryColor: group.repositoryColor, + worktreeName: group.worktreeName, + isFolder: group.isFolder + ) .appFont(.subheadline, weight: .medium) - .lineLimit(1) .textCase(nil) } +} - private var repositoryStyle: AnyShapeStyle { - repository.color.map { AnyShapeStyle($0.color) } ?? AnyShapeStyle(.secondary) +/// Overflow menu; Dismiss All is destructive so the owner routes it through a +/// confirmation. +private struct NotificationOverflowMenu: View { + let onMarkAllRead: () -> Void + let onRequestDismissAll: () -> Void + + var body: some View { + Menu { + Button(action: onMarkAllRead) { + Label("Mark All as Read", systemImage: "checkmark.circle") + } + Button(role: .destructive, action: onRequestDismissAll) { + Label("Dismiss All", systemImage: "trash") + } + } label: { + Image(systemName: "ellipsis") + .appFont(.headline) + .contentShape(.rect) + .accessibilityLabel("More notification actions") + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + .help("More notification actions.") } } +/// Distinguishes "nothing anywhere" from "nothing matches the active filters", +/// which offers a one-tap reset back to every notification. +private struct NotificationsEmptyState: View { + let scope: NotificationScope + let unreadOnly: Bool + let hasNotificationsElsewhere: Bool + let onClearFilters: () -> Void + + var body: some View { + if filtersActive, hasNotificationsElsewhere { + ContentUnavailableView { + Label(title, systemImage: "bell.slash") + } description: { + Text(message) + } actions: { + Button("Show All Notifications", action: onClearFilters) + } + } else { + ContentUnavailableView( + "No Notifications", + systemImage: "bell.slash", + description: Text("Agent and terminal notifications appear here.") + ) + } + } + + private var filtersActive: Bool { + scope == .currentWorktree || unreadOnly + } + + private var title: String { + switch (unreadOnly, scope) { + case (true, .currentWorktree): "No Unread in This Worktree" + case (true, .all): "No Unread Notifications" + case (false, _): "None in This Worktree" + } + } + + private var message: String { + switch (unreadOnly, scope) { + case (true, .currentWorktree): "Read notifications and other worktrees are hidden." + case (true, .all): "Read notifications are hidden." + case (false, _): "Notifications from other worktrees are hidden." + } + } +} + +/// The repo/worktree source shown inside a flat-list row; nil in grouped mode +/// where the section header carries it instead. +private struct NotificationRowSource { + let repositoryName: String + let repositoryColor: RepositoryColor? + let worktreeName: String + let isFolder: Bool + let isSelectedWorktree: Bool +} + private struct NotificationRow: View { let notification: WorktreeTerminalNotification let worktreeID: Worktree.ID + let source: NotificationRowSource? let onSelect: (Worktree.ID, WorktreeTerminalNotification) -> Void + let onMarkRead: (Worktree.ID, UUID) -> Void + let onDismiss: (Worktree.ID, UUID) -> Void var body: some View { - // Notification titles are the agent slug ("claude", "codex", …) when the - // notification came from an agent; show its mark and display name instead. + // Agent notifications carry the agent slug as the title; show its mark and name. let agent = SkillAgent(rawValue: notification.title.lowercased()) let title = agent?.displayName ?? (notification.title.isEmpty ? "Terminal" : notification.title) + // The whole row navigates to the source; the chevron is a passive affordance. Button { onSelect(worktreeID, notification) } label: { HStack(alignment: .top, spacing: 10) { NotificationSourceIcon(agent: agent) .padding(.top, 1) - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: 3) { + // Dimmed for other worktrees so the selected one stands out. + if let source { + NotificationSourceTag( + repositoryName: source.repositoryName, + repositoryColor: source.repositoryColor, + worktreeName: source.worktreeName, + isFolder: source.isFolder + ) + .appFont(.caption) + .opacity(source.isSelectedWorktree ? 1 : 0.5) + } HStack(alignment: .firstTextBaseline, spacing: 6) { Text(title) .appFont(.subheadline, weight: .semibold) .foregroundStyle(notification.isRead ? Color.secondary : Color.primary) .lineLimit(1) Spacer(minLength: 6) - // Self-updating relative time; no shared clock needed, so a row's - // markdown body is never re-parsed just to advance the timestamp. + // Self-updating relative time, so the markdown body isn't re-parsed to tick. Text(notification.createdAt, style: .relative) .appFont(.caption) .foregroundStyle(.tertiary) @@ -660,19 +882,94 @@ private struct NotificationRow: View { .accessibilityHidden(true) } if !notification.body.isEmpty { - Text(Self.markdown(notification.body)) - .appFont(.callout) - .foregroundStyle(notification.isRead ? Color.secondary : Color.primary) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) + NotificationBodyText(text: notification.body, isRead: notification.isRead) } } + Image(systemName: "chevron.forward") + .appFont(.caption) + .foregroundStyle(.tertiary) + .padding(.top, 1) + .accessibilityHidden(true) } .padding(.vertical, 4) .contentShape(.rect) .frame(maxWidth: .infinity, alignment: .leading) } .buttonStyle(.plain) + .contextMenu { + Button { + Self.copyToPasteboard(title: title, body: notification.body) + } label: { + Label("Copy Notification", systemImage: "doc.on.doc") + } + Section { + Button { + onMarkRead(worktreeID, notification.id) + } label: { + Label("Mark as Read", systemImage: "checkmark.circle") + } + .disabled(notification.isRead) + Button(role: .destructive) { + onDismiss(worktreeID, notification.id) + } label: { + Label("Dismiss", systemImage: "trash") + } + } + } + } + + private static func copyToPasteboard(title: String, body: String) { + var parts: [String] = [] + for part in [title, body] where !part.isEmpty { + parts.append(part) + } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(parts.joined(separator: "\n\n"), forType: .string) + } +} + +/// Colored repository name, then "· worktree" for non-folder repos. Shared by +/// the flat row source line and the grouped section header (which set the font). +private struct NotificationSourceTag: View { + let repositoryName: String + let repositoryColor: RepositoryColor? + let worktreeName: String + let isFolder: Bool + + var body: some View { + HStack(spacing: 4) { + // Repo keeps layout priority so the colored tag doesn't truncate first. + Text(repositoryName) + .foregroundStyle(repositoryStyle) + .layoutPriority(1) + // A folder's synthetic worktree repeats the repo name, so skip the trail. + if !isFolder { + Text(verbatim: "·") + .foregroundStyle(.tertiary) + Text(worktreeName) + .foregroundStyle(.secondary) + } + } + .lineLimit(1) + } + + private var repositoryStyle: AnyShapeStyle { + repositoryColor.map { AnyShapeStyle($0.color) } ?? AnyShapeStyle(.secondary) + } +} + +/// Notification body, capped at three lines. Truncated, not expandable: the row +/// click navigates to the source instead. +private struct NotificationBodyText: View { + let text: String + let isRead: Bool + + var body: some View { + Text(Self.markdown(text)) + .appFont(.callout) + .foregroundStyle(isRead ? Color.secondary : Color.primary) + .lineLimit(3) + .frame(maxWidth: .infinity, alignment: .leading) } private static func markdown(_ string: String) -> AttributedString { @@ -683,48 +980,42 @@ private struct NotificationRow: View { } } -/// Synthesized row for a surface whose unread notifications the cap already -/// pruned from the log. Keeps the unread reachable: tapping focuses the surface. -private struct PrunedNotificationRow: View { - let surface: WorktreeUnseenSurface - let worktreeID: Worktree.ID - let onSelect: (Worktree.ID, UUID) -> Void +/// Non-interactive aggregate reconciling the bell badge with the visible rows. +private struct PrunedNotificationSummaryRow: View { + let count: Int var body: some View { - Button { - onSelect(worktreeID, surface.id) - } label: { - HStack(alignment: .top, spacing: 10) { - NotificationSourceIcon(agent: nil) - .padding(.top, 1) - VStack(alignment: .leading, spacing: 2) { - HStack(alignment: .firstTextBaseline, spacing: 6) { - Text(title) - .appFont(.subheadline, weight: .semibold) - .foregroundStyle(.primary) - .lineLimit(1) - Spacer(minLength: 6) - Circle() - .fill(.orange) - .frame(width: 6, height: 6) - .accessibilityHidden(true) - } - Text("Cleared per your Notification settings.") - .appFont(.callout) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) - } + HStack(alignment: .top, spacing: 10) { + NotificationSourceIcon(agent: nil) + .padding(.top, 1) + VStack(alignment: .leading, spacing: 2) { + Text(title) + .appFont(.subheadline, weight: .semibold) + .foregroundStyle(.secondary) + .lineLimit(1) + Text("Older unread cleared per your Notification settings.") + .appFont(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) } - .padding(.vertical, 4) - .contentShape(.rect) - .frame(maxWidth: .infinity, alignment: .leading) } - .buttonStyle(.plain) + .padding(.vertical, 4) + .contentShape(.rect) + .frame(maxWidth: .infinity, alignment: .leading) } private var title: String { - surface.count == 1 ? "1 unread notification" : "\(surface.count) unread notifications" + count == 1 ? "1 more unread beyond retention" : "\(count) more unread beyond retention" + } +} + +extension NotificationScope { + fileprivate var inspectorTitle: String { + switch self { + case .all: "All Notifications" + case .currentWorktree: "Current Worktree" + } } } diff --git a/supacode/Features/Terminal/BusinessLogic/WorktreeContentHost.swift b/supacode/Features/Terminal/BusinessLogic/WorktreeContentHost.swift index f5044cc47..574d2d136 100644 --- a/supacode/Features/Terminal/BusinessLogic/WorktreeContentHost.swift +++ b/supacode/Features/Terminal/BusinessLogic/WorktreeContentHost.swift @@ -375,6 +375,14 @@ final class WorktreeContentHost { emitNotificationStateChanged() } + /// Drops unread entries, visible and pruned; read entries stay. Backs the + /// inspector's "Dismiss All" while the unread-only filter is active. + func dismissUnreadNotifications() { + notifications.removeAll { !$0.isRead } + clearAllUnseenCounters() + emitNotificationStateChanged() + } + func enforceNotificationRetentionLimit() { guard trimNotificationsToRetentionLimit() else { return } emitNotificationStateChanged() diff --git a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift index d24891c31..3692b46d8 100644 --- a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift +++ b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift @@ -1954,6 +1954,11 @@ final class WorktreeTerminalManager { emitProjection(for: worktreeID) } + func dismissNotification(worktreeID: Worktree.ID, notificationID: UUID) { + hosts[worktreeID]?.dismissNotification(notificationID) + emitProjection(for: worktreeID) + } + /// Indicator and projection updates propagate via each state's notification /// callbacks. Every state is swept, not just the unread ones, so a surface /// whose unseen mirror drifted out of sync with its notifications is repaired. diff --git a/supacodeTests/SettingsFeatureTests.swift b/supacodeTests/SettingsFeatureTests.swift index 4c06f4898..7e78f3524 100644 --- a/supacodeTests/SettingsFeatureTests.swift +++ b/supacodeTests/SettingsFeatureTests.swift @@ -183,6 +183,29 @@ struct SettingsFeatureTests { #expect(settingsFile.global.chromeTextSize == .large) } + @Test(.dependencies) func unrelatedSettingsChangeKeepsNotificationInspectorPrefs() async { + // The inspector owns scope / grouping / unread-only; the Settings window only + // carries them through, so an unrelated change here must not reset them. + var initialSettings = GlobalSettings.default + initialSettings.notificationScope = .currentWorktree + initialSettings.notificationsGroupedByWorktree = true + initialSettings.notificationsUnreadOnly = true + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global = initialSettings } + + let store = TestStore(initialState: SettingsFeature.State(settings: initialSettings)) { + SettingsFeature() + } + + await store.send(.binding(.set(\.terminalHibernationEnabled, false))) { + $0.terminalHibernationEnabled = false + } + await store.receive(\.delegate.settingsChanged) + #expect(settingsFile.global.notificationScope == .currentWorktree) + #expect(settingsFile.global.notificationsGroupedByWorktree) + #expect(settingsFile.global.notificationsUnreadOnly) + } + @Test(.dependencies) func togglingAutomaticRepositoryRefreshPersistsChanges() async { @Shared(.settingsFile) var settingsFile $settingsFile.withLock { $0.global = .default } diff --git a/supacodeTests/ToolbarNotificationGroupingTests.swift b/supacodeTests/ToolbarNotificationGroupingTests.swift index 4da0729ca..984801a26 100644 --- a/supacodeTests/ToolbarNotificationGroupingTests.swift +++ b/supacodeTests/ToolbarNotificationGroupingTests.swift @@ -367,7 +367,7 @@ struct ToolbarNotificationGroupingTests { ) } - @Test func prunedUnreadSurfacesFormAGroupWithSyntheticRows() { + @Test func unseenCountCountsSurfacesWhoseNotificationsWereAllPruned() { let repoPath = "/tmp/repo" let main = makeWorktree(id: repoPath, name: "main", repoRoot: repoPath) let feature = makeWorktree(id: "\(repoPath)/feature", name: "feature", repoRoot: repoPath) @@ -383,10 +383,9 @@ struct ToolbarNotificationGroupingTests { let worktree = state.computeToolbarNotificationGroups().first?.worktrees.first #expect(worktree?.unseenNotificationCount == 3) - #expect(worktree?.prunedUnseenSurfaces.map(\.id) == [surfaceID]) } - @Test func surfaceWithVisibleNotificationIsNotSynthesized() { + @Test func unseenCountReflectsSurfaceWithVisibleNotification() { let repoPath = "/tmp/repo" let main = makeWorktree(id: repoPath, name: "main", repoRoot: repoPath) let feature = makeWorktree(id: "\(repoPath)/feature", name: "feature", repoRoot: repoPath) @@ -394,7 +393,6 @@ struct ToolbarNotificationGroupingTests { var state = RepositoriesFeature.State(reconciledRepositories: [repo]) state.repositoryRoots = [repo.rootURL] - // The surface still has a visible notification, so no synthetic row is added. let surfaceID = UUID() setRowNotifications( &state, id: feature.id, @@ -407,7 +405,6 @@ struct ToolbarNotificationGroupingTests { let worktree = state.computeToolbarNotificationGroups().first?.worktrees.first #expect(worktree?.unseenNotificationCount == 1) - #expect(worktree?.prunedUnseenSurfaces.isEmpty == true) } private func setRowNotifications( @@ -478,3 +475,561 @@ struct ScriptMenuIdentityTests { )) } } + +@MainActor +struct NotificationInspectorListTests { + @Test func flattenSortsNewestFirstAndDenormalizesSource() { + let repoPath = "/tmp/repo" + let featureA = makeWorktree(id: "\(repoPath)/a", name: "feature-a", repoRoot: repoPath) + let featureB = makeWorktree(id: "\(repoPath)/b", name: "feature-b", repoRoot: repoPath) + let repo = makeRepository(id: repoPath, name: "Repo", worktrees: [featureA, featureB]) + var state = RepositoriesFeature.State(reconciledRepositories: [repo]) + state.repositoryRoots = [repo.rootURL] + + setRowNotifications( + &state, id: featureA.id, + notifications: [ + WorktreeTerminalNotification(surfaceID: UUID(), title: "old", body: "x", createdAt: at(100)), + WorktreeTerminalNotification(surfaceID: UUID(), title: "new", body: "x", createdAt: at(300)), + ]) + setRowNotifications( + &state, id: featureB.id, + notifications: [ + WorktreeTerminalNotification(surfaceID: UUID(), title: "mid", body: "x", createdAt: at(200)) + ]) + // The group name resolves from the sidebar title, so set a custom one to + // assert flatten denormalizes it onto the row deterministically. + state.sidebarItems[id: featureA.id]?.customTitle = "Feature A" + + let items = NotificationInspectorList.flatten(state.computeToolbarNotificationGroups()) + + #expect(items.map(\.notification.title) == ["new", "mid", "old"]) + #expect(items.first?.worktreeID == featureA.id) + #expect(items.first?.repositoryName == "Repo") + #expect(items.first?.worktreeName == "Feature A") + } + + @Test func visibleItemsAppliesScope() { + let repoPath = "/tmp/repo" + let featureA = makeWorktree(id: "\(repoPath)/a", name: "feature-a", repoRoot: repoPath) + let featureB = makeWorktree(id: "\(repoPath)/b", name: "feature-b", repoRoot: repoPath) + let repo = makeRepository(id: repoPath, name: "Repo", worktrees: [featureA, featureB]) + var state = RepositoriesFeature.State(reconciledRepositories: [repo]) + state.repositoryRoots = [repo.rootURL] + + setRowNotifications( + &state, id: featureA.id, + notifications: [ + WorktreeTerminalNotification(surfaceID: UUID(), title: "a1", body: "x", createdAt: at(300)), + WorktreeTerminalNotification(surfaceID: UUID(), title: "a2", body: "x", createdAt: at(100)), + ]) + setRowNotifications( + &state, id: featureB.id, + notifications: [ + WorktreeTerminalNotification(surfaceID: UUID(), title: "b1", body: "x", createdAt: at(200)) + ]) + + let items = NotificationInspectorList.flatten(state.computeToolbarNotificationGroups()) + + #expect( + NotificationInspectorList.visibleItems( + items, scope: .all, selectedWorktreeID: featureA.id, unreadOnly: false + ).count == 3) + #expect( + NotificationInspectorList.visibleItems( + items, scope: .currentWorktree, selectedWorktreeID: featureA.id, unreadOnly: false + ).map(\.notification.title) == ["a1", "a2"]) + #expect( + NotificationInspectorList.visibleItems( + items, scope: .currentWorktree, selectedWorktreeID: featureB.id, unreadOnly: false + ).map(\.notification.title) == ["b1"]) + // No selection under the current-worktree scope lists nothing, not everything. + #expect( + NotificationInspectorList.visibleItems( + items, scope: .currentWorktree, selectedWorktreeID: nil, unreadOnly: false + ).isEmpty) + } + + @Test func visibleItemsUnreadOnlyHidesReadAcrossScopes() { + let repoPath = "/tmp/repo" + let featureA = makeWorktree(id: "\(repoPath)/a", name: "feature-a", repoRoot: repoPath) + let featureB = makeWorktree(id: "\(repoPath)/b", name: "feature-b", repoRoot: repoPath) + let repo = makeRepository(id: repoPath, name: "Repo", worktrees: [featureA, featureB]) + var state = RepositoriesFeature.State(reconciledRepositories: [repo]) + state.repositoryRoots = [repo.rootURL] + + setRowNotifications( + &state, id: featureA.id, + notifications: [ + WorktreeTerminalNotification(surfaceID: UUID(), title: "a1", body: "x", createdAt: at(300)), + WorktreeTerminalNotification(surfaceID: UUID(), title: "a2", body: "x", createdAt: at(100), isRead: true), + ]) + setRowNotifications( + &state, id: featureB.id, + notifications: [ + WorktreeTerminalNotification(surfaceID: UUID(), title: "b1", body: "x", createdAt: at(200)), + WorktreeTerminalNotification(surfaceID: UUID(), title: "b2", body: "x", createdAt: at(50), isRead: true), + ]) + + let items = NotificationInspectorList.flatten(state.computeToolbarNotificationGroups()) + + // Under `.all`, unread from every worktree survives (selection is ignored) and + // all read entries drop, in global time order. + #expect( + NotificationInspectorList.visibleItems( + items, scope: .all, selectedWorktreeID: featureA.id, unreadOnly: true + ).map(\.notification.title) == ["a1", "b1"]) + // Current-worktree scope narrows the same filter to just the selected worktree's unread. + #expect( + NotificationInspectorList.visibleItems( + items, scope: .currentWorktree, selectedWorktreeID: featureA.id, unreadOnly: true + ).map(\.notification.title) == ["a1"]) + #expect( + NotificationInspectorList.visibleItems( + items, scope: .currentWorktree, selectedWorktreeID: featureB.id, unreadOnly: true + ).map(\.notification.title) == ["b1"]) + } + + @Test func prunedUnreadCountAndActionableWorktreesRespectScope() { + let repoPath = "/tmp/repo" + let featureA = makeWorktree(id: "\(repoPath)/a", name: "feature-a", repoRoot: repoPath) + let featureB = makeWorktree(id: "\(repoPath)/b", name: "feature-b", repoRoot: repoPath) + let repo = makeRepository(id: repoPath, name: "Repo", worktrees: [featureA, featureB]) + var state = RepositoriesFeature.State(reconciledRepositories: [repo]) + state.repositoryRoots = [repo.rootURL] + + // Both worktrees have only pruned unread (no visible notifications). + for (worktree, count) in [(featureA, 3), (featureB, 2)] { + state.sidebarItems[id: worktree.id]?.notifications = [] + state.sidebarItems[id: worktree.id]?.hasUnseenNotifications = true + state.sidebarItems[id: worktree.id]?.unseenSurfaces = [WorktreeUnseenSurface(id: UUID(), count: count)] + } + + let groups = state.computeToolbarNotificationGroups() + + #expect( + NotificationInspectorList.prunedUnreadCount(groups: groups, scope: .all, selectedWorktreeID: nil) == 5) + #expect( + NotificationInspectorList.prunedUnreadCount( + groups: groups, scope: .currentWorktree, selectedWorktreeID: featureA.id) == 3) + // Pruned-only worktrees still count as actionable so bulk actions clear them. + #expect( + Set(NotificationInspectorList.actionableWorktreeIDs(groups: groups, scope: .all, selectedWorktreeID: nil)) + == Set([featureA.id, featureB.id])) + #expect( + NotificationInspectorList.actionableWorktreeIDs( + groups: groups, scope: .currentWorktree, selectedWorktreeID: featureB.id) == [featureB.id]) + } + + @Test func flatCacheRebuildsWithTheGroupsCache() { + let repoPath = "/tmp/repo" + let featureA = makeWorktree(id: "\(repoPath)/a", name: "feature-a", repoRoot: repoPath) + let repo = makeRepository(id: repoPath, name: "Repo", worktrees: [featureA]) + var state = RepositoriesFeature.State(reconciledRepositories: [repo]) + state.repositoryRoots = [repo.rootURL] + + setRowNotifications( + &state, id: featureA.id, + notifications: [ + WorktreeTerminalNotification(surfaceID: UUID(), title: "n1", body: "x", createdAt: at(100)), + WorktreeTerminalNotification(surfaceID: UUID(), title: "n2", body: "x", createdAt: at(200)), + ]) + + #expect(state.toolbarNotificationItemsCache.isEmpty) + state.recomputeToolbarNotificationGroupsIfChanged() + + #expect( + state.toolbarNotificationItemsCache + == NotificationInspectorList.flatten(state.toolbarNotificationGroupsCache)) + #expect(state.toolbarNotificationItemsCache.map(\.notification.title) == ["n2", "n1"]) + } + + @Test func prunedUnreadCountFoldsPartiallyPrunedSurfaces() { + let repoPath = "/tmp/repo" + let feature = makeWorktree(id: "\(repoPath)/feature", name: "feature", repoRoot: repoPath) + let repo = makeRepository(id: repoPath, name: "Repo", worktrees: [feature]) + var state = RepositoriesFeature.State(reconciledRepositories: [repo]) + state.repositoryRoots = [repo.rootURL] + + // Surface X keeps 1 visible unread but has 3 outstanding (2 evicted); surface + // Y is fully pruned (2). Pruned = (3 - 1) + 2 = 4, not the 2 a surface-level + // count would report. + let surfaceX = UUID() + setRowNotifications( + &state, id: feature.id, + notifications: [ + WorktreeTerminalNotification(surfaceID: surfaceX, title: "X", body: "x", createdAt: at(100)) + ]) + state.sidebarItems[id: feature.id]?.unseenSurfaces = [ + WorktreeUnseenSurface(id: surfaceX, count: 3), + WorktreeUnseenSurface(id: UUID(), count: 2), + ] + + let groups = state.computeToolbarNotificationGroups() + #expect(NotificationInspectorList.prunedUnreadCount(groups: groups, scope: .all, selectedWorktreeID: nil) == 4) + } + + @Test func prunedUnreadCountClampsPerSurfaceUnderCounterDrift() { + let repoPath = "/tmp/repo" + let feature = makeWorktree(id: "\(repoPath)/feature", name: "feature", repoRoot: repoPath) + let repo = makeRepository(id: repoPath, name: "Repo", worktrees: [feature]) + var state = RepositoriesFeature.State(reconciledRepositories: [repo]) + state.repositoryRoots = [repo.rootURL] + + // Surface X's counter (1) has drifted below its 2 visible unread rows; surface + // Y is fully pruned (5). Per-surface clamping keeps X at 0, so pruned = 5, not + // the 4 a pooled worktree-level subtraction would report. + let surfaceX = UUID() + setRowNotifications( + &state, id: feature.id, + notifications: [ + WorktreeTerminalNotification(surfaceID: surfaceX, title: "X1", body: "x", createdAt: at(100)), + WorktreeTerminalNotification(surfaceID: surfaceX, title: "X2", body: "x", createdAt: at(110)), + ]) + state.sidebarItems[id: feature.id]?.unseenSurfaces = [ + WorktreeUnseenSurface(id: surfaceX, count: 1), + WorktreeUnseenSurface(id: UUID(), count: 5), + ] + + let groups = state.computeToolbarNotificationGroups() + #expect(NotificationInspectorList.prunedUnreadCount(groups: groups, scope: .all, selectedWorktreeID: nil) == 5) + } + + @Test func flattenTieBreaksEqualTimestampsByDescendingID() { + let repoPath = "/tmp/repo" + let feature = makeWorktree(id: "\(repoPath)/feature", name: "feature", repoRoot: repoPath) + let repo = makeRepository(id: repoPath, name: "Repo", worktrees: [feature]) + var state = RepositoriesFeature.State(reconciledRepositories: [repo]) + state.repositoryRoots = [repo.rootURL] + + let low = UUID(uuidString: "00000000-0000-0000-0000-000000000001")! + let high = UUID(uuidString: "FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF")! + setRowNotifications( + &state, id: feature.id, + notifications: [ + WorktreeTerminalNotification(id: low, surfaceID: UUID(), title: "low", body: "x", createdAt: at(100)), + WorktreeTerminalNotification(id: high, surfaceID: UUID(), title: "high", body: "x", createdAt: at(100)), + ]) + + // Equal timestamps fall back to descending uuidString for a stable order. + let items = NotificationInspectorList.flatten(state.computeToolbarNotificationGroups()) + #expect(items.map(\.notification.title) == ["high", "low"]) + } + + @Test func flattenSortsAcrossRepositoriesAndCarriesEachSource() { + let repoAPath = "/tmp/repo-a" + let repoBPath = "/tmp/repo-b" + let featureA = makeWorktree(id: "\(repoAPath)/a", name: "a", repoRoot: repoAPath) + let featureB = makeWorktree(id: "\(repoBPath)/b", name: "b", repoRoot: repoBPath) + let repoA = makeRepository(id: repoAPath, name: "Repo A", worktrees: [featureA]) + let repoB = makeRepository(id: repoBPath, name: "Repo B", worktrees: [featureB]) + var state = RepositoriesFeature.State(reconciledRepositories: [repoA, repoB]) + state.repositoryRoots = [repoA.rootURL, repoB.rootURL] + state.$sidebar.withLock { sidebar in + sidebar.sections[repoA.id] = .init(title: "Repo A", color: .teal) + sidebar.sections[repoB.id] = .init(title: "Repo B", color: .purple) + } + + setRowNotifications( + &state, id: featureA.id, + notifications: [ + WorktreeTerminalNotification(surfaceID: UUID(), title: "a-old", body: "x", createdAt: at(100)), + WorktreeTerminalNotification(surfaceID: UUID(), title: "a-new", body: "x", createdAt: at(300)), + ]) + setRowNotifications( + &state, id: featureB.id, + notifications: [ + WorktreeTerminalNotification(surfaceID: UUID(), title: "b-mid", body: "x", createdAt: at(200)) + ]) + + let items = NotificationInspectorList.flatten(state.computeToolbarNotificationGroups()) + // Global time sort ignores repository grouping. + #expect(items.map(\.notification.title) == ["a-new", "b-mid", "a-old"]) + let byTitle = Dictionary(uniqueKeysWithValues: items.map { ($0.notification.title, $0) }) + #expect(byTitle["a-new"]?.repositoryName == "Repo A") + #expect(byTitle["a-new"]?.repositoryColor == .teal) + #expect(byTitle["b-mid"]?.repositoryName == "Repo B") + #expect(byTitle["b-mid"]?.repositoryColor == .purple) + } + + @Test func visibleGroupsReadsGroupedCacheScoped() { + let repoPath = "/tmp/repo" + let featureA = makeWorktree(id: "\(repoPath)/a", name: "a", repoRoot: repoPath) + let featureB = makeWorktree(id: "\(repoPath)/b", name: "b", repoRoot: repoPath) + let repo = makeRepository(id: repoPath, name: "Repo", worktrees: [featureA, featureB]) + var state = RepositoriesFeature.State(reconciledRepositories: [repo]) + state.repositoryRoots = [repo.rootURL] + + setRowNotifications( + &state, id: featureA.id, + notifications: [ + WorktreeTerminalNotification(surfaceID: UUID(), title: "a1", body: "x", createdAt: at(100)) + ]) + setRowNotifications( + &state, id: featureB.id, + notifications: [ + WorktreeTerminalNotification(surfaceID: UUID(), title: "b1", body: "x", createdAt: at(200)) + ]) + + let groups = state.computeToolbarNotificationGroups() + let all = NotificationInspectorList.visibleGroups(groups, scope: .all, selectedWorktreeID: nil, unreadOnly: false) + #expect(Set(all.map(\.worktreeID)) == Set([featureA.id, featureB.id])) + #expect(all.allSatisfy { !$0.items.isEmpty }) + + let current = NotificationInspectorList.visibleGroups( + groups, scope: .currentWorktree, selectedWorktreeID: featureB.id, unreadOnly: false) + #expect(current.map(\.worktreeID) == [featureB.id]) + #expect(current.first?.items.map(\.title) == ["b1"]) + } + + @Test func visibleGroupsUnreadOnlyDropsReadAndEmptiedSections() { + let repoPath = "/tmp/repo" + let featureA = makeWorktree(id: "\(repoPath)/a", name: "feature-a", repoRoot: repoPath) + let featureB = makeWorktree(id: "\(repoPath)/b", name: "feature-b", repoRoot: repoPath) + let repo = makeRepository(id: repoPath, name: "Repo", worktrees: [featureA, featureB]) + var state = RepositoriesFeature.State(reconciledRepositories: [repo]) + state.repositoryRoots = [repo.rootURL] + + setRowNotifications( + &state, id: featureA.id, + notifications: [ + WorktreeTerminalNotification(surfaceID: UUID(), title: "a1", body: "x", createdAt: at(300)), + WorktreeTerminalNotification(surfaceID: UUID(), title: "a2", body: "x", createdAt: at(100), isRead: true), + ]) + setRowNotifications( + &state, id: featureB.id, + notifications: [ + WorktreeTerminalNotification(surfaceID: UUID(), title: "b1", body: "x", createdAt: at(200), isRead: true) + ]) + + let groups = state.computeToolbarNotificationGroups() + let sections = NotificationInspectorList.visibleGroups( + groups, scope: .all, selectedWorktreeID: nil, unreadOnly: true) + + // featureB is all-read, so its section drops entirely; featureA keeps only unread. + #expect(sections.map(\.worktreeID) == [featureA.id]) + #expect(sections.first?.items.map(\.title) == ["a1"]) + } + + @Test func visibleGroupsUnreadOnlyCombinesWithCurrentWorktreeScope() { + let repoPath = "/tmp/repo" + let featureA = makeWorktree(id: "\(repoPath)/a", name: "feature-a", repoRoot: repoPath) + let featureB = makeWorktree(id: "\(repoPath)/b", name: "feature-b", repoRoot: repoPath) + let repo = makeRepository(id: repoPath, name: "Repo", worktrees: [featureA, featureB]) + var state = RepositoriesFeature.State(reconciledRepositories: [repo]) + state.repositoryRoots = [repo.rootURL] + + setRowNotifications( + &state, id: featureA.id, + notifications: [ + WorktreeTerminalNotification(surfaceID: UUID(), title: "a1", body: "x", createdAt: at(300)), + WorktreeTerminalNotification(surfaceID: UUID(), title: "a2", body: "x", createdAt: at(100), isRead: true), + ]) + setRowNotifications( + &state, id: featureB.id, + notifications: [ + WorktreeTerminalNotification(surfaceID: UUID(), title: "b1", body: "x", createdAt: at(200)) + ]) + + let groups = state.computeToolbarNotificationGroups() + let sections = NotificationInspectorList.visibleGroups( + groups, scope: .currentWorktree, selectedWorktreeID: featureA.id, unreadOnly: true) + + // Scope keeps only the selected worktree; unread-only drops its read entry. + #expect(sections.map(\.worktreeID) == [featureA.id]) + #expect(sections.first?.items.map(\.title) == ["a1"]) + } + + @Test func visibleGroupsExcludesPrunedOnlyWorktree() { + let repoPath = "/tmp/repo" + let pruned = makeWorktree(id: "\(repoPath)/pruned", name: "pruned", repoRoot: repoPath) + let live = makeWorktree(id: "\(repoPath)/live", name: "live", repoRoot: repoPath) + let repo = makeRepository(id: repoPath, name: "Repo", worktrees: [pruned, live]) + var state = RepositoriesFeature.State(reconciledRepositories: [repo]) + state.repositoryRoots = [repo.rootURL] + + // `pruned` has outstanding unread but no visible notifications (cap-evicted). + state.sidebarItems[id: pruned.id]?.notifications = [] + state.sidebarItems[id: pruned.id]?.hasUnseenNotifications = true + state.sidebarItems[id: pruned.id]?.unseenSurfaces = [WorktreeUnseenSurface(id: UUID(), count: 2)] + setRowNotifications( + &state, id: live.id, + notifications: [ + WorktreeTerminalNotification(surfaceID: UUID(), title: "l1", body: "x", createdAt: at(100)) + ]) + + let groups = state.computeToolbarNotificationGroups() + let sections = NotificationInspectorList.visibleGroups( + groups, scope: .all, selectedWorktreeID: nil, unreadOnly: false) + // The pruned-only worktree renders no section but stays actionable for bulk actions. + #expect(sections.map(\.worktreeID) == [live.id]) + #expect( + Set(NotificationInspectorList.actionableWorktreeIDs(groups: groups, scope: .all, selectedWorktreeID: nil)) + == Set([pruned.id, live.id])) + } + + @Test func visibleGroupsAcrossRepositoriesCarriesSourceAndOrder() { + let repoAPath = "/tmp/repo-a" + let repoBPath = "/tmp/repo-b" + let featureA = makeWorktree(id: "\(repoAPath)/a", name: "a", repoRoot: repoAPath) + let featureB = makeWorktree(id: "\(repoBPath)/b", name: "b", repoRoot: repoBPath) + let repoA = makeRepository(id: repoAPath, name: "Repo A", worktrees: [featureA]) + let repoB = makeRepository(id: repoBPath, name: "Repo B", worktrees: [featureB]) + var state = RepositoriesFeature.State(reconciledRepositories: [repoA, repoB]) + state.repositoryRoots = [repoA.rootURL, repoB.rootURL] + state.$sidebar.withLock { sidebar in + sidebar.sections[repoA.id] = .init(title: "Repo A", color: .teal) + sidebar.sections[repoB.id] = .init(title: "Repo B", color: .purple) + } + setRowNotifications( + &state, id: featureA.id, + notifications: [ + WorktreeTerminalNotification(surfaceID: UUID(), title: "a-new", body: "x", createdAt: at(300)), + WorktreeTerminalNotification(surfaceID: UUID(), title: "a-old", body: "x", createdAt: at(100)), + ]) + setRowNotifications( + &state, id: featureB.id, + notifications: [ + WorktreeTerminalNotification(surfaceID: UUID(), title: "b1", body: "x", createdAt: at(200)) + ]) + + let groups = state.computeToolbarNotificationGroups() + let sections = NotificationInspectorList.visibleGroups( + groups, scope: .all, selectedWorktreeID: nil, unreadOnly: false) + #expect(sections.map(\.worktreeID) == [featureA.id, featureB.id]) + let sectionA = sections.first { $0.worktreeID == featureA.id } + #expect(sectionA?.repositoryName == "Repo A") + #expect(sectionA?.repositoryColor == .teal) + // Grouped items keep the stored order (not the flat list's global sort). + #expect(sectionA?.items.map(\.title) == ["a-new", "a-old"]) + #expect(sections.first { $0.worktreeID == featureB.id }?.repositoryColor == .purple) + } + + @Test func visibleGroupsCurrentWorktreeWithNilSelectionIsEmpty() { + let repoPath = "/tmp/repo" + let feature = makeWorktree(id: "\(repoPath)/a", name: "a", repoRoot: repoPath) + let repo = makeRepository(id: repoPath, name: "Repo", worktrees: [feature]) + var state = RepositoriesFeature.State(reconciledRepositories: [repo]) + state.repositoryRoots = [repo.rootURL] + setRowNotifications( + &state, id: feature.id, + notifications: [ + WorktreeTerminalNotification(surfaceID: UUID(), title: "a1", body: "x", createdAt: at(100)) + ]) + + let groups = state.computeToolbarNotificationGroups() + #expect( + NotificationInspectorList.visibleGroups( + groups, scope: .currentWorktree, selectedWorktreeID: nil, unreadOnly: false + ).isEmpty) + } + + private func at(_ seconds: TimeInterval) -> Date { + Date(timeIntervalSince1970: seconds) + } + + private func setRowNotifications( + _ state: inout RepositoriesFeature.State, + id: SidebarItemID, + notifications: [WorktreeTerminalNotification] + ) { + let hasUnseen = notifications.contains(where: { !$0.isRead }) + state.sidebarItems[id: id]?.notifications = IdentifiedArrayOf(uniqueElements: notifications) + state.sidebarItems[id: id]?.hasUnseenNotifications = hasUnseen + } + + private func makeWorktree(id: String, name: String, repoRoot: String) -> Worktree { + Worktree( + id: WorktreeID(id), + name: name, + detail: "detail", + workingDirectory: URL(fileURLWithPath: id), + repositoryRootURL: URL(fileURLWithPath: repoRoot) + ) + } + + private func makeRepository(id: String, name: String, worktrees: [Worktree]) -> Repository { + Repository( + id: RepositoryID(id), + rootURL: URL(fileURLWithPath: id), + name: name, + worktrees: IdentifiedArray(uniqueElements: worktrees) + ) + } +} + +struct NotificationScopeSettingsTests { + @Test func defaultsToAll() { + #expect(GlobalSettings.default.notificationScope == .all) + } + + @Test func absentKeyDecodesToAll() throws { + let data = try JSONEncoder().encode(GlobalSettings.default) + var object = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + object.removeValue(forKey: "notificationScope") + let stripped = try JSONSerialization.data(withJSONObject: object) + let decoded = try JSONDecoder().decode(GlobalSettings.self, from: stripped) + #expect(decoded.notificationScope == .all) + } + + @Test func roundTripsCurrentWorktree() throws { + var settings = GlobalSettings.default + settings.notificationScope = .currentWorktree + let data = try JSONEncoder().encode(settings) + let decoded = try JSONDecoder().decode(GlobalSettings.self, from: data) + #expect(decoded.notificationScope == .currentWorktree) + } + + @Test func presentButUnknownScopeDecodesToAllWithoutThrowing() throws { + // A scope written by a newer build (or corruption) must fall back, not throw + // and reset the whole settings file. + let data = try JSONEncoder().encode(GlobalSettings.default) + var object = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + object["notificationScope"] = "nonsense" + let mangled = try JSONSerialization.data(withJSONObject: object) + let decoded = try JSONDecoder().decode(GlobalSettings.self, from: mangled) + #expect(decoded.notificationScope == .all) + } + + @Test func groupingDefaultsToOff() { + #expect(GlobalSettings.default.notificationsGroupedByWorktree == false) + } + + @Test func groupingRoundTrips() throws { + var settings = GlobalSettings.default + settings.notificationsGroupedByWorktree = true + let data = try JSONEncoder().encode(settings) + let decoded = try JSONDecoder().decode(GlobalSettings.self, from: data) + #expect(decoded.notificationsGroupedByWorktree) + } + + @Test func groupingAbsentKeyDecodesToOff() throws { + let data = try JSONEncoder().encode(GlobalSettings.default) + var object = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + object.removeValue(forKey: "notificationsGroupedByWorktree") + let stripped = try JSONSerialization.data(withJSONObject: object) + let decoded = try JSONDecoder().decode(GlobalSettings.self, from: stripped) + #expect(decoded.notificationsGroupedByWorktree == false) + } + + @Test func unreadOnlyDefaultsToOff() { + #expect(GlobalSettings.default.notificationsUnreadOnly == false) + } + + @Test func unreadOnlyRoundTrips() throws { + var settings = GlobalSettings.default + settings.notificationsUnreadOnly = true + let data = try JSONEncoder().encode(settings) + let decoded = try JSONDecoder().decode(GlobalSettings.self, from: data) + #expect(decoded.notificationsUnreadOnly) + } + + @Test func unreadOnlyAbsentKeyDecodesToOff() throws { + let data = try JSONEncoder().encode(GlobalSettings.default) + var object = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + object.removeValue(forKey: "notificationsUnreadOnly") + let stripped = try JSONSerialization.data(withJSONObject: object) + let decoded = try JSONDecoder().decode(GlobalSettings.self, from: stripped) + #expect(decoded.notificationsUnreadOnly == false) + } +}