diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator.swift index 62ca2597c327..79fcc405a857 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator.swift @@ -139,6 +139,8 @@ public final class ControlCommandCoordinator { return systemIdentify(request.params, context: context) case "system.tree": return systemTree(request.params, context: context) + case "notification.list": + return notificationList(context: context) case "surface.send_text": return surfaceSendText(request.params, context: context) case "surface.send_key": diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Notification/ControlCommandCoordinator+Notification.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Notification/ControlCommandCoordinator+Notification.swift index 0b9010d18da7..4b001e5cdebc 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Notification/ControlCommandCoordinator+Notification.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Notification/ControlCommandCoordinator+Notification.swift @@ -22,7 +22,7 @@ extension ControlCommandCoordinator { case "notification.create_for_target": return notificationCreateForTarget(request.params) case "notification.list": - return notificationList() + return notificationList(context: context) case "notification.clear": return notificationClear() case "notification.dismiss": @@ -144,10 +144,71 @@ extension ControlCommandCoordinator { // MARK: - List / clear + private struct NotificationPayloadRefs: Sendable { + let workspace: JSONValue + let surface: JSONValue + } + + private struct NotificationListHopOutcome: Sendable { + let snapshots: [ControlNotificationSnapshot] + let refs: [NotificationPayloadRefs] + } + /// `notification.list` — every notification, with read state. - func notificationList() -> ControlCallResult { - let items = (context?.controlNotificationList() ?? []).map { - notificationPayload($0, opened: nil, includeReadState: true) + /// + /// Full-list snapshotting and ref minting take one bounded main hop. Date + /// rendering and JSON row construction scale with history size, so both run + /// on the calling socket-worker thread with response encoding. + nonisolated func notificationList( + context: (any ControlCommandContext)? + ) -> ControlCallResult { + guard let context else { + return .ok(.object(["notifications": .array([])])) + } + + let outcome: NotificationListHopOutcome = context.controlResolveOnMain { seam in + let snapshots = seam.controlNotificationList() + var workspaceRefs: [UUID: JSONValue] = [:] + var surfaceRefs: [UUID: JSONValue] = [:] + var refs: [NotificationPayloadRefs] = [] + refs.reserveCapacity(snapshots.count) + for snapshot in snapshots { + let workspaceRef: JSONValue + if let cached = workspaceRefs[snapshot.workspaceID] { + workspaceRef = cached + } else { + workspaceRef = self.ref(.workspace, snapshot.workspaceID) + workspaceRefs[snapshot.workspaceID] = workspaceRef + } + + let surfaceRef: JSONValue + if let surfaceID = snapshot.surfaceID { + if let cached = surfaceRefs[surfaceID] { + surfaceRef = cached + } else { + surfaceRef = self.ref(.surface, surfaceID) + surfaceRefs[surfaceID] = surfaceRef + } + } else { + surfaceRef = .null + } + refs.append(NotificationPayloadRefs( + workspace: workspaceRef, + surface: surfaceRef + )) + } + return NotificationListHopOutcome(snapshots: snapshots, refs: refs) + } + + let formatter = Self.notificationTimestampFormatter() + let items = outcome.snapshots.enumerated().map { index, snapshot in + JSONValue.object(Self.notificationPayloadObject( + snapshot, + createdAtISO8601: formatter.string(from: snapshot.createdAt), + refs: outcome.refs[index], + opened: nil, + includeReadState: true + )) } return .ok(.object(["notifications": .array(items)])) } @@ -332,18 +393,38 @@ extension ControlCommandCoordinator { _ snapshot: ControlNotificationSnapshot, opened: Bool?, includeReadState: Bool + ) -> [String: JSONValue] { + let formatter = Self.notificationTimestampFormatter() + return Self.notificationPayloadObject( + snapshot, + createdAtISO8601: formatter.string(from: snapshot.createdAt), + refs: NotificationPayloadRefs( + workspace: ref(.workspace, snapshot.workspaceID), + surface: ref(.surface, snapshot.surfaceID) + ), + opened: opened, + includeReadState: includeReadState + ) + } + + private nonisolated static func notificationPayloadObject( + _ snapshot: ControlNotificationSnapshot, + createdAtISO8601: String, + refs: NotificationPayloadRefs, + opened: Bool?, + includeReadState: Bool ) -> [String: JSONValue] { var payload: [String: JSONValue] = [ "id": .string(snapshot.id.uuidString), "workspace_id": .string(snapshot.workspaceID.uuidString), - "workspace_ref": ref(.workspace, snapshot.workspaceID), - "surface_id": orNull(snapshot.surfaceID?.uuidString), - "surface_ref": ref(.surface, snapshot.surfaceID), + "workspace_ref": refs.workspace, + "surface_id": snapshot.surfaceID.map { .string($0.uuidString) } ?? .null, + "surface_ref": refs.surface, "title": .string(snapshot.title), "subtitle": .string(snapshot.subtitle), "body": .string(snapshot.body), - "created_at": .string(snapshot.createdAtISO8601), - "tab_title": orNull(snapshot.tabTitle), + "created_at": .string(createdAtISO8601), + "tab_title": snapshot.tabTitle.map(JSONValue.string) ?? .null, ] if includeReadState { payload["is_read"] = .bool(snapshot.isRead) @@ -354,6 +435,13 @@ extension ControlCommandCoordinator { return payload } + private nonisolated static func notificationTimestampFormatter() -> ISO8601DateFormatter { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + formatter.timeZone = TimeZone(secondsFromGMT: 0) + return formatter + } + // MARK: - Localized error messages /// The localized notification messages from the app conformance, or the diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Notification/ControlNotificationSnapshot.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Notification/ControlNotificationSnapshot.swift index 291dde77c3c1..21428cb19db1 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Notification/ControlNotificationSnapshot.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Notification/ControlNotificationSnapshot.swift @@ -5,11 +5,10 @@ public import Foundation /// ``ControlNotificationContext``. /// /// Mirrors the app target's `TerminalNotification` (plus the two app-resolved -/// adornments the legacy `notificationPayload` builder added: the ISO-8601 -/// `createdAt` rendering and the workspace's tab title) without the package -/// importing the app target. The coordinator turns each snapshot into a -/// notification payload object, byte-identically to the former -/// `[String: Any]` builder. +/// adornments the legacy `notificationPayload` builder added: the creation +/// time and workspace tab title) without the package importing the app target. +/// The coordinator renders the timestamp while building the wire payload on +/// the socket worker. public struct ControlNotificationSnapshot: Sendable, Equatable { /// The notification's stable identifier. public let id: UUID @@ -23,11 +22,9 @@ public struct ControlNotificationSnapshot: Sendable, Equatable { public let subtitle: String /// The notification body. public let body: String - /// The creation timestamp pre-rendered exactly as the legacy - /// `notificationCreatedAtString` did (`ISO8601DateFormatter` with - /// `.withInternetDateTime`, GMT). Carried as a string so the package never - /// re-formats the date and the wire bytes stay identical. - public let createdAtISO8601: String + /// The creation timestamp. Kept as a Sendable value so full-list formatting + /// can run after the bounded main-actor snapshot hop. + public let createdAt: Date /// Whether the notification has been marked read. public let isRead: Bool /// The workspace's tab title, if the app could resolve one (the legacy @@ -43,7 +40,7 @@ public struct ControlNotificationSnapshot: Sendable, Equatable { /// - title: The notification title. /// - subtitle: The notification subtitle. /// - body: The notification body. - /// - createdAtISO8601: The pre-rendered ISO-8601 creation timestamp. + /// - createdAt: The creation timestamp. /// - isRead: Whether the notification is read. /// - tabTitle: The owning workspace's tab title, if any. public init( @@ -53,7 +50,7 @@ public struct ControlNotificationSnapshot: Sendable, Equatable { title: String, subtitle: String, body: String, - createdAtISO8601: String, + createdAt: Date, isRead: Bool, tabTitle: String? ) { @@ -63,7 +60,7 @@ public struct ControlNotificationSnapshot: Sendable, Equatable { self.title = title self.subtitle = subtitle self.body = body - self.createdAtISO8601 = createdAtISO8601 + self.createdAt = createdAt self.isRead = isRead self.tabTitle = tabTitle } diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift index 87ed9a1f197a..bce49fd87f03 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift @@ -220,8 +220,7 @@ public enum ControlCommandExecutionPolicy: Sendable, Equatable { "surface.report_shell_state", "surface.report_tty", "surface.ports_kick", - // The notification-create family and workspace.set_auto_title run the - // same single-hop worker shape (parse/bridge/encode on the worker, one + // Notification creation and workspace.set_auto_title parse/bridge/encode on the worker, with one // v2MainSync around the shared main-actor dispatch). The hop stays // synchronous so the reply is written only after the hop body ran — // matching the legacy main-lane ordering exactly. NOTE: that is NOT @@ -237,6 +236,7 @@ public enum ControlCommandExecutionPolicy: Sendable, Equatable { "notification.create_for_surface", "notification.create_for_target", "notification.create_for_caller", + "notification.list", "workspace.set_auto_title", // The v2 resolution reads (tranche D of issue #5757) — the implicit // handle-normalization reads nearly every CLI invocation pays 1-3 of. @@ -368,8 +368,10 @@ public enum ControlCommandExecutionPolicy: Sendable, Equatable { /// `TerminalController`: parse/format on the worker; `notify_target_async` /// and `clear_notifications` are pure mutation-bus enqueues (zero main /// hops, hooks nohup them and discard the reply); the synchronous - /// notify/list verbs keep one `v2MainSync` hop because their replies - /// depend on tab/surface resolution or the delivered store state. + /// notify verbs keep one `v2MainSync` hop because their replies depend on + /// tab/surface resolution or delivered store state. `list_notifications` + /// snapshots on the main actor but formats on the worker and is not safe + /// to collapse inline for in-process main-thread callers. /// Internal (not private) so the package tests can pin the exact set. static let notificationV1Commands: Set = [ "notify", @@ -482,14 +484,14 @@ public enum ControlCommandExecutionPolicy: Sendable, Equatable { "report_shell_state", "report_tty", "ports_kick", - // The v1 notification family (tranche B2): notify_target_async and - // clear_notifications are pure bus enqueues; the synchronous verbs - // are one inline-collapsing hop each. + // The v1 notification family (tranche B2): clear_notifications is a + // pure bus enqueue; synchronous notify verbs are one inline-collapsing + // hop each. list_notifications formats the full feed and + // notify_target_async can wait for bounded queue capacity, so both + // remain worker-only. "notify", "notify_surface", "notify_target", - "notify_target_async", - "list_notifications", "clear_notifications", // The v1 resolution reads (tranche D): non-blocking single-hop // snapshot reads whose hop collapses inline on a main-thread caller, diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorNotificationTests.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorNotificationTests.swift new file mode 100644 index 000000000000..28dd4612228c --- /dev/null +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorNotificationTests.swift @@ -0,0 +1,84 @@ +import Foundation +import Testing +@testable import CmuxControlSocket + +@MainActor +private final class NotificationListControlCommandContext: ControlCommandContext { + private(set) var resolveOnMainCallCount = 0 + var notifications: [ControlNotificationSnapshot] = [] + + nonisolated func controlResolveOnMain( + _ body: @MainActor (any ControlCommandContext) -> T + ) -> T { + MainActor.assumeIsolated { + resolveOnMainCallCount += 1 + return body(self) + } + } + + func controlNotificationList() -> [ControlNotificationSnapshot] { + notifications + } +} + +@MainActor +@Suite("ControlCommandCoordinator notification domain") +struct ControlCommandCoordinatorNotificationTests { + @Test func notificationListWorkerHandlerUsesOneMainHop() { + let context = NotificationListControlCommandContext() + let coordinator = ControlCommandCoordinator(context: context) + let request = ControlRequest(id: .int(1), method: "notification.list", params: [:]) + + guard case .ok(.object(let payload))? = coordinator.handleSocketWorkerV2( + request, + context: context + ), case .array(let notifications) = payload["notifications"] else { + Issue.record("notification.list worker handler did not return its list payload") + return + } + + #expect(context.resolveOnMainCallCount == 1) + #expect(notifications.isEmpty) + } + + @Test func notificationListPreservesLargeSnapshotOrderAndWireShape() throws { + let context = NotificationListControlCommandContext() + let coordinator = ControlCommandCoordinator(context: context) + let workspaceID = UUID() + let surfaceID = UUID() + let snapshots = (0..<10_500).reversed().map { index in + ControlNotificationSnapshot( + id: UUID(), + workspaceID: workspaceID, + surfaceID: surfaceID, + title: "Notification \(index)", + subtitle: "Subtitle", + body: "Body", + createdAt: Date(timeIntervalSince1970: TimeInterval(index)), + isRead: index.isMultiple(of: 2), + tabTitle: "Workspace" + ) + } + context.notifications = snapshots + let request = ControlRequest(id: .int(1), method: "notification.list", params: [:]) + + guard case .ok(.object(let payload))? = coordinator.handleSocketWorkerV2( + request, + context: context + ), case .array(let notifications) = payload["notifications"], + case .object(let first) = notifications.first, + case .object(let last) = notifications.last else { + Issue.record("notification.list worker handler returned an unexpected payload") + return + } + + #expect(context.resolveOnMainCallCount == 1) + #expect(notifications.count == snapshots.count) + #expect(first["id"] == .string(try #require(snapshots.first).id.uuidString)) + #expect(first["created_at"] == .string("1970-01-01T02:54:59Z")) + #expect(first["workspace_ref"] == .string("workspace:1")) + #expect(first["surface_ref"] == .string("surface:1")) + #expect(last["id"] == .string(try #require(snapshots.last).id.uuidString)) + #expect(last["created_at"] == .string("1970-01-01T00:00:00Z")) + } +} diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift index 09d8c5e3a862..b269c48adb2e 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift @@ -197,18 +197,24 @@ struct ControlCommandExecutionPolicyTests { } } - @Test func v1NotificationFamilyRunsOnTheWorkerAndIsMainThreadCallable() { - // Tranche B2: parse on the worker; notify_target_async and - // clear_notifications are pure bus enqueues, the synchronous verbs - // carry one inline-collapsing v2MainSync hop, so main-thread - // in-process callers stay safe. + @Test func v1NotificationFamilyUsesSafeMainThreadPolicy() { + // The synchronous notify verbs collapse their main hop inline, while + // list_notifications formats the full feed and notify_target_async can + // wait for queue capacity, so both stay worker-only. for command in [ - "notify", "notify_surface", "notify_target", "notify_target_async", - "list_notifications", "clear_notifications", + "notify", "notify_surface", "notify_target", "clear_notifications", ] { let policy = ControlCommandExecutionPolicy(forV1Command: command) #expect(policy == .socketWorker(mainThreadCallable: true), "\(command)") } + #expect( + ControlCommandExecutionPolicy(forV1Command: "list_notifications") + == .socketWorker(mainThreadCallable: false) + ) + #expect( + ControlCommandExecutionPolicy(forV1Command: "notify_target_async") + == .socketWorker(mainThreadCallable: false) + ) } @Test func v2NotificationCreateFamilyRunsOnTheWorkerAndIsMainThreadCallable() { @@ -223,8 +229,13 @@ struct ControlCommandExecutionPolicyTests { #expect(policy == .socketWorker(mainThreadCallable: true), "\(method)") } #expect(ControlCommandExecutionPolicy(forMethod: "notification.reconcile") == .mainActor) - // The read-side notification verbs stay on the main lane. - #expect(ControlCommandExecutionPolicy(forMethod: "notification.list") == .mainActor) + // Full-list snapshotting is one bounded main hop; per-row formatting + // stays on the worker and must never be callable inline on main. + #expect( + ControlCommandExecutionPolicy(forMethod: "notification.list") + == .socketWorker(mainThreadCallable: false) + ) + // The remaining read-side mutation stays on the main lane. #expect(ControlCommandExecutionPolicy(forMethod: "notification.clear") == .mainActor) } @@ -302,13 +313,12 @@ struct ControlCommandExecutionPolicyTests { let expectedWorker = telemetry.union(notification).union(terminalRead) .union(resolutionReads).union(sends).union(["ping"]) #expect(ControlCommandExecutionPolicy.socketWorkerV1Commands == expectedWorker) - // Every member except read_screen is deliberately main-thread - // callable (deadlock-free inline: bus enqueues plus inline-collapsing - // hops). read_screen opts out so its multi-MB formatting can never - // run inline on the main thread. + // read_screen, list_notifications, and notify_target_async remain + // worker-only: the first two can perform multi-row formatting and the + // latter may wait for capacity. #expect( ControlCommandExecutionPolicy.mainThreadCallableSocketWorkerV1Commands - == expectedWorker.subtracting(terminalRead) + == expectedWorker.subtracting(terminalRead.union(["list_notifications", "notify_target_async"])) ) } } diff --git a/Sources/AgentDeliveryTargetResolution.swift b/Sources/AgentDeliveryTargetResolution.swift index 8f189d29c21e..eb5428700a76 100644 --- a/Sources/AgentDeliveryTargetResolution.swift +++ b/Sources/AgentDeliveryTargetResolution.swift @@ -169,6 +169,25 @@ extension AppDelegate { return (owner.workspace.id, surfaceId) } + /// Once an event has been accepted under an already validated surface, a + /// pane disappearing must not erase its feed row. Unknown or never-owned + /// surface ids fail closed instead of being inferred as workspace-level + /// rows. + func agentNotificationRecordTarget( + claimedTabId: UUID, + surfaceId: UUID?, + allowWorkspaceFallbackForValidatedSurface: Bool = false + ) -> (tabId: UUID, surfaceId: UUID?)? { + if let target = agentNotificationDeliveryTarget( + claimedTabId: claimedTabId, + surfaceId: surfaceId + ) { + return target + } + guard allowWorkspaceFallbackForValidatedSurface, surfaceId != nil else { return nil } + return agentNotificationDeliveryTarget(claimedTabId: claimedTabId, surfaceId: nil) + } + private func agentDeliveryTabManagers() -> [TabManager] { var managers: [TabManager] = [] func append(_ manager: TabManager?) { diff --git a/Sources/AgentNotificationDelivery.swift b/Sources/AgentNotificationDelivery.swift index cffc78a6ccff..30c23965ae55 100644 --- a/Sources/AgentNotificationDelivery.swift +++ b/Sources/AgentNotificationDelivery.swift @@ -1,6 +1,13 @@ import CmuxSettings import Foundation +enum AgentNotificationDeliveryResult: Sendable, Equatable { + case gated + case accepted + case saturated + case cancelled +} + /// Applies agent notification policy and publishes accepted events through the shared mutation bus. struct AgentNotificationDelivery: Sendable { private let permissionEnabled: Bool @@ -19,6 +26,37 @@ struct AgentNotificationDelivery: Sendable { /// Gates and enqueues the same notification event for hooks and PTY prompt detectors. @discardableResult func enqueue( + workspaceID: UUID, + surfaceID: UUID, + title: String, + subtitle: String, + body: String, + category: AgentNotifyCategory?, + pending: Bool + ) -> AgentNotificationDeliveryResult { + if let category, + !agentNotificationShouldDeliver( + category: category, + pending: pending, + permissionEnabled: permissionEnabled, + turnMode: turnMode, + idleEnabled: idleEnabled + ) { + return .gated + } + let accepted = TerminalMutationBus.shared.enqueueNotification( + tabId: workspaceID, + surfaceId: surfaceID, + title: title, + subtitle: subtitle, + body: body + ) + return accepted ? .accepted : .saturated + } + + /// Internal delivery path. One serial GCD worker owns capacity waiting, so + /// actor executors stay free and producers remain ordered behind it. + func enqueueReliably( workspaceID: UUID, surfaceID: UUID, title: String, @@ -26,8 +64,8 @@ struct AgentNotificationDelivery: Sendable { body: String, category: AgentNotifyCategory?, pending: Bool, - coalesces: Bool = false - ) -> Bool { + allowWorkspaceFallbackForValidatedSurface: Bool = false + ) async -> AgentNotificationDeliveryResult { if let category, !agentNotificationShouldDeliver( category: category, @@ -36,16 +74,62 @@ struct AgentNotificationDelivery: Sendable { turnMode: turnMode, idleEnabled: idleEnabled ) { - return false + return .gated } - TerminalMutationBus.shared.enqueueNotification( + let result = await TerminalMutationBus.shared.enqueueNotificationReliably( tabId: workspaceID, surfaceId: surfaceID, title: title, subtitle: subtitle, body: body, - coalesces: coalesces + allowWorkspaceFallbackForValidatedSurface: allowWorkspaceFallbackForValidatedSurface ) - return true + switch result { + case .accepted: + return .accepted + case .saturated: + return .saturated + case .cancelled: + return .cancelled + } + } + + /// Synchronous socket-command path. The mutation bus still owns capacity + /// waiting on its serial admission queue; this caller waits for the + /// admission result before acknowledging hook commands that discard replies. + func enqueueReliablySynchronously( + workspaceID: UUID, + surfaceID: UUID, + title: String, + subtitle: String, + body: String, + category: AgentNotifyCategory?, + pending: Bool + ) -> AgentNotificationDeliveryResult { + if let category, + !agentNotificationShouldDeliver( + category: category, + pending: pending, + permissionEnabled: permissionEnabled, + turnMode: turnMode, + idleEnabled: idleEnabled + ) { + return .gated + } + let result = TerminalMutationBus.shared.enqueueNotificationReliablySynchronously( + tabId: workspaceID, + surfaceId: surfaceID, + title: title, + subtitle: subtitle, + body: body + ) + switch result { + case .accepted: + return .accepted + case .saturated: + return .saturated + case .cancelled: + return .cancelled + } } } diff --git a/Sources/App/MenuBarExtraController.swift b/Sources/App/MenuBarExtraController.swift index 7c068ef50570..04a2f5c2e489 100644 --- a/Sources/App/MenuBarExtraController.swift +++ b/Sources/App/MenuBarExtraController.swift @@ -340,16 +340,18 @@ struct NotificationMenuSnapshot: Equatable { enum NotificationMenuSnapshotBuilder { static let defaultInlineNotificationLimit = 6 - static func make( - notifications: [TerminalNotification], + static func make( + notifications: Notifications, workspaceUnreadIndicatorCount: Int = 0, + cachedUnreadNotificationCount: Int? = nil, maxInlineNotificationItems: Int = defaultInlineNotificationLimit - ) -> NotificationMenuSnapshot { - let unreadCount = notifications.reduce(into: 0) { count, notification in + ) -> NotificationMenuSnapshot where Notifications.Element == TerminalNotification { + let unreadNotificationCount = cachedUnreadNotificationCount ?? notifications.reduce(into: 0) { count, notification in if !notification.isRead { count += 1 } - } + workspaceUnreadIndicatorCount + } + let unreadCount = unreadNotificationCount + workspaceUnreadIndicatorCount let inlineLimit = max(0, maxInlineNotificationItems) return NotificationMenuSnapshot( diff --git a/Sources/AppDelegate+NotificationNavSeams.swift b/Sources/AppDelegate+NotificationNavSeams.swift index 0b0a7078ff31..dcc78ca7583e 100644 --- a/Sources/AppDelegate+NotificationNavSeams.swift +++ b/Sources/AppDelegate+NotificationNavSeams.swift @@ -258,7 +258,7 @@ extension AppDelegate { var orderedNotificationsForNav: [NotificationNavSnapshot] { guard let notificationStore else { return [] } - return notificationStore.notifications.map { notification in + return notificationStore.notificationsForUnreadNavigation.map { notification in notification.notificationNavigationSnapshot } } diff --git a/Sources/CmuxEventPublishing.swift b/Sources/CmuxEventPublishing.swift index c0426d71cfb4..d19bfc346a10 100644 --- a/Sources/CmuxEventPublishing.swift +++ b/Sources/CmuxEventPublishing.swift @@ -1,6 +1,12 @@ import Foundation import CMUXAgentLaunch +private let notificationRemovalEventQueue = DispatchQueue( + label: "com.cmux.notification-removal-events", + qos: .utility +) +private let notificationRemovalEventChunkSize = 128 + extension CmuxEventBus { func publishWorkspaceCreated( workspaceId: UUID, @@ -298,7 +304,7 @@ extension CmuxEventBus { cmuxDebugLog( "notification.changes.duplicateOldId function=publishNotificationChanges " + "id=\(notification.id.uuidString) source=oldById " + - "expectedUniqueBy=TerminalNotificationStore.restoreSessionNotifications.notificationWithUniqueId" + "expectedUniqueBy=notification.id" ) } #endif @@ -370,6 +376,41 @@ extension CmuxEventBus { ) } + func publishNotificationsRemoved( + _ notifications: [TerminalNotification], + completion: (@Sendable () -> Void)? = nil + ) { + var seen = Set() + let unique = notifications.filter { seen.insert($0.id).inserted } + guard !unique.isEmpty else { + completion?() + return + } + guard unique.count > notificationRemovalEventChunkSize else { + for notification in unique { + publishNotificationRemoved(notification) + } + completion?() + return + } + + notificationRemovalEventQueue.async { [self] in + var index = unique.startIndex + while index < unique.endIndex { + let end = unique.index( + index, + offsetBy: notificationRemovalEventChunkSize, + limitedBy: unique.endIndex + ) ?? unique.endIndex + for notification in unique[index.. TerminalNotification? { + ownerByKey[SupersededPhoneDismissBuffer.key(tabId: tabId, surfaceId: surfaceId)] + } + + func owners(tabId: UUID) -> [TerminalNotification] { + ownerByKey.values.filter { $0.tabId == tabId } + } + + func ownerIDs(tabId: UUID) -> [UUID] { + owners(tabId: tabId).map(\.id).sorted { $0.uuidString < $1.uuidString } + } + + mutating func setOwner(_ notification: TerminalNotification?) { + guard let notification else { return } + let key = Self.key(notification) + if let previousKey = keyByOwnerID[notification.id], previousKey != key { + ownerByKey.removeValue(forKey: previousKey) + } + if let replaced = ownerByKey[key], replaced.id != notification.id { + keyByOwnerID.removeValue(forKey: replaced.id) + } + ownerByKey[key] = notification + keyByOwnerID[notification.id] = key + } + + mutating func clear(tabId: UUID, surfaceId: UUID?) { + let key = SupersededPhoneDismissBuffer.key(tabId: tabId, surfaceId: surfaceId) + if let removed = ownerByKey.removeValue(forKey: key) { + keyByOwnerID.removeValue(forKey: removed.id) + } + } + + mutating func clear(tabId: UUID) { + let keys = ownerByKey.compactMap { key, owner in + owner.tabId == tabId ? key : nil + } + for key in keys { + if let removed = ownerByKey.removeValue(forKey: key) { + keyByOwnerID.removeValue(forKey: removed.id) + } + } + } + + mutating func clear(id: UUID) { + guard let key = keyByOwnerID.removeValue(forKey: id) else { return } + if ownerByKey[key]?.id == id { + ownerByKey.removeValue(forKey: key) + } + } + + mutating func clear(ids: [String]) { + for id in ids.compactMap(UUID.init(uuidString:)) { + clear(id: id) + } + } + + mutating func clearAll() { + ownerByKey.removeAll() + keyByOwnerID.removeAll() + } + + /// Preserve live owners by stable notification id while rows move during + /// restore, then restore only explicitly persisted owners. Chronology is + /// never evidence that a native or phone banner was displayed. + mutating func reconcile( + previous: [TerminalNotification], + merged: [TerminalNotification], + restoredOwnerIDs: Set = [] + ) { + let mergedById = Dictionary( + merged.map { ($0.id, $0) }, + uniquingKeysWith: { first, _ in first } + ) + var reconciled: [String: TerminalNotification] = [:] + for owner in ownerByKey.values.compactMap({ mergedById[$0.id] }) + .sorted(by: TerminalNotificationStore.notificationSortPrecedes) { + let key = Self.key(owner) + if reconciled[key] == nil { reconciled[key] = owner } + } + replaceOwners(reconciled) + + let restoredOwners = restoredOwnerIDs.compactMap { mergedById[$0] } + .sorted(by: TerminalNotificationStore.notificationSortPrecedes) + for notification in restoredOwners { + let key = Self.key(notification) + if ownerByKey[key] == nil { setOwner(notification) } + } + } + + mutating func resetAssumingOwners(from notifications: [TerminalNotification]) { + clearAll() + for notification in notifications { + let key = Self.key(notification) + if ownerByKey[key] == nil { setOwner(notification) } + } + } + + @discardableResult + mutating func transfer( + fromTabId: UUID, + toTabId: UUID, + panelIdMap: [UUID: UUID] + ) -> [TerminalNotification] { + let moving = ownerByKey.values + .filter { $0.tabId == fromTabId } + .sorted(by: TerminalNotificationStore.notificationSortPrecedes) + clear(tabId: fromTabId) + var displaced: [TerminalNotification] = [] + for owner in moving { + let surfaceId = owner.surfaceId.map { panelIdMap[$0] ?? $0 } + let panelId = owner.panelId.map { panelIdMap[$0] ?? $0 } + let moved = owner.replacingLocation(tabId: toTabId, surfaceId: surfaceId, panelId: panelId) + if let existing = ownerByKey[Self.key(moved)] { + guard existing.id != moved.id else { continue } + if TerminalNotificationStore.notificationSortPrecedes(moved, existing) { + setOwner(moved) + displaced.append(existing) + } else { + displaced.append(moved) + } + } else { + setOwner(moved) + } + } + return displaced + } + + @discardableResult + mutating func rebind(surfaceId: UUID, fromTabId: UUID, toTabId: UUID) -> TerminalNotification? { + guard let owner = ownerMatching(tabId: fromTabId, surfaceId: surfaceId) else { return nil } + guard owner.retargetsToLiveSurfaceOwner else { return nil } + clear(id: owner.id) + let moved = owner.replacingLocation( + tabId: toTabId, + surfaceId: owner.surfaceId, + panelId: owner.panelId + ) + if let existing = ownerMatching(tabId: toTabId, surfaceId: surfaceId) { + guard existing.id != moved.id else { return nil } + if TerminalNotificationStore.notificationSortPrecedes(moved, existing) { + clear(id: existing.id) + setOwner(moved) + return existing + } + return moved + } + setOwner(moved) + return nil + } + + private static func key(_ notification: TerminalNotification) -> String { + SupersededPhoneDismissBuffer.key(tabId: notification.tabId, surfaceId: notification.surfaceId) + } + + private func ownerMatching(tabId: UUID, surfaceId: UUID?) -> TerminalNotification? { + if let exact = owner(tabId: tabId, surfaceId: surfaceId) { return exact } + return ownerByKey.values.first { + $0.matches(tabId: tabId, surfaceId: surfaceId) + } + } + + private mutating func replaceOwners(_ owners: [String: TerminalNotification]) { + ownerByKey = owners + keyByOwnerID.removeAll(keepingCapacity: true) + for (key, owner) in ownerByKey { + keyByOwnerID[owner.id] = key + } + } +} diff --git a/Sources/Mobile/MobileWorkspaceListObserver.swift b/Sources/Mobile/MobileWorkspaceListObserver.swift index b2e6d68de932..12c41cce0ed8 100644 --- a/Sources/Mobile/MobileWorkspaceListObserver.swift +++ b/Sources/Mobile/MobileWorkspaceListObserver.swift @@ -149,20 +149,20 @@ final class MobileWorkspaceListObserver { // Last-activity preview lines come from the notification store, which is // not part of the TabManager graph. A new notification (or a cleared one) // changes a row's preview + relative time without touching the tab set, - // groups, panels, or title, so observe `$notifications` to push it. - // Marking a notification read also flows through `$notifications` (the - // mutated element re-publishes the array), which the unread flag in the - // per-workspace signature turns into a hash change. + // groups, panels, or title, so observe `$notificationFeedRevision` to + // push it. Marking a notification read also advances the revision, + // which the unread flag in the per-workspace signature turns into a + // hash change. // // Ordering invariant: `@Published` emits from `willSet`, but every sink - // here reads the store's post-`didSet` state (latestNotification / + // here reads the store's post-mutation state (latestNotification / // unread indexes) rather than the emitted value. That is safe because // `throttle(for:scheduler: RunLoop.main)` always hops through the run // loop, so delivery happens after the assignment (and its `didSet` // index rebuild) completes; it never fires synchronously from // `willSet`. The pre-existing `$tabs` / `$selectedTabId` sinks rely on // the same property. - notificationsCancellable = notificationStore?.$notifications + notificationsCancellable = notificationStore?.$notificationFeedRevision .throttle(for: .milliseconds(throttleMilliseconds), scheduler: RunLoop.main, latest: true) .sink { [weak self] _ in self?.emitIfNeeded(force: false) diff --git a/Sources/NotificationCooldownReservations.swift b/Sources/NotificationCooldownReservations.swift new file mode 100644 index 000000000000..a730eeeaf655 --- /dev/null +++ b/Sources/NotificationCooldownReservations.swift @@ -0,0 +1,93 @@ +import Foundation + +extension TerminalNotificationStore { + nonisolated static func externalBannerTransition( + incoming: TerminalNotification, + latestExisting: TerminalNotification? + ) -> (supersededId: String?, suppressIncoming: Bool) { + guard let latestExisting else { return (nil, false) } + guard notificationSortPrecedes(incoming, latestExisting) else { return (nil, true) } + // Every chronological owner superseded its predecessor, so retained + // history must not re-enter bounded external-dismissal buffers. + return (latestExisting.id.uuidString, false) + } +} + +struct NotificationCooldownReservations { + struct Reservation: Sendable { + let key: String + let interval: TimeInterval + let owner: UUID + } + + private struct KeyState { + var committedDate: Date? + var activeDatesByOwner: [UUID: Date] + } + + private var stateByKey: [String: KeyState] = [:] + + mutating func reserve( + key: String?, + interval: TimeInterval?, + acceptedAt: Date, + dates: inout [String: Date] + ) -> Reservation? { + guard let key, let interval else { return nil } + let reservation = Reservation(key: key, interval: interval, owner: UUID()) + var state = stateByKey[key] ?? KeyState( + committedDate: dates[key], + activeDatesByOwner: [:] + ) + state.activeDatesByOwner[reservation.owner] = acceptedAt + stateByKey[key] = state + return reservation + } + + mutating func commit( + _ reservation: Reservation?, + at date: Date, + dates: inout [String: Date] + ) { + guard let reservation, + var state = stateByKey[reservation.key], + state.activeDatesByOwner.removeValue(forKey: reservation.owner) != nil else { return } + state.committedDate = max(state.committedDate ?? date, date) + finish(reservation, state: state, dates: &dates) + } + + mutating func restore( + _ reservation: Reservation?, + dates: inout [String: Date] + ) { + guard let reservation, + var state = stateByKey[reservation.key], + state.activeDatesByOwner.removeValue(forKey: reservation.owner) != nil else { return } + finish(reservation, state: state, dates: &dates) + } + + private mutating func finish( + _ reservation: Reservation, + state: KeyState, + dates: inout [String: Date] + ) { + publishCommittedDate(for: reservation.key, state: state, dates: &dates) + if state.activeDatesByOwner.isEmpty { + stateByKey.removeValue(forKey: reservation.key) + } else { + stateByKey[reservation.key] = state + } + } + + private func publishCommittedDate( + for key: String, + state: KeyState, + dates: inout [String: Date] + ) { + if let committedDate = state.committedDate { + dates[key] = committedDate + } else { + dates.removeValue(forKey: key) + } + } +} diff --git a/Sources/PromptTurnNotificationHandler.swift b/Sources/PromptTurnNotificationHandler.swift index 5f5da47119d5..73c23d6f53b3 100644 --- a/Sources/PromptTurnNotificationHandler.swift +++ b/Sources/PromptTurnNotificationHandler.swift @@ -9,12 +9,25 @@ actor PromptTurnNotificationHandler { private var latestRevisionByAgentID: [String: UInt64] = [:] private var latestSubmissionCountByAgentID: [String: UInt64] = [:] private var turnForegroundPIDByAgentID: [String: Int] = [:] - private var deliveredConfirmationIdentifierByAgentID: [String: UInt64] = [:] + private var deliveredConfirmationIdentifiersByAgentID: [String: Set] = [:] + private var deliveredConfirmationOrderByAgentID: [String: [UInt64]] = [:] + private var deliveringConfirmationIdentifiersByAgentID: [String: Set] = [:] private var debounceTasksByAgentID: [String: Task] = [:] + private var saturationRetryTasksByConfirmation: [SaturationRetryKey: Task] = [:] + private var saturationRetryOrderByAgentID: [String: [UInt64]] = [:] private var inFlightPID: Int? private var inFlightVerification: Task? + private static let maximumSaturationRetryAttempts = 5 + private static let maximumPendingSaturationRetriesPerAgent = 16 + private static let maximumRememberedDeliveredConfirmationsPerAgent = 128 + + private struct SaturationRetryKey: Hashable { + let agentID: String + let confirmationIdentifier: UInt64 + } + init(workspaceID: UUID, surfaceID: UUID) { self.workspaceID = workspaceID self.surfaceID = surfaceID @@ -24,6 +37,9 @@ actor PromptTurnNotificationHandler { for task in debounceTasksByAgentID.values { task.cancel() } + for task in saturationRetryTasksByConfirmation.values { + task.cancel() + } inFlightVerification?.cancel() } @@ -96,10 +112,11 @@ actor PromptTurnNotificationHandler { private func deliverVerifiedTurn( agentID: String, confirmation: PromptLineTurnConfirmation, - requiredRevision: UInt64? + requiredRevision: UInt64?, + saturationRetryAttempt: Int = 0 ) async { guard confirmation.confirmedTurnCount > 0, - deliveredConfirmationIdentifierByAgentID[agentID, default: 0] < confirmation.identifier, + !hasDeliveredConfirmation(agentID: agentID, identifier: confirmation.identifier), let turnPID = turnForegroundPIDByAgentID[agentID], let context = await Self.currentTurnContext( surfaceID: surfaceID, @@ -122,12 +139,11 @@ actor PromptTurnNotificationHandler { } // Re-check after the suspension points above so concurrent local and // timer confirmations of the same candidate deliver exactly once. - guard deliveredConfirmationIdentifierByAgentID[agentID, default: 0] < confirmation.identifier else { + guard beginDeliveringConfirmation(agentID: agentID, identifier: confirmation.identifier) else { return } - deliveredConfirmationIdentifierByAgentID[agentID] = confirmation.identifier - AgentNotificationDelivery().enqueue( + let result = await AgentNotificationDelivery().enqueueReliably( workspaceID: recheck.workspaceID, surfaceID: surfaceID, title: definition.displayName, @@ -140,10 +156,125 @@ actor PromptTurnNotificationHandler { defaultValue: "Task completed" ), category: .turnComplete, - pending: false + pending: false, + allowWorkspaceFallbackForValidatedSurface: true + ) + endDeliveringConfirmation(agentID: agentID, identifier: confirmation.identifier) + if result != .saturated { + markDeliveredConfirmation(agentID: agentID, identifier: confirmation.identifier) + } else { + scheduleSaturationRetry( + agentID: agentID, + confirmation: confirmation, + requiredRevision: nil, + attempt: saturationRetryAttempt + ) + } + } + + private func scheduleSaturationRetry( + agentID: String, + confirmation: PromptLineTurnConfirmation, + requiredRevision: UInt64?, + attempt: Int + ) { + guard attempt < Self.maximumSaturationRetryAttempts else { return } + let key = SaturationRetryKey(agentID: agentID, confirmationIdentifier: confirmation.identifier) + saturationRetryTasksByConfirmation.removeValue(forKey: key)?.cancel() + appendSaturationRetryKey(key) + let clock = ContinuousClock() + let delay = Duration.milliseconds(100 * (1 << min(attempt, 4))) + saturationRetryTasksByConfirmation[key] = Task { [weak self] in + await MainActor.run { + TerminalMutationBus.shared.drainForBackpressure() + } + do { + try await clock.sleep(for: delay) + } catch { + return + } + guard !Task.isCancelled else { return } + await self?.saturationRetryReached( + agentID: agentID, + confirmation: confirmation, + requiredRevision: requiredRevision, + nextAttempt: attempt + 1 + ) + } + } + + private func saturationRetryReached( + agentID: String, + confirmation: PromptLineTurnConfirmation, + requiredRevision: UInt64?, + nextAttempt: Int + ) async { + let key = SaturationRetryKey(agentID: agentID, confirmationIdentifier: confirmation.identifier) + saturationRetryTasksByConfirmation.removeValue(forKey: key) + removeSaturationRetryKey(key) + await deliverVerifiedTurn( + agentID: agentID, + confirmation: confirmation, + requiredRevision: requiredRevision, + saturationRetryAttempt: nextAttempt ) } + private func hasDeliveredConfirmation(agentID: String, identifier: UInt64) -> Bool { + deliveredConfirmationIdentifiersByAgentID[agentID]?.contains(identifier) == true + } + + private func markDeliveredConfirmation(agentID: String, identifier: UInt64) { + if deliveredConfirmationIdentifiersByAgentID[agentID, default: []].insert(identifier).inserted { + deliveredConfirmationOrderByAgentID[agentID, default: []].append(identifier) + } + var order = deliveredConfirmationOrderByAgentID[agentID, default: []] + while order.count > Self.maximumRememberedDeliveredConfirmationsPerAgent { + let retired = order.removeFirst() + deliveredConfirmationIdentifiersByAgentID[agentID]?.remove(retired) + } + deliveredConfirmationOrderByAgentID[agentID] = order + } + + private func beginDeliveringConfirmation(agentID: String, identifier: UInt64) -> Bool { + guard !hasDeliveredConfirmation(agentID: agentID, identifier: identifier), + deliveringConfirmationIdentifiersByAgentID[agentID]?.contains(identifier) != true else { + return false + } + deliveringConfirmationIdentifiersByAgentID[agentID, default: []].insert(identifier) + return true + } + + private func endDeliveringConfirmation(agentID: String, identifier: UInt64) { + deliveringConfirmationIdentifiersByAgentID[agentID]?.remove(identifier) + if deliveringConfirmationIdentifiersByAgentID[agentID]?.isEmpty == true { + deliveringConfirmationIdentifiersByAgentID.removeValue(forKey: agentID) + } + } + + private func appendSaturationRetryKey(_ key: SaturationRetryKey) { + if saturationRetryOrderByAgentID[key.agentID]?.contains(key.confirmationIdentifier) != true { + saturationRetryOrderByAgentID[key.agentID, default: []].append(key.confirmationIdentifier) + } + var order = saturationRetryOrderByAgentID[key.agentID, default: []] + while order.count > Self.maximumPendingSaturationRetriesPerAgent { + let retiredIdentifier = order.removeFirst() + let retiredKey = SaturationRetryKey( + agentID: key.agentID, + confirmationIdentifier: retiredIdentifier + ) + saturationRetryTasksByConfirmation.removeValue(forKey: retiredKey)?.cancel() + } + saturationRetryOrderByAgentID[key.agentID] = order + } + + private func removeSaturationRetryKey(_ key: SaturationRetryKey) { + saturationRetryOrderByAgentID[key.agentID]?.removeAll { $0 == key.confirmationIdentifier } + if saturationRetryOrderByAgentID[key.agentID]?.isEmpty == true { + saturationRetryOrderByAgentID.removeValue(forKey: key.agentID) + } + } + /// Verifies process identity fresh for every delivery. Deliveries happen /// at most once per completed turn, so re-reading the process argv is a /// single bounded lookup; caching identity across turns would let a diff --git a/Sources/SessionNotificationSnapshot.swift b/Sources/SessionNotificationSnapshot.swift index 4861baeda7f1..6d8300779f59 100644 --- a/Sources/SessionNotificationSnapshot.swift +++ b/Sources/SessionNotificationSnapshot.swift @@ -11,6 +11,8 @@ struct SessionNotificationSnapshot: Codable, Sendable { var retargetsToLiveSurfaceOwner: Bool? var scrollPosition: TerminalNotificationScrollPosition? var clickAction: TerminalNotificationClickAction? + var surfaceId: UUID? + var panelId: UUID? init( id: UUID, @@ -22,7 +24,9 @@ struct SessionNotificationSnapshot: Codable, Sendable { paneFlash: Bool? = nil, retargetsToLiveSurfaceOwner: Bool? = nil, scrollPosition: TerminalNotificationScrollPosition? = nil, - clickAction: TerminalNotificationClickAction? = nil + clickAction: TerminalNotificationClickAction? = nil, + surfaceId: UUID? = nil, + panelId: UUID? = nil ) { self.id = id self.title = title @@ -34,6 +38,8 @@ struct SessionNotificationSnapshot: Codable, Sendable { self.retargetsToLiveSurfaceOwner = retargetsToLiveSurfaceOwner self.scrollPosition = scrollPosition self.clickAction = clickAction + self.surfaceId = surfaceId + self.panelId = panelId } init(notification: TerminalNotification) { @@ -50,7 +56,9 @@ struct SessionNotificationSnapshot: Codable, Sendable { paneFlash: notification.paneFlash, retargetsToLiveSurfaceOwner: notification.retargetsToLiveSurfaceOwner, scrollPosition: persistedScrollPosition, - clickAction: notification.clickAction + clickAction: notification.clickAction, + surfaceId: notification.surfaceId, + panelId: notification.panelId ) } @@ -58,20 +66,371 @@ struct SessionNotificationSnapshot: Codable, Sendable { let restoredScrollPosition = scrollPosition.map { TerminalNotificationScrollPosition(row: $0.row, totalRows: $0.totalRows) } + let normalizedText = TerminalNotificationStore.normalizedNotificationText( + title: title, + subtitle: subtitle, + body: body + ) return TerminalNotification( + id: id, + tabId: tabId, + surfaceId: surfaceId ?? self.surfaceId, + panelId: panelId ?? self.panelId, + retargetsToLiveSurfaceOwner: retargetsToLiveSurfaceOwner ?? true, + title: normalizedText.title, + subtitle: normalizedText.subtitle, + body: normalizedText.body, + createdAt: Date(timeIntervalSince1970: createdAt), + isRead: isRead, + paneFlash: paneFlash ?? true, + scrollPosition: restoredScrollPosition, + clickAction: clickAction + ) + } +} + +struct SessionNotificationSnapshotIndex { + private struct PanelKey: Hashable { + let tabId: UUID + let panelId: UUID + } + + private struct AddressedNotification { + let surfaceId: UUID? + let panelId: UUID? + let snapshot: SessionNotificationSnapshot + } + + static let empty = SessionNotificationSnapshotIndex() + + private var workspaceByTabId: [UUID: [SessionNotificationSnapshot]] = [:] + private var panelByKey: [PanelKey: [SessionNotificationSnapshot]] = [:] + private var addressedByTabId: [UUID: [AddressedNotification]] = [:] + + init() {} + + init( + notifications: Notifications + ) where Notifications.Element == TerminalNotification { + for notification in notifications { + let snapshot = SessionNotificationSnapshot(notification: notification) + if notification.surfaceId == nil, notification.panelId == nil { + workspaceByTabId[notification.tabId, default: []].append(snapshot) + continue + } + addressedByTabId[notification.tabId, default: []].append( + AddressedNotification( + surfaceId: notification.surfaceId, + panelId: notification.panelId, + snapshot: snapshot + ) + ) + if let surfaceId = notification.surfaceId { + panelByKey[PanelKey(tabId: notification.tabId, panelId: surfaceId), default: []].append(snapshot) + } + if let panelId = notification.panelId, panelId != notification.surfaceId { + panelByKey[PanelKey(tabId: notification.tabId, panelId: panelId), default: []].append(snapshot) + } + } + } + + func workspaceSnapshots(tabId: UUID) -> [SessionNotificationSnapshot] { + workspaceByTabId[tabId] ?? [] + } + + func panelSnapshots(tabId: UUID, panelId: UUID) -> [SessionNotificationSnapshot] { + panelByKey[PanelKey(tabId: tabId, panelId: panelId)] ?? [] + } + + func orphanedSnapshots(tabId: UUID, persistedPanelIds: Set) -> [SessionNotificationSnapshot] { + addressedByTabId[tabId]?.compactMap { addressed in + if let surfaceId = addressed.surfaceId, persistedPanelIds.contains(surfaceId) { + return nil + } + if let panelId = addressed.panelId, persistedPanelIds.contains(panelId) { + return nil + } + return addressed.snapshot + } ?? [] + } +} + +extension TerminalNotificationStore { + func restoreSessionNotifications( + _ restoredNotifications: [TerminalNotification], + forTabId tabId: UUID, + replacingTabId: UUID? = nil, + panelIdMap: [UUID: UUID] = [:], + restoredExternalBannerOwnerIDs: Set = [], + inferLegacyExternalBannerOwners: Bool = false + ) { + clearFocusedReadIndicator(forTabId: tabId) + let existing = Array(notifications) + let merged = Self.mergeRestoredSessionNotifications( + existing: existing, + restored: restoredNotifications, + tabId: tabId, + replacingTabId: replacingTabId, + panelIdMap: panelIdMap + ) + let ownerIDs = inferLegacyExternalBannerOwners && restoredExternalBannerOwnerIDs.isEmpty + ? Self.legacyExternalBannerOwnerIDs(from: restoredNotifications) + : restoredExternalBannerOwnerIDs + guard merged != existing || !ownerIDs.isEmpty else { return } + applySessionNotificationMerge( + merged, + restoredExternalBannerOwnerIDs: ownerIDs + ) + } + + func transferSessionNotifications( + fromTabId: UUID, + toTabId: UUID, + panelIdMap: [UUID: UUID] + ) { + TerminalMutationBus.shared.transferPendingNotifications( + fromTabId: fromTabId, + toTabId: toTabId, + panelIdMap: panelIdMap + ) + transferSessionNotificationState( + fromTabId: fromTabId, + toTabId: toTabId, + panelIdMap: panelIdMap + ) + let merged = Self.mergeRestoredSessionNotifications( + existing: Array(notifications), + restored: [], + tabId: toTabId, + replacingTabId: fromTabId, + panelIdMap: panelIdMap + ) + applySessionNotificationMerge(merged) + } + + nonisolated static func replacingWorkspaceId( + in ids: Set, + from oldId: UUID, + to newId: UUID + ) -> Set { + var result = ids + if result.remove(oldId) != nil { result.insert(newId) } + return result + } + + nonisolated static func legacyExternalBannerOwnerIDs( + from notifications: [TerminalNotification] + ) -> Set { + var latestBySurface: [TabSurfaceKey: TerminalNotification] = [:] + for notification in notifications where !notification.isRead { + let key = TabSurfaceKey(tabId: notification.tabId, surfaceId: notification.surfaceId) + if latestBySurface[key].map({ notificationSortPrecedes(notification, $0) }) ?? true { + latestBySurface[key] = notification + } + } + return Set(latestBySurface.values.map(\.id)) + } + + nonisolated static func mergeRestoredSessionNotifications( + existing: [TerminalNotification], + restored: [TerminalNotification], + tabId: UUID, + replacingTabId: UUID?, + panelIdMap: [UUID: UUID] + ) -> [TerminalNotification] { + var canonicalById: [UUID: TerminalNotification] = [:] + for candidate in restored where candidate.tabId == tabId { + if let canonical = canonicalById[candidate.id] { + if notificationRestoreCanonicalPrecedes(candidate, canonical) { + canonicalById[candidate.id] = candidate + } + } else { + canonicalById[candidate.id] = candidate + } + } + + var merged = existing + var didRemapExisting = false + if let replacingTabId { + for index in merged.indices where merged[index].tabId == replacingTabId { + let current = merged[index] + let restoredLocation = canonicalById[current.id] + let surfaceId = restoredLocation.map(\.surfaceId) + ?? current.surfaceId.map { panelIdMap[$0] ?? $0 } + let panelId = restoredLocation.map(\.panelId) + ?? current.panelId.map { panelIdMap[$0] ?? $0 } + merged[index] = current.replacingLocation( + tabId: tabId, + surfaceId: surfaceId, + panelId: panelId + ) + didRemapExisting = true + } + } + guard didRemapExisting || !canonicalById.isEmpty else { return existing } + + var knownIds = Set(merged.map(\.id)) + let additions = canonicalById.values + .filter { knownIds.insert($0.id).inserted } + .sorted(by: notificationSortPrecedes) + guard !additions.isEmpty else { return merged } + return mergeSortedNotifications(merged, additions) + } + + private nonisolated static func mergeSortedNotifications( + _ existing: [TerminalNotification], + _ additions: [TerminalNotification] + ) -> [TerminalNotification] { + var merged: [TerminalNotification] = [] + merged.reserveCapacity(existing.count + additions.count) + var existingIndex = 0 + var additionIndex = 0 + while existingIndex < existing.count || additionIndex < additions.count { + if additionIndex == additions.count { + merged.append(contentsOf: existing[existingIndex...]) + break + } + if existingIndex == existing.count { + merged.append(contentsOf: additions[additionIndex...]) + break + } + if notificationSortPrecedes(additions[additionIndex], existing[existingIndex]) { + merged.append(additions[additionIndex]) + additionIndex += 1 + } else { + merged.append(existing[existingIndex]) + existingIndex += 1 + } + } + return merged + } + + nonisolated static func notificationRestoreCanonicalPrecedes( + _ lhs: TerminalNotification, + _ rhs: TerminalNotification + ) -> Bool { + if lhs.createdAt != rhs.createdAt { + return lhs.createdAt < rhs.createdAt + } + return notificationRestoreCanonicalPayloadKey(lhs) + .lexicographicallyPrecedes(notificationRestoreCanonicalPayloadKey(rhs)) + } + + private nonisolated static func notificationRestoreCanonicalPayloadKey( + _ notification: TerminalNotification + ) -> [String] { + let clickActionKey: [String] + switch notification.clickAction { + case .none: + clickActionKey = ["0", ""] + case .revealInFinder(let path): + clickActionKey = ["1", path] + } + + var key = [notification.id.uuidString, notification.tabId.uuidString] + key.append(notification.surfaceId == nil ? "0" : "1") + key.append(notification.surfaceId?.uuidString ?? "") + key.append(notification.panelId == nil ? "0" : "1") + key.append(notification.panelId?.uuidString ?? "") + key.append(notification.retargetsToLiveSurfaceOwner ? "1" : "0") + key.append(contentsOf: [notification.title, notification.subtitle, notification.body]) + key.append(notification.isRead ? "1" : "0") + key.append(notification.paneFlash ? "1" : "0") + key.append(notification.scrollPosition == nil ? "0" : "1") + key.append(notification.scrollPosition.map { String($0.row) } ?? "") + key.append(notification.scrollPosition?.totalRows == nil ? "0" : "1") + key.append(notification.scrollPosition?.totalRows.map(String.init) ?? "") + key.append(notification.scrollPosition?.rowSpaceRevision == nil ? "0" : "1") + key.append(notification.scrollPosition?.rowSpaceRevision.map(String.init) ?? "") + key.append(contentsOf: clickActionKey) + return key + } +} + +extension TerminalNotification { + func replacingLocation(tabId: UUID, surfaceId: UUID?, panelId: UUID?) -> TerminalNotification { + TerminalNotification( id: id, tabId: tabId, surfaceId: surfaceId, panelId: panelId, - retargetsToLiveSurfaceOwner: retargetsToLiveSurfaceOwner ?? true, + retargetsToLiveSurfaceOwner: retargetsToLiveSurfaceOwner, title: title, subtitle: subtitle, body: body, - createdAt: Date(timeIntervalSince1970: createdAt), + createdAt: createdAt, isRead: isRead, - paneFlash: paneFlash ?? true, - scrollPosition: restoredScrollPosition, + paneFlash: paneFlash, + scrollPosition: scrollPosition, clickAction: clickAction ) } } + +extension TerminalMutationBus { + nonisolated static func remappingPendingEntries( + _ entries: [TerminalSocketMutationEntry], + fromTabId: UUID, + toTabId: UUID, + panelIdMap: [UUID: UUID] + ) -> [TerminalSocketMutationEntry] { + entries.map { entry in + TerminalSocketMutationEntry( + sequence: entry.sequence, + mutation: entry.mutation.replacingTarget( + fromTabId: fromTabId, + toTabId: toTabId, + panelIdMap: panelIdMap + ), + notificationGeneration: entry.notificationGeneration, + performReplaceKey: entry.performReplaceKey + ) + } + } + + nonisolated static func remappingNotificationKey( + _ key: QueuedTerminalNotificationKey, + fromTabId: UUID, + toTabId: UUID, + panelIdMap: [UUID: UUID] + ) -> QueuedTerminalNotificationKey { + guard key.tabId == fromTabId else { return key } + return QueuedTerminalNotificationKey( + tabId: toTabId, + surfaceId: key.surfaceId.map { panelIdMap[$0] ?? $0 } + ) + } +} + +private extension TerminalSocketMutation { + func replacingTarget( + fromTabId: UUID, + toTabId: UUID, + panelIdMap: [UUID: UUID] + ) -> TerminalSocketMutation { + switch self { + case .deliverNotification(let notification) where notification.key.tabId == fromTabId: + let surfaceId = notification.key.surfaceId.map { panelIdMap[$0] ?? $0 } + return .deliverNotification(QueuedTerminalNotification( + id: notification.id, + acceptedAt: notification.acceptedAt, + key: QueuedTerminalNotificationKey(tabId: toTabId, surfaceId: surfaceId), + allowWorkspaceFallbackForValidatedSurface: notification.allowWorkspaceFallbackForValidatedSurface, + title: notification.title, + subtitle: notification.subtitle, + body: notification.body, + contentByteCount: notification.contentByteCount + )) + case .clearNotificationsForTab(let tabId, let boundary) where tabId == fromTabId: + return .clearNotificationsForTab(toTabId, through: boundary) + case .clearNotificationsForSurface(let tabId, let surfaceId, let boundary) where tabId == fromTabId: + return .clearNotificationsForSurface( + toTabId, + panelIdMap[surfaceId] ?? surfaceId, + through: boundary + ) + default: + return self + } + } +} diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index c886e9673d7a..b543ed99188c 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -1758,6 +1758,10 @@ struct SessionWorkspaceSnapshot: Codable, Sendable { var isManuallyUnread: Bool? = nil var hasUnreadIndicator: Bool? = nil var notifications: [SessionNotificationSnapshot]? = nil + /// Stable ids of rows that owned a native/phone banner when saved. Absent + /// in older snapshots, where ownership remains unknown instead of being + /// inferred from feed chronology. + var externalBannerOwnerNotificationIds: [UUID]? = nil var terminalScrollBarHidden: Bool? var currentDirectory: String var focusedPanelId: UUID? diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowSlotViews.swift b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowSlotViews.swift index df8b7e78b4b1..2a4af7ed85a5 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowSlotViews.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowSlotViews.swift @@ -114,7 +114,7 @@ final class SidebarRowPullRequestIconView: NSView { // transparent backing draws nothing (no destination pixels). let tinted = NSImage(size: image.size, flipped: false) { [color] drawRect in image.draw(in: drawRect, from: .zero, operation: .sourceOver, fraction: 1) - color.set() + self.color.set() drawRect.fill(using: .sourceAtop) return true } diff --git a/Sources/SidebarWorkspaceNotificationIndex.swift b/Sources/SidebarWorkspaceNotificationIndex.swift index 2cff33543dbc..4668389d9672 100644 --- a/Sources/SidebarWorkspaceNotificationIndex.swift +++ b/Sources/SidebarWorkspaceNotificationIndex.swift @@ -11,7 +11,7 @@ struct SidebarWorkspaceNotificationIndex { private let notificationsByWorkspaceId: [UUID: [TerminalNotification]] - init(notifications: [TerminalNotification]) { + init(notifications: Notifications) where Notifications.Element == TerminalNotification { var grouped: [UUID: [TerminalNotification]] = [:] grouped.reserveCapacity(min(notifications.count, 64)) for notification in notifications { diff --git a/Sources/SupersededPhoneDismissBuffer.swift b/Sources/SupersededPhoneDismissBuffer.swift index a64a359a9a1a..2fd14260d2ef 100644 --- a/Sources/SupersededPhoneDismissBuffer.swift +++ b/Sources/SupersededPhoneDismissBuffer.swift @@ -50,6 +50,46 @@ struct SupersededPhoneDismissBuffer { return drained } + mutating func transfer( + fromTabId: UUID, + toTabId: UUID, + panelIdMap: [UUID: UUID], + drainingSourceKeys: Set = [] + ) -> [String] { + let prefix = fromTabId.uuidString + ":" + var drained: [String] = [] + for sourceKey in idsByKey.keys.filter({ $0.hasPrefix(prefix) }).sorted() { + let surfaceText = String(sourceKey.dropFirst(prefix.count)) + let surfaceId = UUID(uuidString: surfaceText).map { panelIdMap[$0] ?? $0 } + let sourceIds = idsByKey.removeValue(forKey: sourceKey) ?? [] + if drainingSourceKeys.contains(sourceKey) { + drained.append(contentsOf: sourceIds) + continue + } + stash( + ids: sourceIds, + forKey: Self.key(tabId: toTabId, surfaceId: surfaceId) + ) + } + return drained + } + + mutating func rebind( + surfaceId: UUID, + fromTabId: UUID, + toTabId: UUID, + drainSource: Bool = false + ) -> [String] { + let sourceKey = Self.key(tabId: fromTabId, surfaceId: surfaceId) + let sourceIds = idsByKey.removeValue(forKey: sourceKey) ?? [] + guard !drainSource else { return sourceIds } + stash( + ids: sourceIds, + forKey: Self.key(tabId: toTabId, surfaceId: surfaceId) + ) + return [] + } + /// Take (and clear) everything stashed across all keys, for clear-all / /// mark-all-read operations. mutating func flushAll() -> [String] { diff --git a/Sources/TabManager+NotificationDismissalHosting.swift b/Sources/TabManager+NotificationDismissalHosting.swift index 147375f56735..3c50994cabd4 100644 --- a/Sources/TabManager+NotificationDismissalHosting.swift +++ b/Sources/TabManager+NotificationDismissalHosting.swift @@ -109,7 +109,10 @@ extension TabManager: NotificationDismissalHosting { into hasher: inout Hasher ) { hasher.combine(notifications.count) - for notification in notifications.sorted(by: { $0.id.uuidString < $1.id.uuidString }) { + // SessionAutosaveNotificationIndex assembles buckets in retained feed + // order. Hash that deterministic order directly instead of sorting + // every notification bucket on each autosave tick. + for notification in notifications { hasher.combine(notification.id) hasher.combine(notification.title) hasher.combine(notification.subtitle) @@ -122,6 +125,28 @@ extension TabManager: NotificationDismissalHosting { } } + nonisolated static func uuidSortPrecedes(_ lhs: UUID, _ rhs: UUID) -> Bool { + let left = lhs.uuid + let right = rhs.uuid + if left.0 != right.0 { return left.0 < right.0 } + if left.1 != right.1 { return left.1 < right.1 } + if left.2 != right.2 { return left.2 < right.2 } + if left.3 != right.3 { return left.3 < right.3 } + if left.4 != right.4 { return left.4 < right.4 } + if left.5 != right.5 { return left.5 < right.5 } + if left.6 != right.6 { return left.6 < right.6 } + if left.7 != right.7 { return left.7 < right.7 } + if left.8 != right.8 { return left.8 < right.8 } + if left.9 != right.9 { return left.9 < right.9 } + if left.10 != right.10 { return left.10 < right.10 } + if left.11 != right.11 { return left.11 < right.11 } + if left.12 != right.12 { return left.12 < right.12 } + if left.13 != right.13 { return left.13 < right.13 } + if left.14 != right.14 { return left.14 < right.14 } + if left.15 != right.15 { return left.15 < right.15 } + return false + } + func workspaceClearManualUnread(workspaceId: UUID, panelId: UUID) { tabs.first(where: { $0.id == workspaceId })?.clearManualUnread(panelId: panelId) } diff --git a/Sources/TabManager+SessionRestoreIdentity.swift b/Sources/TabManager+SessionRestoreIdentity.swift index 1f0c7dd7b7fc..af1258090a3d 100644 --- a/Sources/TabManager+SessionRestoreIdentity.swift +++ b/Sources/TabManager+SessionRestoreIdentity.swift @@ -1,6 +1,8 @@ import Foundation extension TabManager { + typealias SessionRestoreNotificationReplacement = (workspace: Workspace, panelIdMap: [UUID: UUID]) + func liveStableIdentitySet() -> Set { var identities: Set = [] for workspace in tabs { @@ -16,4 +18,45 @@ extension TabManager { } return identities } + + func releaseRestoredAwayWorkspaces( + _ previousWorkspaces: [Workspace], + originalWorkspaceIds: [UUID?], + replacements: [Workspace], + panelIdMaps: [[UUID: UUID]] + ) { + precondition(originalWorkspaceIds.count == panelIdMaps.count) + precondition(replacements.count >= originalWorkspaceIds.count) + var replacementByOriginalId: [UUID: SessionRestoreNotificationReplacement] = [:] + for (originalId, replacement) in zip(originalWorkspaceIds, zip(replacements, panelIdMaps)) { + guard let originalId, replacementByOriginalId[originalId] == nil else { continue } + replacementByOriginalId[originalId] = (replacement.0, replacement.1) + } + + for workspace in previousWorkspaces { + releaseRestoredAwayWorkspace( + workspace, + replacement: replacementByOriginalId[workspace.id] + ) + } + } + + private func releaseRestoredAwayWorkspace( + _ workspace: Workspace, + replacement: SessionRestoreNotificationReplacement? + ) { + if let replacement { + AppDelegate.shared?.notificationStore?.transferSessionNotifications( + fromTabId: workspace.id, + toTabId: replacement.workspace.id, + panelIdMap: replacement.panelIdMap + ) + workspace.teardownAllPanels(clearSurfaceNotifications: false) + } else { + AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: workspace.id) + workspace.teardownAllPanels() + } + workspace.teardownRemoteConnection() + workspace.owningTabManager = nil + } } diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index de809f2eab09..e8b6e5a4d2c4 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -5613,6 +5613,91 @@ class TabManager: ObservableObject { } extension TabManager { + private struct SessionAutosaveNotificationIndex { + private struct Key: Hashable { + let tabId: UUID + let panelId: UUID + } + + private struct AddressedNotification { + let surfaceId: UUID? + let panelId: UUID? + let notification: TerminalNotification + } + + private var workspaceNotificationsByTabId: [UUID: [TerminalNotification]] = [:] + private var addressedNotificationsByTabId: [UUID: [AddressedNotification]] = [:] + private var notificationsByPanelKey: [Key: [TerminalNotification]] = [:] + + init(notifications: TerminalNotificationFeed) { + for notification in notifications { + if notification.surfaceId == nil, notification.panelId == nil { + workspaceNotificationsByTabId[notification.tabId, default: []].append(notification) + } else { + addressedNotificationsByTabId[notification.tabId, default: []].append( + AddressedNotification( + surfaceId: notification.surfaceId, + panelId: notification.panelId, + notification: notification + ) + ) + } + if let surfaceId = notification.surfaceId { + notificationsByPanelKey[ + Key(tabId: notification.tabId, panelId: surfaceId), + default: [] + ].append(notification) + } + if let panelId = notification.panelId, panelId != notification.surfaceId { + notificationsByPanelKey[ + Key(tabId: notification.tabId, panelId: panelId), + default: [] + ].append(notification) + } + } + } + + func workspaceAndOrphanNotifications(forTabId tabId: UUID, panelIds: Set) -> [TerminalNotification] { + var result = workspaceNotificationsByTabId[tabId] ?? [] + for addressed in addressedNotificationsByTabId[tabId] ?? [] { + if let surfaceId = addressed.surfaceId, panelIds.contains(surfaceId) { + continue + } + if let panelId = addressed.panelId, panelIds.contains(panelId) { + continue + } + result.append(addressed.notification) + } + return result + } + + func notifications(forTabId tabId: UUID, panelId: UUID) -> [TerminalNotification] { + notificationsByPanelKey[Key(tabId: tabId, panelId: panelId)] ?? [] + } + } + + private struct SessionAutosaveNotificationIndexCache { + private var storeID: ObjectIdentifier? + private var revision: UInt64? + private var index: SessionAutosaveNotificationIndex? + + @MainActor + mutating func index(for store: TerminalNotificationStore) -> SessionAutosaveNotificationIndex { + let storeID = ObjectIdentifier(store) + let revision = store.notificationFeedRevision + if self.storeID == storeID, self.revision == revision, let index { + return index + } + let index = SessionAutosaveNotificationIndex(notifications: store.notifications) + self.storeID = storeID + self.revision = revision + self.index = index + return index + } + } + + private static var sessionAutosaveNotificationIndexCache = SessionAutosaveNotificationIndexCache() + func sessionAutosaveFingerprint( restorableAgentIndex: RestorableAgentSessionIndex = .empty, surfaceResumeBindingIndex: SurfaceResumeBindingIndex = .empty @@ -5621,6 +5706,9 @@ extension TabManager { hasher.combine(selectedTabId) hasher.combine(tabs.count) let notificationStore = AppDelegate.shared?.notificationStore + let notificationIndex = notificationStore.map { store in + Self.sessionAutosaveNotificationIndexCache.index(for: store) + } // Workspace groups participate in the session snapshot, so changes // that only touch group metadata (rename / collapse / pin a group, // or move a workspace between groups without reordering tabs) must @@ -5655,11 +5743,15 @@ extension TabManager { hasher.combine(workspace.surfaceListeningPorts.count); workspace.combineTodoStateIntoSessionAutosaveFingerprint(into: &hasher) hasher.combine(notificationStore?.hasManualUnread(forTabId: workspace.id) ?? false) hasher.combine(notificationStore?.workspaceIsUnread(forTabId: workspace.id) ?? false) + let panelIds = workspace.panels.keys.sorted(by: Self.uuidSortPrecedes) + let panelIdSet = Set(panelIds) Self.hashNotifications( - notificationStore?.notifications(forTabId: workspace.id, surfaceId: nil) ?? [], + notificationIndex?.workspaceAndOrphanNotifications( + forTabId: workspace.id, + panelIds: panelIdSet + ) ?? [], into: &hasher ) - let panelIds = workspace.panels.keys.sorted { $0.uuidString < $1.uuidString } hasher.combine(panelIds.count) for panelId in panelIds { hasher.combine(panelId) @@ -5676,7 +5768,7 @@ extension TabManager { ) ?? false ) Self.hashNotifications( - notificationStore?.notifications(forTabId: workspace.id, surfaceId: panelId) ?? [], + notificationIndex?.notifications(forTabId: workspace.id, panelId: panelId) ?? [], into: &hasher ) Self.hashRestorableAgentSnapshot( @@ -5900,12 +5992,16 @@ extension TabManager { let restorableTabs = tabs .filter(\.isRestorableInSessionSnapshot) .prefix(SessionPersistencePolicy.maxWorkspacesPerWindow) + let notificationSnapshotIndex = AppDelegate.shared?.notificationStore.map { + SessionNotificationSnapshotIndex(notifications: $0.notifications) + } ?? .empty let workspaceSnapshots = restorableTabs .map { $0.sessionSnapshot( includeScrollback: includeScrollback, restorableAgentIndex: restorableAgentIndex, - surfaceResumeBindingIndex: surfaceResumeBindingIndex + surfaceResumeBindingIndex: surfaceResumeBindingIndex, + notificationSnapshotIndex: notificationSnapshotIndex ) } let selectedWorkspaceIndex = selectedTabId.flatMap { selectedTabId in @@ -5958,16 +6054,6 @@ extension TabManager { ) } - private func releaseRestoredAwayWorkspace(_ workspace: Workspace) { - // Session restore replaces the bootstrap workspace objects with freshly - // restored ones. Tear the old graph down after the atomic swap so late - // panel/socket callbacks cannot keep mutating hidden pre-restore state. - AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: workspace.id) - workspace.teardownAllPanels() - workspace.teardownRemoteConnection() - workspace.owningTabManager = nil - } - private static func normalizedCloudVMSessionRestoreWorkspaces( _ snapshots: S, selectedWorkspaceIndex: Int? @@ -6145,9 +6231,12 @@ extension TabManager { let existingIds = Set(newTabs.map(\.id)) pruneBackgroundWorkspaceLoads(existingIds: existingIds) sidebarMultiSelection.intersectSelection(with: existingIds) - for workspace in previousTabs { - releaseRestoredAwayWorkspace(workspace) - } + releaseRestoredAwayWorkspaces( + previousTabs, + originalWorkspaceIds: restoredOriginalWorkspaceIds, + replacements: newTabs, + panelIdMaps: restoredPanelIdsByWorkspaceIndex + ) for workspace in newTabs { let terminalPanels = workspace.panels.values.compactMap { $0 as? TerminalPanel } for terminalPanel in terminalPanels { diff --git a/Sources/TerminalController+ControlNotificationContext.swift b/Sources/TerminalController+ControlNotificationContext.swift index a89d0225295e..b194046c0a88 100644 --- a/Sources/TerminalController+ControlNotificationContext.swift +++ b/Sources/TerminalController+ControlNotificationContext.swift @@ -163,17 +163,28 @@ extension TerminalController: ControlNotificationContext { } func controlNotificationList() -> [ControlNotificationSnapshot] { - TerminalNotificationStore.shared.notifications.map { Self.controlSnapshot($0) } + controlNotificationList(limit: nil) } - func controlNotificationDismissAllRead() -> Int { - let readIds = TerminalNotificationStore.shared.notifications - .filter(\.isRead) - .map(\.id) - for id in readIds { - TerminalNotificationStore.shared.remove(id: id) + func controlNotificationList(limit: Int?) -> [ControlNotificationSnapshot] { + let notifications = TerminalNotificationStore.shared.notifications + let tabIDs = Set(notifications.map(\.tabId)) + let tabTitles = Dictionary(uniqueKeysWithValues: tabIDs.compactMap { tabID in + AppDelegate.shared?.tabTitle(for: tabID).map { (tabID, $0) } + }) + let source: AnySequence + if let limit { + source = AnySequence(notifications.prefix(limit)) + } else { + source = AnySequence(notifications) } - return readIds.count + return source.map { + Self.controlSnapshot($0, cachedTabTitle: tabTitles[$0.tabId]) + } + } + + func controlNotificationDismissAllRead() -> Int { + TerminalNotificationStore.shared.removeReadNotifications() } func controlNotificationDismiss(id: UUID) -> ControlNotificationDismissResolution { @@ -188,7 +199,7 @@ extension TerminalController: ControlNotificationContext { func controlNotificationMarkRead(id: UUID) -> ControlNotificationMarkReadResolution { let store = TerminalNotificationStore.shared - let before = store.notifications + let before = Array(store.notifications) guard before.contains(where: { $0.id == id }) else { return .notFound } @@ -204,7 +215,7 @@ extension TerminalController: ControlNotificationContext { hasSurfaceSelector: Bool ) -> Int { let store = TerminalNotificationStore.shared - let before = store.notifications + let before = Array(store.notifications) if hasSurfaceSelector { store.markRead(forTabId: workspaceID, surfaceId: surfaceID) } else { @@ -215,7 +226,7 @@ extension TerminalController: ControlNotificationContext { func controlNotificationMarkReadAll() -> Int { let store = TerminalNotificationStore.shared - let before = store.notifications + let before = Array(store.notifications) store.markAllRead() return Self.markedCount(before: before, store: store) } @@ -312,13 +323,24 @@ extension TerminalController: ControlNotificationContext { return before.filter { !$0.isRead && afterById[$0.id] == true }.count } - /// Converts a `TerminalNotification` to the Sendable snapshot, pre-rendering - /// the ISO-8601 `created_at` and resolving the workspace tab title exactly as - /// the legacy `notificationPayload` builder did. The date rendering mirrors - /// the (file-private) `TerminalController.notificationCreatedAtString`. + /// Converts one notification for the single-row commands. Full-list reads + /// use the cached-title overload below so repeated notifications from one + /// workspace resolve that title only once. private static func controlSnapshot( _ notification: TerminalNotification, surfaceID: UUID? = nil + ) -> ControlNotificationSnapshot { + controlSnapshot( + notification, + surfaceID: surfaceID, + cachedTabTitle: AppDelegate.shared?.tabTitle(for: notification.tabId) + ) + } + + private static func controlSnapshot( + _ notification: TerminalNotification, + surfaceID: UUID? = nil, + cachedTabTitle: String? ) -> ControlNotificationSnapshot { ControlNotificationSnapshot( id: notification.id, @@ -327,18 +349,9 @@ extension TerminalController: ControlNotificationContext { title: notification.title, subtitle: notification.subtitle, body: notification.body, - createdAtISO8601: notificationCreatedAtISO8601(notification.createdAt), + createdAt: notification.createdAt, isRead: notification.isRead, - tabTitle: AppDelegate.shared?.tabTitle(for: notification.tabId) + tabTitle: cachedTabTitle ) } - - /// Byte-identical reproduction of the file-private - /// `TerminalController.notificationCreatedAtString`. - private static func notificationCreatedAtISO8601(_ date: Date) -> String { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime] - formatter.timeZone = TimeZone(secondsFromGMT: 0) - return formatter.string(from: date) - } } diff --git a/Sources/TerminalController+ControlSidebarContextSupport.swift b/Sources/TerminalController+ControlSidebarContextSupport.swift index de81d5ecb6a1..8313d2ca6050 100644 --- a/Sources/TerminalController+ControlSidebarContextSupport.swift +++ b/Sources/TerminalController+ControlSidebarContextSupport.swift @@ -1,6 +1,20 @@ import CmuxControlSocket +import CmuxRemoteSession import Foundation +/// Identity for last-write-wins terminal mutations. A fresh enqueue removes +/// the pending same-key entry while preserving unrelated FIFO work. +struct TerminalMutationReplaceKey: Hashable, Sendable { + enum Kind: Hashable, Sendable { + case shellActivity, gitBranch, directory + case portsKick(PortScanKickReason) + } + + let tabId: UUID + let surfaceId: UUID + let kind: Kind +} + /// Shared resolution/scheduling twins for the `ControlSidebarContext` /// conformance split (`+ControlSidebarContext.swift` / `2` / `3`): byte-faithful /// re-declarations of the deleted (or file-private) `TerminalController.swift` diff --git a/Sources/TerminalController+MobileNotificationSync.swift b/Sources/TerminalController+MobileNotificationSync.swift index 7e1720a5e382..d9f923736fda 100644 --- a/Sources/TerminalController+MobileNotificationSync.swift +++ b/Sources/TerminalController+MobileNotificationSync.swift @@ -8,7 +8,7 @@ extension TerminalController { /// phone merges snapshots from all connected Macs into its global feed. func v2MobileNotificationFeedList(params _: [String: Any]) -> V2CallResult { let store = TerminalNotificationStore.shared - store.notificationFeedHistory.reconcileActiveNotifications(store.notifications) + store.notificationFeedHistory.reconcileActiveNotifications(Array(store.notifications)) let snapshot = store.notificationFeedHistory.snapshot return .ok([ "revision": snapshot.revision, diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 141de4b4d12f..631f0ea6d829 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -3225,10 +3225,14 @@ class TerminalController { } private nonisolated static func notificationCreatedAtString(_ date: Date) -> String { + notificationListDateFormatter().string(from: date) + } + + private nonisolated static func notificationListDateFormatter() -> ISO8601DateFormatter { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime] formatter.timeZone = TimeZone(secondsFromGMT: 0) - return formatter.string(from: date) + return formatter } private nonisolated static func notificationListTrailingField(_ value: String) -> String { @@ -12209,11 +12213,10 @@ class TerminalController { } } - /// `notify_target_async` — worker-lane body with ZERO main hops: parse + - /// mutation-bus enqueue on the calling thread (the bus coalesces and - /// drains on the main actor). Explicitly fire-and-forget: hooks nohup it - /// and discard the reply; existence checks are deferred to bus delivery - /// by design. + /// `notify_target_async` — worker-lane body with ZERO main hops: parse, + /// then register reliable admission before acknowledging. Hooks nohup this + /// command and discard the reply, so transient queue pressure must not + /// degrade to an acknowledged dropped notification. private nonisolated func notifyTargetQueued(_ args: String) -> String { let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { @@ -12236,9 +12239,7 @@ class TerminalController { return "ERROR: Usage: notify_target_async |<subtitle>|<body>" } let (title, subtitle, body, meta) = parseNotificationPayload(payload) - - // Hook and PTY-derived agent notifications share one gate + mutation-bus path. - guard AgentNotificationDelivery().enqueue( + switch AgentNotificationDelivery().enqueueReliablySynchronously( workspaceID: tabId, surfaceID: surfaceId, title: title, @@ -12246,47 +12247,40 @@ class TerminalController { body: body, category: meta?.category, pending: meta?.pending ?? false - ) else { + ) { + case .gated: #if DEBUG if let meta { - cmuxDebugLog( - "socket.notifyTargetAsync.gated category=\(meta.category.rawValue) pending=\(meta.pending) workspace=\(tabId.uuidString.prefix(8)) surface=\(surfaceId.uuidString.prefix(8))" - ) + cmuxDebugLog("socket.notifyTargetAsync.gated category=\(meta.category.rawValue) pending=\(meta.pending) workspace=\(tabId.uuidString.prefix(8)) surface=\(surfaceId.uuidString.prefix(8))") } #endif return "OK" + case .saturated, .cancelled: + return ReliableTerminalNotificationEnqueueResult.saturatedSocketResponse + case .accepted: break } #if DEBUG cmuxDebugLog( - "socket.notifyTargetAsync.enqueue workspace=\(tabId.uuidString.prefix(8)) surface=\(surfaceId.uuidString.prefix(8)) titleLen=\(title.count) subtitleLen=\(subtitle.count) bodyLen=\(body.count) coalesces=0" + "socket.notifyTargetAsync.enqueue workspace=\(tabId.uuidString.prefix(8)) surface=\(surfaceId.uuidString.prefix(8)) titleLen=\(title.count) subtitleLen=\(subtitle.count) bodyLen=\(body.count)" ) #endif return "OK" } /// `list_notifications` — worker-lane body: one main hop snapshots the - /// store (plus each notification's tab title); the ISO8601 formatting, - /// percent-escaping, and line join run on the calling thread. + /// store with cached tab titles; the ISO8601 formatting, percent-escaping, + /// and line join run on the calling worker thread. private nonisolated func listNotifications() -> String { - let rows: [(id: UUID, tabId: UUID, surfaceText: String, readText: String, title: String, subtitle: String, body: String, createdAt: Date, tabTitle: String)] = v2MainSync { - TerminalNotificationStore.shared.notifications.map { notification in - ( - id: notification.id, - tabId: notification.tabId, - surfaceText: notification.surfaceId?.uuidString ?? "none", - readText: notification.isRead ? "read" : "unread", - title: notification.title, - subtitle: notification.subtitle, - body: notification.body, - createdAt: notification.createdAt, - tabTitle: AppDelegate.shared?.tabTitle(for: notification.tabId) ?? "" - ) - } + let rows: [ControlNotificationSnapshot] = v2MainSync { + controlNotificationList() } + let formatter = Self.notificationListDateFormatter() let lines = rows.enumerated().map { index, row in - let createdAt = Self.notificationCreatedAtString(row.createdAt) - let tabTitle = Self.notificationListTrailingField(row.tabTitle) - return "\(index):\(row.id.uuidString)|\(row.tabId.uuidString)|\(row.surfaceText)|\(row.readText)|\(row.title)|\(row.subtitle)|\(row.body)|\(createdAt)|\(tabTitle)" + let createdAt = formatter.string(from: row.createdAt) + let surfaceText = row.surfaceID?.uuidString ?? "none" + let readText = row.isRead ? "read" : "unread" + let tabTitle = Self.notificationListTrailingField(row.tabTitle ?? "") + return "\(index):\(row.id.uuidString)|\(row.workspaceID.uuidString)|\(surfaceText)|\(readText)|\(row.title)|\(row.subtitle)|\(row.body)|\(createdAt)|\(tabTitle)" } let result = lines.joined(separator: "\n") return result.isEmpty ? "No notifications" : result diff --git a/Sources/TerminalMutationBus+NotificationLifecycle.swift b/Sources/TerminalMutationBus+NotificationLifecycle.swift new file mode 100644 index 000000000000..1c207b6a2f85 --- /dev/null +++ b/Sources/TerminalMutationBus+NotificationLifecycle.swift @@ -0,0 +1,342 @@ +import Foundation + +extension TerminalMutationBus { + nonisolated func markNotificationClearBoundary() -> UInt64 { + lock.lock() + let boundary = currentNotificationGeneration + currentNotificationGeneration &+= 1 + lock.unlock() + return boundary + } + + nonisolated func notificationGenerationSnapshot() -> UInt64 { + lock.withLock { currentNotificationGeneration } + } + + nonisolated func discardPendingNotifications(forTabId tabId: UUID, through boundary: UInt64) { + discardPendingNotifications { key, generation in + key.tabId == tabId && generation <= boundary + } + } + + nonisolated func discardPendingNotifications( + forTabId tabId: UUID, + surfaceId: UUID, + through boundary: UInt64 + ) { + discardPendingNotifications { key, generation in + key.tabId == tabId && key.surfaceId == surfaceId && generation <= boundary + } + } + + nonisolated func discardPendingNotifications() { + discardPendingNotifications(advanceGeneration: true) { _, _ in true } + } + + nonisolated func discardPendingNotifications(forTabId tabId: UUID) { + discardPendingNotifications { key, _ in key.tabId == tabId } + } + + /// Exact enqueue-key discard for source-scoped operations. + nonisolated func discardPendingNotifications(forTabId tabId: UUID, surfaceId: UUID?) { + discardPendingNotifications { key, _ in + key.tabId == tabId && key.surfaceId == surfaceId + } + } + + /// Canonical surface identity for clears and supersedes. + nonisolated func discardPendingNotifications(forSurfaceId surfaceId: UUID) { + discardPendingNotifications { key, _ in key.surfaceId == surfaceId } + } + + @MainActor + func discardPendingNotificationsForClear(tabId: UUID, surfaceId: UUID?) { + if let surfaceId { + discardPendingNotifications(forSurfaceId: surfaceId) + } else { + discardPendingNotificationsResolvingLiveOwner(forTabId: tabId) + } + } + + nonisolated func pendingNotificationAddressesSnapshot() -> [( + sequence: UInt64, + tabId: UUID, + surfaceId: UUID?, + allowWorkspaceFallbackForValidatedSurface: Bool + )] { + lock.lock() + defer { lock.unlock() } + return pending[pendingHead...].compactMap { entry in + guard case .deliverNotification(let notification) = entry.mutation else { return nil } + return ( + entry.sequence, + notification.key.tabId, + notification.key.surfaceId, + notification.allowWorkspaceFallbackForValidatedSurface + ) + } + } + + nonisolated func queuedNotificationAddressesSnapshot() -> [( + id: UUID, + tabId: UUID, + surfaceId: UUID?, + allowWorkspaceFallbackForValidatedSurface: Bool + )] { + lock.lock() + defer { lock.unlock() } + var addresses = pending[pendingHead...].compactMap { entry -> (UUID, UUID, UUID?, Bool)? in + guard case .deliverNotification(let notification) = entry.mutation else { return nil } + return ( + notification.id, + notification.key.tabId, + notification.key.surfaceId, + notification.allowWorkspaceFallbackForValidatedSurface + ) + } + for admission in reliableAdmissionsById.values.sorted(by: Self.reliableAdmissionSortPrecedes) { + let key = notificationKeyFollowingReplacementRoutes(admission.key) + addresses.append(( + admission.id, + key.tabId, + key.surfaceId, + admission.allowWorkspaceFallbackForValidatedSurface + )) + } + return addresses + } + + nonisolated func discardPendingNotifications(sequences: Set<UInt64>) { + guard !sequences.isEmpty else { return } + lock.lock() + compactPendingForMutation() + pending.removeAll { entry in + guard case .deliverNotification = entry.mutation else { return false } + return sequences.contains(entry.sequence) + } + lock.broadcast() + lock.unlock() + } + + nonisolated func discardQueuedNotifications(ids: Set<UUID>) { + guard !ids.isEmpty else { return } + lock.lock() + reliableAdmissionsById = reliableAdmissionsById.filter { !ids.contains($0.key) } + compactPendingForMutation() + pending.removeAll { entry in + guard case .deliverNotification(let notification) = entry.mutation else { return false } + return ids.contains(notification.id) + } + lock.broadcast() + lock.unlock() + } + + nonisolated func transferPendingNotifications( + fromTabId: UUID, + toTabId: UUID, + panelIdMap: [UUID: UUID] + ) { + guard fromTabId != toTabId else { return } + lock.lock() + installNotificationReplacementRoute( + fromTabId: fromTabId, + toTabId: toTabId, + panelIdMap: panelIdMap + ) + compactPendingForMutation() + pending = Self.remappingPendingEntries( + pending, + fromTabId: fromTabId, + toTabId: toTabId, + panelIdMap: panelIdMap + ).map(notificationEntryFollowingReplacementRoutes) + reliableAdmissionsById = reliableAdmissionsById.mapValues { admission in + var remapped = admission + if remapped.key.tabId == fromTabId { + remapped.key = QueuedTerminalNotificationKey( + tabId: toTabId, + surfaceId: remapped.key.surfaceId.map { panelIdMap[$0] ?? $0 } + ) + } + remapped.key = notificationKeyFollowingReplacementRoutes(remapped.key) + return remapped + } + lock.broadcast() + lock.unlock() + } + + func notificationKeyFollowingReplacementRoutes( + _ original: QueuedTerminalNotificationKey + ) -> QueuedTerminalNotificationKey { + // All callers hold `lock`, making route installation, admission + // capture, and pending enqueue one atomic routing boundary. + var tabId = original.tabId + var surfaceId = original.surfaceId + var visitedReplacementTabIds: Set<UUID> = [] + var visitedLiveOwnerKeys: Set<String> = [] + while true { + var changed = false + while visitedReplacementTabIds.insert(tabId).inserted, + let route = notificationReplacementRoutesByTabId[tabId] { + surfaceId = surfaceId.map { route.panelIdMap[$0] ?? $0 } + tabId = route.toTabId + changed = true + } + if let surfaceId, + let liveOwnerTabId = notificationLiveOwnerTabIdBySurfaceId[surfaceId], + liveOwnerTabId != tabId { + let liveOwnerKey = "\(surfaceId.uuidString):\(liveOwnerTabId.uuidString)" + guard visitedLiveOwnerKeys.insert(liveOwnerKey).inserted else { break } + tabId = liveOwnerTabId + changed = true + continue + } + if !changed { break } + } + return QueuedTerminalNotificationKey(tabId: tabId, surfaceId: surfaceId) + } + + nonisolated func routedNotificationKey( + tabId: UUID, + surfaceId: UUID? + ) -> QueuedTerminalNotificationKey { + lock.lock() + defer { lock.unlock() } + return notificationKeyFollowingReplacementRoutes( + QueuedTerminalNotificationKey(tabId: tabId, surfaceId: surfaceId) + ) + } + + nonisolated func rebindPendingNotifications( + fromTabId: UUID, + toTabId: UUID, + surfaceId: UUID + ) { + guard fromTabId != toTabId else { return } + lock.lock() + installNotificationLiveOwnerRoute(surfaceId: surfaceId, toTabId: toTabId) + compactPendingForMutation() + pending = pending.map(notificationEntryFollowingReplacementRoutes) + reliableAdmissionsById = reliableAdmissionsById.mapValues { admission in + var routed = admission + routed.key = notificationKeyFollowingReplacementRoutes(routed.key) + return routed + } + lock.broadcast() + lock.unlock() + } + + nonisolated func removeNotificationLiveOwnerRoute(surfaceId: UUID) { + lock.lock() + notificationLiveOwnerTabIdBySurfaceId.removeValue(forKey: surfaceId) + notificationLiveOwnerSurfaceOrder.removeAll { $0 == surfaceId } + lock.broadcast() + lock.unlock() + } + + private func installNotificationReplacementRoute( + fromTabId: UUID, + toTabId: UUID, + panelIdMap: [UUID: UUID] + ) { + notificationReplacementRoutesByTabId[fromTabId] = TerminalNotificationReplacementRoute( + toTabId: toTabId, + panelIdMap: panelIdMap + ) + notificationReplacementRouteOrder.removeAll { $0 == fromTabId } + notificationReplacementRouteOrder.append(fromTabId) + while notificationReplacementRouteOrder.count > Self.maximumNotificationReplacementRouteCount { + let retiredTabId = notificationReplacementRouteOrder.removeFirst() + notificationReplacementRoutesByTabId.removeValue(forKey: retiredTabId) + } + } + + private func installNotificationLiveOwnerRoute(surfaceId: UUID, toTabId: UUID) { + notificationLiveOwnerTabIdBySurfaceId[surfaceId] = toTabId + notificationLiveOwnerSurfaceOrder.removeAll { $0 == surfaceId } + notificationLiveOwnerSurfaceOrder.append(surfaceId) + while notificationLiveOwnerSurfaceOrder.count > Self.maximumNotificationLiveOwnerRouteCount { + let retiredSurfaceId = notificationLiveOwnerSurfaceOrder.removeFirst() + notificationLiveOwnerTabIdBySurfaceId.removeValue(forKey: retiredSurfaceId) + } + } + + private func notificationEntryFollowingReplacementRoutes( + _ entry: TerminalSocketMutationEntry + ) -> TerminalSocketMutationEntry { + let routedMutation: TerminalSocketMutation + switch entry.mutation { + case .deliverNotification(let notification): + routedMutation = .deliverNotification(QueuedTerminalNotification( + id: notification.id, + acceptedAt: notification.acceptedAt, + key: notificationKeyFollowingReplacementRoutes(notification.key), + allowWorkspaceFallbackForValidatedSurface: notification.allowWorkspaceFallbackForValidatedSurface, + title: notification.title, + subtitle: notification.subtitle, + body: notification.body, + contentByteCount: notification.contentByteCount + )) + case .clearNotificationsForTab(let tabId, let boundary): + let routed = notificationKeyFollowingReplacementRoutes( + QueuedTerminalNotificationKey(tabId: tabId, surfaceId: nil) + ) + routedMutation = .clearNotificationsForTab(routed.tabId, through: boundary) + case .clearNotificationsForSurface(let tabId, let surfaceId, let boundary): + let routed = notificationKeyFollowingReplacementRoutes( + QueuedTerminalNotificationKey(tabId: tabId, surfaceId: surfaceId) + ) + routedMutation = .clearNotificationsForSurface( + routed.tabId, + routed.surfaceId ?? surfaceId, + through: boundary + ) + case .clearAllNotifications, .perform: + routedMutation = entry.mutation + } + return TerminalSocketMutationEntry( + sequence: entry.sequence, + mutation: routedMutation, + notificationGeneration: entry.notificationGeneration, + performReplaceKey: entry.performReplaceKey + ) + } + + private nonisolated func discardPendingNotifications( + advanceGeneration: Bool = false, + where shouldDiscard: (QueuedTerminalNotificationKey, UInt64) -> Bool + ) { + lock.lock() + if advanceGeneration { + recordReliableClearBoundaryLocked(.all, boundary: currentNotificationGeneration) + } + reliableAdmissionsById = reliableAdmissionsById.filter { _, admission in + let key = notificationKeyFollowingReplacementRoutes(admission.key) + if shouldDiscard(key, admission.notificationGeneration) { + return false + } + return true + } + compactPendingForMutation() + pending.removeAll { entry in + guard case .deliverNotification(let notification) = entry.mutation, + let generation = entry.notificationGeneration else { + return false + } + return shouldDiscard(notification.key, generation) + } + if advanceGeneration { + currentNotificationGeneration &+= 1 + } + lock.broadcast() + lock.unlock() + } + + private static func reliableAdmissionSortPrecedes( + _ lhs: ReliableTerminalNotificationAdmission, + _ rhs: ReliableTerminalNotificationAdmission + ) -> Bool { + if lhs.acceptedAt != rhs.acceptedAt { return lhs.acceptedAt < rhs.acceptedAt } + return lhs.id.uuidString < rhs.id.uuidString + } +} diff --git a/Sources/TerminalMutationBus+Perform.swift b/Sources/TerminalMutationBus+Perform.swift new file mode 100644 index 000000000000..46646cb9d726 --- /dev/null +++ b/Sources/TerminalMutationBus+Perform.swift @@ -0,0 +1,46 @@ +extension TerminalMutationBus { + @MainActor + func perform(_ batch: [TerminalSocketMutationEntry]) { + for entry in batch { + switch entry.mutation { + case .deliverNotification(let notification): +#if DEBUG + cmuxDebugLog( + "notification.queue.perform seq=\(entry.sequence) workspace=\(notification.key.tabId.uuidString.prefix(8)) surface=\(notification.key.surfaceId?.uuidString.prefix(8) ?? "nil") titleLen=\(notification.title.count) subtitleLen=\(notification.subtitle.count) bodyLen=\(notification.body.count)" + ) +#endif + TerminalNotificationStore.shared.deliverQueuedNotification( + claimedTabId: notification.key.tabId, + surfaceId: notification.key.surfaceId, + title: notification.title, + subtitle: notification.subtitle, + body: notification.body, + id: notification.id, + acceptedAt: notification.acceptedAt, + notificationGeneration: entry.notificationGeneration ?? 0, + allowWorkspaceFallbackForValidatedSurface: notification.allowWorkspaceFallbackForValidatedSurface + ) + case .clearAllNotifications(let boundary): + TerminalNotificationStore.shared.clearAll( + discardQueuedNotifications: false, + throughNotificationGeneration: boundary + ) + case .clearNotificationsForTab(let tabId, let boundary): + TerminalNotificationStore.shared.clearNotifications( + forTabId: tabId, + discardQueuedNotifications: false, + throughNotificationGeneration: boundary + ) + case .clearNotificationsForSurface(let tabId, let surfaceId, let boundary): + TerminalNotificationStore.shared.clearNotifications( + forTabId: tabId, + surfaceId: surfaceId, + discardQueuedNotifications: false, + throughNotificationGeneration: boundary + ) + case .perform(let mutation): + mutation() + } + } + } +} diff --git a/Sources/TerminalNotificationLiveRetargetDelivery.swift b/Sources/TerminalNotificationLiveRetargetDelivery.swift index c4a0ec11c163..4ed34cb5ffb0 100644 --- a/Sources/TerminalNotificationLiveRetargetDelivery.swift +++ b/Sources/TerminalNotificationLiveRetargetDelivery.swift @@ -32,18 +32,10 @@ extension TerminalController { // rehome that source-confined claim from an untrusted surface UUID. target = (tabId, surfaceId) } - if retargetsToLiveSurfaceOwner, let liveSurfaceId = target.surfaceId { - // Supersede by canonical surface identity: stale-keyed local - // entries would retarget to this same pane at drain. - TerminalMutationBus.shared.discardPendingNotifications(forSurfaceId: liveSurfaceId) - } else { - // Source-confined relay delivery may supersede only its authorized - // enqueue key, not another workspace's entry for the same UUID. - TerminalMutationBus.shared.discardPendingNotifications( - forTabId: target.tabId, - surfaceId: target.surfaceId - ) - } + // Chronological feed delivery is append-only by accepted notification + // id. A synchronous notification may supersede the phone/banner owner + // for a pane, but it must not delete already accepted queued rows that + // have not drained yet. #if DEBUG cmuxDebugLog( "notification.sync.deliver workspace=\(target.tabId.uuidString.prefix(8)) surface=\(target.surfaceId?.uuidString.prefix(8) ?? "nil") claimedWorkspace=\(tabId.uuidString.prefix(8)) titleLen=\(title.count) subtitleLen=\(subtitle.count) bodyLen=\(body.count)" @@ -72,11 +64,15 @@ extension TerminalNotificationStore { title: String, subtitle: String, body: String, - notificationGeneration: UInt64 + id: UUID, + acceptedAt: Date, + notificationGeneration: UInt64, + allowWorkspaceFallbackForValidatedSurface: Bool = false ) { - guard let target = AppDelegate.shared?.agentNotificationDeliveryTarget( + guard let target = AppDelegate.shared?.agentNotificationRecordTarget( claimedTabId: claimedTabId, - surfaceId: surfaceId + surfaceId: surfaceId, + allowWorkspaceFallbackForValidatedSurface: allowWorkspaceFallbackForValidatedSurface ) else { #if DEBUG cmuxDebugLog( @@ -91,6 +87,8 @@ extension TerminalNotificationStore { ) #endif addNotification( + id: id, + acceptedAt: acceptedAt, tabId: target.tabId, surfaceId: target.surfaceId, title: title, @@ -107,14 +105,15 @@ extension TerminalNotificationStore { _ request: TerminalNotificationPolicyRequest ) -> TerminalNotificationPolicyRequest? { guard request.retargetsToLiveSurfaceOwner else { return request } - guard let target = AppDelegate.shared?.agentNotificationDeliveryTarget( + guard let target = AppDelegate.shared?.agentNotificationRecordTarget( claimedTabId: request.tabId, - surfaceId: request.surfaceId + surfaceId: request.surfaceId, + allowWorkspaceFallbackForValidatedSurface: request.panelId != nil ) else { return nil } return TerminalNotificationPolicyRequest( tabId: target.tabId, surfaceId: target.surfaceId, - panelId: request.panelId, + panelId: target.surfaceId == nil ? nil : request.panelId, retargetsToLiveSurfaceOwner: true, title: request.title, subtitle: request.subtitle, @@ -136,11 +135,13 @@ extension TerminalMutationBus { /// lock, resolve each UNIQUE pending surface's live owner on the main /// actor (mirroring `agentNotificationDeliveryTarget`'s /// preferred-workspace resolution), then discard exactly the snapshotted - /// sequences. Repeated non-coalescing notifications for one pane share a - /// cached lookup, keeping the global workspace scan bounded by unique live - /// surfaces instead of total backlog length. Entries enqueued between - /// snapshot and discard are newer than the clear and are deliberately - /// preserved. + /// stable notification ids from both pending entries and reliable + /// admissions. Stable ids also cover an admission that becomes pending + /// between the two lock acquisitions. Repeated non-coalescing + /// notifications for one pane share a cached lookup, keeping the global + /// workspace scan bounded by unique live surfaces instead of total backlog + /// length. Entries accepted between snapshot and discard have new ids and + /// are deliberately preserved. func pendingNotificationSequencesResolvingLiveOwner(forTabId tabId: UUID) -> Set<UInt64> { pendingNotificationSequencesResolvingLiveOwner( forTabId: tabId, @@ -177,16 +178,61 @@ extension TerminalMutationBus { unresolvedSurfaceIds.insert(surfaceId) liveOwner = nil } - if liveOwner == tabId { + if liveOwner == tabId || + (liveOwner == nil && + entry.allowWorkspaceFallbackForValidatedSurface && + entry.tabId == tabId) { sequences.insert(entry.sequence) } } return sequences } + func queuedNotificationIDsResolvingLiveOwner( + forTabId tabId: UUID, + liveOwnerResolver: (_ claimedTabId: UUID, _ surfaceId: UUID) -> UUID? + ) -> Set<UUID> { + var ids: Set<UUID> = [] + var liveOwnerBySurfaceId: [UUID: UUID] = [:] + var unresolvedSurfaceIds: Set<UUID> = [] + for entry in queuedNotificationAddressesSnapshot() { + guard let surfaceId = entry.surfaceId else { + if entry.tabId == tabId { ids.insert(entry.id) } + continue + } + let liveOwner: UUID? + if let cached = liveOwnerBySurfaceId[surfaceId] { + liveOwner = cached + } else if unresolvedSurfaceIds.contains(surfaceId) { + liveOwner = nil + } else if let resolved = liveOwnerResolver(entry.tabId, surfaceId) { + liveOwnerBySurfaceId[surfaceId] = resolved + liveOwner = resolved + } else { + unresolvedSurfaceIds.insert(surfaceId) + liveOwner = nil + } + if liveOwner == tabId || + (liveOwner == nil && + entry.allowWorkspaceFallbackForValidatedSurface && + entry.tabId == tabId) { + ids.insert(entry.id) + } + } + return ids + } + func discardPendingNotificationsResolvingLiveOwner(forTabId tabId: UUID) { - discardPendingNotifications( - sequences: pendingNotificationSequencesResolvingLiveOwner(forTabId: tabId) + discardQueuedNotifications( + ids: queuedNotificationIDsResolvingLiveOwner( + forTabId: tabId, + liveOwnerResolver: { claimedTabId, surfaceId in + AppDelegate.shared?.agentNotificationDeliveryTarget( + claimedTabId: claimedTabId, + surfaceId: surfaceId + )?.tabId + } + ) ) } } diff --git a/Sources/TerminalNotificationPolicy.swift b/Sources/TerminalNotificationPolicy.swift index d01373cb5074..1e5a1bcd1945 100644 --- a/Sources/TerminalNotificationPolicy.swift +++ b/Sources/TerminalNotificationPolicy.swift @@ -218,6 +218,21 @@ struct TerminalNotificationPolicyRequest: Sendable { self.isAppFocused = isAppFocused self.isFocusedPanel = isFocusedPanel } + + func replacingLocation(tabId: UUID, surfaceId: UUID?, panelId: UUID?) -> Self { + Self( + tabId: tabId, + surfaceId: surfaceId, + panelId: panelId, + retargetsToLiveSurfaceOwner: retargetsToLiveSurfaceOwner, + title: title, + subtitle: subtitle, + body: body, + cwd: cwd, + isAppFocused: isAppFocused, + isFocusedPanel: isFocusedPanel + ) + } } struct TerminalNotificationPolicyFailure: Error, Sendable, Hashable { let hookId: String diff --git a/Sources/TerminalNotificationPolicyDeliveryIdentity.swift b/Sources/TerminalNotificationPolicyDeliveryIdentity.swift index d5a68f3bd8d4..ecde2bdbd8c4 100644 --- a/Sources/TerminalNotificationPolicyDeliveryIdentity.swift +++ b/Sources/TerminalNotificationPolicyDeliveryIdentity.swift @@ -1,11 +1,20 @@ import Foundation -/// Serializes notification-policy completion only with work that targets the -/// same user-visible delivery destination. +/// Serializes notification-policy completion by cooldown key when present, +/// otherwise by the same user-visible delivery destination. enum TerminalNotificationPolicyDeliveryIdentity: Hashable, Sendable { + case cooldown(String) case surface(UUID) case workspace(UUID) + init(request: TerminalNotificationPolicyRequest, cooldownKey: String?) { + if let cooldownKey { + self = .cooldown(cooldownKey) + } else { + self.init(request: request) + } + } + init(request: TerminalNotificationPolicyRequest) { if let surfaceId = request.panelId ?? request.surfaceId { self = .surface(surfaceId) diff --git a/Sources/TerminalNotificationPolicyInFlightStore.swift b/Sources/TerminalNotificationPolicyInFlightStore.swift index 51b4b94e5bdd..111d7b26f7bf 100644 --- a/Sources/TerminalNotificationPolicyInFlightStore.swift +++ b/Sources/TerminalNotificationPolicyInFlightStore.swift @@ -6,13 +6,13 @@ import Foundation @MainActor final class TerminalNotificationPolicyInFlightStore { private struct Entry { - let request: TerminalNotificationPolicyRequest + var request: TerminalNotificationPolicyRequest let generation: UInt64 - let deliveryIdentity: TerminalNotificationPolicyDeliveryIdentity + var deliveryIdentity: TerminalNotificationPolicyDeliveryIdentity var onDiscard: @MainActor @Sendable () -> Void var indexedTabId: UUID var task: Task<Void, Never>? - var completion: (@MainActor () -> Void)? + var completion: (@MainActor (TerminalNotificationPolicyRequest) -> Void)? } private let maximumRequestCount = 1_024 private var requests: [UUID: Entry] = [:] @@ -26,6 +26,7 @@ final class TerminalNotificationPolicyInFlightStore { func register( _ request: TerminalNotificationPolicyRequest, generation: UInt64, + cooldownKey: String? = nil, onDiscard: @escaping @MainActor @Sendable () -> Void ) -> UUID { compactEvictionOrderIfNeeded() @@ -39,7 +40,10 @@ final class TerminalNotificationPolicyInFlightStore { } identitiesToDrain.forEach(drainCompletedRequests) let id = UUID() - let deliveryIdentity = TerminalNotificationPolicyDeliveryIdentity(request: request) + let deliveryIdentity = TerminalNotificationPolicyDeliveryIdentity( + request: request, + cooldownKey: cooldownKey + ) requests[id] = Entry( request: request, generation: generation, @@ -64,9 +68,18 @@ final class TerminalNotificationPolicyInFlightStore { /// Transfers cleanup ownership when an early reservation reaches policy evaluation. func updateOnDiscard( _ onDiscard: @escaping @MainActor @Sendable () -> Void, + cooldownKey: String? = nil, for id: UUID ) -> Bool { guard var entry = requests[id] else { return false } + let deliveryIdentity = TerminalNotificationPolicyDeliveryIdentity( + request: entry.request, + cooldownKey: cooldownKey + ) + if deliveryIdentity != entry.deliveryIdentity { + entry.deliveryIdentity = deliveryIdentity + requestIDsByDeliveryIdentity[deliveryIdentity, default: []].append(id) + } entry.onDiscard = onDiscard requests[id] = entry return true @@ -80,17 +93,27 @@ final class TerminalNotificationPolicyInFlightStore { return true } - func claim(_ id: UUID?) -> Bool { - guard let id else { return true } - guard let entry = requests.removeValue(forKey: id) else { return false } + func claim( + _ id: UUID?, + applying request: TerminalNotificationPolicyRequest + ) -> TerminalNotificationPolicyRequest? { + guard let id else { return request } + guard let entry = requests.removeValue(forKey: id) else { return nil } decrementIndexes(for: entry.request, tabId: entry.indexedTabId) drainCompletedRequests(for: entry.deliveryIdentity) - return true + return request.replacingLocation( + tabId: entry.request.tabId, + surfaceId: entry.request.surfaceId, + panelId: entry.request.panelId + ) } /// Completes one asynchronous policy evaluation while preserving order /// within its delivery target without blocking unrelated workspaces. - func complete(_ id: UUID, apply: @escaping @MainActor () -> Void) { + func complete( + _ id: UUID, + apply: @escaping @MainActor (TerminalNotificationPolicyRequest) -> Void + ) { guard var entry = requests[id] else { return } entry.completion = apply requests[id] = entry @@ -131,6 +154,24 @@ final class TerminalNotificationPolicyInFlightStore { identities.forEach(drainCompletedRequests) } + func transfer(fromTabId: UUID, toTabId: UUID, panelIdMap: [UUID: UUID]) { + let idsToTransfer = requests.compactMap { id, entry in + entry.indexedTabId == fromTabId ? id : nil + } + for id in idsToTransfer { + guard var entry = requests[id] else { continue } + decrementIndexes(for: entry.request, tabId: entry.indexedTabId) + entry.request = entry.request.replacingLocation( + tabId: toTabId, + surfaceId: entry.request.surfaceId.map { panelIdMap[$0] ?? $0 }, + panelId: entry.request.panelId.map { panelIdMap[$0] ?? $0 } + ) + entry.indexedTabId = toTabId + requests[id] = entry + incrementIndexes(for: entry.request, tabId: toTabId) + } + } + /// Moves pending trusted-local work with the surface so O(1) unread and /// dismissal gates always reflect the workspace that currently owns it. func rebindSurface(fromTabId sourceTabId: UUID, toTabId destinationTabId: UUID, surfaceId: UUID) { @@ -146,6 +187,11 @@ final class TerminalNotificationPolicyInFlightStore { for id in idsToRebind { guard var entry = requests[id] else { continue } decrementIndexes(for: entry.request, tabId: entry.indexedTabId) + entry.request = entry.request.replacingLocation( + tabId: destinationTabId, + surfaceId: entry.request.surfaceId, + panelId: entry.request.panelId + ) entry.indexedTabId = destinationTabId requests[id] = entry incrementIndexes(for: entry.request, tabId: destinationTabId) @@ -175,11 +221,15 @@ final class TerminalNotificationPolicyInFlightStore { advanceRequestOffset(for: deliveryIdentity) continue } + guard entry.deliveryIdentity == deliveryIdentity else { + advanceRequestOffset(for: deliveryIdentity) + continue + } guard let completion = entry.completion else { break } requests.removeValue(forKey: id) decrementIndexes(for: entry.request, tabId: entry.indexedTabId) advanceRequestOffset(for: deliveryIdentity) - completion() + completion(entry.request) } compactRequestOrderIfNeeded(for: deliveryIdentity) } diff --git a/Sources/TerminalNotificationQueue.swift b/Sources/TerminalNotificationQueue.swift index b0c56eb308b0..56b7f8318356 100644 --- a/Sources/TerminalNotificationQueue.swift +++ b/Sources/TerminalNotificationQueue.swift @@ -1,265 +1,486 @@ -import CmuxRemoteSession import Foundation -fileprivate struct QueuedTerminalNotificationKey: Hashable, Sendable { - let tabId: UUID - let surfaceId: UUID? +enum ReliableTerminalNotificationEnqueueResult: Sendable, Equatable { + case accepted + case saturated + case cancelled } -fileprivate struct QueuedTerminalNotification: Sendable { - let key: QueuedTerminalNotificationKey - let title: String - let subtitle: String - let body: String -} - -fileprivate enum TerminalSocketMutation { - case deliverNotification(QueuedTerminalNotification) - case clearAllNotifications(through: UInt64) - case clearNotificationsForTab(UUID, through: UInt64) - case clearNotificationsForSurface(UUID, UUID, through: UInt64) - case perform(@MainActor () -> Void) -} - -fileprivate struct TerminalSocketMutationEntry { - let sequence: UInt64 - let mutation: TerminalSocketMutation - let notificationGeneration: UInt64? - let notificationCoalescingKey: TerminalNotificationCoalescingKey? - let performReplaceKey: TerminalMutationReplaceKey? -} - -/// Identity for last-write-wins `.perform` mutations: a fresh enqueue removes -/// the pending same-key entry, bounding `pending` at one entry per key even -/// while the main actor is blocked and cannot drain. -struct TerminalMutationReplaceKey: Hashable, Sendable { - enum Kind: Hashable, Sendable { - case shellActivity, gitBranch, directory - case portsKick(PortScanKickReason) - } - - let tabId: UUID - let surfaceId: UUID - let kind: Kind -} - -fileprivate struct TerminalNotificationCoalescingKey: Hashable { - let generation: UInt64 - let notificationKey: QueuedTerminalNotificationKey +enum TerminalNotificationClearTarget { + case all + case workspace(UUID) + case surface(tabId: UUID, surfaceId: UUID) } final class TerminalMutationBus: @unchecked Sendable { static let shared = TerminalMutationBus() - - private let lock = NSLock() - private var pending: [TerminalSocketMutationEntry] = [] + static let maximumPendingMutationCount = 256 + static let maximumWaitingNotificationProducerCount = 16 + static let maximumReliableAdmissionBacklogCount = maximumPendingMutationCount + static let notificationCapacityWaitTimeout: TimeInterval = 1 + nonisolated static let maximumQueuedNotificationContentBytes = + TerminalNotificationStore.maximumNotificationFeedContentBytes + static let maximumNotificationLiveOwnerRouteCount = 2_048 + + private static let reliableAdmissionQueue = DispatchQueue( + label: "com.cmux.agent-notification-reliable-admission", + qos: .utility + ) + let lock = NSCondition() + var pending: [TerminalSocketMutationEntry] = [] + var pendingHead = 0 private var drainScheduled = false private var nextSequence: UInt64 = 0 - private var currentNotificationGeneration: UInt64 = 0 + var currentNotificationGeneration: UInt64 = 0 + private var waitingNotificationProducerCount = 0 + static let maximumNotificationReplacementRouteCount = 256 + var notificationReplacementRoutesByTabId: [UUID: TerminalNotificationReplacementRoute] = [:] + var notificationReplacementRouteOrder: [UUID] = [] + var notificationLiveOwnerTabIdBySurfaceId: [UUID: UUID] = [:] + var notificationLiveOwnerSurfaceOrder: [UUID] = [] + var reliableAdmissionsById: [UUID: ReliableTerminalNotificationAdmission] = [:] + private var reliableClearAllBoundary: UInt64? + private var reliableClearWorkspaceBoundaryByTabId: [UUID: UInt64] = [:] + private var reliableClearWorkspaceBoundaryOrder: [UUID] = [] + private var reliableClearSurfaceBoundaryBySurfaceId: [UUID: UInt64] = [:] + private var reliableClearSurfaceBoundaryOrder: [UUID] = [] + private var reliablyWaitingNotificationProducerCount = 0 private let maxMutationsPerDrain = 16 #if DEBUG private var drainsSuspendedForTesting = false #endif + @discardableResult nonisolated func enqueueNotification( tabId: UUID, surfaceId: UUID?, title: String, subtitle: String, body: String, - coalesces: Bool = true - ) { - enqueueNotification(QueuedTerminalNotification( - key: QueuedTerminalNotificationKey(tabId: tabId, surfaceId: surfaceId), - title: title, + saturationHandler: (() -> Void)? = nil + ) -> Bool { + let payload = QueuedTerminalNotificationPayload( + normalizingTitle: title, subtitle: subtitle, body: body - ), coalesces: coalesces) - } + ) + while true { + lock.lock() + if !hasUnreservedNotificationCapacityLocked(for: payload.contentByteCount) { + if let saturationHandler { + lock.unlock() + saturationHandler() + continue + } + guard waitingNotificationProducerCount < Self.maximumWaitingNotificationProducerCount else { + lock.unlock() + return false + } + waitingNotificationProducerCount += 1 + let deadline = Date(timeIntervalSinceNow: Self.notificationCapacityWaitTimeout) + while !hasUnreservedNotificationCapacityLocked(for: payload.contentByteCount) { + guard lock.wait(until: deadline) else { + waitingNotificationProducerCount -= 1 + lock.unlock() + return false + } + } + waitingNotificationProducerCount -= 1 + } - nonisolated func enqueueClearAllNotifications() { - enqueueClear({ .clearAllNotifications(through: $0) }) { _ in true } + let routedKey = notificationKeyFollowingReplacementRoutes( + QueuedTerminalNotificationKey(tabId: tabId, surfaceId: surfaceId) + ) + let notification = QueuedTerminalNotification( + id: UUID(), + acceptedAt: Date(), + key: routedKey, + allowWorkspaceFallbackForValidatedSurface: false, + title: payload.title, + subtitle: payload.subtitle, + body: payload.body, + contentByteCount: payload.contentByteCount + ) + let generation = currentNotificationGeneration + nextSequence &+= 1 + let sequence = nextSequence + pending.append(TerminalSocketMutationEntry( + sequence: sequence, + mutation: .deliverNotification(notification), + notificationGeneration: generation, + performReplaceKey: nil + )) + let shouldScheduleDrain = !drainScheduled + if shouldScheduleDrain { + drainScheduled = true + } + let pendingCount = pending.count - pendingHead + lock.unlock() +#if DEBUG + cmuxDebugLog( + "notification.queue.enqueue seq=\(sequence) workspace=\(notification.key.tabId.uuidString.prefix(8)) surface=\(notification.key.surfaceId?.uuidString.prefix(8) ?? "nil") pending=\(pendingCount) generation=\(generation) titleLen=\(notification.title.count) subtitleLen=\(notification.subtitle.count) bodyLen=\(notification.body.count)" + ) +#endif + if shouldScheduleDrain { + scheduleDrain() + } + return true + } } - nonisolated func enqueueClearNotifications(forTabId tabId: UUID) { - // Surface-addressed entries may have moved since enqueue. Keep them - // ahead of the barrier so delivery can resolve their live owner first. - enqueueClear({ .clearNotificationsForTab(tabId, through: $0) }) { notification in - notification.key.tabId == tabId && notification.key.surfaceId == nil + /// Registers acceptance and submits capacity waiting through the bus-owned + /// serial worker, so callers cannot bypass the single-waiter invariant. + nonisolated func enqueueNotificationReliably( + tabId: UUID, + surfaceId: UUID?, + title: String, + subtitle: String, + body: String, + allowWorkspaceFallbackForValidatedSurface: Bool = false + ) async -> ReliableTerminalNotificationEnqueueResult { + let payload = QueuedTerminalNotificationPayload( + normalizingTitle: title, + subtitle: subtitle, + body: body + ) + return await withCheckedContinuation { (continuation: CheckedContinuation<ReliableTerminalNotificationEnqueueResult, Never>) in + guard let admissionToken = captureReliableNotificationAdmission( + tabId: tabId, + surfaceId: surfaceId, + payload: payload, + allowWorkspaceFallbackForValidatedSurface: allowWorkspaceFallbackForValidatedSurface + ) else { + continuation.resume(returning: .saturated) + return + } + Self.reliableAdmissionQueue.async { [self] in + continuation.resume(returning: enqueueCapturedNotificationReliably(admissionToken: admissionToken)) + } } } - nonisolated func enqueueClearNotifications(forTabId tabId: UUID, surfaceId: UUID) { - // Canonical surface identity: a stale-keyed entry would retarget here at drain. - enqueueClear({ .clearNotificationsForSurface(tabId, surfaceId, through: $0) }) { notification in - notification.key.surfaceId == surfaceId + nonisolated func enqueueNotificationReliablySynchronously( + tabId: UUID, + surfaceId: UUID?, + title: String, + subtitle: String, + body: String, + allowWorkspaceFallbackForValidatedSurface: Bool = false + ) -> ReliableTerminalNotificationEnqueueResult { + let payload = QueuedTerminalNotificationPayload( + normalizingTitle: title, + subtitle: subtitle, + body: body + ) + guard let admissionToken = captureReliableNotificationAdmission( + tabId: tabId, + surfaceId: surfaceId, + payload: payload, + allowWorkspaceFallbackForValidatedSurface: allowWorkspaceFallbackForValidatedSurface + ) else { + return .saturated + } + return Self.reliableAdmissionQueue.sync { [self] in + enqueueCapturedNotificationReliably(admissionToken: admissionToken) } } - nonisolated func enqueueMainActorMutation(_ mutation: @escaping @MainActor () -> Void) { - enqueueBarrierMutation(.perform(mutation)) + private nonisolated func captureReliableNotificationAdmission( + tabId: UUID, + surfaceId: UUID?, + payload: QueuedTerminalNotificationPayload, + allowWorkspaceFallbackForValidatedSurface: Bool + ) -> TerminalNotificationAdmissionToken? { + lock.lock() + guard reliableAdmissionsById.count < Self.maximumReliableAdmissionBacklogCount, + queuedNotificationContentByteCountLocked() + payload.contentByteCount + <= Self.maximumQueuedNotificationContentBytes else { + lock.unlock() + return nil + } + let routedKey = notificationKeyFollowingReplacementRoutes( + QueuedTerminalNotificationKey(tabId: tabId, surfaceId: surfaceId) + ) + let registered = ReliableTerminalNotificationAdmission( + id: UUID(), + acceptedAt: Date(), + key: routedKey, + allowWorkspaceFallbackForValidatedSurface: allowWorkspaceFallbackForValidatedSurface, + payload: payload, + notificationGeneration: currentNotificationGeneration, + deadline: Self.reliableAdmissionDeadline() + ) + reliableAdmissionsById[registered.id] = registered + lock.unlock() + return TerminalNotificationAdmissionToken(id: registered.id) } - nonisolated func markNotificationClearBoundary() -> UInt64 { + /// Called only from the bus-owned serial admission queue. At most one + /// worker waits on the condition while later internal producers remain + /// suspended behind that worker without occupying cooperative threads. The + /// worker carries only a bounded token; all retained payloads are already + /// registered under `lock`, so clears and byte limits see the full backlog. + private nonisolated func enqueueCapturedNotificationReliably( + admissionToken: TerminalNotificationAdmissionToken + ) -> ReliableTerminalNotificationEnqueueResult { lock.lock() - let boundary = currentNotificationGeneration - currentNotificationGeneration &+= 1 + reliablyWaitingNotificationProducerCount += 1 + while let admission = reliableAdmissionsById[admissionToken.id], + !shouldDropReliableAdmissionLocked(admission), + !hasReservedAdmissionCapacityLocked() { + guard let waitUntil = Self.waitDateBeforeReliableAdmissionDeadline(admission.deadline) else { + reliableAdmissionsById.removeValue(forKey: admissionToken.id) + reliablyWaitingNotificationProducerCount -= 1 + lock.broadcast() + lock.unlock() + return .saturated + } + _ = lock.wait(until: waitUntil) + } + reliablyWaitingNotificationProducerCount -= 1 + guard var admission = reliableAdmissionsById.removeValue(forKey: admissionToken.id) else { + lock.broadcast() + lock.unlock() + return .cancelled + } + let routedKey = notificationKeyFollowingReplacementRoutes(admission.key) + admission.key = routedKey + guard !shouldDropReliableAdmissionLocked( + key: routedKey, + generation: admission.notificationGeneration + ) else { + lock.broadcast() + lock.unlock() + return .cancelled + } + guard hasUnreservedNotificationCapacityLocked(for: admission.payload.contentByteCount) else { + lock.broadcast() + lock.unlock() + return .saturated + } + let notification = QueuedTerminalNotification( + id: admission.id, + acceptedAt: admission.acceptedAt, + key: routedKey, + allowWorkspaceFallbackForValidatedSurface: admission.allowWorkspaceFallbackForValidatedSurface, + title: admission.payload.title, + subtitle: admission.payload.subtitle, + body: admission.payload.body, + contentByteCount: admission.payload.contentByteCount + ) + let generation = admission.notificationGeneration + nextSequence &+= 1 + let sequence = nextSequence + pending.append(TerminalSocketMutationEntry( + sequence: sequence, + mutation: .deliverNotification(notification), + notificationGeneration: generation, + performReplaceKey: nil + )) + let shouldScheduleDrain = !drainScheduled + if shouldScheduleDrain { drainScheduled = true } + lock.broadcast() lock.unlock() - return boundary +#if DEBUG + cmuxDebugLog( + "notification.queue.enqueueReliable seq=\(sequence) workspace=\(notification.key.tabId.uuidString.prefix(8)) surface=\(notification.key.surfaceId?.uuidString.prefix(8) ?? "nil") generation=\(generation)" + ) +#endif + if shouldScheduleDrain { scheduleDrain() } + return .accepted } - nonisolated func notificationGenerationSnapshot() -> UInt64 { lock.withLock { currentNotificationGeneration } } + private nonisolated func shouldDropReliableAdmissionLocked( + _ admission: ReliableTerminalNotificationAdmission + ) -> Bool { + let routedKey = notificationKeyFollowingReplacementRoutes(admission.key) + return shouldDropReliableAdmissionLocked( + key: routedKey, + generation: admission.notificationGeneration + ) + } + + private nonisolated func shouldDropReliableAdmissionLocked( + key: QueuedTerminalNotificationKey, + generation: UInt64 + ) -> Bool { + if let boundary = reliableClearAllBoundary, + generation <= boundary { + return true + } + if let boundary = reliableClearWorkspaceBoundaryByTabId[key.tabId], + generation <= boundary { + return true + } + if let surfaceId = key.surfaceId, + let boundary = reliableClearSurfaceBoundaryBySurfaceId[surfaceId], + generation <= boundary { + return true + } + return false + } - nonisolated func discardPendingNotifications(forTabId tabId: UUID, through boundary: UInt64) { - discardPendingNotifications { notification, generation in - notification.key.tabId == tabId && generation <= boundary + nonisolated func recordReliableClearBoundaryLocked( + _ target: TerminalNotificationClearTarget, + boundary: UInt64 + ) { + switch target { + case .all: + reliableClearAllBoundary = max(reliableClearAllBoundary ?? boundary, boundary) + case .workspace(let tabId): + recordReliableClearBoundaryLocked( + id: tabId, + boundary: boundary, + boundaries: &reliableClearWorkspaceBoundaryByTabId, + order: &reliableClearWorkspaceBoundaryOrder + ) + case .surface(_, let surfaceId): + recordReliableClearBoundaryLocked( + id: surfaceId, + boundary: boundary, + boundaries: &reliableClearSurfaceBoundaryBySurfaceId, + order: &reliableClearSurfaceBoundaryOrder + ) } } - nonisolated func discardPendingNotifications(forTabId tabId: UUID, surfaceId: UUID, through boundary: UInt64) { - discardPendingNotifications { notification, generation in - notification.key.tabId == tabId - && notification.key.surfaceId == surfaceId - && generation <= boundary + private nonisolated func recordReliableClearBoundaryLocked( + id: UUID, + boundary: UInt64, + boundaries: inout [UUID: UInt64], + order: inout [UUID] + ) { + boundaries[id] = max(boundaries[id] ?? boundary, boundary) + order.removeAll { $0 == id } + order.append(id) + while order.count > Self.maximumNotificationLiveOwnerRouteCount { + let retiredId = order.removeFirst() + boundaries.removeValue(forKey: retiredId) } } - nonisolated func discardPendingNotifications() { - discardPendingNotifications(advanceGeneration: true) { _, _ in true } + private nonisolated static func reliableAdmissionDeadline() -> DispatchTime { + .now() + .nanoseconds(Int(notificationCapacityWaitTimeout * 1_000_000_000)) } - nonisolated func discardPendingNotifications(forTabId tabId: UUID) { - discardPendingNotifications { notification, _ in - notification.key.tabId == tabId - } + private nonisolated static func waitDateBeforeReliableAdmissionDeadline( + _ deadline: DispatchTime + ) -> Date? { + let now = DispatchTime.now().uptimeNanoseconds + guard deadline.uptimeNanoseconds > now else { return nil } + let remainingNanoseconds = deadline.uptimeNanoseconds - now + let remainingSeconds = Double(remainingNanoseconds) / 1_000_000_000 + return Date(timeIntervalSinceNow: min(remainingSeconds, 0.05)) } - /// Exact enqueue-key discard. Use for source-scoped operations like - /// `rebindSurfaceNotifications`, where a surface-wide discard could drop a - /// newer entry legitimately queued under the destination key mid-move. - nonisolated func discardPendingNotifications(forTabId tabId: UUID, surfaceId: UUID?) { - discardPendingNotifications { notification, _ in - notification.key.tabId == tabId && notification.key.surfaceId == surfaceId + private nonisolated func queuedNotificationContentByteCountLocked() -> Int { + var total = 0 + for entry in pending[pendingHead...] { + if case .deliverNotification(let notification) = entry.mutation { + total += notification.contentByteCount + } + } + for admission in reliableAdmissionsById.values { + total += admission.payload.contentByteCount } + return total } - /// Canonical-identity discard for clears and supersedes: pending entries - /// are keyed by their enqueue-time (claimed) workspace but DELIVER to the - /// surface's live owner (#7939), so a clear that matched only the claimed - /// key would let a stale-keyed entry resurrect the notification at drain. - nonisolated func discardPendingNotifications(forSurfaceId surfaceId: UUID) { - discardPendingNotifications { notification, _ in - notification.key.surfaceId == surfaceId - } + private nonisolated func hasUnreservedNotificationCapacityLocked( + for contentByteCount: Int + ) -> Bool { + pending.count - pendingHead < Self.maximumPendingMutationCount + && queuedNotificationContentByteCountLocked() + contentByteCount + <= Self.maximumQueuedNotificationContentBytes } - /// Clear-scoped discard: canonical surface identity when surface-scoped; - /// live destination workspace when workspace-scoped. - @MainActor - func discardPendingNotificationsForClear(tabId: UUID, surfaceId: UUID?) { - if let surfaceId { discardPendingNotifications(forSurfaceId: surfaceId) } - else { discardPendingNotificationsResolvingLiveOwner(forTabId: tabId) } + private nonisolated func hasReservedAdmissionCapacityLocked() -> Bool { + pending.count - pendingHead < Self.maximumPendingMutationCount + && queuedNotificationContentByteCountLocked() <= Self.maximumQueuedNotificationContentBytes } - /// Phase 1 of the live-owner workspace clear (see - /// `discardPendingNotificationsResolvingLiveOwner(forTabId:)`): the - /// pending notification addresses, identified by their exact sequence. - nonisolated func pendingNotificationAddressesSnapshot() -> [(sequence: UInt64, tabId: UUID, surfaceId: UUID?)] { - lock.lock() - defer { lock.unlock() } - return pending.compactMap { entry in - guard case .deliverNotification(let notification) = entry.mutation else { return nil } - return (entry.sequence, notification.key.tabId, notification.key.surfaceId) - } + nonisolated func enqueueClearAllNotifications() { + enqueueClear(.all) + } + nonisolated func enqueueClearNotifications(forTabId tabId: UUID) { + enqueueClear(.workspace(tabId)) } - /// Phase 2: discard exactly the snapshotted entries; anything enqueued - /// after the snapshot keeps its place. - nonisolated func discardPendingNotifications(sequences: Set<UInt64>) { - guard !sequences.isEmpty else { return } - lock.lock() - pending.removeAll { entry in - guard case .deliverNotification = entry.mutation else { return false } - return sequences.contains(entry.sequence) - } - lock.unlock() + nonisolated func enqueueClearNotifications(forTabId tabId: UUID, surfaceId: UUID) { + enqueueClear(.surface(tabId: tabId, surfaceId: surfaceId)) } - private func enqueueNotification(_ notification: QueuedTerminalNotification, coalesces: Bool) { + nonisolated func enqueueMainActorMutation(_ mutation: @escaping @MainActor () -> Void) { + enqueueBarrierMutation(.perform(mutation)) + } + + private func enqueueClear(_ target: TerminalNotificationClearTarget) { let shouldScheduleDrain: Bool - let removedCount: Int - let pendingCount: Int - let sequence: UInt64 - let generation: UInt64 lock.lock() - generation = currentNotificationGeneration - let coalescingKey = coalesces - ? TerminalNotificationCoalescingKey( - generation: generation, - notificationKey: notification.key + let routedTarget: TerminalNotificationClearTarget + switch target { + case .all: + routedTarget = .all + case .workspace(let tabId): + let key = notificationKeyFollowingReplacementRoutes( + QueuedTerminalNotificationKey(tabId: tabId, surfaceId: nil) ) - : nil - let beforeCount = pending.count - if let coalescingKey { - pending.removeAll { entry in - entry.notificationCoalescingKey == coalescingKey - } + routedTarget = .workspace(key.tabId) + case .surface(let tabId, let surfaceId): + let key = notificationKeyFollowingReplacementRoutes( + QueuedTerminalNotificationKey(tabId: tabId, surfaceId: surfaceId) + ) + routedTarget = .surface(tabId: key.tabId, surfaceId: key.surfaceId ?? surfaceId) } - removedCount = beforeCount - pending.count - nextSequence &+= 1 - sequence = nextSequence - pending.append(TerminalSocketMutationEntry( - sequence: sequence, - mutation: .deliverNotification(notification), - notificationGeneration: generation, - notificationCoalescingKey: coalescingKey, - performReplaceKey: nil - )) - shouldScheduleDrain = !drainScheduled - if shouldScheduleDrain { - drainScheduled = true + func shouldDropPending(_ key: QueuedTerminalNotificationKey) -> Bool { + switch routedTarget { + case .all: + return true + case .workspace(let tabId): + // Surface-addressed entries may have moved since enqueue. The + // live-owner route is authoritative when present; unresolved + // entries remain before the barrier for main-actor resolution. + return key.tabId == tabId && key.surfaceId == nil + case .surface(_, let surfaceId): + return key.surfaceId == surfaceId + } } - pendingCount = pending.count - lock.unlock() - -#if DEBUG - cmuxDebugLog( - "notification.queue.enqueue seq=\(sequence) workspace=\(notification.key.tabId.uuidString.prefix(8)) surface=\(notification.key.surfaceId?.uuidString.prefix(8) ?? "nil") coalesces=\(coalesces ? 1 : 0) removed=\(removedCount) pending=\(pendingCount) generation=\(generation) titleLen=\(notification.title.count) subtitleLen=\(notification.subtitle.count) bodyLen=\(notification.body.count)" - ) -#endif - - guard shouldScheduleDrain else { return } - scheduleDrain() - } - - private func enqueueClear( - _ mutation: (UInt64) -> TerminalSocketMutation, - dropping shouldDrop: (QueuedTerminalNotification) -> Bool - ) { - let shouldScheduleDrain: Bool - lock.lock() let boundary = currentNotificationGeneration currentNotificationGeneration &+= 1 + recordReliableClearBoundaryLocked(routedTarget, boundary: boundary) + reliableAdmissionsById = reliableAdmissionsById.filter { _, admission in + let key = notificationKeyFollowingReplacementRoutes(admission.key) + return !shouldDropPending(key) + } + compactPendingForMutation() pending.removeAll { entry in if case .deliverNotification(let notification) = entry.mutation { - return shouldDrop(notification) + return shouldDropPending(notification.key) } return false } nextSequence &+= 1 + let mutation: TerminalSocketMutation + switch routedTarget { + case .all: + mutation = .clearAllNotifications(through: boundary) + case .workspace(let tabId): + mutation = .clearNotificationsForTab(tabId, through: boundary) + case .surface(let tabId, let surfaceId): + mutation = .clearNotificationsForSurface(tabId, surfaceId, through: boundary) + } pending.append(TerminalSocketMutationEntry( sequence: nextSequence, - mutation: mutation(boundary), + mutation: mutation, notificationGeneration: nil, - notificationCoalescingKey: nil, performReplaceKey: nil )) shouldScheduleDrain = !drainScheduled if shouldScheduleDrain { drainScheduled = true } + lock.broadcast() lock.unlock() - guard shouldScheduleDrain else { return } scheduleDrain() } @@ -267,12 +488,12 @@ final class TerminalMutationBus: @unchecked Sendable { private func enqueueBarrierMutation(_ mutation: TerminalSocketMutation) { let shouldScheduleDrain: Bool lock.lock() + compactPendingForMutation() nextSequence &+= 1 pending.append(TerminalSocketMutationEntry( sequence: nextSequence, mutation: mutation, notificationGeneration: nil, - notificationCoalescingKey: nil, performReplaceKey: nil )) shouldScheduleDrain = !drainScheduled @@ -280,28 +501,26 @@ final class TerminalMutationBus: @unchecked Sendable { drainScheduled = true } lock.unlock() - guard shouldScheduleDrain else { return } scheduleDrain() } /// Last-write-wins `enqueueMainActorMutation`: drops any still-pending /// mutation with the same `replaceKey` before appending, so the survivor - /// applies at its new enqueue position (the notification coalescing - /// semantics above, for `.perform` mutations). + /// applies at its new enqueue position. nonisolated func enqueueReplacingMainActorMutation( replaceKey: TerminalMutationReplaceKey, _ mutation: @escaping @MainActor () -> Void ) { let shouldScheduleDrain: Bool lock.lock() + compactPendingForMutation() pending.removeAll { $0.performReplaceKey == replaceKey } nextSequence &+= 1 pending.append(TerminalSocketMutationEntry( sequence: nextSequence, mutation: .perform(mutation), notificationGeneration: nil, - notificationCoalescingKey: nil, performReplaceKey: replaceKey )) shouldScheduleDrain = !drainScheduled @@ -309,29 +528,10 @@ final class TerminalMutationBus: @unchecked Sendable { drainScheduled = true } lock.unlock() - guard shouldScheduleDrain else { return } scheduleDrain() } - private func discardPendingNotifications( - advanceGeneration: Bool = false, - where shouldDiscard: (QueuedTerminalNotification, UInt64) -> Bool - ) { - lock.lock() - pending.removeAll { entry in - guard case .deliverNotification(let notification) = entry.mutation, - let generation = entry.notificationGeneration else { - return false - } - return shouldDiscard(notification, generation) - } - if advanceGeneration { - currentNotificationGeneration &+= 1 - } - lock.unlock() - } - private func scheduleDrain() { #if DEBUG lock.lock() @@ -345,13 +545,31 @@ final class TerminalMutationBus: @unchecked Sendable { } #if DEBUG + nonisolated func notificationQueueStateForTesting() -> (Int, [String], [UInt64]) { + lock.lock(); defer { lock.unlock() } + let live = pending[pendingHead...] + let notifications = live.compactMap { entry -> QueuedTerminalNotification? in + if case .deliverNotification(let value) = entry.mutation { return value }; return nil + } + return (waitingNotificationProducerCount, notifications.map(\.title), live.map(\.sequence)) + } + nonisolated func reliablyWaitingNotificationProducerCountForTesting() -> Int { + lock.lock(); defer { lock.unlock() } + return reliablyWaitingNotificationProducerCount + } + nonisolated func notificationIdentityStateForTesting() -> [(UUID, Date, UUID, UUID?, UInt64)] { + lock.lock(); defer { lock.unlock() } + return pending[pendingHead...].compactMap { entry in + guard case .deliverNotification(let notification) = entry.mutation else { return nil } + return (notification.id, notification.acceptedAt, notification.key.tabId, notification.key.surfaceId, entry.sequence) + } + } nonisolated func setDrainsSuspendedForTesting(_ suspended: Bool) { let shouldScheduleDrain: Bool lock.lock() drainsSuspendedForTesting = suspended - shouldScheduleDrain = !suspended && drainScheduled && !pending.isEmpty + shouldScheduleDrain = !suspended && drainScheduled && pendingHead < pending.count lock.unlock() - if shouldScheduleDrain { scheduleDrain() } @@ -370,6 +588,14 @@ final class TerminalMutationBus: @unchecked Sendable { } #endif + /// Applies one bounded FIFO batch when a socket producer reaches the queue + /// limit. The producer does not receive `OK` until its notification has a + /// slot, so accepted events remain lossless without unbounded buffering. + @MainActor + func drainForBackpressure() { + drainOnMainActor() + } + @MainActor private func drainOnMainActor() { let batch = takeNextBatch() @@ -381,7 +607,7 @@ final class TerminalMutationBus: @unchecked Sendable { perform(batch) lock.lock() - let hasMore = !pending.isEmpty + let hasMore = pendingHead < pending.count if !hasMore { drainScheduled = false } @@ -394,12 +620,18 @@ final class TerminalMutationBus: @unchecked Sendable { private func takeNextBatch() -> [TerminalSocketMutationEntry] { lock.lock() - let count = min(maxMutationsPerDrain, pending.count) - let batch = Array(pending.prefix(count)) - if !batch.isEmpty { - pending.removeFirst(count) + let count = min(maxMutationsPerDrain, pending.count - pendingHead) + let batch: [TerminalSocketMutationEntry] + if count > 0 { + let end = pendingHead + count + batch = Array(pending[pendingHead..<end]) + pendingHead = end + compactPendingAfterDrain() + lock.broadcast() + } else { + batch = [] } - let remaining = pending.count + let remaining = pending.count - pendingHead lock.unlock() #if DEBUG if !batch.isEmpty { @@ -413,89 +645,31 @@ final class TerminalMutationBus: @unchecked Sendable { private func markDrainCompleteIfEmpty() { lock.lock() - if pending.isEmpty { + if pendingHead == pending.count { drainScheduled = false lock.unlock() return } lock.unlock() - scheduleDrain() } - @MainActor - private func perform(_ batch: [TerminalSocketMutationEntry]) { - for entry in batch { - switch entry.mutation { - case .deliverNotification(let notification): -#if DEBUG - cmuxDebugLog( - "notification.queue.perform seq=\(entry.sequence) workspace=\(notification.key.tabId.uuidString.prefix(8)) surface=\(notification.key.surfaceId?.uuidString.prefix(8) ?? "nil") titleLen=\(notification.title.count) subtitleLen=\(notification.subtitle.count) bodyLen=\(notification.body.count)" - ) -#endif - TerminalNotificationStore.shared.deliverQueuedNotification( - claimedTabId: notification.key.tabId, - surfaceId: notification.key.surfaceId, - title: notification.title, - subtitle: notification.subtitle, - body: notification.body, - notificationGeneration: entry.notificationGeneration ?? 0 - ) - case .clearAllNotifications(let boundary): - TerminalNotificationStore.shared.clearAll(discardQueuedNotifications: false, throughNotificationGeneration: boundary) - case .clearNotificationsForTab(let tabId, let boundary): - TerminalNotificationStore.shared.clearNotifications( - forTabId: tabId, - discardQueuedNotifications: false, - throughNotificationGeneration: boundary - ) - case .clearNotificationsForSurface(let tabId, let surfaceId, let boundary): - TerminalNotificationStore.shared.clearNotifications( - forTabId: tabId, - surfaceId: surfaceId, - discardQueuedNotifications: false, - throughNotificationGeneration: boundary - ) - case .perform(let mutation): - mutation() - } + /// Occasionally compacts consumed storage, keeping FIFO drains amortized O(1). + private func compactPendingAfterDrain() { + if pendingHead == pending.count { + pending.removeAll(keepingCapacity: true) + pendingHead = 0 + } else if pendingHead >= 4_096, pendingHead * 2 >= pending.count { + pending.removeFirst(pendingHead) + pendingHead = 0 } } -} -extension TerminalNotificationStore { - static func cachedDeliveryAuthorizationDecision( - for state: NotificationAuthorizationState, - isAppActive: Bool - ) -> Bool? { - switch state { - case .authorized, .provisional, .ephemeral: - return nil - case .denied: - return false - case .notDetermined: - return isAppActive ? nil : false - case .unknown: - return nil - } + /// Discards the consumed prefix before mutations scan live work. + func compactPendingForMutation() { + guard pendingHead > 0 else { return } + pending.removeFirst(pendingHead) + pendingHead = 0 } - /// Effects for the out-of-band fallback path, where cmux plays feedback - /// itself because the OS will not deliver the banner. - /// - /// A user who explicitly turned cmux notifications off (`.denied`) asked - /// for silence, so the direct `NSSound` fallback must not punch through - /// the denial (https://github.com/manaflow-ai/cmux/issues/5650). Every - /// other state keeps the audible fallback: fresh installs - /// (`.notDetermined`) have expressed no preference, and granted states - /// only reach the fallback when delivery itself failed. - nonisolated static func fallbackEffects( - _ effects: TerminalNotificationPolicyEffects, - authorizationState: NotificationAuthorizationState - ) -> TerminalNotificationPolicyEffects { - guard authorizationState == .denied else { return effects } - var silenced = effects - silenced.sound = false - return silenced - } } diff --git a/Sources/TerminalNotificationQueueTypes.swift b/Sources/TerminalNotificationQueueTypes.swift new file mode 100644 index 000000000000..5c5c23678e11 --- /dev/null +++ b/Sources/TerminalNotificationQueueTypes.swift @@ -0,0 +1,80 @@ +import CmuxRemoteSession +import Dispatch +import Foundation + +extension ReliableTerminalNotificationEnqueueResult { + static let saturatedSocketResponse = "ERROR: notification queue saturated; retry" +} + +struct QueuedTerminalNotificationKey: Hashable, Sendable { + let tabId: UUID + let surfaceId: UUID? +} + +struct QueuedTerminalNotification: Sendable { + let id: UUID + let acceptedAt: Date + let key: QueuedTerminalNotificationKey + let allowWorkspaceFallbackForValidatedSurface: Bool + let title: String + let subtitle: String + let body: String + let contentByteCount: Int +} + +struct QueuedTerminalNotificationPayload: Sendable { + let title: String + let subtitle: String + let body: String + let contentByteCount: Int +} + +extension QueuedTerminalNotificationPayload { + nonisolated init(normalizingTitle title: String, subtitle: String, body: String) { + let normalized = TerminalNotificationStore.normalizedNotificationText( + title: title, + subtitle: subtitle, + body: body + ) + self.title = normalized.title + self.subtitle = normalized.subtitle + self.body = normalized.body + contentByteCount = normalized.title.utf8.count + + normalized.subtitle.utf8.count + + normalized.body.utf8.count + } +} + +struct TerminalNotificationAdmissionToken: Sendable { + let id: UUID +} + +struct ReliableTerminalNotificationAdmission { + let id: UUID + let acceptedAt: Date + var key: QueuedTerminalNotificationKey + let allowWorkspaceFallbackForValidatedSurface: Bool + let payload: QueuedTerminalNotificationPayload + let notificationGeneration: UInt64 + let deadline: DispatchTime +} + +struct TerminalNotificationReplacementRoute { + let toTabId: UUID + let panelIdMap: [UUID: UUID] +} + +enum TerminalSocketMutation { + case deliverNotification(QueuedTerminalNotification) + case clearAllNotifications(through: UInt64) + case clearNotificationsForTab(UUID, through: UInt64) + case clearNotificationsForSurface(UUID, UUID, through: UInt64) + case perform(@MainActor () -> Void) +} + +struct TerminalSocketMutationEntry { + let sequence: UInt64 + let mutation: TerminalSocketMutation + let notificationGeneration: UInt64? + let performReplaceKey: TerminalMutationReplaceKey? +} diff --git a/Sources/TerminalNotificationStore+Indexes.swift b/Sources/TerminalNotificationStore+Indexes.swift new file mode 100644 index 000000000000..8366d230ba8d --- /dev/null +++ b/Sources/TerminalNotificationStore+Indexes.swift @@ -0,0 +1,57 @@ +import Foundation + +extension TerminalNotificationStore { +#if DEBUG + private static var fullIndexRebuildCount = 0 + + static func resetFullIndexRebuildCountForTesting() { + fullIndexRebuildCount = 0 + } + + static var fullIndexRebuildCountForTesting: Int { fullIndexRebuildCount } +#endif + + static func indexByIdPreservingFirst<Notifications: Sequence>( + _ notifications: Notifications + ) -> [UUID: TerminalNotification] where Notifications.Element == TerminalNotification { + Dictionary(notifications.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + } + + static func buildIndexes<Notifications: Sequence>( + for notifications: Notifications + ) -> NotificationIndexes where Notifications.Element == TerminalNotification { +#if DEBUG + fullIndexRebuildCount += 1 +#endif + var indexes = NotificationIndexes() + for notification in notifications { + indexes.ids.insert(notification.id) + if indexes.latestByTabId[notification.tabId] == nil { + indexes.latestByTabId[notification.tabId] = notification + } + let tabSurfaceKey = TabSurfaceKey(tabId: notification.tabId, surfaceId: notification.surfaceId) + if indexes.latestByTabSurface[tabSurfaceKey] == nil { + indexes.latestByTabSurface[tabSurfaceKey] = notification + } + guard !notification.isRead else { continue } + indexes.unreadCount += 1 + indexes.unreadCountByTabId[notification.tabId, default: 0] += 1 + let unreadKeys = unreadIndexKeys(for: notification) + for key in unreadKeys { + indexes.unreadCountByTabSurface[key, default: 0] += 1 + indexes.unreadByTabSurface.insert(key) + } + } + return indexes + } + + static func unreadIndexKeys(for notification: TerminalNotification) -> Set<TabSurfaceKey> { + var keys: Set<TabSurfaceKey> = [ + TabSurfaceKey(tabId: notification.tabId, surfaceId: notification.surfaceId) + ] + if let panelId = notification.panelId { + keys.insert(TabSurfaceKey(tabId: notification.tabId, surfaceId: panelId)) + } + return keys + } +} diff --git a/Sources/TerminalNotificationStore+Mutation.swift b/Sources/TerminalNotificationStore+Mutation.swift new file mode 100644 index 000000000000..ec11570e97b2 --- /dev/null +++ b/Sources/TerminalNotificationStore+Mutation.swift @@ -0,0 +1,195 @@ +import Foundation + +extension TerminalNotificationStore { + static func insertionIndex<Notifications: RandomAccessCollection>( + for notification: TerminalNotification, + in notifications: Notifications + ) -> Int where Notifications.Element == TerminalNotification, Notifications.Index == Int { + var lowerBound = 0 + var upperBound = notifications.count + while lowerBound < upperBound { + let candidate = lowerBound + (upperBound - lowerBound) / 2 + if notificationSortPrecedes(notification, notifications[candidate]) { + upperBound = candidate + } else { + lowerBound = candidate + 1 + } + } + return lowerBound + } + + static func insertNotification<Notifications: Collection>( + _ notification: TerminalNotification, + into indexes: inout NotificationIndexes, + notifications: Notifications + ) where Notifications.Element == TerminalNotification { + let expectedPreviousCount = notifications.count - 1 + guard indexes.ids.count == expectedPreviousCount, + indexes.ids.insert(notification.id).inserted else { +#if DEBUG + cmuxDebugLog( + "notification.indexes.recover function=insertNotification " + + "notificationId=\(notification.id.uuidString) " + + "indexedCount=\(indexes.ids.count) expectedPreviousCount=\(expectedPreviousCount)" + ) +#endif + indexes = buildIndexes(for: notifications) + return + } + let tabSurfaceKey = TabSurfaceKey(tabId: notification.tabId, surfaceId: notification.surfaceId) + if indexes.latestByTabId[notification.tabId].map({ notificationSortPrecedes(notification, $0) }) ?? true { + indexes.latestByTabId[notification.tabId] = notification + } + if indexes.latestByTabSurface[tabSurfaceKey].map({ notificationSortPrecedes(notification, $0) }) ?? true { + indexes.latestByTabSurface[tabSurfaceKey] = notification + } + guard !notification.isRead else { return } + indexes.unreadCount += 1 + indexes.unreadCountByTabId[notification.tabId, default: 0] += 1 + for key in unreadIndexKeys(for: notification) { + indexes.unreadCountByTabSurface[key, default: 0] += 1 + indexes.unreadByTabSurface.insert(key) + } + + } + + static func insertNotification<Notifications: Collection>( + _ notification: TerminalNotification, + evicting evicted: TerminalNotification, + into indexes: inout NotificationIndexes, + notifications: Notifications + ) where Notifications.Element == TerminalNotification { + insertNotification( + notification, + evicting: [evicted], + into: &indexes, + notifications: notifications + ) + } + + static func insertNotification<Notifications: Collection>( + _ notification: TerminalNotification, + evicting evicted: [TerminalNotification], + into indexes: inout NotificationIndexes, + notifications: Notifications + ) where Notifications.Element == TerminalNotification { + let expectedPreviousCount = notifications.count + evicted.count - 1 + guard indexes.ids.count == expectedPreviousCount, + !indexes.ids.contains(notification.id) else { + indexes = buildIndexes(for: notifications) + return + } + for evictedNotification in evicted { + guard indexes.ids.remove(evictedNotification.id) != nil else { + indexes = buildIndexes(for: notifications) + return + } + + let evictedTabSurfaceKey = TabSurfaceKey( + tabId: evictedNotification.tabId, + surfaceId: evictedNotification.surfaceId + ) + let evictedWasLatestForTab = + indexes.latestByTabId[evictedNotification.tabId]?.id == evictedNotification.id + let evictedWasLatestForSurface = + indexes.latestByTabSurface[evictedTabSurfaceKey]?.id == evictedNotification.id + if evictedWasLatestForTab { + indexes.latestByTabId.removeValue(forKey: evictedNotification.tabId) + } + if evictedWasLatestForSurface { + indexes.latestByTabSurface.removeValue(forKey: evictedTabSurfaceKey) + } + if !evictedNotification.isRead { + indexes.unreadCount -= 1 + adjustCount(for: evictedNotification.tabId, by: -1, in: &indexes.unreadCountByTabId) + for key in unreadIndexKeys(for: evictedNotification) { + adjustCount(for: key, by: -1, in: &indexes.unreadCountByTabSurface) + if indexes.unreadCountByTabSurface[key] == nil { + indexes.unreadByTabSurface.remove(key) + } + } + } + } + + insertNotification(notification, into: &indexes, notifications: notifications) + // This path is only used by append-newest-at-capacity. The evicted row + // is the oldest retained row, so if it owned the latest slot for its + // tab or surface there is no replacement row for that key. Avoid a + // full-feed scan on every capped insertion. + } + + static func updateReadState( + from before: TerminalNotification, + to after: TerminalNotification, + in indexes: inout NotificationIndexes, + notifications: [TerminalNotification] + ) -> Bool { + guard before.id == after.id, + before.tabId == after.tabId, + before.surfaceId == after.surfaceId, + before.panelId == after.panelId, + before.isRead != after.isRead, + indexes.ids.count == notifications.count, + indexes.ids.contains(after.id) else { + indexes = buildIndexes(for: notifications) + return false + } + + if indexes.latestByTabId[after.tabId]?.id == after.id { + indexes.latestByTabId[after.tabId] = after + } + let tabSurfaceKey = TabSurfaceKey(tabId: after.tabId, surfaceId: after.surfaceId) + if indexes.latestByTabSurface[tabSurfaceKey]?.id == after.id { + indexes.latestByTabSurface[tabSurfaceKey] = after + } + + let delta = after.isRead ? -1 : 1 + indexes.unreadCount += delta + adjustCount(for: after.tabId, by: delta, in: &indexes.unreadCountByTabId) + for key in unreadIndexKeys(for: after) { + adjustCount(for: key, by: delta, in: &indexes.unreadCountByTabSurface) + if indexes.unreadCountByTabSurface[key] == nil { + indexes.unreadByTabSurface.remove(key) + } else { + indexes.unreadByTabSurface.insert(key) + } + } + return true + } + + private static func adjustCount<Key: Hashable>( + for key: Key, + by delta: Int, + in counts: inout [Key: Int] + ) { + let next = (counts[key] ?? 0) + delta + if next > 0 { + counts[key] = next + } else { + counts.removeValue(forKey: key) + } + } + + nonisolated static func notificationSortPrecedes( + _ lhs: TerminalNotification, + _ rhs: TerminalNotification + ) -> Bool { + if lhs.createdAt != rhs.createdAt { return lhs.createdAt > rhs.createdAt } + return lhs.id.uuidString < rhs.id.uuidString + } + + static func dockBadgeLabel( + unreadCount: Int, + isEnabled: Bool, + runTag: String? = nil + ) -> String? { + let unreadLabel: String? = { + guard isEnabled, unreadCount > 0 else { return nil } + return unreadCount > 99 ? "99+" : String(unreadCount) + }() + if let tag = TaggedRunBadgeSettings.normalizedTag(runTag) { + return unreadLabel.map { "\(tag):\($0)" } ?? tag + } + return unreadLabel + } +} diff --git a/Sources/TerminalNotificationStore+NativeDelivery.swift b/Sources/TerminalNotificationStore+NativeDelivery.swift new file mode 100644 index 000000000000..b62ccb6e2b71 --- /dev/null +++ b/Sources/TerminalNotificationStore+NativeDelivery.swift @@ -0,0 +1,118 @@ +import Foundation +import UserNotifications + +extension TerminalNotificationStore { + func resolvedNotificationTitle(for notification: TerminalNotification) -> String { + let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String + ?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String + ?? "cmux" + return notification.title.isEmpty ? appName : notification.title + } + + func scheduleUserNotification( + _ notification: TerminalNotification, + effects: TerminalNotificationPolicyEffects + ) { + guard effects.desktop else { + playLocalNotificationFeedback( + title: resolvedNotificationTitle(for: notification), + subtitle: notification.subtitle, + body: notification.body, + effects: effects + ) + return + } + + let nativeDeliveryHooks = nativeNotificationDeliveryHooks + let notificationTitle = resolvedNotificationTitle(for: notification) + let notificationSubtitle = notification.subtitle + let notificationBody = notification.body + let notificationId = notification.id + let notificationTabId = notification.tabId + let notificationSurfaceId = notification.surfaceId + let retargetsToLiveSurfaceOwner = notification.retargetsToLiveSurfaceOwner + let clickActionUserInfo = notification.clickAction?.userInfo ?? [:] + let categoryIdentifier = Self.categoryIdentifier + + let handleAuthorization: NativeNotificationDeliveryHooks.AuthorizationCompletion = { + authorized, + effectiveAuthorizationState in + let content = UNMutableNotificationContent() + content.title = notificationTitle + content.subtitle = notificationSubtitle + content.body = notificationBody + guard authorized else { + NativeNotificationDeliveryHooks.playNativeUnavailableFeedback( + effects: Self.fallbackEffects(effects, authorizationState: effectiveAuthorizationState) + ) + return + } + content.sound = effects.sound ? NotificationSoundSettings.sound() : nil + content.categoryIdentifier = categoryIdentifier + content.userInfo = [ + "tabId": notificationTabId.uuidString, + "notificationId": notificationId.uuidString, + Self.retargetsToLiveSurfaceOwnerUserInfoKey: retargetsToLiveSurfaceOwner, + ] + if let surfaceId = notificationSurfaceId { + content.userInfo["surfaceId"] = surfaceId.uuidString + } + for (key, value) in clickActionUserInfo { + content.userInfo[key] = value + } + + let request = UNNotificationRequest( + identifier: notificationId.uuidString, + content: content, + trigger: nil + ) + let commandTitle = content.title + let commandSubtitle = content.subtitle + let commandBody = content.body + + nativeDeliveryHooks.schedule(request) { error in + if let error { + terminalNotificationLogger.error( + "Failed to schedule notification error=\(error.localizedDescription, privacy: .private)" + ) + NativeNotificationDeliveryHooks.playNativeUnavailableFeedback(effects: effects) + } else if effects.command { + nativeDeliveryHooks.runCommand( + title: commandTitle, + subtitle: commandSubtitle, + body: commandBody + ) + } + } + } + if !nativeDeliveryHooks.authorizeForTesting(handleAuthorization) { + ensureAuthorization(origin: .notificationDelivery, handleAuthorization) + } + } + + func playSuppressedNotificationFeedback( + for notification: TerminalNotification, + effects: TerminalNotificationPolicyEffects + ) { + nativeNotificationDeliveryHooks.runLocalFeedback( + title: resolvedNotificationTitle(for: notification), + subtitle: notification.subtitle, + body: notification.body, + effects: effects + ) + } + + func playLocalNotificationFeedback( + title: String, + subtitle: String, + body: String, + effects: TerminalNotificationPolicyEffects + ) { + nativeNotificationDeliveryHooks.runLocalFeedback( + title: title, + subtitle: subtitle, + body: body, + effects: effects + ) + } +} diff --git a/Sources/TerminalNotificationStore+QueuedDelivery.swift b/Sources/TerminalNotificationStore+QueuedDelivery.swift new file mode 100644 index 000000000000..ee7787a14169 --- /dev/null +++ b/Sources/TerminalNotificationStore+QueuedDelivery.swift @@ -0,0 +1,38 @@ +import Foundation + +extension TerminalNotificationStore { + static func cachedDeliveryAuthorizationDecision( + for state: NotificationAuthorizationState, + isAppActive: Bool + ) -> Bool? { + switch state { + case .authorized, .provisional, .ephemeral: + return nil + case .denied: + return false + case .notDetermined: + return isAppActive ? nil : false + case .unknown: + return nil + } + } + + /// Effects for the out-of-band fallback path, where cmux plays feedback + /// itself because the OS will not deliver the banner. + /// + /// A user who explicitly turned cmux notifications off (`.denied`) asked + /// for silence, so the direct `NSSound` fallback must not punch through + /// the denial (https://github.com/manaflow-ai/cmux/issues/5650). Every + /// other state keeps the audible fallback: fresh installs + /// (`.notDetermined`) have expressed no preference, and granted states + /// only reach the fallback when delivery itself failed. + nonisolated static func fallbackEffects( + _ effects: TerminalNotificationPolicyEffects, + authorizationState: NotificationAuthorizationState + ) -> TerminalNotificationPolicyEffects { + guard authorizationState == .denied else { return effects } + var silenced = effects + silenced.sound = false + return silenced + } +} diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index f4d1dcd0443f..2d1059a6c728 100644 --- a/Sources/TerminalNotificationStore.swift +++ b/Sources/TerminalNotificationStore.swift @@ -6,11 +6,101 @@ import UserNotifications import Bonsplit import CmuxSettings -nonisolated private let terminalNotificationLogger = Logger( +nonisolated let terminalNotificationLogger = Logger( subsystem: "com.cmuxterm.app", category: "notification" ) +private final class TerminalNotificationFeedStorage { + var oldestFirst: [TerminalNotification] + private(set) var startOffset: Int + private var discardedContentBytes: Int + + init(newestFirst: [TerminalNotification] = []) { + oldestFirst = Array(newestFirst.reversed()) + startOffset = 0 + discardedContentBytes = 0 + } + + private init(activeOldestFirst: [TerminalNotification]) { + oldestFirst = activeOldestFirst + startOffset = 0 + discardedContentBytes = 0 + } + + var count: Int { + oldestFirst.count - startOffset + } + + func appendNewest(_ notification: TerminalNotification) { + oldestFirst.append(notification) + } + + func appendNewestEvictingOldest( + _ notification: TerminalNotification, + count evictionCount: Int, + compactingAfter maxOffset: Int, + compactingAfterDiscardedBytes maxDiscardedBytes: Int + ) -> (evicted: [TerminalNotification], replacementStorage: TerminalNotificationFeedStorage?)? { + guard evictionCount > 0 else { + appendNewest(notification) + return ([], nil) + } + guard count >= evictionCount else { return nil } + let nextStartOffset = startOffset + evictionCount + let evicted = Array(oldestFirst[startOffset..<nextStartOffset]) + let evictedBytes = evicted.reduce(0) { + $0 + TerminalNotificationStore.notificationContentByteCount($1) + } + if nextStartOffset >= maxOffset || + discardedContentBytes + evictedBytes >= maxDiscardedBytes { + var active = Array(oldestFirst[nextStartOffset...]) + active.append(notification) + return (evicted, TerminalNotificationFeedStorage(activeOldestFirst: active)) + } + startOffset = nextStartOffset + discardedContentBytes += evictedBytes + oldestFirst.append(notification) + return (evicted, nil) + } +} + +/// A frozen-count, newest-first view over append-optimized feed storage. +/// Existing snapshots remain valid when a newer notification is appended; +/// non-append mutations replace the backing storage instead of editing it. +struct TerminalNotificationFeed: RandomAccessCollection, Equatable, ExpressibleByArrayLiteral { + typealias Index = Int + typealias Element = TerminalNotification + + fileprivate let storage: TerminalNotificationFeedStorage + private let startOffset: Int + let count: Int + + fileprivate init(storage: TerminalNotificationFeedStorage) { + self.storage = storage + startOffset = storage.startOffset + count = storage.count + } + + init(arrayLiteral elements: TerminalNotification...) { + storage = TerminalNotificationFeedStorage(newestFirst: elements) + startOffset = 0 + count = elements.count + } + + var startIndex: Int { 0 } + var endIndex: Int { count } + + subscript(position: Int) -> TerminalNotification { + precondition(indices.contains(position)) + return storage.oldestFirst[startOffset + count - position - 1] + } + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.elementsEqual(rhs) + } +} + // UNUserNotificationCenter.removeDeliveredNotifications(withIdentifiers:) and // removePendingNotificationRequests(withIdentifiers:) perform synchronous XPC to // usernoted under the hood. When usernoted is slow, this blocks the calling thread @@ -145,17 +235,19 @@ enum NotificationAuthorizationState: Equatable, Sendable { @MainActor final class TerminalNotificationStore: ObservableObject { - private struct TabSurfaceKey: Hashable { + struct TabSurfaceKey: Hashable { let tabId: UUID let surfaceId: UUID? } - private struct NotificationIndexes { + struct NotificationIndexes { + var ids = Set<UUID>() var unreadCount = 0 var unreadCountByTabId: [UUID: Int] = [:] + var unreadCountByTabSurface: [TabSurfaceKey: Int] = [:] var unreadByTabSurface = Set<TabSurfaceKey>() - var latestUnreadByTabId: [UUID: TerminalNotification] = [:] var latestByTabId: [UUID: TerminalNotification] = [:] + var latestByTabSurface: [TabSurfaceKey: TerminalNotification] = [:] } static let shared = TerminalNotificationStore() @@ -164,6 +256,7 @@ final class TerminalNotificationStore: ObservableObject { static let categoryIdentifier = "com.cmuxterm.app.userNotification" static let actionShowIdentifier = "com.cmuxterm.app.userNotification.show" nonisolated static let retargetsToLiveSurfaceOwnerUserInfoKey = "retargetsToLiveSurfaceOwner" + /// Mobile-host event topic the Mac emits when one or more delivered /// notifications are dismissed/cleared on this Mac, so an attached phone can /// clear the matching banners it is mirroring. Payload carries the stable @@ -178,6 +271,23 @@ final class TerminalNotificationStore: ObservableObject { /// drift self-heals on the next event. Emitted from the same chokepoint that /// refreshes the Mac Dock badge, so every mutation lane is covered. static let badgeEventTopic = "notification.badge" + static let maximumNotificationFeedCount = 20_000 + nonisolated static let maximumNotificationTitleBytes = 8 * 1024 + nonisolated static let maximumNotificationSubtitleBytes = 16 * 1024 + nonisolated static let maximumNotificationBodyBytes = 64 * 1024 + nonisolated static let maximumNotificationFeedContentBytes = 8 * 1024 * 1024 +#if DEBUG + static var notificationFeedCompactionOffsetForTesting: Int? +#endif + + private static var notificationFeedCompactionOffset: Int { +#if DEBUG + notificationFeedCompactionOffsetForTesting ?? maximumNotificationFeedCount +#else + maximumNotificationFeedCount +#endif + } + private static let notificationFeedCompactionDiscardedBytes = maximumNotificationFeedContentBytes / 4 /// Invalidates the paired phone's notification feed without sending any /// notification content in the event frame. The phone fetches the @@ -209,6 +319,20 @@ final class TerminalNotificationStore: ObservableObject { private var dismissedTombstonesLoaded = false private static let dismissedTombstoneCapacity = 512 static let dismissedTombstoneDefaultsKey = "cmux.notifications.dismissedTombstoneIds" + private var retainedSupersededBannerIDs = Set<UUID>() + private var retainedSupersededBannerIDsLoaded = false + private var retainedSupersededBannerPersistenceDeltas: [String] = [] + private var retainedSupersededBannerDeltaPersistenceInFlight = false + private var retainedSupersededBannerDeltaPersistenceDirty = false + private var retainedSupersededBannerSnapshotPersistenceInFlight = false + private var retainedSupersededBannerSnapshotPersistenceDirty = false + private static let retainedSupersededBannerPersistenceQueue = DispatchQueue( + label: "com.cmux.notification.retained-superseded-banner-persistence", + qos: .utility + ) + static let retainedSupersededBannerDefaultsKey = "cmux.notifications.retainedSupersededBannerIds" + static let retainedSupersededBannerDeltaDefaultsKey = "cmux.notifications.retainedSupersededBannerIdDeltas" + private static let retainedSupersededBannerDeltaCompactionThreshold = 512 private func loadDismissedTombstonesIfNeeded() { guard !dismissedTombstonesLoaded else { return } @@ -237,12 +361,156 @@ final class TerminalNotificationStore: ObservableObject { ) } + private func removeDismissTombstones(ids: [UUID]) { + guard !ids.isEmpty else { return } + loadDismissedTombstonesIfNeeded() + removeRetainedSupersededBannerIDs(ids: ids) + let removedIds = Set(ids.filter { dismissedTombstoneIDs.remove($0) != nil }) + guard !removedIds.isEmpty else { return } + dismissedTombstoneOrder.removeAll { removedIds.contains($0) } + UserDefaults.standard.set( + dismissedTombstoneOrder.map(\.uuidString), + forKey: Self.dismissedTombstoneDefaultsKey + ) + } + /// Drop the in-memory tombstone copy so the next use re-reads the persisted /// ring — the behavior-test analogue of a process restart. func reloadDismissedTombstonesForTesting() { dismissedTombstoneIDs.removeAll() dismissedTombstoneOrder.removeAll() dismissedTombstonesLoaded = false + retainedSupersededBannerIDs.removeAll() + retainedSupersededBannerPersistenceDeltas.removeAll() + retainedSupersededBannerIDsLoaded = false + retainedSupersededBannerDeltaPersistenceInFlight = false + retainedSupersededBannerDeltaPersistenceDirty = false + retainedSupersededBannerSnapshotPersistenceInFlight = false + retainedSupersededBannerSnapshotPersistenceDirty = false + } + + private func loadRetainedSupersededBannerIDsIfNeeded() { + guard !retainedSupersededBannerIDsLoaded else { return } + retainedSupersededBannerIDsLoaded = true + let stored = UserDefaults.standard.stringArray(forKey: Self.retainedSupersededBannerDefaultsKey) ?? [] + var ids = Set(stored.compactMap { UUID(uuidString: $0) }) + let deltas = UserDefaults.standard.stringArray(forKey: Self.retainedSupersededBannerDeltaDefaultsKey) ?? [] + for delta in deltas { + Self.applyRetainedSupersededBannerDelta(delta, to: &ids) + } + retainedSupersededBannerIDs = ids + retainedSupersededBannerPersistenceDeltas = deltas + if deltas.count >= Self.retainedSupersededBannerDeltaCompactionThreshold { + scheduleRetainedSupersededBannerSnapshotPersistence() + } + } + + private nonisolated static func retainedSupersededBannerDelta(id: UUID, isInsertion: Bool) -> String { + "\(isInsertion ? "+" : "-")\(id.uuidString)" + } + + private nonisolated static func applyRetainedSupersededBannerDelta(_ delta: String, to ids: inout Set<UUID>) { + guard let operation = delta.first, + let id = UUID(uuidString: String(delta.dropFirst())) else { return } + switch operation { + case "+": + ids.insert(id) + case "-": + ids.remove(id) + default: + break + } + } + + private func scheduleRetainedSupersededBannerDeltaPersistence() { + guard !retainedSupersededBannerDeltaPersistenceInFlight else { + retainedSupersededBannerDeltaPersistenceDirty = true + return + } + retainedSupersededBannerDeltaPersistenceInFlight = true + retainedSupersededBannerDeltaPersistenceDirty = false + let deltas = retainedSupersededBannerPersistenceDeltas + Self.retainedSupersededBannerPersistenceQueue.async { + UserDefaults.standard.set(deltas, forKey: Self.retainedSupersededBannerDeltaDefaultsKey) + Task { @MainActor [weak self] in + guard let self else { return } + retainedSupersededBannerDeltaPersistenceInFlight = false + if retainedSupersededBannerDeltaPersistenceDirty, + !retainedSupersededBannerPersistenceDeltas.isEmpty { + scheduleRetainedSupersededBannerDeltaPersistence() + } + } + } + } + + private func scheduleRetainedSupersededBannerSnapshotPersistence() { + guard !retainedSupersededBannerSnapshotPersistenceInFlight else { + retainedSupersededBannerSnapshotPersistenceDirty = true + scheduleRetainedSupersededBannerDeltaPersistence() + return + } + retainedSupersededBannerSnapshotPersistenceInFlight = true + retainedSupersededBannerSnapshotPersistenceDirty = false + let ids = retainedSupersededBannerIDs + retainedSupersededBannerPersistenceDeltas.removeAll() + Self.retainedSupersededBannerPersistenceQueue.async { + let snapshot = ids.map(\.uuidString).sorted() + let defaults = UserDefaults.standard + defaults.set(snapshot, forKey: Self.retainedSupersededBannerDefaultsKey) + defaults.removeObject(forKey: Self.retainedSupersededBannerDeltaDefaultsKey) + Task { @MainActor [weak self] in + guard let self else { return } + retainedSupersededBannerSnapshotPersistenceInFlight = false + if retainedSupersededBannerSnapshotPersistenceDirty || + retainedSupersededBannerPersistenceDeltas.count >= Self.retainedSupersededBannerDeltaCompactionThreshold { + scheduleRetainedSupersededBannerSnapshotPersistence() + } else if !retainedSupersededBannerPersistenceDeltas.isEmpty { + scheduleRetainedSupersededBannerDeltaPersistence() + } + } + } + } + + private func persistRetainedSupersededBannerIDChanges(inserted: [UUID] = [], removed: [UUID] = []) { + guard !inserted.isEmpty || !removed.isEmpty else { return } + let deltas = inserted.map { Self.retainedSupersededBannerDelta(id: $0, isInsertion: true) } + + removed.map { Self.retainedSupersededBannerDelta(id: $0, isInsertion: false) } + retainedSupersededBannerPersistenceDeltas.append(contentsOf: deltas) + if retainedSupersededBannerPersistenceDeltas.count >= Self.retainedSupersededBannerDeltaCompactionThreshold || + deltas.count >= Self.retainedSupersededBannerDeltaCompactionThreshold { + scheduleRetainedSupersededBannerSnapshotPersistence() + } else { + scheduleRetainedSupersededBannerDeltaPersistence() + } + } + + private func recordRetainedSupersededBannerIDs(ids: [UUID]) { + guard !ids.isEmpty else { return } + loadRetainedSupersededBannerIDsIfNeeded() + var inserted: [UUID] = [] + for id in ids where retainedSupersededBannerIDs.insert(id).inserted { + inserted.append(id) + } + persistRetainedSupersededBannerIDChanges(inserted: inserted) + } + + private func removeRetainedSupersededBannerIDs(ids: [UUID]) { + guard !ids.isEmpty else { return } + loadRetainedSupersededBannerIDsIfNeeded() + var removed: [UUID] = [] + for id in ids where retainedSupersededBannerIDs.remove(id) != nil { + removed.append(id) + } + persistRetainedSupersededBannerIDChanges(removed: removed) + } + + private func reconcileRetainedSupersededBannerIDs(retainedIDs: Set<UUID>) { + loadRetainedSupersededBannerIDsIfNeeded() + let reconciled = retainedSupersededBannerIDs.intersection(retainedIDs) + guard reconciled != retainedSupersededBannerIDs else { return } + let removed = Array(retainedSupersededBannerIDs.subtracting(reconciled)) + retainedSupersededBannerIDs = reconciled + persistRetainedSupersededBannerIDChanges(removed: removed) } /// Phone-banner dismissals for superseded notifications, deferred until the @@ -259,16 +527,19 @@ final class TerminalNotificationStore: ObservableObject { /// the reconcile sweep still classifies the ids correctly because they are /// tombstoned at supersede time. private var supersededPhoneDismissBuffer = SupersededPhoneDismissBuffer() + private var externalBannerOwnership = ExternalNotificationBannerOwnership() /// Classify which of the phone's delivered banner ids have been handled on /// this Mac: still in the store and read, or recently removed (tombstoned). /// Ids this Mac has never seen are NOT reported handled — they may belong to - /// a different paired Mac — so the phone leaves those banners alone. An id - /// that is currently unread is never handled, even if an older tombstone - /// exists (markUnread after a dismiss resurrects it). + /// a different paired Mac — so the phone leaves those banners alone. A + /// retained unread row with a tombstone represents superseded history, so + /// its older phone banner is handled. Explicitly marking a row unread + /// removes its tombstone and resurrects that banner identity. func reconcileHandledNotificationIDs(deliveredIDs: [UUID]) -> [String] { guard !deliveredIDs.isEmpty else { return [] } loadDismissedTombstonesIfNeeded() + loadRetainedSupersededBannerIDsIfNeeded() var readIDs = Set<UUID>() var knownIDs = Set<UUID>() for notification in notifications { @@ -277,7 +548,11 @@ final class TerminalNotificationStore: ObservableObject { } return deliveredIDs .filter { id in - if knownIDs.contains(id) { return readIDs.contains(id) } + if knownIDs.contains(id) { + return readIDs.contains(id) || + dismissedTombstoneIDs.contains(id) || + retainedSupersededBannerIDs.contains(id) + } return dismissedTombstoneIDs.contains(id) } .map(\.uuidString) @@ -286,8 +561,8 @@ final class TerminalNotificationStore: ObservableObject { /// Forwards a dismiss/clear to the user's phone. Call only from the /// change-confirmed branch of a user-driven read/clear/remove path, so the /// Mac→iOS→Mac echo can't loop. Session restore / surface rebind paths must - /// NOT call this: they reassign ids on churn and would clear a phone banner - /// that should persist. + /// not call this unless two owner keys collide: ordinary churn preserves + /// the phone banner, while a collision must dismiss the displaced owner. /// /// Two lanes share this chokepoint: the instant peer event for a /// live-attached phone, and a silent APNs badge push (the cold lane) so a @@ -351,18 +626,50 @@ final class TerminalNotificationStore: ObservableObject { ) } - private enum AuthorizationRequestOrigin: String { + enum AuthorizationRequestOrigin: String { case notificationDelivery = "notification_delivery" case settingsButton = "settings_button" case settingsTest = "settings_test" } - @Published private(set) var notifications: [TerminalNotification] = [] { - didSet { - indexes = Self.buildIndexes(for: notifications) - refreshUnreadPresentation() - if !suppressNotificationDiffPublishing { CmuxEventBus.shared.publishNotificationChanges(oldValue: oldValue, newValue: notifications) } + private enum NotificationMutationHint { + case insertion(TerminalNotification) + case insertionEvicting(inserted: TerminalNotification, evicted: [TerminalNotification]) + case readState(before: TerminalNotification, after: TerminalNotification) + } + + private struct NotificationInsertionCommit { + let evicted: [TerminalNotification] + } + + /// Every accepted notification, once per stable id, newest first. Equal + /// timestamps use ascending UUID text as the deterministic tie break. + private var notificationFeedStorage = TerminalNotificationFeedStorage() + private var notificationFeedContentByteCount = 0 + @Published private(set) var notificationFeedRevision: UInt64 = 0 + var notifications: TerminalNotificationFeed { + TerminalNotificationFeed(storage: notificationFeedStorage) + } + private var deferredUnreadNavigationIds: [UUID] = [] + private var unreadNavigationNotifications: [TerminalNotification] = [] + private var unreadNavigationProjectionIsDirty = true + + var notificationsForUnreadNavigation: [TerminalNotification] { + if unreadNavigationProjectionIsDirty { rebuildUnreadNavigationNotifications() } + return unreadNavigationNotifications + } + + private func rebuildUnreadNavigationNotifications() { + unreadNavigationProjectionIsDirty = false + guard !deferredUnreadNavigationIds.isEmpty else { + unreadNavigationNotifications = Array(notifications) + return } + let deferredIds = Set(deferredUnreadNavigationIds) + var ordered = notifications.filter { !deferredIds.contains($0.id) } + let notificationById = Self.indexByIdPreservingFirst(notifications) + ordered.append(contentsOf: deferredUnreadNavigationIds.compactMap { notificationById[$0] }) + unreadNavigationNotifications = ordered } @Published private(set) var notificationMenuSnapshot = NotificationMenuSnapshotBuilder.make(notifications: []) /// Coalesced, equality-guarded per-workspace unread projection for the @@ -426,7 +733,7 @@ final class TerminalNotificationStore: ObservableObject { effects in store.scheduleUserNotification(notification, effects: effects) } - private var nativeNotificationDeliveryHooks = NativeNotificationDeliveryHooks() + var nativeNotificationDeliveryHooks = NativeNotificationDeliveryHooks() private var suppressedNotificationFeedbackHandler: (TerminalNotificationStore, TerminalNotification, TerminalNotificationPolicyEffects) -> Void = { store, notification, @@ -440,9 +747,11 @@ final class TerminalNotificationStore: ObservableObject { private static let notificationHookFailureThrottle: TimeInterval = 300 var lastNotificationDateByCooldownKey: [String: Date] = [:] + private var notificationCooldownReservations = NotificationCooldownReservations() var lastNotificationHookFailureDateByKey: [NotificationHookFailureThrottleKey: Date] = [:] private var indexes = NotificationIndexes() private let inFlightPolicyRequests = TerminalNotificationPolicyInFlightStore() + private init() { notificationFeedHistory = NotificationFeedHistoryStore( fileURL: NotificationFeedHistoryStore.defaultFileURL() @@ -472,25 +781,6 @@ final class TerminalNotificationStore: ObservableObject { } } - static func dockBadgeLabel(unreadCount: Int, isEnabled: Bool, runTag: String? = nil) -> String? { - let unreadLabel: String? = { - guard isEnabled, unreadCount > 0 else { return nil } - if unreadCount > 99 { - return "99+" - } - return String(unreadCount) - }() - - if let tag = TaggedRunBadgeSettings.normalizedTag(runTag) { - if let unreadLabel { - return "\(tag):\(unreadLabel)" - } - return tag - } - - return unreadLabel - } - var unreadCount: Int { indexes.unreadCount + workspaceUnreadIndicatorCount } @@ -508,7 +798,8 @@ final class TerminalNotificationStore: ObservableObject { private func refreshUnreadPresentation() { let nextMenuSnapshot = NotificationMenuSnapshotBuilder.make( notifications: notifications, - workspaceUnreadIndicatorCount: workspaceUnreadIndicatorCount + workspaceUnreadIndicatorCount: workspaceUnreadIndicatorCount, + cachedUnreadNotificationCount: indexes.unreadCount ) if notificationMenuSnapshot != nextMenuSnapshot { notificationMenuSnapshot = nextMenuSnapshot @@ -526,6 +817,68 @@ final class TerminalNotificationStore: ObservableObject { emitUnreadBadgeEventIfChanged() } + private func refreshUnreadPresentation( + changedWorkspaceIds: Set<UUID>, + changedSurfaceKeys: Set<SidebarSurfaceUnreadKey> + ) { + guard !changedWorkspaceIds.isEmpty || !changedSurfaceKeys.isEmpty else { + refreshUnreadPresentation() + return + } + let nextMenuSnapshot = NotificationMenuSnapshotBuilder.make( + notifications: notifications, + workspaceUnreadIndicatorCount: workspaceUnreadIndicatorCount, + cachedUnreadNotificationCount: indexes.unreadCount + ) + if notificationMenuSnapshot != nextMenuSnapshot { + notificationMenuSnapshot = nextMenuSnapshot + } + var changedSummaries: [UUID: SidebarWorkspaceUnreadSummary] = [:] + var removedSummaryIds: Set<UUID> = [] + for id in changedWorkspaceIds { + if let summary = sidebarUnreadSummary(forWorkspaceId: id) { + changedSummaries[id] = summary + } else { + removedSummaryIds.insert(id) + } + } + var insertedSurfaceKeys: Set<SidebarSurfaceUnreadKey> = [] + var removedSurfaceKeys: Set<SidebarSurfaceUnreadKey> = [] + for key in changedSurfaceKeys { + if hasUnreadNotification(forTabId: key.workspaceId, surfaceId: key.surfaceId) { + insertedSurfaceKeys.insert(key) + } else { + removedSurfaceKeys.insert(key) + } + } + sidebarUnread.applyIncremental( + totalUnreadCount: unreadCount, + changedSummaries: changedSummaries, + removedSummaryIds: removedSummaryIds, + insertedUnreadSurfaceKeys: insertedSurfaceKeys, + removedUnreadSurfaceKeys: removedSurfaceKeys, + focusedReadIndicatorByWorkspaceId: focusedReadIndicatorByTabId, + manualUnreadWorkspaceIds: manualUnreadWorkspaceIds + ) + refreshDockBadge() + emitUnreadBadgeEventIfChanged() + } + + private static func sidebarUnreadSurfaceKeys(for notification: TerminalNotification) -> Set<SidebarSurfaceUnreadKey> { + Set(unreadIndexKeys(for: notification).map { + SidebarSurfaceUnreadKey(workspaceId: $0.tabId, surfaceId: $0.surfaceId) + }) + } + + private static func recordChangedUnreadPresentationScope( + for notification: TerminalNotification, + workspaceIds: inout Set<UUID>, + surfaceKeys: inout Set<SidebarSurfaceUnreadKey> + ) { + workspaceIds.insert(notification.tabId) + surfaceKeys.formUnion(sidebarUnreadSurfaceKeys(for: notification)) + } + /// Builds the per-workspace unread summaries the sidebar renders. Mirrors /// `unreadCount(forTabId:)` and `latestNotification(forTabId:)` so the /// coalesced model is a drop-in source for the sidebar's per-row reads. @@ -553,6 +906,20 @@ final class TerminalNotificationStore: ObservableObject { return result } + private func sidebarUnreadSummary(forWorkspaceId id: UUID) -> SidebarWorkspaceUnreadSummary? { + let count = unreadCount(forTabId: id) + let latestText: String? = indexes.latestByTabId[id].flatMap { notification in + let text = notification.body.isEmpty ? notification.title : notification.body + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + guard count != 0 || latestText != nil else { return nil } + return SidebarWorkspaceUnreadSummary( + unreadCount: count, + latestNotificationText: latestText + ) + } + private func logAuthorization(_ message: String) { #if DEBUG cmuxDebugLog("notification.auth \(message)") @@ -808,6 +1175,8 @@ final class TerminalNotificationStore: ObservableObject { indexes.latestByTabId[tabId] } + var notificationWorkspaceIds: Set<UUID> { Set(indexes.latestByTabId.keys) } + func notifications(forTabId tabId: UUID, surfaceId: UUID?) -> [TerminalNotification] { notifications.filter { $0.matches(tabId: tabId, surfaceId: surfaceId) } } @@ -850,6 +1219,8 @@ final class TerminalNotificationStore: ObservableObject { } func addNotification( + id: UUID = UUID(), + acceptedAt: Date = Date(), tabId: UUID, surfaceId: UUID?, title: String, @@ -858,16 +1229,25 @@ final class TerminalNotificationStore: ObservableObject { retargetsToLiveSurfaceOwner: Bool = true, cooldownKey: String? = nil, cooldownInterval: TimeInterval? = nil, - clickAction: TerminalNotificationClickAction? = nil, notificationGeneration: UInt64? = nil, + clickAction: TerminalNotificationClickAction? = nil, + notificationGeneration: UInt64? = nil, resolvedHooks: [CmuxResolvedNotificationHook]? = nil, preRegisteredPolicyRequestId: UUID? = nil ) { + let routedKey: QueuedTerminalNotificationKey + if retargetsToLiveSurfaceOwner { + routedKey = TerminalMutationBus.shared.routedNotificationKey( + tabId: tabId, + surfaceId: surfaceId + ) + } else { + routedKey = QueuedTerminalNotificationKey(tabId: tabId, surfaceId: surfaceId) + } #if DEBUG cmuxDebugLog( - "notification.store.add workspace=\(tabId.uuidString.prefix(8)) surface=\(surfaceId?.uuidString.prefix(8) ?? "nil") titleLen=\(title.count) subtitleLen=\(subtitle.count) bodyLen=\(body.count) cooldown=\(cooldownKey == nil ? 0 : 1)" + "notification.store.add workspace=\(routedKey.tabId.uuidString.prefix(8)) surface=\(routedKey.surfaceId?.uuidString.prefix(8) ?? "nil") titleLen=\(title.count) subtitleLen=\(subtitle.count) bodyLen=\(body.count) cooldown=\(cooldownKey == nil ? 0 : 1)" ) #endif - let now = Date() let resolvedCooldownInterval: TimeInterval? if let cooldownInterval, cooldownInterval.isFinite, cooldownInterval > 0 { resolvedCooldownInterval = cooldownInterval @@ -877,10 +1257,10 @@ final class TerminalNotificationStore: ObservableObject { if let cooldownKey, let resolvedCooldownInterval, let lastNotificationDate = lastNotificationDateByCooldownKey[cooldownKey], - now.timeIntervalSince(lastNotificationDate) < resolvedCooldownInterval { + acceptedAt.timeIntervalSince(lastNotificationDate) < resolvedCooldownInterval { #if DEBUG cmuxDebugLog( - "notification.store.add.skip workspace=\(tabId.uuidString.prefix(8)) surface=\(surfaceId?.uuidString.prefix(8) ?? "nil") reason=cooldown" + "notification.store.add.skip workspace=\(routedKey.tabId.uuidString.prefix(8)) surface=\(routedKey.surfaceId?.uuidString.prefix(8) ?? "nil") reason=cooldown" ) #endif if let preRegisteredPolicyRequestId { @@ -888,16 +1268,16 @@ final class TerminalNotificationStore: ObservableObject { } return } - let cooldownReservation = makeCooldownReservation( + let cooldownReservation = notificationCooldownReservations.reserve( key: cooldownKey, - interval: resolvedCooldownInterval + interval: resolvedCooldownInterval, + acceptedAt: acceptedAt, + dates: &lastNotificationDateByCooldownKey ) - if let cooldownReservation { - lastNotificationDateByCooldownKey[cooldownReservation.key] = now - } + let policyContext = makeNotificationPolicyContext( - tabId: tabId, - surfaceId: surfaceId, + tabId: routedKey.tabId, + surfaceId: routedKey.surfaceId, title: title, subtitle: subtitle, body: body, @@ -905,15 +1285,18 @@ final class TerminalNotificationStore: ObservableObject { resolvedHooks: resolvedHooks ) if policyContext.hooks.isEmpty, preRegisteredPolicyRequestId == nil { - applyNotification( - request: policyContext.request, - effects: TerminalNotificationPolicyEffects(), - now: now, - cooldownReservation: cooldownReservation, - scrollPosition: policyContext.scrollPosition, - clickAction: clickAction - ) - return + if cooldownReservation == nil { + applyNotification( + id: id, + request: policyContext.request, + effects: TerminalNotificationPolicyEffects(), + acceptedAt: acceptedAt, + cooldownReservation: nil, + scrollPosition: policyContext.scrollPosition, + clickAction: clickAction + ) + return + } } guard let policyRequestId = prepareNotificationPolicyRequestId( preRegisteredPolicyRequestId: preRegisteredPolicyRequestId, @@ -926,8 +1309,10 @@ final class TerminalNotificationStore: ObservableObject { guard !policyContext.hooks.isEmpty else { completePolicyRequest( policyRequestId, + id: id, request: policyContext.request, effects: TerminalNotificationPolicyEffects(), + acceptedAt: acceptedAt, cooldownReservation: cooldownReservation, scrollPosition: policyContext.scrollPosition, clickAction: clickAction @@ -944,14 +1329,17 @@ final class TerminalNotificationStore: ObservableObject { guard !authorizedHooks.isEmpty else { self.completePolicyRequest( policyRequestId, + id: id, request: policyContext.request, effects: TerminalNotificationPolicyEffects(), + acceptedAt: acceptedAt, cooldownReservation: cooldownReservation, scrollPosition: policyContext.scrollPosition, clickAction: clickAction ) return } + let result = await TerminalNotificationPolicyEngine.evaluate( request: policyContext.request, hooks: authorizedHooks @@ -961,8 +1349,10 @@ final class TerminalNotificationStore: ObservableObject { case .success(let envelope): self.completePolicyRequest( policyRequestId, + id: id, request: policyContext.request, envelope: envelope, + acceptedAt: acceptedAt, cooldownReservation: cooldownReservation, scrollPosition: policyContext.scrollPosition, clickAction: clickAction @@ -970,8 +1360,10 @@ final class TerminalNotificationStore: ObservableObject { case .failure(let failure): self.completePolicyRequest( policyRequestId, + id: id, request: policyContext.request, effects: TerminalNotificationPolicyEffects(), + acceptedAt: acceptedAt, cooldownReservation: cooldownReservation, scrollPosition: policyContext.scrollPosition, clickAction: clickAction @@ -982,22 +1374,64 @@ final class TerminalNotificationStore: ObservableObject { inFlightPolicyRequests.attach(task: task, to: policyRequestId) } - private func completePolicyRequest(_ policyRequestId: UUID, request: TerminalNotificationPolicyRequest, envelope: TerminalNotificationPolicyEnvelope, cooldownReservation: NotificationCooldownReservation?, scrollPosition: TerminalNotificationScrollPosition?, clickAction: TerminalNotificationClickAction?) { - inFlightPolicyRequests.complete(policyRequestId) { [weak self] in - self?.applyNotification(request: request, envelope: envelope, now: Date(), cooldownReservation: cooldownReservation, scrollPosition: scrollPosition, clickAction: clickAction, policyRequestId: nil) + private func completePolicyRequest( + _ policyRequestId: UUID, + id: UUID, + request: TerminalNotificationPolicyRequest, + envelope: TerminalNotificationPolicyEnvelope, + acceptedAt: Date, + cooldownReservation: NotificationCooldownReservations.Reservation?, + scrollPosition: TerminalNotificationScrollPosition?, + clickAction: TerminalNotificationClickAction? + ) { + inFlightPolicyRequests.complete(policyRequestId) { [weak self] registeredRequest in + let claimedRequest = request.replacingLocation( + tabId: registeredRequest.tabId, + surfaceId: registeredRequest.surfaceId, + panelId: registeredRequest.panelId + ) + self?.applyNotification( + id: id, + request: claimedRequest, + envelope: envelope, + acceptedAt: acceptedAt, + cooldownReservation: cooldownReservation, + scrollPosition: scrollPosition, + clickAction: clickAction, + policyRequestId: nil + ) } } - private func completePolicyRequest(_ policyRequestId: UUID, request: TerminalNotificationPolicyRequest, effects: TerminalNotificationPolicyEffects, cooldownReservation: NotificationCooldownReservation?, scrollPosition: TerminalNotificationScrollPosition?, clickAction: TerminalNotificationClickAction?) { - inFlightPolicyRequests.complete(policyRequestId) { [weak self] in - self?.applyNotification(request: request, effects: effects, now: Date(), cooldownReservation: cooldownReservation, scrollPosition: scrollPosition, clickAction: clickAction, policyRequestId: nil) + private func completePolicyRequest( + _ policyRequestId: UUID, + id: UUID, + request: TerminalNotificationPolicyRequest, + effects: TerminalNotificationPolicyEffects, + acceptedAt: Date, + cooldownReservation: NotificationCooldownReservations.Reservation?, + scrollPosition: TerminalNotificationScrollPosition?, + clickAction: TerminalNotificationClickAction? + ) { + inFlightPolicyRequests.complete(policyRequestId) { [weak self] registeredRequest in + let claimedRequest = request.replacingLocation( + tabId: registeredRequest.tabId, + surfaceId: registeredRequest.surfaceId, + panelId: registeredRequest.panelId + ) + self?.applyNotification( + id: id, + request: claimedRequest, + effects: effects, + acceptedAt: acceptedAt, + cooldownReservation: cooldownReservation, + scrollPosition: scrollPosition, + clickAction: clickAction, + policyRequestId: nil + ) } } - private struct NotificationCooldownReservation: Sendable { - let key: String - let previousDate: Date? - } private struct NotificationPolicyContext: Sendable { let request: TerminalNotificationPolicyRequest let scrollPosition: TerminalNotificationScrollPosition? @@ -1009,7 +1443,7 @@ final class TerminalNotificationStore: ObservableObject { preRegisteredPolicyRequestId: UUID?, request: TerminalNotificationPolicyRequest, notificationGeneration: UInt64?, - cooldownReservation: NotificationCooldownReservation? + cooldownReservation: NotificationCooldownReservations.Reservation? ) -> UUID? { let onDiscard: @MainActor @Sendable () -> Void = { [weak self] in self?.restoreCooldownReservation(cooldownReservation) @@ -1017,6 +1451,7 @@ final class TerminalNotificationStore: ObservableObject { if let preRegisteredPolicyRequestId { guard inFlightPolicyRequests.updateOnDiscard( onDiscard, + cooldownKey: cooldownReservation?.key, for: preRegisteredPolicyRequestId ) else { restoreCooldownReservation(cooldownReservation) @@ -1028,34 +1463,38 @@ final class TerminalNotificationStore: ObservableObject { request, generation: notificationGeneration ?? TerminalMutationBus.shared.notificationGenerationSnapshot(), + cooldownKey: cooldownReservation?.key, onDiscard: onDiscard ) } - private func makeCooldownReservation( - key: String?, - interval: TimeInterval? - ) -> NotificationCooldownReservation? { - guard let key, interval != nil else { return nil } - return NotificationCooldownReservation( - key: key, - previousDate: lastNotificationDateByCooldownKey[key] - ) - } private func commitCooldownReservation( - _ reservation: NotificationCooldownReservation?, + _ reservation: NotificationCooldownReservations.Reservation?, at date: Date ) { - guard let reservation else { return } - lastNotificationDateByCooldownKey[reservation.key] = date + notificationCooldownReservations.commit( + reservation, + at: date, + dates: &lastNotificationDateByCooldownKey + ) } - private func restoreCooldownReservation(_ reservation: NotificationCooldownReservation?) { - guard let reservation else { return } - if let previousDate = reservation.previousDate { - lastNotificationDateByCooldownKey[reservation.key] = previousDate - } else { - lastNotificationDateByCooldownKey.removeValue(forKey: reservation.key) + + private func restoreCooldownReservation(_ reservation: NotificationCooldownReservations.Reservation?) { + notificationCooldownReservations.restore( + reservation, + dates: &lastNotificationDateByCooldownKey + ) + } + + private func hasCommittedCooldown( + _ reservation: NotificationCooldownReservations.Reservation?, + at acceptedAt: Date + ) -> Bool { + guard let reservation, + let lastNotificationDate = lastNotificationDateByCooldownKey[reservation.key] else { + return false } + return acceptedAt.timeIntervalSince(lastNotificationDate) < reservation.interval } private func makeNotificationPolicyContext( @@ -1067,6 +1506,11 @@ final class TerminalNotificationStore: ObservableObject { retargetsToLiveSurfaceOwner: Bool, resolvedHooks: [CmuxResolvedNotificationHook]? ) -> NotificationPolicyContext { + let normalizedText = Self.normalizedNotificationText( + title: title, + subtitle: subtitle, + body: body + ) let appDelegate = AppDelegate.shared let context = appDelegate?.contextContainingTabId(tabId) let tabManager = context?.tabManager ?? appDelegate?.tabManagerFor(tabId: tabId) ?? appDelegate?.tabManager @@ -1103,9 +1547,9 @@ final class TerminalNotificationStore: ObservableObject { surfaceId: surfaceId, panelId: panelId, retargetsToLiveSurfaceOwner: retargetsToLiveSurfaceOwner, - title: title, - subtitle: subtitle, - body: body, + title: normalizedText.title, + subtitle: normalizedText.subtitle, + body: normalizedText.body, cwd: cwd, isAppFocused: isAppFocused, isFocusedPanel: isFocusedPanel @@ -1117,71 +1561,105 @@ final class TerminalNotificationStore: ObservableObject { globalConfigPath: cmuxConfigStore?.globalConfigPath ) } + private func applyNotification( + id: UUID, request: TerminalNotificationPolicyRequest, envelope: TerminalNotificationPolicyEnvelope, - now: Date, - cooldownReservation: NotificationCooldownReservation?, + acceptedAt: Date, + cooldownReservation: NotificationCooldownReservations.Reservation?, scrollPosition: TerminalNotificationScrollPosition?, - clickAction: TerminalNotificationClickAction?, policyRequestId: UUID? + clickAction: TerminalNotificationClickAction?, + policyRequestId: UUID? ) { let payload = envelope.notification + let normalizedPayload = Self.normalizedNotificationText( + title: payload.title, + subtitle: payload.subtitle, + body: payload.body + ) applyNotification( + id: id, request: TerminalNotificationPolicyRequest( tabId: request.tabId, surfaceId: request.surfaceId, panelId: request.panelId, retargetsToLiveSurfaceOwner: request.retargetsToLiveSurfaceOwner, - title: payload.title, - subtitle: payload.subtitle, - body: payload.body, + title: normalizedPayload.title, + subtitle: normalizedPayload.subtitle, + body: normalizedPayload.body, cwd: request.cwd, isAppFocused: request.isAppFocused, isFocusedPanel: request.isFocusedPanel ), effects: envelope.effects, - now: now, + acceptedAt: acceptedAt, cooldownReservation: cooldownReservation, scrollPosition: scrollPosition, - clickAction: clickAction, policyRequestId: policyRequestId + clickAction: clickAction, + policyRequestId: policyRequestId ) } private func applyNotification( + id: UUID, request: TerminalNotificationPolicyRequest, effects: TerminalNotificationPolicyEffects, - now: Date, - cooldownReservation: NotificationCooldownReservation?, + acceptedAt: Date, + cooldownReservation: NotificationCooldownReservations.Reservation?, scrollPosition: TerminalNotificationScrollPosition?, - clickAction: TerminalNotificationClickAction?, policyRequestId: UUID? = nil + clickAction: TerminalNotificationClickAction?, + policyRequestId: UUID? = nil ) { - guard inFlightPolicyRequests.claim(policyRequestId) else { return } - guard let request = notificationPolicyRequestAtLiveOwner(request) else { restoreCooldownReservation(cooldownReservation); return } - let shouldSuppressExternalDelivery = shouldSuppressExternalDelivery( - tabId: request.tabId, - surfaceId: request.surfaceId + guard let claimedRequest = inFlightPolicyRequests.claim( + policyRequestId, + applying: request + ) else { return } + guard let request = notificationPolicyRequestAtLiveOwner(claimedRequest) else { + restoreCooldownReservation(cooldownReservation) + return + } + guard !hasCommittedCooldown(cooldownReservation, at: acceptedAt) else { + restoreCooldownReservation(cooldownReservation) + return + } + let normalizedText = Self.normalizedNotificationText( + title: request.title, + subtitle: request.subtitle, + body: request.body ) let notification = TerminalNotification( - id: UUID(), + id: id, tabId: request.tabId, surfaceId: request.surfaceId, panelId: request.panelId, retargetsToLiveSurfaceOwner: request.retargetsToLiveSurfaceOwner, - title: request.title, - subtitle: request.subtitle, - body: request.body, - createdAt: now, + title: normalizedText.title, + subtitle: normalizedText.subtitle, + body: normalizedText.body, + createdAt: acceptedAt, isRead: !effects.markUnread, paneFlash: effects.paneFlash, scrollPosition: scrollPosition, clickAction: clickAction ) + + guard !indexes.ids.contains(notification.id) else { + restoreCooldownReservation(cooldownReservation) + return + } + + let shouldSuppressExternalDelivery = shouldSuppressExternalDelivery( + tabId: notification.tabId, + surfaceId: notification.surfaceId + ) + if effects.record { recordNotification( notification, shouldSuppressExternalDelivery: shouldSuppressExternalDelivery, effects: effects, - now: now, + acceptedAt: acceptedAt, cooldownReservation: cooldownReservation ) return @@ -1198,7 +1676,7 @@ final class TerminalNotificationStore: ObservableObject { .moveTabToTopForNotification(notification.tabId) } if hasAnyNotificationEffect(effects) { - commitCooldownReservation(cooldownReservation, at: now) + commitCooldownReservation(cooldownReservation, at: acceptedAt) } else { restoreCooldownReservation(cooldownReservation) } @@ -1208,21 +1686,14 @@ final class TerminalNotificationStore: ObservableObject { effects: effects ) } + private func recordNotification( _ notification: TerminalNotification, shouldSuppressExternalDelivery: Bool, effects: TerminalNotificationPolicyEffects, - now: Date, - cooldownReservation: NotificationCooldownReservation? + acceptedAt: Date, + cooldownReservation: NotificationCooldownReservations.Reservation? ) { - var updated = notifications - var idsToClear: [String] = [] - updated.removeAll { existing in - guard existing.tabId == notification.tabId, existing.surfaceId == notification.surfaceId else { return false } - idsToClear.append(existing.id.uuidString) - return true - } - if let existingIndicatorSurfaceId = focusedReadIndicatorByTabId[notification.tabId], existingIndicatorSurfaceId != notification.surfaceId { focusedReadIndicatorByTabId.removeValue(forKey: notification.tabId) @@ -1238,80 +1709,408 @@ final class TerminalNotificationStore: ObservableObject { .moveTabToTopForNotification(notification.tabId) } - updated.insert(notification, at: 0) - setWorkspaceManualUnread(false, forTabId: notification.tabId) - notifications = updated - notificationFeedHistory.record( - notification, - supersededIDs: Set(idsToClear.compactMap { UUID(uuidString: $0) }) + let insertionIndex = Self.insertionIndex(for: notification, in: notifications) + let latestExisting = indexes.latestByTabSurface[ + TabSurfaceKey(tabId: notification.tabId, surfaceId: notification.surfaceId) + ] + let externalBannerTransition = Self.externalBannerTransition( + incoming: notification, + latestExisting: latestExisting + ) + let bannerOwner = externalBannerOwnership.owner( + tabId: notification.tabId, + surfaceId: notification.surfaceId ) - commitCooldownReservation(cooldownReservation, at: now) + let supersededExternalIds = externalBannerTransition.suppressIncoming + ? [] + : bannerOwner.map { [$0.id.uuidString] } ?? [] + let supersededExternalIdSet = Set(supersededExternalIds) + let suppressExternalDelivery = shouldSuppressExternalDelivery || externalBannerTransition.suppressIncoming + guard let insertionCommit = commitInsertion(notification, at: insertionIndex) else { + restoreCooldownReservation(cooldownReservation) + return + } + setWorkspaceManualUnread(false, forTabId: notification.tabId) + notificationFeedHistory.record(notification, supersededIDs: []) + commitCooldownReservation(cooldownReservation, at: acceptedAt) #if DEBUG cmuxDebugLog( - "notification.store.record workspace=\(notification.tabId.uuidString.prefix(8)) surface=\(notification.surfaceId?.uuidString.prefix(8) ?? "nil") removed=\(idsToClear.count) unread=\(!notification.isRead ? 1 : 0) paneFlash=\(notification.paneFlash ? 1 : 0) suppressExternal=\(shouldSuppressExternalDelivery ? 1 : 0) total=\(notifications.count)" + "notification.store.record workspace=\(notification.tabId.uuidString.prefix(8)) surface=\(notification.surfaceId?.uuidString.prefix(8) ?? "nil") unread=\(!notification.isRead ? 1 : 0) paneFlash=\(notification.paneFlash ? 1 : 0) suppressExternal=\(shouldSuppressExternalDelivery ? 1 : 0) total=\(notifications.count)" ) #endif - if !idsToClear.isEmpty { - center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear) - center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear) - // A newer notification for this tab+surface superseded the old one - // and its Mac banner was just cleared. When a replacement banner - // push is expected, DEFER the phone-banner dismiss until that push - // is actually queued (see ``deliverNotificationSideEffects``): the - // phone must never lose its only banner to a dismissal whose - // replacement was throttled. When no replacement will be forwarded - // (suppressed/focused, non-desktop effects, forwarding off, or the - // `.onlyWhenAway` presence gate suppressing it while the Mac is - // active), emit the dismiss immediately — nothing is coming to - // replace the banner, and the Mac is not showing one either, so - // deferring would just leave the stale banner stuck until a later - // forward. Only the burst throttle is a legitimate defer-and-flush - // case, which is why ``PhonePushClient/willForwardReplacement()`` - // mirrors the real send gate but ignores that throttle. - let replacementWillForward = !shouldSuppressExternalDelivery - && effects.desktop - && PhonePushClient.shared.willForwardReplacement() - if replacementWillForward { - // The superseded entries already left the store; tombstone them - // now so the reconcile sweep stays correct while the dismiss is - // deferred. - recordDismissTombstones(ids: idsToClear.compactMap { UUID(uuidString: $0) }) - supersededPhoneDismissBuffer.stash( - ids: idsToClear, - forKey: SupersededPhoneDismissBuffer.key( - tabId: notification.tabId, - surfaceId: notification.surfaceId - ) - ) - } else { - // Also drain anything still parked for this key from an earlier - // throttled supersede; this emit is its last guaranteed ride. - emitNotificationsDismissed( - ids: idsToClear, - drainedSuperseded: supersededPhoneDismissBuffer.flush( - forKey: SupersededPhoneDismissBuffer.key( - tabId: notification.tabId, - surfaceId: notification.surfaceId - ) - ) - ) - } + supersedeExternalBanners( + ids: supersededExternalIds, + with: notification, + shouldSuppressExternalDelivery: suppressExternalDelivery, + effects: effects + ) + if !externalBannerTransition.suppressIncoming { + externalBannerOwnership.clear(tabId: notification.tabId, surfaceId: notification.surfaceId) + if !suppressExternalDelivery, effects.desktop { externalBannerOwnership.setOwner(notification) } } + dismissEvictedExternalBannerOwners( + insertionCommit.evicted, + excluding: supersededExternalIdSet + ) deliverNotificationSideEffects( notification, - shouldSuppressExternalDelivery: shouldSuppressExternalDelivery, + shouldSuppressExternalDelivery: suppressExternalDelivery, effects: effects ) } - private func shouldSuppressExternalDelivery(tabId: UUID, surfaceId: UUID?) -> Bool { - let appDelegate = AppDelegate.shared - let context = appDelegate?.contextContainingTabId(tabId) - let tabManager = context?.tabManager ?? appDelegate?.tabManagerFor(tabId: tabId) ?? appDelegate?.tabManager - let focusedSurfaceId = tabManager?.focusedSurfaceId(for: tabId) - let isActiveTab = tabManager?.selectedTabId == tabId - let isFocusedSurface = surfaceId == nil || focusedSurfaceId == surfaceId - return AppFocusState.isAppFocused() && isActiveTab && isFocusedSurface + @discardableResult + private func commitInsertion( + _ notification: TerminalNotification, + at index: Int + ) -> NotificationInsertionCommit? { + let notificationBytes = Self.notificationContentByteCount(notification) + if index == 0 { + var bytesAfterInsertion = notificationFeedContentByteCount + notificationBytes + var countAfterInsertion = notifications.count + 1 + var evictionCount = 0 + var evicted: [TerminalNotification] = [] + while (countAfterInsertion > Self.maximumNotificationFeedCount + || bytesAfterInsertion > Self.maximumNotificationFeedContentBytes), + evictionCount < notifications.count { + let oldest = notifications[notifications.count - evictionCount - 1] + evicted.append(oldest) + bytesAfterInsertion -= Self.notificationContentByteCount(oldest) + countAfterInsertion -= 1 + evictionCount += 1 + } + if countAfterInsertion <= Self.maximumNotificationFeedCount, + bytesAfterInsertion <= Self.maximumNotificationFeedContentBytes { + notificationFeedRevision &+= 1 + if evictionCount == 0 { + notificationFeedStorage.appendNewest(notification) + } else if let eviction = notificationFeedStorage.appendNewestEvictingOldest( + notification, + count: evictionCount, + compactingAfter: Self.notificationFeedCompactionOffset, + compactingAfterDiscardedBytes: Self.notificationFeedCompactionDiscardedBytes + ) { + evicted = eviction.evicted + if let replacementStorage = eviction.replacementStorage { + notificationFeedStorage = replacementStorage + } + } else { + return nil + } + notificationFeedContentByteCount = bytesAfterInsertion + notificationFeedDidChange( + oldValue: nil, + mutation: evicted.isEmpty + ? .insertion(notification) + : .insertionEvicting(inserted: notification, evicted: evicted) + ) + return NotificationInsertionCommit(evicted: evicted) + } + } + var updated = Array(notifications) + updated.insert(notification, at: index) + let evicted = replaceNotificationFeed( + updated, + mutation: .insertion(notification), + dismissEvictedExternalBannerOwners: false + ) + guard indexes.ids.contains(notification.id) else { + dismissEvictedExternalBannerOwners(evicted) + return nil + } + return NotificationInsertionCommit(evicted: evicted) + } + + private func dismissEvictedExternalBannerOwners( + _ evicted: [TerminalNotification], + excluding ids: Set<String> = [] + ) { + for notification in evicted where !ids.contains(notification.id.uuidString) { + dismissEvictedExternalBannerOwner(notification) + } + } + + private func dismissEvictedExternalBannerOwner(_ evicted: TerminalNotification) { + guard externalBannerOwnership.owner( + tabId: evicted.tabId, + surfaceId: evicted.surfaceId + )?.id == evicted.id else { + externalBannerOwnership.clear(id: evicted.id) + return + } + let id = evicted.id.uuidString + let drainedSuperseded = supersededPhoneDismissesForRowAction(evicted) + externalBannerOwnership.clear(id: evicted.id) + center.removeDeliveredNotificationsOffMain(withIdentifiers: [id]) + center.removePendingNotificationRequestsOffMain(withIdentifiers: [id]) + emitNotificationsDismissed(ids: [id], drainedSuperseded: drainedSuperseded) + } + + private static func retainedNotificationFeed( + from newestFirst: [TerminalNotification] + ) -> (retained: [TerminalNotification], evicted: [TerminalNotification]) { + var retained: [TerminalNotification] = [] + retained.reserveCapacity(min(newestFirst.count, maximumNotificationFeedCount)) + var evicted: [TerminalNotification] = [] + var retainedBytes = 0 + for (index, notification) in newestFirst.enumerated() { + let normalized = normalizedNotificationContent(notification) + let notificationBytes = notificationContentByteCount(normalized) + if retained.count < maximumNotificationFeedCount, + retainedBytes + notificationBytes <= maximumNotificationFeedContentBytes { + retained.append(normalized) + retainedBytes += notificationBytes + } else { + evicted.append(normalized) + evicted.append( + contentsOf: newestFirst.dropFirst(index + 1).map(normalizedNotificationContent) + ) + break + } + } + return (retained, evicted) + } + + nonisolated static func normalizedNotificationText( + title: String, + subtitle: String, + body: String + ) -> (title: String, subtitle: String, body: String) { + ( + title: truncatedUTF8(title, maxBytes: maximumNotificationTitleBytes), + subtitle: truncatedUTF8(subtitle, maxBytes: maximumNotificationSubtitleBytes), + body: truncatedUTF8(body, maxBytes: maximumNotificationBodyBytes) + ) + } + + private nonisolated static func normalizedNotificationContent( + _ notification: TerminalNotification + ) -> TerminalNotification { + let normalized = normalizedNotificationText( + title: notification.title, + subtitle: notification.subtitle, + body: notification.body + ) + guard normalized.title != notification.title + || normalized.subtitle != notification.subtitle + || normalized.body != notification.body else { + return notification + } + return TerminalNotification( + id: notification.id, + tabId: notification.tabId, + surfaceId: notification.surfaceId, + panelId: notification.panelId, + retargetsToLiveSurfaceOwner: notification.retargetsToLiveSurfaceOwner, + title: normalized.title, + subtitle: normalized.subtitle, + body: normalized.body, + createdAt: notification.createdAt, + isRead: notification.isRead, + paneFlash: notification.paneFlash, + scrollPosition: notification.scrollPosition, + clickAction: notification.clickAction + ) + } + + fileprivate nonisolated static func notificationContentByteCount( + _ notification: TerminalNotification + ) -> Int { + notification.title.utf8.count + + notification.subtitle.utf8.count + + notification.body.utf8.count + } + + private nonisolated static func truncatedUTF8(_ value: String, maxBytes: Int) -> String { + guard value.utf8.count > maxBytes else { return value } + var result = "" + result.reserveCapacity(min(value.count, maxBytes)) + var usedBytes = 0 + for character in value { + let characterBytes = String(character).utf8.count + guard usedBytes + characterBytes <= maxBytes else { break } + result.append(character) + usedBytes += characterBytes + } + return result + } + + private func commitReadStateChange( + from before: TerminalNotification, + to after: TerminalNotification, + in updated: [TerminalNotification] + ) { + replaceNotificationFeed(updated, mutation: .readState(before: before, after: after)) + } + + @discardableResult + private func replaceNotificationFeed( + _ next: [TerminalNotification], + mutation: NotificationMutationHint? = nil, + dismissEvictedExternalBannerOwners shouldDismissEvictedExternalBannerOwners: Bool = true + ) -> [TerminalNotification] { + let previous = Array(notifications) + let retained = Self.retainedNotificationFeed(from: next) + notificationFeedRevision &+= 1 + notificationFeedStorage = TerminalNotificationFeedStorage(newestFirst: retained.retained) + notificationFeedContentByteCount = retained.retained.reduce(0) { + $0 + Self.notificationContentByteCount($1) + } + reconcileRetainedSupersededBannerIDs(retainedIDs: Set(retained.retained.map(\.id))) + notificationFeedDidChange( + oldValue: previous, + mutation: retained.evicted.isEmpty ? mutation : nil + ) + if shouldDismissEvictedExternalBannerOwners { + dismissEvictedExternalBannerOwners(retained.evicted) + } + return retained.evicted + } + + private func notificationFeedDidChange( + oldValue: [TerminalNotification]?, + mutation: NotificationMutationHint? + ) { + let appliedIncrementally: Bool + var changedWorkspaceIds: Set<UUID> = [] + var changedSurfaceKeys: Set<SidebarSurfaceUnreadKey> = [] + var shouldRefreshUnreadPresentationFully = false + switch mutation { + case .insertion(let inserted): + Self.insertNotification(inserted, into: &indexes, notifications: notifications) + Self.recordChangedUnreadPresentationScope( + for: inserted, + workspaceIds: &changedWorkspaceIds, + surfaceKeys: &changedSurfaceKeys + ) + appliedIncrementally = true + case .insertionEvicting(let inserted, let evicted): + Self.insertNotification( + inserted, + evicting: evicted, + into: &indexes, + notifications: notifications + ) + let evictedIds = Set(evicted.map(\.id)) + deferredUnreadNavigationIds.removeAll { evictedIds.contains($0) } + removeRetainedSupersededBannerIDs(ids: Array(evictedIds)) + Self.recordChangedUnreadPresentationScope( + for: inserted, + workspaceIds: &changedWorkspaceIds, + surfaceKeys: &changedSurfaceKeys + ) + for notification in evicted { + Self.recordChangedUnreadPresentationScope( + for: notification, + workspaceIds: &changedWorkspaceIds, + surfaceKeys: &changedSurfaceKeys + ) + } + appliedIncrementally = true + case .readState(let before, let after): + appliedIncrementally = Self.updateReadState( + from: before, + to: after, + in: &indexes, + notifications: Array(notifications) + ) + Self.recordChangedUnreadPresentationScope( + for: before, + workspaceIds: &changedWorkspaceIds, + surfaceKeys: &changedSurfaceKeys + ) + Self.recordChangedUnreadPresentationScope( + for: after, + workspaceIds: &changedWorkspaceIds, + surfaceKeys: &changedSurfaceKeys + ) + case nil: + indexes = Self.buildIndexes(for: notifications) + deferredUnreadNavigationIds.removeAll { !indexes.ids.contains($0) } + shouldRefreshUnreadPresentationFully = true + appliedIncrementally = false + } + unreadNavigationProjectionIsDirty = true + if shouldRefreshUnreadPresentationFully { + refreshUnreadPresentation() + } else { + refreshUnreadPresentation( + changedWorkspaceIds: changedWorkspaceIds, + changedSurfaceKeys: changedSurfaceKeys + ) + } + switch mutation { + case .insertion(let inserted): + CmuxEventBus.shared.publishNotificationCreated(inserted, delivery: "store", replacedNotificationIds: []) + case .insertionEvicting(let inserted, let evicted): + CmuxEventBus.shared.publishNotificationsRemoved(evicted) { + CmuxEventBus.shared.publishNotificationCreated( + inserted, + delivery: "store", + replacedNotificationIds: [] + ) + } + case .readState(let before, let after) where appliedIncrementally: + if !before.isRead, after.isRead { + CmuxEventBus.shared.publishNotificationRead( + ids: [after.id.uuidString], + workspaceId: after.tabId, + surfaceId: after.surfaceId + ) + } + case .readState where !suppressNotificationDiffPublishing: + CmuxEventBus.shared.publishNotificationChanges( + oldValue: oldValue ?? [], + newValue: Array(notifications) + ) + case nil where !suppressNotificationDiffPublishing: + CmuxEventBus.shared.publishNotificationChanges( + oldValue: oldValue ?? [], + newValue: Array(notifications) + ) + default: + break + } + } + + private func supersedeExternalBanners( + ids: [String], + with notification: TerminalNotification, + shouldSuppressExternalDelivery: Bool, + effects: TerminalNotificationPolicyEffects + ) { + guard !ids.isEmpty else { return } + center.removeDeliveredNotificationsOffMain(withIdentifiers: ids) + center.removePendingNotificationRequestsOffMain(withIdentifiers: ids) + let supersededUUIDs = ids.compactMap { UUID(uuidString: $0) } + recordRetainedSupersededBannerIDs(ids: supersededUUIDs) + + let key = SupersededPhoneDismissBuffer.key( + tabId: notification.tabId, + surfaceId: notification.surfaceId + ) + let replacementWillForward = !shouldSuppressExternalDelivery + && effects.desktop + && PhonePushClient.shared.willForwardReplacement() + if replacementWillForward { + recordDismissTombstones(ids: supersededUUIDs) + supersededPhoneDismissBuffer.stash(ids: ids, forKey: key) + } else { + emitNotificationsDismissed( + ids: ids, + drainedSuperseded: supersededPhoneDismissBuffer.flush(forKey: key) + ) + } + } + + private func shouldSuppressExternalDelivery(tabId: UUID, surfaceId: UUID?) -> Bool { + let appDelegate = AppDelegate.shared + let context = appDelegate?.contextContainingTabId(tabId) + let tabManager = context?.tabManager ?? appDelegate?.tabManagerFor(tabId: tabId) ?? appDelegate?.tabManager + let focusedSurfaceId = tabManager?.focusedSurfaceId(for: tabId) + let isActiveTab = tabManager?.selectedTabId == tabId + let isFocusedSurface = surfaceId == nil || focusedSurfaceId == surfaceId + return AppFocusState.isAppFocused() && isActiveTab && isFocusedSurface } private func deliverNotificationSideEffects( @@ -1422,28 +2221,32 @@ final class TerminalNotificationStore: ObservableObject { func markNotificationFeedRead(ids: Set<UUID>) -> Int { let marked = notificationFeedHistory.markRead(ids: ids) guard !ids.isEmpty else { return marked } - var updated = notifications + var updated = Array(notifications) var activeIDs: [String] = [] var drainedSuperseded: [String] = [] + var readChanges: [(before: TerminalNotification, after: TerminalNotification)] = [] for index in updated.indices where ids.contains(updated[index].id) && !updated[index].isRead { + let before = updated[index] updated[index].isRead = true + readChanges.append((before, updated[index])) activeIDs.append(updated[index].id.uuidString) - drainedSuperseded.append(contentsOf: supersededPhoneDismissBuffer.flush( - forKey: SupersededPhoneDismissBuffer.key( - tabId: updated[index].tabId, - surfaceId: updated[index].surfaceId - ) - )) + drainedSuperseded.append(contentsOf: supersededPhoneDismissesForRowAction(before)) } if !activeIDs.isEmpty { - notifications = updated + deferredUnreadNavigationIds.removeAll { ids.contains($0) } + if readChanges.count == 1, let change = readChanges.first { + commitReadStateChange(from: change.before, to: change.after, in: updated) + } else { + replaceNotificationFeed(updated) + } + externalBannerOwnership.clear(ids: activeIDs) center.removeDeliveredNotificationsOffMain(withIdentifiers: activeIDs) emitNotificationsDismissed( ids: activeIDs, drainedSuperseded: drainedSuperseded ) } - return marked + return max(marked, readChanges.count) } func markUnread(id: UUID) { @@ -1456,14 +2259,23 @@ final class TerminalNotificationStore: ObservableObject { func markNotificationFeedUnread(ids: Set<UUID>) -> Int { let marked = notificationFeedHistory.markUnread(ids: ids) guard !ids.isEmpty else { return marked } - var updated = notifications + var updated = Array(notifications) var tabIDs = Set<UUID>() + var unreadChanges: [(before: TerminalNotification, after: TerminalNotification)] = [] for index in updated.indices where ids.contains(updated[index].id) && updated[index].isRead { + let before = updated[index] updated[index].isRead = false + unreadChanges.append((before, updated[index])) tabIDs.insert(updated[index].tabId) } if !tabIDs.isEmpty { - notifications = updated + removeDismissTombstones(ids: Array(ids)) + deferredUnreadNavigationIds.removeAll { ids.contains($0) } + if unreadChanges.count == 1, let change = unreadChanges.first { + commitReadStateChange(from: change.before, to: change.after, in: updated) + } else { + replaceNotificationFeed(updated) + } } // The notification itself now provides the workspace unread indicator. Clear any // existing manual or restored workspace unread state for the same tab so we don't @@ -1474,13 +2286,13 @@ final class TerminalNotificationStore: ObservableObject { setWorkspaceManualUnread(false, forTabId: tabID) setWorkspaceRestoredUnread(false, forTabId: tabID) } - return marked + return max(marked, unreadChanges.count) } func markRead(forTabId tabId: UUID) { inFlightPolicyRequests.discard(forTabId: tabId, surfaceId: nil) notificationFeedHistory.markRead(inWorkspace: tabId) - var updated = notifications + var updated = Array(notifications) var idsToClear: [String] = [] for index in updated.indices { if updated[index].tabId == tabId && !updated[index].isRead { @@ -1489,7 +2301,9 @@ final class TerminalNotificationStore: ObservableObject { } } if !idsToClear.isEmpty { - notifications = updated + let clearedIds = Set(idsToClear.compactMap(UUID.init(uuidString:))) + deferredUnreadNavigationIds.removeAll { clearedIds.contains($0) } + replaceNotificationFeed(updated) } clearFocusedReadIndicator(forTabId: tabId) setWorkspaceManualUnread(false, forTabId: tabId) @@ -1497,6 +2311,7 @@ final class TerminalNotificationStore: ObservableObject { setPanelDerivedWorkspaceUnread(false, forTabId: tabId) setWorkspaceRestoredUnread(false, forTabId: tabId) if !idsToClear.isEmpty { + externalBannerOwnership.clear(tabId: tabId) center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear) emitNotificationsDismissed( ids: idsToClear, @@ -1508,7 +2323,7 @@ final class TerminalNotificationStore: ObservableObject { func markRead(forTabId tabId: UUID, surfaceId: UUID?) { inFlightPolicyRequests.discard(forTabId: tabId, surfaceId: surfaceId) notificationFeedHistory.markRead(inWorkspace: tabId, surfaceId: surfaceId) - var updated = notifications + var updated = Array(notifications) var idsToClear: [String] = [] var supersededDrained = supersededPhoneDismissBuffer.flush( forKey: SupersededPhoneDismissBuffer.key(tabId: tabId, surfaceId: surfaceId) @@ -1527,7 +2342,9 @@ final class TerminalNotificationStore: ObservableObject { } } if !idsToClear.isEmpty { - notifications = updated + let clearedIds = Set(idsToClear.compactMap(UUID.init(uuidString:))) + deferredUnreadNavigationIds.removeAll { clearedIds.contains($0) } + replaceNotificationFeed(updated) } clearFocusedReadIndicator(forTabId: tabId, surfaceId: surfaceId) if surfaceId == nil { @@ -1536,6 +2353,7 @@ final class TerminalNotificationStore: ObservableObject { setWorkspaceRestoredUnread(false, forTabId: tabId) } if !idsToClear.isEmpty { + externalBannerOwnership.clear(ids: idsToClear) center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear) center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear) emitNotificationsDismissed(ids: idsToClear, drainedSuperseded: supersededDrained) @@ -1549,7 +2367,7 @@ final class TerminalNotificationStore: ObservableObject { @discardableResult func markLatestNotificationAsOldestUnread(forTabId tabId: UUID, surfaceId: UUID?) -> UUID? { - var updated = notifications + var updated = Array(notifications) guard let index = latestNotificationIndex(forTabId: tabId, surfaceId: surfaceId, in: updated) else { if surfaceId == nil, !workspaceIsUnread(forTabId: tabId) { setWorkspaceManualUnread(true, forTabId: tabId) @@ -1557,14 +2375,16 @@ final class TerminalNotificationStore: ObservableObject { return nil } - var notification = updated.remove(at: index) - notification.isRead = false - let insertionIndex = updated.lastIndex(where: { !$0.isRead }).map { $0 + 1 } ?? updated.endIndex - updated.insert(notification, at: insertionIndex) + let before = updated[index] + updated[index].isRead = false + let notificationId = updated[index].id + removeDismissTombstones(ids: [notificationId]) + deferredUnreadNavigationIds.removeAll { $0 == notificationId } + deferredUnreadNavigationIds.append(notificationId) setWorkspaceManualUnread(false, forTabId: tabId) - notifications = updated - notificationFeedHistory.markUnread(ids: [notification.id]) - return notification.id + notificationFeedHistory.markUnread(ids: [notificationId]) + commitReadStateChange(from: before, to: updated[index], in: updated) + return notificationId } private func latestNotificationIndex(forTabId tabId: UUID, surfaceId: UUID?, in notifications: [TerminalNotification]) -> Int? { @@ -1598,7 +2418,7 @@ final class TerminalNotificationStore: ObservableObject { func markAllRead() { notificationFeedHistory.markAllRead() - var updated = notifications + var updated = Array(notifications) var idsToClear: [String] = [] var tabIdsToClearPanelUnread = panelDerivedUnreadWorkspaceIds for index in updated.indices { @@ -1609,13 +2429,14 @@ final class TerminalNotificationStore: ObservableObject { } } if !idsToClear.isEmpty { - notifications = updated + replaceNotificationFeed(updated) } clearWorkspaceManualUnread() clearAllWorkspacePanelUnread(forTabIds: tabIdsToClearPanelUnread) clearPanelDerivedWorkspaceUnread() clearWorkspaceRestoredUnread() if !idsToClear.isEmpty { + externalBannerOwnership.clearAll() center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear) center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear) emitNotificationsDismissed( @@ -1626,90 +2447,162 @@ final class TerminalNotificationStore: ObservableObject { } func remove(id: UUID) { - var updated = notifications + var updated = Array(notifications) let removed = updated.first(where: { $0.id == id }) let originalCount = updated.count updated.removeAll { $0.id == id } guard updated.count != originalCount else { return } - notifications = updated + let drainedSuperseded = removed.map(supersededPhoneDismissesForRowAction) ?? [] notificationFeedHistory.markRead(ids: [id]) + replaceNotificationFeed(updated) + externalBannerOwnership.clear(id: id) if let removed { clearFocusedReadIndicator(forTabId: removed.tabId, surfaceId: removed.surfaceId) } center.removeDeliveredNotificationsOffMain(withIdentifiers: [id.uuidString]) - let supersededDrained = removed.map { removedNotification in - supersededPhoneDismissBuffer.flush( - forKey: SupersededPhoneDismissBuffer.key( - tabId: removedNotification.tabId, - surfaceId: removedNotification.surfaceId - ) - ) - } ?? [] - emitNotificationsDismissed(ids: [id.uuidString], drainedSuperseded: supersededDrained) - } - - func restoreSessionNotifications(_ restoredNotifications: [TerminalNotification], forTabId tabId: UUID) { - TerminalMutationBus.shared.discardPendingNotifications(forTabId: tabId) - - let removedIds = notifications - .filter { $0.tabId == tabId } - .map { $0.id.uuidString } - var usedNotificationIds = Set(notifications.filter { $0.tabId != tabId }.map(\.id)) - let restoredForTab = restoredNotifications - .filter { $0.tabId == tabId } - .sorted(by: Self.notificationSortPrecedes) - .map { Self.notificationWithUniqueId($0, usedIds: &usedNotificationIds) } - let keptNotifications = notifications.filter { $0.tabId != tabId } - let nextNotifications = (restoredForTab + keptNotifications).sorted(by: Self.notificationSortPrecedes) - - let didChangeNotifications = nextNotifications != notifications - if didChangeNotifications { - notifications = nextNotifications - } - notificationFeedHistory.reconcileActiveNotifications(nextNotifications) - let nextIDs = Set(nextNotifications.map(\.id)) - notificationFeedHistory.markRead( - ids: Set(removedIds.compactMap { UUID(uuidString: $0) }).subtracting(nextIDs) - ) - clearFocusedReadIndicator(forTabId: tabId) + emitNotificationsDismissed(ids: [id.uuidString], drainedSuperseded: drainedSuperseded) + } - if didChangeNotifications, !removedIds.isEmpty { - center.removeDeliveredNotificationsOffMain(withIdentifiers: removedIds) - center.removePendingNotificationRequestsOffMain(withIdentifiers: removedIds) + @discardableResult + func removeReadNotifications() -> Int { + var retained: [TerminalNotification] = [] + var removed: [TerminalNotification] = [] + retained.reserveCapacity(notifications.count) + for notification in notifications { + if notification.isRead { + removed.append(notification) + } else { + retained.append(notification) + } } + guard !removed.isEmpty else { return 0 } + var drainedSuperseded: [String] = [] + drainedSuperseded.reserveCapacity(removed.count) + for notification in removed { + drainedSuperseded.append(contentsOf: supersededPhoneDismissesForRowAction(notification)) + } + notificationFeedHistory.markRead(ids: Set(removed.map(\.id))) + replaceNotificationFeed(retained) + let ids = removed.map { $0.id.uuidString } + externalBannerOwnership.clear(ids: ids) + for notification in removed { + clearFocusedReadIndicator(forTabId: notification.tabId, surfaceId: notification.surfaceId) + } + center.removeDeliveredNotificationsOffMain(withIdentifiers: ids) + emitNotificationsDismissed(ids: ids, drainedSuperseded: drainedSuperseded) + return removed.count } - private static func notificationWithUniqueId( - _ notification: TerminalNotification, - usedIds: inout Set<UUID> - ) -> TerminalNotification { - if usedIds.insert(notification.id).inserted { - return notification + private func supersededPhoneDismissesForRowAction(_ notification: TerminalNotification) -> [String] { + guard externalBannerOwnership.owner( + tabId: notification.tabId, + surfaceId: notification.surfaceId + )?.id == notification.id else { return [] } + let phoneKey = SupersededPhoneDismissBuffer.key(tabId: notification.tabId, surfaceId: notification.surfaceId) + return supersededPhoneDismissBuffer.flush(forKey: phoneKey) + } + + func externalBannerOwnerNotificationIDs(forTabId tabId: UUID) -> [UUID] { + externalBannerOwnership.ownerIDs(tabId: tabId) + } + + func applySessionNotificationMerge( + _ merged: [TerminalNotification], + restoredExternalBannerOwnerIDs: Set<UUID> = [] + ) { + let existing = Array(notifications) + guard merged != existing || !restoredExternalBannerOwnerIDs.isEmpty else { return } + externalBannerOwnership.reconcile( + previous: existing, + merged: merged, + restoredOwnerIDs: restoredExternalBannerOwnerIDs + ) + guard merged != existing else { return } + replaceNotificationFeed(merged) + for notification in merged { + notificationFeedHistory.record(notification, supersededIDs: []) } + let mergedIDs = Set(merged.map(\.id)) + notificationFeedHistory.markRead(ids: Set(existing.map(\.id)).subtracting(mergedIDs)) + } - var replacementId = UUID() - while !usedIds.insert(replacementId).inserted { - replacementId = UUID() + func transferSessionNotificationState(fromTabId: UUID, toTabId: UUID, panelIdMap: [UUID: UUID]) { + inFlightPolicyRequests.transfer( + fromTabId: fromTabId, + toTabId: toTabId, + panelIdMap: panelIdMap + ) + for surfaceId in panelIdMap.keys { + notificationFeedHistory.rebindSurface( + fromTabId: fromTabId, + toTabId: toTabId, + surfaceId: surfaceId + ) } - return TerminalNotification( - id: replacementId, - tabId: notification.tabId, - surfaceId: notification.surfaceId, - panelId: notification.panelId, - retargetsToLiveSurfaceOwner: notification.retargetsToLiveSurfaceOwner, - title: notification.title, - subtitle: notification.subtitle, - body: notification.body, - createdAt: notification.createdAt, - isRead: notification.isRead, - paneFlash: notification.paneFlash, - scrollPosition: notification.scrollPosition, - clickAction: notification.clickAction + let manual = Self.replacingWorkspaceId(in: manualUnreadWorkspaceIds, from: fromTabId, to: toTabId) + if manual != manualUnreadWorkspaceIds { manualUnreadWorkspaceIds = manual } + let panel = Self.replacingWorkspaceId(in: panelDerivedUnreadWorkspaceIds, from: fromTabId, to: toTabId) + if panel != panelDerivedUnreadWorkspaceIds { panelDerivedUnreadWorkspaceIds = panel } + let restored = Self.replacingWorkspaceId(in: restoredUnreadWorkspaceIds, from: fromTabId, to: toTabId) + if restored != restoredUnreadWorkspaceIds { restoredUnreadWorkspaceIds = restored } + var focused = focusedReadIndicatorByTabId + if let oldSurfaceId = focused.removeValue(forKey: fromTabId), focused[toTabId] == nil { + focused[toTabId] = panelIdMap[oldSurfaceId] ?? oldSurfaceId + } + if focused != focusedReadIndicatorByTabId { focusedReadIndicatorByTabId = focused } + let sourceOwnersByID = Dictionary( + uniqueKeysWithValues: externalBannerOwnership.owners(tabId: fromTabId).map { ($0.id, $0) } + ) + let destinationOwnersByID = Dictionary( + uniqueKeysWithValues: externalBannerOwnership.owners(tabId: toTabId).map { ($0.id, $0) } ) + let displacedOwners = externalBannerOwnership.transfer( + fromTabId: fromTabId, + toTabId: toTabId, + panelIdMap: panelIdMap + ) + let displacedOwnerIDs = Set(displacedOwners.map(\.id)) + let displacedSourceKeys = Set<String>(sourceOwnersByID.values.compactMap { owner in + guard displacedOwnerIDs.contains(owner.id) else { return nil } + return SupersededPhoneDismissBuffer.key(tabId: owner.tabId, surfaceId: owner.surfaceId) + }) + let displacedDestinationKeys = Set<String>(destinationOwnersByID.values.compactMap { owner in + guard displacedOwnerIDs.contains(owner.id) else { return nil } + return SupersededPhoneDismissBuffer.key(tabId: owner.tabId, surfaceId: owner.surfaceId) + }) + var displacedSuperseded = displacedDestinationKeys.sorted().flatMap { + supersededPhoneDismissBuffer.flush(forKey: $0) + } + displacedSuperseded.append(contentsOf: supersededPhoneDismissBuffer.transfer( + fromTabId: fromTabId, + toTabId: toTabId, + panelIdMap: panelIdMap, + drainingSourceKeys: displacedSourceKeys + )) + dismissDisplacedExternalBannerOwners(displacedOwners, drainedSuperseded: displacedSuperseded) + } + + private func dismissDisplacedExternalBannerOwners( + _ displacedOwners: [TerminalNotification], + drainedSuperseded: [String] + ) { + guard !displacedOwners.isEmpty else { return } + let displacedIds = Set(displacedOwners.map { $0.id.uuidString }).sorted() + center.removeDeliveredNotificationsOffMain(withIdentifiers: displacedIds) + center.removePendingNotificationRequestsOffMain(withIdentifiers: displacedIds) + emitNotificationsDismissed(ids: displacedIds, drainedSuperseded: drainedSuperseded) } - private func replaceNotificationsForClear(_ next: [TerminalNotification]) { suppressNotificationDiffPublishing = true; notifications = next; suppressNotificationDiffPublishing = false } - func clearAll(discardQueuedNotifications: Bool = true, throughNotificationGeneration: UInt64? = nil) { + private func replaceNotificationsForClear(_ next: [TerminalNotification]) { + suppressNotificationDiffPublishing = true + replaceNotificationFeed(next) + suppressNotificationDiffPublishing = false + } + + func clearAll( + discardQueuedNotifications: Bool = true, + throughNotificationGeneration: UInt64? = nil + ) { inFlightPolicyRequests.discardAll(through: throughNotificationGeneration) if discardQueuedNotifications { TerminalMutationBus.shared.discardPendingNotifications() } guard !notifications.isEmpty || @@ -1723,6 +2616,7 @@ final class TerminalNotificationStore: ObservableObject { ids: Set(ids.compactMap { UUID(uuidString: $0) }) ) replaceNotificationsForClear([]) + externalBannerOwnership.clearAll() clearWorkspaceManualUnread() clearAllWorkspacePanelUnread(forTabIds: tabIdsToClearPanelUnread) clearPanelDerivedWorkspaceUnread() @@ -1737,20 +2631,34 @@ final class TerminalNotificationStore: ObservableObject { func clearNotifications( forTabId tabId: UUID, surfaceId: UUID?, - discardQueuedNotifications: Bool = true, throughNotificationGeneration: UInt64? = nil + discardQueuedNotifications: Bool = true, + throughNotificationGeneration: UInt64? = nil ) { - let liveTabId = surfaceId.flatMap { AppDelegate.shared?.agentNotificationDeliveryTarget(claimedTabId: tabId, surfaceId: $0)?.tabId } ?? tabId + let liveTabId = surfaceId.flatMap { + AppDelegate.shared?.agentNotificationDeliveryTarget(claimedTabId: tabId, surfaceId: $0)?.tabId + } ?? tabId let tabIds = Set([tabId, liveTabId]) - inFlightPolicyRequests.discard(forTabId: tabId, surfaceId: surfaceId, through: throughNotificationGeneration) - if discardQueuedNotifications { TerminalMutationBus.shared.discardPendingNotificationsForClear(tabId: liveTabId, surfaceId: surfaceId) } + inFlightPolicyRequests.discard( + forTabId: tabId, + surfaceId: surfaceId, + through: throughNotificationGeneration + ) + if discardQueuedNotifications { + TerminalMutationBus.shared.discardPendingNotificationsForClear( + tabId: liveTabId, + surfaceId: surfaceId + ) + } let hadRestoredWorkspaceUnread = surfaceId == nil && restoredUnreadWorkspaceIds.contains(tabId) var updated: [TerminalNotification] = [] updated.reserveCapacity(notifications.count) - var idsToClear: [String] = [], indicatorTabIds: Set<UUID> = [tabId] + var idsToClear: [String] = [] + var indicatorTabIds: Set<UUID> = [tabId] var supersededDrained: [String] = [] for notification in notifications { if notification.matchesClear(tabId: tabId, liveTabId: liveTabId, surfaceId: surfaceId) { - idsToClear.append(notification.id.uuidString); indicatorTabIds.insert(notification.tabId) + idsToClear.append(notification.id.uuidString) + indicatorTabIds.insert(notification.tabId) supersededDrained.append(contentsOf: supersededPhoneDismissBuffer.flush( forKey: SupersededPhoneDismissBuffer.key( tabId: notification.tabId, @@ -1761,7 +2669,9 @@ final class TerminalNotificationStore: ObservableObject { updated.append(notification) } } - let hadFocusedReadIndicator = indicatorTabIds.contains { focusedReadIndicatorByTabId[$0].map { $0 == surfaceId } ?? false } + let hadFocusedReadIndicator = indicatorTabIds.contains { + focusedReadIndicatorByTabId[$0].map { $0 == surfaceId } ?? false + } guard !idsToClear.isEmpty || hadFocusedReadIndicator || hadRestoredWorkspaceUnread else { return } if !idsToClear.isEmpty { notificationFeedHistory.markRead( @@ -1774,7 +2684,12 @@ final class TerminalNotificationStore: ObservableObject { } indicatorTabIds.forEach { clearFocusedReadIndicator(forTabId: $0, surfaceId: surfaceId) } if !idsToClear.isEmpty { - CmuxEventBus.shared.publishNotificationCleared(ids: idsToClear, workspaceId: tabIds.count == 1 ? tabId : nil, surfaceId: surfaceId) + externalBannerOwnership.clear(ids: idsToClear) + CmuxEventBus.shared.publishNotificationCleared( + ids: idsToClear, + workspaceId: tabIds.count == 1 ? tabId : nil, + surfaceId: surfaceId + ) center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear) center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear) emitNotificationsDismissed(ids: idsToClear, drainedSuperseded: supersededDrained) @@ -1783,47 +2698,100 @@ final class TerminalNotificationStore: ObservableObject { func rebindSurfaceNotifications(fromTabId sourceTabId: UUID, toTabId destinationTabId: UUID, surfaceId: UUID) { guard sourceTabId != destinationTabId else { return } - inFlightPolicyRequests.rebindSurface(fromTabId: sourceTabId, toTabId: destinationTabId, surfaceId: surfaceId) + TerminalMutationBus.shared.rebindPendingNotifications( + fromTabId: sourceTabId, + toTabId: destinationTabId, + surfaceId: surfaceId + ) + inFlightPolicyRequests.rebindSurface( + fromTabId: sourceTabId, + toTabId: destinationTabId, + surfaceId: surfaceId + ) notificationFeedHistory.rebindSurface( fromTabId: sourceTabId, toTabId: destinationTabId, surfaceId: surfaceId ) var didMoveNotification = false + let hasUnreadSourceConfinedNotification = notifications.contains { + !$0.isRead && + !$0.retargetsToLiveSurfaceOwner && + $0.matches(tabId: sourceTabId, surfaceId: surfaceId) + } let updated = notifications.map { notification -> TerminalNotification in guard notification.retargetsToLiveSurfaceOwner, notification.matches(tabId: sourceTabId, surfaceId: surfaceId) else { return notification } didMoveNotification = true - return TerminalNotification( - id: notification.id, + return notification.replacingLocation( tabId: destinationTabId, surfaceId: notification.surfaceId, - panelId: notification.panelId, - title: notification.title, - subtitle: notification.subtitle, - body: notification.body, - createdAt: notification.createdAt, - isRead: notification.isRead, - paneFlash: notification.paneFlash, - scrollPosition: notification.scrollPosition, - clickAction: notification.clickAction + panelId: notification.panelId ) } if didMoveNotification { - notifications = updated + let bannerOwnerRetargets = externalBannerOwnership.owner( + tabId: sourceTabId, + surfaceId: surfaceId + )?.retargetsToLiveSurfaceOwner == true + let sourceOwner = externalBannerOwnership.owner(tabId: sourceTabId, surfaceId: surfaceId) + let destinationOwner = externalBannerOwnership.owner(tabId: destinationTabId, surfaceId: surfaceId) + let displacedOwner: TerminalNotification? + if bannerOwnerRetargets { + displacedOwner = externalBannerOwnership.rebind( + surfaceId: surfaceId, + fromTabId: sourceTabId, + toTabId: destinationTabId + ) + let displacedOwnerId = displacedOwner?.id + let sourceWasDisplaced = displacedOwnerId == sourceOwner?.id + let destinationWasDisplaced = displacedOwnerId == destinationOwner?.id + var displacedSuperseded: [String] = [] + if destinationWasDisplaced { + displacedSuperseded.append(contentsOf: supersededPhoneDismissBuffer.flush( + forKey: SupersededPhoneDismissBuffer.key(tabId: destinationTabId, surfaceId: surfaceId) + )) + } + displacedSuperseded.append(contentsOf: supersededPhoneDismissBuffer.rebind( + surfaceId: surfaceId, + fromTabId: sourceTabId, + toTabId: destinationTabId, + drainSource: sourceWasDisplaced + )) + dismissDisplacedExternalBannerOwners( + displacedOwner.map { [$0] } ?? [], + drainedSuperseded: displacedSuperseded + ) + } else { + displacedOwner = nil + } + replaceNotificationFeed(updated) } - if didMoveNotification, focusedReadIndicatorByTabId[sourceTabId] == surfaceId { + + if !hasUnreadSourceConfinedNotification, + focusedReadIndicatorByTabId[sourceTabId] == surfaceId { focusedReadIndicatorByTabId.removeValue(forKey: sourceTabId) if focusedReadIndicatorByTabId[destinationTabId] == nil { focusedReadIndicatorByTabId[destinationTabId] = surfaceId } } } - func clearNotifications(forTabId tabId: UUID, discardQueuedNotifications: Bool = true, throughNotificationGeneration: UInt64? = nil) { - inFlightPolicyRequests.discard(forTabId: tabId, surfaceId: nil, through: throughNotificationGeneration) - if discardQueuedNotifications { TerminalMutationBus.shared.discardPendingNotificationsForClear(tabId: tabId, surfaceId: nil) } + + func clearNotifications( + forTabId tabId: UUID, + discardQueuedNotifications: Bool = true, + throughNotificationGeneration: UInt64? = nil + ) { + inFlightPolicyRequests.discard( + forTabId: tabId, + surfaceId: nil, + through: throughNotificationGeneration + ) + if discardQueuedNotifications { + TerminalMutationBus.shared.discardPendingNotificationsForClear(tabId: tabId, surfaceId: nil) + } let hadFocusedReadIndicator = focusedReadIndicatorByTabId[tabId] != nil var updated: [TerminalNotification] = [] updated.reserveCapacity(notifications.count) @@ -1848,6 +2816,7 @@ final class TerminalNotificationStore: ObservableObject { } clearFocusedReadIndicator(forTabId: tabId) if !idsToClear.isEmpty { + externalBannerOwnership.clear(tabId: tabId) CmuxEventBus.shared.publishNotificationCleared(ids: idsToClear, workspaceId: tabId, surfaceId: nil) center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear) center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear) @@ -1858,119 +2827,13 @@ final class TerminalNotificationStore: ObservableObject { } } - private func resolvedNotificationTitle(for notification: TerminalNotification) -> String { - let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String - ?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String - ?? "cmux" - return notification.title.isEmpty ? appName : notification.title - } - - private func scheduleUserNotification( - _ notification: TerminalNotification, - effects: TerminalNotificationPolicyEffects - ) { - guard effects.desktop else { - playLocalNotificationFeedback( - title: resolvedNotificationTitle(for: notification), - subtitle: notification.subtitle, - body: notification.body, - effects: effects - ) - return - } - - let nativeDeliveryHooks = nativeNotificationDeliveryHooks - let notificationTitle = resolvedNotificationTitle(for: notification) - let notificationSubtitle = notification.subtitle - let notificationBody = notification.body - let notificationId = notification.id - let notificationTabId = notification.tabId - let notificationSurfaceId = notification.surfaceId - let retargetsToLiveSurfaceOwner = notification.retargetsToLiveSurfaceOwner - let clickActionUserInfo = notification.clickAction?.userInfo ?? [:] - let categoryIdentifier = Self.categoryIdentifier - let handleAuthorization: NativeNotificationDeliveryHooks.AuthorizationCompletion = { authorized, effectiveAuthorizationState in - let content = UNMutableNotificationContent() - content.title = notificationTitle - content.subtitle = notificationSubtitle - content.body = notificationBody - guard authorized else { - NativeNotificationDeliveryHooks.playNativeUnavailableFeedback( - effects: Self.fallbackEffects(effects, authorizationState: effectiveAuthorizationState) - ) - return - } - content.sound = effects.sound ? NotificationSoundSettings.sound() : nil - content.categoryIdentifier = categoryIdentifier - content.userInfo = [ - "tabId": notificationTabId.uuidString, - "notificationId": notificationId.uuidString, - Self.retargetsToLiveSurfaceOwnerUserInfoKey: retargetsToLiveSurfaceOwner, - ] - if let surfaceId = notificationSurfaceId { - content.userInfo["surfaceId"] = surfaceId.uuidString - } - for (key, value) in clickActionUserInfo { - content.userInfo[key] = value - } - let request = UNNotificationRequest( - identifier: notificationId.uuidString, - content: content, - trigger: nil - ) - let commandTitle = content.title - let commandSubtitle = content.subtitle - let commandBody = content.body - - nativeDeliveryHooks.schedule(request) { error in - if let error { - terminalNotificationLogger.error( - "Failed to schedule notification error=\(error.localizedDescription, privacy: .private)" - ) - NativeNotificationDeliveryHooks.playNativeUnavailableFeedback(effects: effects) - } else if effects.command { - nativeDeliveryHooks.runCommand(title: commandTitle, subtitle: commandSubtitle, body: commandBody) - } - } - } - if !nativeDeliveryHooks.authorizeForTesting(handleAuthorization) { - ensureAuthorization(origin: .notificationDelivery, handleAuthorization) - } - } - - private func playSuppressedNotificationFeedback( - for notification: TerminalNotification, - effects: TerminalNotificationPolicyEffects - ) { - nativeNotificationDeliveryHooks.runLocalFeedback( - title: resolvedNotificationTitle(for: notification), - subtitle: notification.subtitle, - body: notification.body, - effects: effects - ) - } - - private func playLocalNotificationFeedback( - title: String, - subtitle: String, - body: String, - effects: TerminalNotificationPolicyEffects - ) { - nativeNotificationDeliveryHooks.runLocalFeedback( - title: title, - subtitle: subtitle, - body: body, - effects: effects - ) - } - /// `completion` receives the decision plus the effective authorization /// state behind it. The state matters for the just-prompted-and-declined /// case: `authorizationState` is refreshed asynchronously there, so a /// caller reading the property would still see `.notDetermined` and play /// the fallback sound for the very notification whose prompt the user /// just denied. - private func ensureAuthorization( + func ensureAuthorization( origin: AuthorizationRequestOrigin, _ completion: @escaping (Bool, NotificationAuthorizationState) -> Void ) { @@ -2146,37 +3009,6 @@ final class TerminalNotificationStore: ObservableObject { return shouldDeferAutomaticAuthorizationRequest(status: status, isAppActive: isAppActive) } - private static func buildIndexes(for notifications: [TerminalNotification]) -> NotificationIndexes { - var indexes = NotificationIndexes() - for notification in notifications { - if indexes.latestByTabId[notification.tabId] == nil { - indexes.latestByTabId[notification.tabId] = notification - } - guard !notification.isRead else { continue } - indexes.unreadCount += 1 - indexes.unreadCountByTabId[notification.tabId, default: 0] += 1 - indexes.unreadByTabSurface.insert( - TabSurfaceKey(tabId: notification.tabId, surfaceId: notification.surfaceId) - ) - if let panelId = notification.panelId, panelId != notification.surfaceId { - indexes.unreadByTabSurface.insert( - TabSurfaceKey(tabId: notification.tabId, surfaceId: panelId) - ) - } - if indexes.latestUnreadByTabId[notification.tabId] == nil { - indexes.latestUnreadByTabId[notification.tabId] = notification - } - } - return indexes - } - - static func notificationSortPrecedes(_ lhs: TerminalNotification, _ rhs: TerminalNotification) -> Bool { - if lhs.createdAt != rhs.createdAt { - return lhs.createdAt > rhs.createdAt - } - return lhs.id.uuidString < rhs.id.uuidString - } - #if DEBUG func configureNotificationSettingsPromptHooksForTesting( windowProvider: @escaping () -> NSWindow?, @@ -2249,23 +3081,45 @@ final class TerminalNotificationStore: ObservableObject { } } - func replaceNotificationsForTesting(_ notifications: [TerminalNotification]) { + func promptToEnableNotificationsForTesting() { + promptToEnableNotifications() + } + + func replaceNotificationsForTesting<S: Sequence>(_ notificationSequence: S) + where S.Element == TerminalNotification { + let notifications = Array(notificationSequence) TerminalMutationBus.shared.discardPendingNotifications() - self.notifications = notifications + deferredUnreadNavigationIds.removeAll() notificationFeedHistory = NotificationFeedHistoryStore(fileURL: nil) { revision in MobileHostService.emitEvent( topic: Self.feedChangedEventTopic, payload: ["revision": revision] ) } + replaceNotificationFeed(notifications) + notificationFeedHistory.reconcileActiveNotifications(Array(self.notifications)) + externalBannerOwnership.resetAssumingOwners(from: Array(self.notifications)) clearWorkspaceManualUnread() clearPanelDerivedWorkspaceUnread() clearWorkspaceRestoredUnread() focusedReadIndicatorByTabId.removeAll() } - func promptToEnableNotificationsForTesting() { - promptToEnableNotifications() + func stashSupersededPhoneDismissIDsForTesting(_ ids: [String], tabId: UUID, surfaceId: UUID?) { + supersededPhoneDismissBuffer.stash( + ids: ids, + forKey: SupersededPhoneDismissBuffer.key(tabId: tabId, surfaceId: surfaceId) + ) + } + + func flushSupersededPhoneDismissIDsForTesting(tabId: UUID, surfaceId: UUID?) -> [String] { + supersededPhoneDismissBuffer.flush( + forKey: SupersededPhoneDismissBuffer.key(tabId: tabId, surfaceId: surfaceId) + ) + } + + func externalBannerOwnerIDForTesting(tabId: UUID, surfaceId: UUID?) -> UUID? { + externalBannerOwnership.owner(tabId: tabId, surfaceId: surfaceId)?.id } #endif @@ -2338,6 +3192,46 @@ final class SidebarUnreadModel: ObservableObject { } } + func applyIncremental( + totalUnreadCount: Int, + changedSummaries: [UUID: SidebarWorkspaceUnreadSummary], + removedSummaryIds: Set<UUID>, + insertedUnreadSurfaceKeys: Set<SidebarSurfaceUnreadKey>, + removedUnreadSurfaceKeys: Set<SidebarSurfaceUnreadKey>, + focusedReadIndicatorByWorkspaceId: [UUID: UUID], + manualUnreadWorkspaceIds: Set<UUID> + ) { + if self.totalUnreadCount != totalUnreadCount { + self.totalUnreadCount = totalUnreadCount + } + var nextSummaries = summaryByWorkspaceId + for id in removedSummaryIds where nextSummaries[id] != nil { + nextSummaries.removeValue(forKey: id) + } + for (id, summary) in changedSummaries where nextSummaries[id] != summary { + nextSummaries[id] = summary + } + if summaryByWorkspaceId != nextSummaries { + summaryByWorkspaceId = nextSummaries + } + var nextSurfaceKeys = unreadSurfaceKeys + for key in removedUnreadSurfaceKeys { + nextSurfaceKeys.remove(key) + } + for key in insertedUnreadSurfaceKeys { + nextSurfaceKeys.insert(key) + } + if unreadSurfaceKeys != nextSurfaceKeys { + unreadSurfaceKeys = nextSurfaceKeys + } + if self.focusedReadIndicatorByWorkspaceId != focusedReadIndicatorByWorkspaceId { + self.focusedReadIndicatorByWorkspaceId = focusedReadIndicatorByWorkspaceId + } + if self.manualUnreadWorkspaceIds != manualUnreadWorkspaceIds { + self.manualUnreadWorkspaceIds = manualUnreadWorkspaceIds + } + } + func summary(forWorkspaceId id: UUID) -> SidebarWorkspaceUnreadSummary { summaryByWorkspaceId[id] ?? SidebarWorkspaceUnreadSummary(unreadCount: 0, latestNotificationText: nil) } diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index db749d751499..f0b582bbac39 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -2161,7 +2161,7 @@ private struct NotificationsPopoverView: View { resizeHandle } .onAppear { refreshWorkspaceTitles() } - .onChange(of: notificationStore.notifications.map(\.tabId)) { _, _ in + .onChange(of: notificationStore.notificationWorkspaceIds) { _, _ in refreshWorkspaceTitles() } .onReceive(NotificationCenter.default.publisher(for: .workspaceTitleDidChange)) { notification in @@ -2336,12 +2336,12 @@ private struct NotificationsPopoverView: View { // the list boundary, which is the same anti-pattern CLAUDE.md flags for the // sidebar/sessions panel (https://github.com/manaflow-ai/cmux/issues/2586). let snapshot = notificationStore.notifications - let lastIndex = snapshot.count - 1 + let lastId = snapshot.last?.id // One tabId -> title index per render, not an O(tabs) scan per row (#5794). let titleSnapshot = loadedWorkspaceTitles ?? currentWorkspaceTitles() ScrollView { LazyVStack(spacing: 0) { - ForEach(Array(snapshot.enumerated()), id: \.element.id) { index, notification in + ForEach(snapshot) { notification in NotificationPopoverRow( notification: notification, workspaceTitle: titleSnapshot[notification.tabId], @@ -2373,7 +2373,7 @@ private struct NotificationsPopoverView: View { } ) .equatable() // snapshot-boundary: skip unchanged rows (#5794) - if index < lastIndex { + if notification.id != lastId { Divider() .opacity(0.4) .padding(.leading, 18) @@ -2385,12 +2385,12 @@ private struct NotificationsPopoverView: View { } private func currentWorkspaceTitles() -> [UUID: String] { - let notificationWorkspaceIds = Set(notificationStore.notifications.map(\.tabId)) + let notificationWorkspaceIds = notificationStore.notificationWorkspaceIds return AppDelegate.shared?.tabTitlesByTabId(for: notificationWorkspaceIds) ?? [:] } private func refreshWorkspaceTitles(ifRelevantTo notification: Notification? = nil) { - let notificationWorkspaceIds = Set(notificationStore.notifications.map(\.tabId)) + let notificationWorkspaceIds = notificationStore.notificationWorkspaceIds guard let notification else { let nextTitles = currentWorkspaceTitles() guard loadedWorkspaceTitles != nextTitles else { return } diff --git a/Sources/Workspace+PanelLifecycle.swift b/Sources/Workspace+PanelLifecycle.swift index 5726a83c3559..b009fa79fe02 100644 --- a/Sources/Workspace+PanelLifecycle.swift +++ b/Sources/Workspace+PanelLifecycle.swift @@ -190,7 +190,6 @@ extension Workspace { } if didChange { if refreshPorts { refreshTrackedAgentPorts() } - AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: id) } return didChange } @@ -213,7 +212,6 @@ extension Workspace { } if didChange { if refreshPorts { refreshTrackedAgentPorts() } - AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: id, surfaceId: panelId) } return didChange } @@ -479,6 +477,7 @@ extension Workspace { PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId) removeTerminalConfigInheritanceSource(panelId: panelId) if clearSurfaceNotifications { + TerminalMutationBus.shared.removeNotificationLiveOwnerRoute(surfaceId: panelId) AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: id, surfaceId: panelId) } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 76dc32bbfc6f..31ad5e19c892 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -54,6 +54,7 @@ extension Workspace { includeScrollback: Bool, restorableAgentIndex: RestorableAgentSessionIndex? = nil, surfaceResumeBindingIndex: SurfaceResumeBindingIndex? = nil, + notificationSnapshotIndex: SessionNotificationSnapshotIndex? = nil, currentAgentProcessIdentity: (Int) -> AgentPIDProcessIdentity? = { guard $0 > 0, $0 <= Int(Int32.max) else { return nil } return AgentPIDProcessIdentity(pid: pid_t($0)) @@ -71,6 +72,10 @@ extension Workspace { restorableAgentIndex: restorableAgentIndex ) } + let notificationStore = AppDelegate.shared?.notificationStore + let resolvedNotificationSnapshotIndex = notificationSnapshotIndex + ?? notificationStore.map { SessionNotificationSnapshotIndex(notifications: $0.notifications) } + ?? .empty let orderedPanelIds = sidebarOrderedPanelIds() var seen: Set<UUID> = [] var allPanelIds: [UUID] = [] @@ -91,6 +96,7 @@ extension Workspace { panelId: panelId, surfaceResumeBindingIndex: surfaceResumeBindingIndex ), + notificationSnapshotIndex: resolvedNotificationSnapshotIndex, currentAgentProcessIdentity: currentAgentProcessIdentity, agentProcessPresence: agentProcessPresence ) @@ -127,12 +133,15 @@ extension Workspace { let gitBranchSnapshot = gitBranch.map { branch in SessionGitBranchSnapshot(branch: branch.branch, isDirty: branch.isDirty) } - let notificationStore = AppDelegate.shared?.notificationStore let isWorkspaceManuallyUnread = notificationStore?.hasManualUnread(forTabId: id) ?? false let hasWorkspaceUnreadIndicator = (notificationStore?.hasUnreadNotification(forTabId: id, surfaceId: nil) ?? false) || (notificationStore?.hasRestoredUnreadIndicator(forTabId: id) ?? false) - let workspaceNotificationSnapshots = notificationSnapshots(surfaceId: nil) + let workspaceNotificationSnapshots = resolvedNotificationSnapshotIndex.workspaceSnapshots(tabId: id) + + resolvedNotificationSnapshotIndex.orphanedSnapshots( + tabId: id, + persistedPanelIds: persistedPanelIds + ) var snapshot = SessionWorkspaceSnapshot( workspaceId: id, stableId: stableId, @@ -147,6 +156,7 @@ extension Workspace { isManuallyUnread: isWorkspaceManuallyUnread, hasUnreadIndicator: hasWorkspaceUnreadIndicator, notifications: workspaceNotificationSnapshots.isEmpty ? nil : workspaceNotificationSnapshots, + externalBannerOwnerNotificationIds: notificationStore?.externalBannerOwnerNotificationIDs(forTabId: id), currentDirectory: currentDirectory, focusedPanelId: focusedPanelId, layout: layout, @@ -305,7 +315,14 @@ extension Workspace { } else { AppDelegate.shared?.notificationStore?.clearRestoredUnreadIndicator(forTabId: id) } - AppDelegate.shared?.notificationStore?.restoreSessionNotifications(restoredNotifications, forTabId: id) + let restoredExternalBannerOwnerIDs = snapshot.externalBannerOwnerNotificationIds.map(Set.init) + AppDelegate.shared?.notificationStore?.restoreSessionNotifications( + restoredNotifications, + forTabId: id, + panelIdMap: oldToNewPanelIds, + restoredExternalBannerOwnerIDs: restoredExternalBannerOwnerIDs ?? [], + inferLegacyExternalBannerOwners: restoredExternalBannerOwnerIDs == nil + ) syncUnreadBadgeStateForAllPanels() return oldToNewPanelIds } @@ -415,6 +432,7 @@ extension Workspace { includeScrollback: Bool, restorableAgentObservation: RestorableAgentSessionIndex.Entry?, resumeBinding: SurfaceResumeBindingSnapshot?, + notificationSnapshotIndex: SessionNotificationSnapshotIndex? = nil, currentAgentProcessIdentity: (Int) -> AgentPIDProcessIdentity? = { guard $0 > 0, $0 <= Int(Int32.max) else { return nil } return AgentPIDProcessIdentity(pid: pid_t($0)) @@ -425,6 +443,11 @@ extension Workspace { } ) -> SessionPanelSnapshot? { guard let panel = panels[panelId] else { return nil } + let resolvedNotificationSnapshotIndex = notificationSnapshotIndex + ?? AppDelegate.shared?.notificationStore.map { + SessionNotificationSnapshotIndex(notifications: $0.notifications) + } + ?? .empty let indexedRestorableAgent = restorableAgentObservation?.snapshot let compatibleIndexedRestorableAgent = indexedRestorableAgent.flatMap { @@ -498,7 +521,7 @@ extension Workspace { }() let isPinned = pinnedPanelIds.contains(panelId) let isManuallyUnread = manualUnreadPanelIds.contains(panelId) - let panelNotificationSnapshots = notificationSnapshots(surfaceId: panelId) + let panelNotificationSnapshots = resolvedNotificationSnapshotIndex.panelSnapshots(tabId: id, panelId: panelId) let panelHasUnreadNotification = hasUnreadNotification(panelId: panelId) let hasUnreadIndicator = restoredUnreadPanelIds.contains(panelId) || @@ -821,7 +844,8 @@ extension Workspace { resumeBinding: effectiveSurfaceResumeBinding( panelId: panelId, surfaceResumeBindingIndex: nil - ) + ), + notificationSnapshotIndex: nil ) else { return nil } @@ -1931,12 +1955,6 @@ extension Workspace { syncUnreadBadgeStateForAllPanels() } - private func notificationSnapshots(surfaceId: UUID?) -> [SessionNotificationSnapshot] { - AppDelegate.shared?.notificationStore? - .notifications(forTabId: id, surfaceId: surfaceId) - .map(SessionNotificationSnapshot.init(notification:)) ?? [] - } - private func restoredSessionNotifications( from snapshot: SessionWorkspaceSnapshot, oldToNewPanelIds: [UUID: UUID] @@ -8572,7 +8590,7 @@ final class Workspace: Identifiable, ObservableObject { } /// Tear down all panels before removing the workspace. - func teardownAllPanels() { + func teardownAllPanels(clearSurfaceNotifications: Bool = true) { portalRenderingEnabled = false clearLayoutFollowUp() hideAllTerminalPortalViews() @@ -8587,7 +8605,7 @@ final class Workspace: Identifiable, ObservableObject { origin: "workspace_teardown", closePanel: true, publishSurfaceClosedEvent: true, - clearSurfaceNotifications: true, + clearSurfaceNotifications: clearSurfaceNotifications, requestTransferredRemoteCleanup: true, cleanupControllerSurfaceState: true ) diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index cb63a32f1ab1..a804666025c6 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -357,7 +357,7 @@ struct WorkspaceContentView: View { .onDisappear { workspace.setAgentHibernationAutoResumePresentationVisible(false) } - .onChange(of: notificationStore.notifications) { _, _ in + .onChange(of: notificationStore.notificationFeedRevision) { _, _ in syncBonsplitNotificationBadges() } .onChange(of: workspace.manualUnreadPanelIds) { _, _ in diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 244d93ceda0a..03f86bb6e027 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -87,6 +87,7 @@ 79390003AA11BB22CC33DD03 /* AgentNotificationLiveRetargetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79390004AA11BB22CC33DD04 /* AgentNotificationLiveRetargetTests.swift */; }; 7939000FAA11BB22CC33DD0F /* AgentNotificationMoveRaceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79390010AA11BB22CC33DD10 /* AgentNotificationMoveRaceTests.swift */; }; 79460001AA11BB22CC33EE01 /* AgentNotificationMutationBoundaryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79460002AA11BB22CC33EE02 /* AgentNotificationMutationBoundaryTests.swift */; }; + EE5000050000000000000005 /* AgentNotificationReliableDeliveryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE5000060000000000000006 /* AgentNotificationReliableDeliveryTests.swift */; }; C3744E030000000000000001 /* AgentPIDProcessIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3744E040000000000000001 /* AgentPIDProcessIdentity.swift */; }; B79500130000000000000001 /* AgentPortPublicationHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = B79500130000000000000002 /* AgentPortPublicationHistory.swift */; }; B79500120000000000000001 /* AgentPortRootIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = B79500120000000000000002 /* AgentPortRootIdentity.swift */; }; @@ -813,6 +814,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources E4C001000000000000000001 /* ExtensionSidebarWorkspaceRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4C001000000000000000002 /* ExtensionSidebarWorkspaceRowView.swift */; }; E4C001000000000000000003 /* ExtensionWorktreePrototype.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4C001000000000000000004 /* ExtensionWorktreePrototype.swift */; }; E50320010000000000000001 /* ExtensionWorktreeSpawnArgsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E50320010000000000000002 /* ExtensionWorktreeSpawnArgsTests.swift */; }; + EE5000090000000000000009 /* ExternalNotificationBannerOwnership.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE5000100000000000000010 /* ExternalNotificationBannerOwnership.swift */; }; C0DEA7710000000000000007 /* FeatureFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEA7710000000000000008 /* FeatureFlags.swift */; }; FEED0A00000000000000F007 /* feed-tui in Resources */ = {isa = PBXBuildFile; fileRef = FEED0A00000000000000F006 /* feed-tui */; }; FEED0000000000000000F00D /* FeedButtonStyleDebugWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED0000000000000000F00C /* FeedButtonStyleDebugWindowController.swift */; }; @@ -1151,16 +1153,21 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources 734F49D37E543DD01C2F4FEF /* NotificationAndMenuBarTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2C075029771815DD5DA1332 /* NotificationAndMenuBarTests.swift */; }; C06552010000000000000003 /* NotificationBurstCoalescer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C06552010000000000000004 /* NotificationBurstCoalescer.swift */; }; 7490C00D7490C00D7490C00D /* NotificationCacheMemoryPressureResponder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7490D00D7490D00D7490D00D /* NotificationCacheMemoryPressureResponder.swift */; }; + EE5000010000000000000001 /* NotificationChronologyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE5000020000000000000002 /* NotificationChronologyTests.swift */; }; + 7A1100017A1100017A110001 /* NotificationCooldownReservations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1100027A1100027A110002 /* NotificationCooldownReservations.swift */; }; D7AB00000000000000B021 /* NotificationDismissSyncTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB00000000000000B020 /* NotificationDismissSyncTests.swift */; }; + E7A500020000000000000001 /* NotificationEffectsOnlyReplayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7A500020000000000000002 /* NotificationEffectsOnlyReplayTests.swift */; }; C0DEFEE30000000000000001 /* NotificationFeedHistoryPersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEFEE30000000000000002 /* NotificationFeedHistoryPersistence.swift */; }; C0DEFEE10000000000000001 /* NotificationFeedHistoryRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEFEE10000000000000002 /* NotificationFeedHistoryRecord.swift */; }; C0DEFEE20000000000000001 /* NotificationFeedHistorySnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEFEE20000000000000002 /* NotificationFeedHistorySnapshot.swift */; }; C0DEFEE40000000000000001 /* NotificationFeedHistoryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEFEE40000000000000002 /* NotificationFeedHistoryStore.swift */; }; C0DEFEE50000000000000001 /* NotificationFeedHistoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEFEE50000000000000002 /* NotificationFeedHistoryTests.swift */; }; + E7A500040000000000000001 /* NotificationFeedScaleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7A500040000000000000002 /* NotificationFeedScaleTests.swift */; }; D7769000000000000000000B /* NotificationPopoverHoverTrackingRepresentable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7769000000000000000000C /* NotificationPopoverHoverTrackingRepresentable.swift */; }; D7769000000000000000000D /* NotificationPopoverHoverTrackingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7769000000000000000000E /* NotificationPopoverHoverTrackingView.swift */; }; D77690000000000000000003 /* NotificationPopoverRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77690000000000000000004 /* NotificationPopoverRow.swift */; }; D78970000000000000000005 /* NotificationReplayRestoreContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78970000000000000000006 /* NotificationReplayRestoreContext.swift */; }; + EE5000070000000000000007 /* NotificationRestoreBannerOwnershipTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE5000080000000000000008 /* NotificationRestoreBannerOwnershipTests.swift */; }; C7A507000000000000005794 /* NotificationRowSnapshotBoundaryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A507000000000000005795 /* NotificationRowSnapshotBoundaryTests.swift */; }; D79010000000000000000003 /* NotificationScrollRecordingSurfaceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D79010000000000000000004 /* NotificationScrollRecordingSurfaceView.swift */; }; D78970000000000000000007 /* NotificationScrollReplayPhase.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78970000000000000000008 /* NotificationScrollReplayPhase.swift */; }; @@ -1171,6 +1178,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources C79010000000000000000003 /* NotificationScrollRestoreRowSpaceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C79010000000000000000004 /* NotificationScrollRestoreRowSpaceTests.swift */; }; D78970000000000000000001 /* NotificationScrollRestoreState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78970000000000000000002 /* NotificationScrollRestoreState.swift */; }; C79010000000000000000005 /* NotificationScrollRestoreTestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = C79010000000000000000006 /* NotificationScrollRestoreTestSupport.swift */; }; + FA5000010000000000000001 /* NotificationSessionReplacementIdentityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA5000020000000000000002 /* NotificationSessionReplacementIdentityTests.swift */; }; B7F00002 /* NotificationSoundSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7F00001 /* NotificationSoundSettings.swift */; }; 4E5F60720000000000000001 /* NotificationSoundSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E5F60720000000000000002 /* NotificationSoundSettingsTests.swift */; }; A5001094 /* NotificationsPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001091 /* NotificationsPage.swift */; }; @@ -1888,8 +1896,11 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources 859110000000000000000007 /* TerminalLinkOpenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 859110000000000000000008 /* TerminalLinkOpenCoordinator.swift */; }; 859100000000000000000001 /* TerminalLinkOpenCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 859100000000000000000002 /* TerminalLinkOpenCoordinatorTests.swift */; }; 859110000000000000000009 /* TerminalLinkOpenRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85911000000000000000000A /* TerminalLinkOpenRequest.swift */; }; + E7A500060000000000000001 /* TerminalMutationBus+NotificationLifecycle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7A500060000000000000002 /* TerminalMutationBus+NotificationLifecycle.swift */; }; + E7A500010000000000000001 /* TerminalMutationBus+Perform.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7A500010000000000000002 /* TerminalMutationBus+Perform.swift */; }; D79020000000000000000001 /* TerminalNotification+NavigationSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = D79020000000000000000002 /* TerminalNotification+NavigationSnapshot.swift */; }; A5F10000000000000000000B /* TerminalNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5F10000000000000000000C /* TerminalNotification.swift */; }; + EE5000030000000000000003 /* TerminalNotificationBackpressureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE5000040000000000000004 /* TerminalNotificationBackpressureTests.swift */; }; A5C41101A1B2C3D4E5F60718 /* TerminalNotificationCallerResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5C41102A1B2C3D4E5F60718 /* TerminalNotificationCallerResolver.swift */; }; A5C41103A1B2C3D4E5F60718 /* TerminalNotificationCallerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5C41104A1B2C3D4E5F60718 /* TerminalNotificationCallerTests.swift */; }; A5A5A507A1B2C3D4E5F60718 /* TerminalNotificationClearAllTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A5A508A1B2C3D4E5F60718 /* TerminalNotificationClearAllTests.swift */; }; @@ -1902,11 +1913,16 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources 79390013AA11BB22CC33DD13 /* TerminalNotificationPolicyInFlightStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79390014AA11BB22CC33DD14 /* TerminalNotificationPolicyInFlightStore.swift */; }; A5A5A501A1B2C3D4E5F60718 /* TerminalNotificationQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A5A502A1B2C3D4E5F60718 /* TerminalNotificationQueue.swift */; }; A5A5A503A1B2C3D4E5F60718 /* TerminalNotificationQueueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A5A504A1B2C3D4E5F60718 /* TerminalNotificationQueueTests.swift */; }; + A5A5A50BA1B2C3D4E5F60718 /* TerminalNotificationQueueTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A5A50CA1B2C3D4E5F60718 /* TerminalNotificationQueueTypes.swift */; }; A5001098 /* TerminalNotificationScrollPosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001099 /* TerminalNotificationScrollPosition.swift */; }; A5E380700000000000000001 /* TerminalNotificationSocketActionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E380700000000000000002 /* TerminalNotificationSocketActionTests.swift */; }; - 468120000000000000000005 /* TerminalNotificationStore+HookResolution.swift in Sources */ = {isa = PBXBuildFile; fileRef = 468120000000000000000006 /* TerminalNotificationStore+HookResolution.swift */; }; - 7490C0107490C0107490C010 /* TerminalNotificationStore+MemoryPressure.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7490D0107490D0107490D010 /* TerminalNotificationStore+MemoryPressure.swift */; }; + 468120000000000000000005 /* TerminalNotificationStore+HookResolution.swift in Sources */ = {isa = PBXBuildFile; fileRef = 468120000000000000000006 /* TerminalNotificationStore+HookResolution.swift */; }; + EE5000110000000000000011 /* TerminalNotificationStore+Indexes.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE5000120000000000000012 /* TerminalNotificationStore+Indexes.swift */; }; + 7490C0107490C0107490C010 /* TerminalNotificationStore+MemoryPressure.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7490D0107490D0107490D010 /* TerminalNotificationStore+MemoryPressure.swift */; }; + E7A500030000000000000001 /* TerminalNotificationStore+Mutation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7A500030000000000000002 /* TerminalNotificationStore+Mutation.swift */; }; + E7A500050000000000000001 /* TerminalNotificationStore+NativeDelivery.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7A500050000000000000002 /* TerminalNotificationStore+NativeDelivery.swift */; }; 596100000000000000000007 /* TerminalNotificationStore+NativeNotificationDeliveryTesting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 596100000000000000000006 /* TerminalNotificationStore+NativeNotificationDeliveryTesting.swift */; }; + A5A5A509A1B2C3D4E5F60718 /* TerminalNotificationStore+QueuedDelivery.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A5A50AA1B2C3D4E5F60718 /* TerminalNotificationStore+QueuedDelivery.swift */; }; A5F10000000000000000000D /* TerminalNotificationStore+WorkspaceNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5F10000000000000000000E /* TerminalNotificationStore+WorkspaceNotifications.swift */; }; A5001095 /* TerminalNotificationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001092 /* TerminalNotificationStore.swift */; }; 0A110711000000000000001C /* TerminalOutputTeeCallback.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A110711000000000000001D /* TerminalOutputTeeCallback.swift */; }; @@ -2322,6 +2338,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources 79390004AA11BB22CC33DD04 /* AgentNotificationLiveRetargetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentNotificationLiveRetargetTests.swift; sourceTree = "<group>"; }; 79390010AA11BB22CC33DD10 /* AgentNotificationMoveRaceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentNotificationMoveRaceTests.swift; sourceTree = "<group>"; }; 79460002AA11BB22CC33EE02 /* AgentNotificationMutationBoundaryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentNotificationMutationBoundaryTests.swift; sourceTree = "<group>"; }; + EE5000060000000000000006 /* AgentNotificationReliableDeliveryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentNotificationReliableDeliveryTests.swift; sourceTree = "<group>"; }; C3744E040000000000000001 /* AgentPIDProcessIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentPIDProcessIdentity.swift; sourceTree = "<group>"; }; B79500130000000000000002 /* AgentPortPublicationHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentPortPublicationHistory.swift; sourceTree = "<group>"; }; B79500120000000000000002 /* AgentPortRootIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentPortRootIdentity.swift; sourceTree = "<group>"; }; @@ -2985,6 +3002,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = E4C001000000000000000002 /* ExtensionSidebarWorkspaceRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionSidebarWorkspaceRowView.swift; sourceTree = "<group>"; }; E4C001000000000000000004 /* ExtensionWorktreePrototype.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionWorktreePrototype.swift; sourceTree = "<group>"; }; E50320010000000000000002 /* ExtensionWorktreeSpawnArgsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionWorktreeSpawnArgsTests.swift; sourceTree = "<group>"; }; + EE5000100000000000000010 /* ExternalNotificationBannerOwnership.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExternalNotificationBannerOwnership.swift; sourceTree = "<group>"; }; C0DEA7710000000000000008 /* FeatureFlags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureFlags.swift; sourceTree = "<group>"; }; FEED0A00000000000000F006 /* feed-tui */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder; path = "feed-tui"; sourceTree = "<group>"; }; FEED0000000000000000F00C /* FeedButtonStyleDebugWindowController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeedButtonStyleDebugWindowController.swift; sourceTree = "<group>"; }; @@ -3319,16 +3337,21 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = D2C075029771815DD5DA1332 /* NotificationAndMenuBarTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationAndMenuBarTests.swift; sourceTree = "<group>"; }; C06552010000000000000004 /* NotificationBurstCoalescer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationBurstCoalescer.swift; sourceTree = "<group>"; }; 7490D00D7490D00D7490D00D /* NotificationCacheMemoryPressureResponder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/NotificationCacheMemoryPressureResponder.swift; sourceTree = "<group>"; }; + EE5000020000000000000002 /* NotificationChronologyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationChronologyTests.swift; sourceTree = "<group>"; }; + 7A1100027A1100027A110002 /* NotificationCooldownReservations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationCooldownReservations.swift; sourceTree = "<group>"; }; D7AB00000000000000B020 /* NotificationDismissSyncTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationDismissSyncTests.swift"; sourceTree = "<group>"; }; + E7A500020000000000000002 /* NotificationEffectsOnlyReplayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationEffectsOnlyReplayTests.swift; sourceTree = "<group>"; }; C0DEFEE30000000000000002 /* NotificationFeedHistoryPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationFeedHistoryPersistence.swift; sourceTree = "<group>"; }; C0DEFEE10000000000000002 /* NotificationFeedHistoryRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationFeedHistoryRecord.swift; sourceTree = "<group>"; }; C0DEFEE20000000000000002 /* NotificationFeedHistorySnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationFeedHistorySnapshot.swift; sourceTree = "<group>"; }; C0DEFEE40000000000000002 /* NotificationFeedHistoryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationFeedHistoryStore.swift; sourceTree = "<group>"; }; C0DEFEE50000000000000002 /* NotificationFeedHistoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationFeedHistoryTests.swift; sourceTree = "<group>"; }; + E7A500040000000000000002 /* NotificationFeedScaleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationFeedScaleTests.swift; sourceTree = "<group>"; }; D7769000000000000000000C /* NotificationPopoverHoverTrackingRepresentable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/NotificationPopoverHoverTrackingRepresentable.swift; sourceTree = "<group>"; }; D7769000000000000000000E /* NotificationPopoverHoverTrackingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/NotificationPopoverHoverTrackingView.swift; sourceTree = "<group>"; }; D77690000000000000000004 /* NotificationPopoverRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/NotificationPopoverRow.swift; sourceTree = "<group>"; }; D78970000000000000000006 /* NotificationReplayRestoreContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationReplayRestoreContext.swift; sourceTree = "<group>"; }; + EE5000080000000000000008 /* NotificationRestoreBannerOwnershipTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationRestoreBannerOwnershipTests.swift; sourceTree = "<group>"; }; C7A507000000000000005795 /* NotificationRowSnapshotBoundaryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationRowSnapshotBoundaryTests.swift; sourceTree = "<group>"; }; D79010000000000000000004 /* NotificationScrollRecordingSurfaceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationScrollRecordingSurfaceView.swift; sourceTree = "<group>"; }; D78970000000000000000008 /* NotificationScrollReplayPhase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationScrollReplayPhase.swift; sourceTree = "<group>"; }; @@ -3339,6 +3362,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C79010000000000000000004 /* NotificationScrollRestoreRowSpaceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationScrollRestoreRowSpaceTests.swift; sourceTree = "<group>"; }; D78970000000000000000002 /* NotificationScrollRestoreState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationScrollRestoreState.swift; sourceTree = "<group>"; }; C79010000000000000000006 /* NotificationScrollRestoreTestSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationScrollRestoreTestSupport.swift; sourceTree = "<group>"; }; + FA5000020000000000000002 /* NotificationSessionReplacementIdentityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSessionReplacementIdentityTests.swift; sourceTree = "<group>"; }; B7F00001 /* NotificationSoundSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSoundSettings.swift; sourceTree = "<group>"; }; 4E5F60720000000000000002 /* NotificationSoundSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSoundSettingsTests.swift; sourceTree = "<group>"; }; A5001091 /* NotificationsPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsPage.swift; sourceTree = "<group>"; }; @@ -4040,8 +4064,11 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 859110000000000000000008 /* TerminalLinkOpenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalLinkOpenCoordinator.swift; sourceTree = "<group>"; }; 859100000000000000000002 /* TerminalLinkOpenCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalLinkOpenCoordinatorTests.swift; sourceTree = "<group>"; }; 85911000000000000000000A /* TerminalLinkOpenRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalLinkOpenRequest.swift; sourceTree = "<group>"; }; + E7A500060000000000000002 /* TerminalMutationBus+NotificationLifecycle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalMutationBus+NotificationLifecycle.swift"; sourceTree = "<group>"; }; + E7A500010000000000000002 /* TerminalMutationBus+Perform.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalMutationBus+Perform.swift"; sourceTree = "<group>"; }; D79020000000000000000002 /* TerminalNotification+NavigationSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalNotification+NavigationSnapshot.swift"; sourceTree = "<group>"; }; A5F10000000000000000000C /* TerminalNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotification.swift; sourceTree = "<group>"; }; + EE5000040000000000000004 /* TerminalNotificationBackpressureTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotificationBackpressureTests.swift; sourceTree = "<group>"; }; A5C41102A1B2C3D4E5F60718 /* TerminalNotificationCallerResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotificationCallerResolver.swift; sourceTree = "<group>"; }; A5C41104A1B2C3D4E5F60718 /* TerminalNotificationCallerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotificationCallerTests.swift; sourceTree = "<group>"; }; A5A5A508A1B2C3D4E5F60718 /* TerminalNotificationClearAllTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotificationClearAllTests.swift; sourceTree = "<group>"; }; @@ -4054,11 +4081,16 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 79390014AA11BB22CC33DD14 /* TerminalNotificationPolicyInFlightStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotificationPolicyInFlightStore.swift; sourceTree = "<group>"; }; A5A5A502A1B2C3D4E5F60718 /* TerminalNotificationQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotificationQueue.swift; sourceTree = "<group>"; }; A5A5A504A1B2C3D4E5F60718 /* TerminalNotificationQueueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotificationQueueTests.swift; sourceTree = "<group>"; }; + A5A5A50CA1B2C3D4E5F60718 /* TerminalNotificationQueueTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotificationQueueTypes.swift; sourceTree = "<group>"; }; A5001099 /* TerminalNotificationScrollPosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotificationScrollPosition.swift; sourceTree = "<group>"; }; A5E380700000000000000002 /* TerminalNotificationSocketActionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotificationSocketActionTests.swift; sourceTree = "<group>"; }; - 468120000000000000000006 /* TerminalNotificationStore+HookResolution.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalNotificationStore+HookResolution.swift"; sourceTree = "<group>"; }; - 7490D0107490D0107490D010 /* TerminalNotificationStore+MemoryPressure.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalNotificationStore+MemoryPressure.swift"; sourceTree = "<group>"; }; + 468120000000000000000006 /* TerminalNotificationStore+HookResolution.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalNotificationStore+HookResolution.swift"; sourceTree = "<group>"; }; + EE5000120000000000000012 /* TerminalNotificationStore+Indexes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalNotificationStore+Indexes.swift"; sourceTree = "<group>"; }; + 7490D0107490D0107490D010 /* TerminalNotificationStore+MemoryPressure.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalNotificationStore+MemoryPressure.swift"; sourceTree = "<group>"; }; + E7A500030000000000000002 /* TerminalNotificationStore+Mutation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalNotificationStore+Mutation.swift"; sourceTree = "<group>"; }; + E7A500050000000000000002 /* TerminalNotificationStore+NativeDelivery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalNotificationStore+NativeDelivery.swift"; sourceTree = "<group>"; }; 596100000000000000000006 /* TerminalNotificationStore+NativeNotificationDeliveryTesting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalNotificationStore+NativeNotificationDeliveryTesting.swift"; sourceTree = "<group>"; }; + A5A5A50AA1B2C3D4E5F60718 /* TerminalNotificationStore+QueuedDelivery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalNotificationStore+QueuedDelivery.swift"; sourceTree = "<group>"; }; A5F10000000000000000000E /* TerminalNotificationStore+WorkspaceNotifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalNotificationStore+WorkspaceNotifications.swift"; sourceTree = "<group>"; }; A5001092 /* TerminalNotificationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotificationStore.swift; sourceTree = "<group>"; }; 0A110711000000000000001D /* TerminalOutputTeeCallback.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalOutputTeeCallback.swift; sourceTree = "<group>"; }; @@ -5427,6 +5459,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = D1F0A00500000000000000E2 /* MacPresenceDecisionCache.swift */, D1F0A00600000000000000F2 /* ScreenLockObserver.swift */, B7F00001 /* NotificationSoundSettings.swift */, + 7A1100027A1100027A110002 /* NotificationCooldownReservations.swift */, C0DEFEE30000000000000002 /* NotificationFeedHistoryPersistence.swift */, C0DEFEE10000000000000002 /* NotificationFeedHistoryRecord.swift */, C0DEFEE20000000000000002 /* NotificationFeedHistorySnapshot.swift */, @@ -5434,15 +5467,23 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A5F10000000000000000000C /* TerminalNotification.swift */, D79020000000000000000002 /* TerminalNotification+NavigationSnapshot.swift */, A5001092 /* TerminalNotificationStore.swift */, - 468110000000000000000008 /* TerminalNotificationClickAction.swift */, - A5F10000000000000000000E /* TerminalNotificationStore+WorkspaceNotifications.swift */, + 468110000000000000000008 /* TerminalNotificationClickAction.swift */, + A5A5A50AA1B2C3D4E5F60718 /* TerminalNotificationStore+QueuedDelivery.swift */, + A5F10000000000000000000E /* TerminalNotificationStore+WorkspaceNotifications.swift */, A5001099 /* TerminalNotificationScrollPosition.swift */, 7490D0107490D0107490D010 /* TerminalNotificationStore+MemoryPressure.swift */, + EE5000120000000000000012 /* TerminalNotificationStore+Indexes.swift */, + E7A500030000000000000002 /* TerminalNotificationStore+Mutation.swift */, + E7A500050000000000000002 /* TerminalNotificationStore+NativeDelivery.swift */, 596100000000000000000004 /* NativeNotificationDeliveryHooks.swift */, D7AB00000000000000B040 /* SupersededPhoneDismissBuffer.swift */, + EE5000100000000000000010 /* ExternalNotificationBannerOwnership.swift */, A5001097 /* TerminalNotificationPolicy.swift */, F1A0C0DE0000000000000001 /* FindTextFieldSupport.swift */, A5A5A502A1B2C3D4E5F60718 /* TerminalNotificationQueue.swift */, + E7A500060000000000000002 /* TerminalMutationBus+NotificationLifecycle.swift */, + E7A500010000000000000002 /* TerminalMutationBus+Perform.swift */, + A5A5A50CA1B2C3D4E5F60718 /* TerminalNotificationQueueTypes.swift */, 79390006AA11BB22CC33DD06 /* AgentDeliveryTargetResolution.swift */, 8362A0020000000000000002 /* TerminalCallerTTYResolver.swift */, 8362A0040000000000000004 /* TerminalCallerTTYBinding.swift */, @@ -6529,6 +6570,13 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 79390004AA11BB22CC33DD04 /* AgentNotificationLiveRetargetTests.swift */, 79390010AA11BB22CC33DD10 /* AgentNotificationMoveRaceTests.swift */, 79460002AA11BB22CC33EE02 /* AgentNotificationMutationBoundaryTests.swift */, + EE5000060000000000000006 /* AgentNotificationReliableDeliveryTests.swift */, + EE5000020000000000000002 /* NotificationChronologyTests.swift */, + E7A500020000000000000002 /* NotificationEffectsOnlyReplayTests.swift */, + E7A500040000000000000002 /* NotificationFeedScaleTests.swift */, + EE5000080000000000000008 /* NotificationRestoreBannerOwnershipTests.swift */, + FA5000020000000000000002 /* NotificationSessionReplacementIdentityTests.swift */, + EE5000040000000000000004 /* TerminalNotificationBackpressureTests.swift */, 79390012AA11BB22CC33DD12 /* ClaudeHookPIDAuthenticationTests.swift */, C7A534000000000000000001 /* ClaudeHookFeedTelemetrySwiftTests.swift */, A5D41223A1B2C3D4E5F60718 /* ClaudeNotificationStatusLifecycleTests.swift */, @@ -7514,6 +7562,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 5732A0135732A0135732A013 /* EnvironmentValues+SidebarLazyContractProbe.swift in Sources */, E4C001000000000000000001 /* ExtensionSidebarWorkspaceRowView.swift in Sources */, E4C001000000000000000003 /* ExtensionWorktreePrototype.swift in Sources */, + EE5000090000000000000009 /* ExternalNotificationBannerOwnership.swift in Sources */, C0DEA7710000000000000007 /* FeatureFlags.swift in Sources */, FEED0000000000000000F00D /* FeedButtonStyleDebugWindowController.swift in Sources */, FEED0000000000000000F002 /* FeedCoordinator.swift in Sources */, @@ -7738,6 +7787,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C0DE77060000000000000003 /* NewWorkspaceMenuModel.swift in Sources */, C06552010000000000000003 /* NotificationBurstCoalescer.swift in Sources */, 7490C00D7490C00D7490C00D /* NotificationCacheMemoryPressureResponder.swift in Sources */, + 7A1100017A1100017A110001 /* NotificationCooldownReservations.swift in Sources */, C0DEFEE30000000000000001 /* NotificationFeedHistoryPersistence.swift in Sources */, C0DEFEE10000000000000001 /* NotificationFeedHistoryRecord.swift in Sources */, C0DEFEE20000000000000001 /* NotificationFeedHistorySnapshot.swift in Sources */, @@ -8242,6 +8292,8 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 859110000000000000000005 /* TerminalLinkOpenContainer.swift in Sources */, 859110000000000000000007 /* TerminalLinkOpenCoordinator.swift in Sources */, 859110000000000000000009 /* TerminalLinkOpenRequest.swift in Sources */, + E7A500060000000000000001 /* TerminalMutationBus+NotificationLifecycle.swift in Sources */, + E7A500010000000000000001 /* TerminalMutationBus+Perform.swift in Sources */, D79020000000000000000001 /* TerminalNotification+NavigationSnapshot.swift in Sources */, A5F10000000000000000000B /* TerminalNotification.swift in Sources */, A5C41101A1B2C3D4E5F60718 /* TerminalNotificationCallerResolver.swift in Sources */, @@ -8251,9 +8303,14 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 468120000000000000000007 /* TerminalNotificationPolicyDeliveryIdentity.swift in Sources */, 79390013AA11BB22CC33DD13 /* TerminalNotificationPolicyInFlightStore.swift in Sources */, A5A5A501A1B2C3D4E5F60718 /* TerminalNotificationQueue.swift in Sources */, + A5A5A50BA1B2C3D4E5F60718 /* TerminalNotificationQueueTypes.swift in Sources */, A5001098 /* TerminalNotificationScrollPosition.swift in Sources */, - 468120000000000000000005 /* TerminalNotificationStore+HookResolution.swift in Sources */, - 7490C0107490C0107490C010 /* TerminalNotificationStore+MemoryPressure.swift in Sources */, + 468120000000000000000005 /* TerminalNotificationStore+HookResolution.swift in Sources */, + EE5000110000000000000011 /* TerminalNotificationStore+Indexes.swift in Sources */, + 7490C0107490C0107490C010 /* TerminalNotificationStore+MemoryPressure.swift in Sources */, + E7A500030000000000000001 /* TerminalNotificationStore+Mutation.swift in Sources */, + E7A500050000000000000001 /* TerminalNotificationStore+NativeDelivery.swift in Sources */, + A5A5A509A1B2C3D4E5F60718 /* TerminalNotificationStore+QueuedDelivery.swift in Sources */, A5F10000000000000000000D /* TerminalNotificationStore+WorkspaceNotifications.swift in Sources */, A5001095 /* TerminalNotificationStore.swift in Sources */, 0A110711000000000000001C /* TerminalOutputTeeCallback.swift in Sources */, @@ -8628,6 +8685,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 79390003AA11BB22CC33DD03 /* AgentNotificationLiveRetargetTests.swift in Sources */, 7939000FAA11BB22CC33DD0F /* AgentNotificationMoveRaceTests.swift in Sources */, 79460001AA11BB22CC33EE01 /* AgentNotificationMutationBoundaryTests.swift in Sources */, + EE5000050000000000000005 /* AgentNotificationReliableDeliveryTests.swift in Sources */, E8DF7FD45DA2E55E4DA65B38 /* AgentResumeLaunchGuardTests.swift in Sources */, B679CCBA4CF56982C8564009 /* AgentResumeLivenessTests.swift in Sources */, D3610B010000000000000001 /* AgentSessionAutoResumeSettingsTests.swift in Sources */, @@ -8913,14 +8971,19 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 596100000000000000000001 /* NativeNotificationFallbackCommandTests.swift in Sources */, A5FB1400 /* NewWorkspaceMenuModelTests.swift in Sources */, 734F49D37E543DD01C2F4FEF /* NotificationAndMenuBarTests.swift in Sources */, + EE5000010000000000000001 /* NotificationChronologyTests.swift in Sources */, D7AB00000000000000B021 /* NotificationDismissSyncTests.swift in Sources */, + E7A500020000000000000001 /* NotificationEffectsOnlyReplayTests.swift in Sources */, C0DEFEE50000000000000001 /* NotificationFeedHistoryTests.swift in Sources */, + E7A500040000000000000001 /* NotificationFeedScaleTests.swift in Sources */, + EE5000070000000000000007 /* NotificationRestoreBannerOwnershipTests.swift in Sources */, C7A507000000000000005794 /* NotificationRowSnapshotBoundaryTests.swift in Sources */, D79010000000000000000003 /* NotificationScrollRecordingSurfaceView.swift in Sources */, C7A507000000000000005796 /* NotificationScrollRestoreLifecycleTests.swift in Sources */, C79010000000000000000001 /* NotificationScrollRestoreRecoveryTests.swift in Sources */, C79010000000000000000003 /* NotificationScrollRestoreRowSpaceTests.swift in Sources */, C79010000000000000000005 /* NotificationScrollRestoreTestSupport.swift in Sources */, + FA5000010000000000000001 /* NotificationSessionReplacementIdentityTests.swift in Sources */, 4E5F60720000000000000001 /* NotificationSoundSettingsTests.swift in Sources */, 645645000000000000000002 /* NumberedShortcutSwapTests.swift in Sources */, 0A1107110000000000000001 /* OllamaAgentDetectionTests.swift in Sources */, @@ -9105,6 +9168,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 6190C0126190C0126190C012 /* TerminalCopyOnSelectManagedConfigLayeringTests.swift in Sources */, 851500000000000000000001 /* TerminalFontZoomSessionPersistenceTests.swift in Sources */, 859100000000000000000001 /* TerminalLinkOpenCoordinatorTests.swift in Sources */, + EE5000030000000000000003 /* TerminalNotificationBackpressureTests.swift in Sources */, A5C41103A1B2C3D4E5F60718 /* TerminalNotificationCallerTests.swift in Sources */, A5A5A507A1B2C3D4E5F60718 /* TerminalNotificationClearAllTests.swift in Sources */, A5A5A505A1B2C3D4E5F60718 /* TerminalNotificationDirectInteractionTests.swift in Sources */, diff --git a/cmuxTests/AgentNotificationLiveRetargetTests.swift b/cmuxTests/AgentNotificationLiveRetargetTests.swift index 20eaccd152f6..bb600f2b818b 100644 --- a/cmuxTests/AgentNotificationLiveRetargetTests.swift +++ b/cmuxTests/AgentNotificationLiveRetargetTests.swift @@ -123,7 +123,7 @@ extension AgentNotificationRegressionTests { } @Test - func testSyncDeliverySupersedesPendingNotificationUnderStaleClaimedKey() throws { + func testSyncDeliveryPreservesAcceptedQueuedNotificationUnderStaleClaimedKey() throws { let fixture = try makeLiveRetargetFixture() defer { fixture.restore() } @@ -136,9 +136,10 @@ extension AgentNotificationRegressionTests { subtitle: "Working", body: "Old queued" ) - // ...must be superseded by a newer synchronous notification for the - // same surface, even though sync delivery retargets to the owning - // workspace — a different queue key than the stale claim. + // ...must still appear when a newer synchronous notification for the + // same surface is accepted before the queue drains. The feed is + // chronological by stable notification id; banner ownership may + // supersede, but accepted rows are not coalesced away. TerminalController.shared.deliverNotificationSynchronously( tabId: fixture.claimedWorkspace.id, surfaceId: fixture.panelId, @@ -150,10 +151,10 @@ extension AgentNotificationRegressionTests { let recorded = fixture.store.notifications.filter { $0.title == "Claude Code" } #expect( - recorded.map(\.body) == ["New sync"], - "A stale-keyed pending notification must not survive (or replace) the newer synchronous one for the same surface" + recorded.map(\.body) == ["New sync", "Old queued"], + "Synchronous delivery must not delete an already accepted queued notification for the same surface" ) - #expect(recorded.map(\.tabId) == [fixture.owningWorkspace.id]) + #expect(recorded.map(\.tabId) == [fixture.owningWorkspace.id, fixture.owningWorkspace.id]) } @Test @@ -438,6 +439,34 @@ extension AgentNotificationRegressionTests { #expect(recorded.map(\.tabId) == [fixture.owningWorkspace.id]) } + @Test + func testRebindRetargetsInFlightPolicyRequest() throws { + let sourceTabId = UUID() + let destinationTabId = UUID() + let surfaceId = UUID() + let store = TerminalNotificationPolicyInFlightStore() + let request = TerminalNotificationPolicyRequest( + tabId: sourceTabId, + surfaceId: surfaceId, + panelId: surfaceId, + retargetsToLiveSurfaceOwner: true, + title: "Claude Code", + subtitle: "Completed", + body: "Policy result", + cwd: nil, + isAppFocused: false, + isFocusedPanel: false + ) + let requestId = store.register(request, generation: 0, onDiscard: {}) + + store.rebindSurface(fromTabId: sourceTabId, toTabId: destinationTabId, surfaceId: surfaceId) + + let claimed = try #require(store.claim(requestId, applying: request)) + #expect(claimed.tabId == destinationTabId) + #expect(claimed.surfaceId == surfaceId) + #expect(claimed.panelId == surfaceId) + } + @Test func pidSignalCombiningUsesExactProcessEnvironmentFallback() { let tty = AgentDeliveryTargetCandidate(workspaceId: UUID(), surfaceId: UUID()) diff --git a/cmuxTests/AgentNotificationMoveRaceTests.swift b/cmuxTests/AgentNotificationMoveRaceTests.swift index c975142892aa..a425efd18bd0 100644 --- a/cmuxTests/AgentNotificationMoveRaceTests.swift +++ b/cmuxTests/AgentNotificationMoveRaceTests.swift @@ -104,7 +104,7 @@ struct AgentNotificationRegressionTests { // Generous for loaded CI runners; only slows the failure path. let deadline = ContinuousClock.now + .seconds(15) while store.notifications.isEmpty, ContinuousClock.now < deadline { - await Task.yield() + try? await Task.sleep(for: .milliseconds(10)) } if store.notifications.isEmpty { Issue.record("Timed out waiting for policy-delayed notification") @@ -134,8 +134,7 @@ struct AgentNotificationRegressionTests { surfaceId: surfaceId, title: "Claude Code", subtitle: "Completed", - body: "Queued \(index)", - coalesces: false + body: "Queued \(index)" ) } @@ -152,6 +151,181 @@ struct AgentNotificationRegressionTests { #expect(resolutionCount == 1) } + @Test("Workspace clear cancels a blocked reliable admission after its surface moves") + func workspaceClearCancelsBlockedReliableAdmissionAtMovedSurface() async throws { + let fixture = try makeFixture() + let bus = TerminalMutationBus.shared + bus.discardPendingNotifications() + bus.setDrainsSuspendedForTesting(true) + defer { + bus.discardPendingNotifications() + bus.drainForTesting() + bus.setDrainsSuspendedForTesting(false) + fixture.restore() + } + for index in 0..<TerminalMutationBus.maximumPendingMutationCount { + #expect(bus.enqueueNotification( + tabId: UUID(), + surfaceId: nil, + title: "Capacity seed \(index)", + subtitle: "", + body: "" + )) + } + let delivery = Task { @MainActor in + await AgentNotificationDelivery().enqueueReliably( + workspaceID: fixture.source.id, + surfaceID: fixture.panelId, + title: "Must not cross moved-surface clear", + subtitle: "", + body: "", + category: nil, + pending: false + ) + } + let directClearDeadline = Date(timeIntervalSinceNow: 2) + while Date() < directClearDeadline { + if bus.reliablyWaitingNotificationProducerCountForTesting() == 1 { break } + await Task.yield() + } + #expect(bus.reliablyWaitingNotificationProducerCountForTesting() == 1) + + try movePanel(fixture) + fixture.store.clearNotifications(forTabId: fixture.destination.id, surfaceId: nil) + bus.drainForBackpressure() + + #expect(await delivery.value == .cancelled) + #expect(!bus.notificationQueueStateForTesting().1.contains("Must not cross moved-surface clear")) + } + + @Test("Queued workspace clear cancels a blocked admission after its surface moves") + func queuedWorkspaceClearCancelsBlockedAdmissionAtMovedSurface() async throws { + let fixture = try makeFixture() + let bus = TerminalMutationBus.shared + bus.discardPendingNotifications() + bus.setDrainsSuspendedForTesting(true) + defer { + bus.discardPendingNotifications() + bus.drainForTesting() + bus.setDrainsSuspendedForTesting(false) + fixture.restore() + } + for index in 0..<TerminalMutationBus.maximumPendingMutationCount { + #expect(bus.enqueueNotification( + tabId: UUID(), + surfaceId: nil, + title: "Queued clear seed \(index)", + subtitle: "", + body: "" + )) + } + let delivery = Task { @MainActor in + await AgentNotificationDelivery().enqueueReliably( + workspaceID: fixture.source.id, + surfaceID: fixture.panelId, + title: "Must not cross queued moved-surface clear", + subtitle: "", + body: "", + category: nil, + pending: false + ) + } + let queuedClearDeadline = Date(timeIntervalSinceNow: 2) + while Date() < queuedClearDeadline { + if bus.reliablyWaitingNotificationProducerCountForTesting() == 1 { break } + await Task.yield() + } + #expect(bus.reliablyWaitingNotificationProducerCountForTesting() == 1) + + try movePanel(fixture) + bus.enqueueClearNotifications(forTabId: fixture.destination.id) + bus.drainForBackpressure() + + #expect(await delivery.value == .cancelled) + #expect(!bus.notificationQueueStateForTesting().1.contains("Must not cross queued moved-surface clear")) + } + + @Test("Direct store producer follows a session replacement route") + func directStoreProducerFollowsSessionReplacementRoute() throws { + let fixture = try makeFixture() + defer { fixture.restore() } + let destinationSurfaceId = try #require(fixture.destination.focusedPanelId) + TerminalMutationBus.shared.transferPendingNotifications( + fromTabId: fixture.source.id, + toTabId: fixture.destination.id, + panelIdMap: [fixture.panelId: destinationSurfaceId] + ) + + fixture.store.addNotification( + tabId: fixture.source.id, + surfaceId: fixture.panelId, + title: "Direct producer", + subtitle: "", + body: "", + retargetsToLiveSurfaceOwner: true + ) + + let notification = try #require(fixture.store.notifications.first) + #expect(notification.tabId == fixture.destination.id) + #expect(notification.surfaceId == destinationSurfaceId) + } + + @Test("Queued notification with an unknown surface fails closed") + func queuedNotificationWithUnknownSurfaceFailsClosed() throws { + let fixture = try makeFixture() + defer { fixture.restore() } + let missingSurfaceId = UUID() + + fixture.store.deliverQueuedNotification( + claimedTabId: fixture.source.id, + surfaceId: missingSurfaceId, + title: "Orphaned accepted notification", + subtitle: "", + body: "", + id: UUID(), + acceptedAt: Date(), + notificationGeneration: TerminalMutationBus.shared.notificationGenerationSnapshot() + ) + + #expect(fixture.store.notifications.isEmpty) + } + + @Test("Policy-delayed accepted notification falls back only after surface validation") + func policyDelayedValidatedSurfaceFallsBackToWorkspaceWhenSurfaceDisappears() async throws { + let gateURL = FileManager.default.temporaryDirectory.appendingPathComponent( + "cmux-policy-gate-\(UUID().uuidString)" + ) + let completionURL = FileManager.default.temporaryDirectory.appendingPathComponent( + "cmux-policy-complete-\(UUID().uuidString)" + ) + let fixture = try makeFixture( + policyHookCommand: "while [ ! -f '\(gateURL.path)' ]; do /bin/sleep 0.01; done; cat; touch '\(completionURL.path)'", + policyHookTimeoutSeconds: 5 + ) + defer { + try? FileManager.default.removeItem(at: gateURL) + try? FileManager.default.removeItem(at: completionURL) + fixture.restore() + } + + fixture.store.addNotification( + tabId: fixture.source.id, + surfaceId: fixture.panelId, + title: "Validated before disappearance", + subtitle: "", + body: "", + retargetsToLiveSurfaceOwner: true + ) + _ = try #require(fixture.source.detachSurface(panelId: fixture.panelId)) + try Data().write(to: gateURL) + #expect(await waitForFile(at: completionURL)) + await waitForNotification(in: fixture.store) + + let notification = try #require(fixture.store.notifications.first) + #expect(notification.tabId == fixture.source.id) + #expect(notification.surfaceId == nil) + } + @Test("Source-confined synchronous delivery does not follow a moved surface") func sourceConfinedSynchronousDeliveryDoesNotRetarget() throws { let fixture = try makeFixture() @@ -253,6 +427,32 @@ struct AgentNotificationRegressionTests { #expect(recorded.first?.surfaceId == fixture.panelId) } + @Test("Session replacement remaps a policy evaluation already in flight") + func sessionReplacementRemapsInFlightPolicyDelivery() async throws { + let fixture = try makeFixture(policyHookCommand: "cat") + defer { fixture.restore() } + + fixture.store.addNotification( + tabId: fixture.source.id, + surfaceId: nil, + title: "Policy in flight", + subtitle: "Completed", + body: "Must follow replacement workspace" + ) + + // The policy task cannot run until this MainActor test yields, so this + // transfer deterministically crosses the in-flight evaluation. + fixture.store.transferSessionNotifications( + fromTabId: fixture.source.id, + toTabId: fixture.destination.id, + panelIdMap: [:] + ) + await waitForNotification(in: fixture.store) + + let recorded = fixture.store.notifications.filter { $0.title == "Policy in flight" } + #expect(recorded.map(\.tabId) == [fixture.destination.id]) + } + @Test("Policy-delayed relay delivery stays in its authorized workspace") func policyDelayedRelayDeliveryDoesNotCrossWorkspaceBoundary() async throws { let fixture = try makeFixture(policyHookCommand: "cat") @@ -412,7 +612,7 @@ struct AgentNotificationRegressionTests { fixture.store.clearNotifications(forTabId: fixture.destination.id) #expect(await waitForFile(at: completionURL)) - for _ in 0..<100 { await Task.yield() } + await waitForNotification(in: fixture.store) let recorded = fixture.store.notifications.filter { $0.title == "Relay" } #expect(recorded.map(\.tabId) == [fixture.source.id]) #expect(!recorded.contains { $0.tabId == fixture.destination.id }) @@ -456,7 +656,7 @@ struct AgentNotificationRegressionTests { fixture.store.clearNotifications(forTabId: fixture.source.id, surfaceId: fixture.panelId) #expect(await waitForFile(at: completionURL)) - for _ in 0..<100 { await Task.yield() } + await waitForNotification(in: fixture.store) let recorded = fixture.store.notifications.filter { $0.title.hasPrefix("Relay") } #expect(recorded.map(\.tabId) == [fixture.destination.id]) #expect(recorded.map(\.body) == ["Must survive stale source clear"]) diff --git a/cmuxTests/AgentNotificationMutationBoundaryTests.swift b/cmuxTests/AgentNotificationMutationBoundaryTests.swift index 7a4d5c1f6bd5..ac233c846b9a 100644 --- a/cmuxTests/AgentNotificationMutationBoundaryTests.swift +++ b/cmuxTests/AgentNotificationMutationBoundaryTests.swift @@ -229,6 +229,137 @@ extension AgentNotificationRegressionTests { #expect(fixture.store.notifications.map(\.body) == ["Replacement after clear"]) } + @Test("A filtered in-flight notification does not consume a later arrival's cooldown") + func filteredEarlierInFlightCooldownAllowsLaterArrival() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent( + "cmux-filtered-cooldown-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let firstStartedURL = root.appendingPathComponent("first-started") + let releaseFirstURL = root.appendingPathComponent("release-first") + let command = """ + payload="$(cat)" + if printf '%s' "$payload" | grep -q 'Filtered first'; then + touch '\(firstStartedURL.path)' + while [ ! -e '\(releaseFirstURL.path)' ]; do sleep 0.01; done + printf '%s' '{"effects":{"record":false,"markUnread":false,"desktop":false,"sound":false,"command":false,"reorderWorkspace":false,"paneFlash":false},"stop":true}' + else + printf '%s' "$payload" + fi + """ + let fixture = try makeFixture( + policyHookCommand: command, + policyHookTimeoutSeconds: 30 + ) + defer { + FileManager.default.createFile(atPath: releaseFirstURL.path, contents: nil) + fixture.restore() + try? FileManager.default.removeItem(at: root) + } + let cooldownKey = "filtered-in-flight-\(UUID().uuidString)" + + fixture.store.addNotification( + acceptedAt: Date(timeIntervalSince1970: 100), + tabId: fixture.source.id, + surfaceId: fixture.panelId, + title: "Claude Code", + subtitle: "Completed", + body: "Filtered first", + cooldownKey: cooldownKey, + cooldownInterval: 60 + ) + #expect(await waitForMarker(at: firstStartedURL)) + + fixture.store.addNotification( + acceptedAt: Date(timeIntervalSince1970: 101), + tabId: fixture.source.id, + surfaceId: fixture.panelId, + title: "Claude Code", + subtitle: "Completed", + body: "Later must survive", + cooldownKey: cooldownKey, + cooldownInterval: 60 + ) + FileManager.default.createFile(atPath: releaseFirstURL.path, contents: nil) + + let deadline = ContinuousClock.now + .seconds(5) + while fixture.store.notifications.isEmpty, ContinuousClock.now < deadline { + try? await Task.sleep(for: .milliseconds(10)) + } + #expect(fixture.store.notifications.map(\.body) == ["Later must survive"]) + } + + @Test("A shared cooldown orders policy results across delivery targets") + func sharedCooldownKeySerializesAcrossDistinctDeliveryTargets() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent( + "cmux-shared-cooldown-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let firstStartedURL = root.appendingPathComponent("first-started") + let releaseFirstURL = root.appendingPathComponent("release-first") + let laterEvaluatedURL = root.appendingPathComponent("later-evaluated") + let command = """ + payload="$(cat)" + if printf '%s' "$payload" | grep -q 'First must win'; then + touch '\(firstStartedURL.path)' + while [ ! -e '\(releaseFirstURL.path)' ]; do sleep 0.01; done + elif printf '%s' "$payload" | grep -q 'Later must cool down'; then + touch '\(laterEvaluatedURL.path)' + fi + printf '%s' "$payload" + """ + let fixture = try makeFixture( + policyHookCommand: command, + policyHookTimeoutSeconds: 30 + ) + defer { + FileManager.default.createFile(atPath: releaseFirstURL.path, contents: nil) + fixture.restore() + try? FileManager.default.removeItem(at: root) + } + let destinationPanelId = try #require(fixture.destination.focusedPanelId) + let cooldownKey = "shared-targets-\(UUID().uuidString)" + + fixture.store.addNotification( + acceptedAt: Date(timeIntervalSince1970: 100), + tabId: fixture.source.id, + surfaceId: fixture.panelId, + title: "Claude Code", + subtitle: "Completed", + body: "First must win", + cooldownKey: cooldownKey, + cooldownInterval: 60 + ) + #expect(await waitForMarker(at: firstStartedURL)) + + fixture.store.addNotification( + acceptedAt: Date(timeIntervalSince1970: 101), + tabId: fixture.destination.id, + surfaceId: destinationPanelId, + title: "Claude Code", + subtitle: "Completed", + body: "Later must cool down", + cooldownKey: cooldownKey, + cooldownInterval: 60 + ) + #expect(await waitForMarker(at: laterEvaluatedURL, timeout: .seconds(2))) + FileManager.default.createFile(atPath: releaseFirstURL.path, contents: nil) + + let deadline = ContinuousClock.now + .seconds(5) + while ( + fixture.store.hasPendingNotification(forTabId: fixture.source.id, surfaceId: fixture.panelId) + || fixture.store.hasPendingNotification( + forTabId: fixture.destination.id, + surfaceId: destinationPanelId + ) + ), ContinuousClock.now < deadline { + try? await Task.sleep(for: .milliseconds(10)) + } + #expect(fixture.store.notifications.map(\.body) == ["First must win"]) + } + @Test("Clearing policy work discards a hook result that completes afterwards") func clearTerminatesInFlightPolicyHookProcess() async throws { // Subprocess termination on cancellation is best-effort and is NOT diff --git a/cmuxTests/AgentNotificationReliableDeliveryTests.swift b/cmuxTests/AgentNotificationReliableDeliveryTests.swift new file mode 100644 index 000000000000..be4fb3e4b98c --- /dev/null +++ b/cmuxTests/AgentNotificationReliableDeliveryTests.swift @@ -0,0 +1,603 @@ +import Foundation +import XCTest +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +final class AgentNotificationReliableDeliveryTests: XCTestCase { + func testSerializesMultipleCallersOffMainActor() async { + let bus = TerminalMutationBus.shared + bus.discardPendingNotifications() + bus.setDrainsSuspendedForTesting(true) + defer { reset(bus) } + for index in 0..<TerminalMutationBus.maximumPendingMutationCount { + XCTAssertTrue(bus.enqueueNotification( + tabId: UUID(), surfaceId: nil, title: "Seed \(index)", subtitle: "", body: "" + )) + } + + let deliveries = (0..<3).map { index in + Task { @MainActor in + await AgentNotificationDelivery().enqueueReliably( + workspaceID: UUID(), surfaceID: UUID(), title: "Reliable \(index)", + subtitle: "", body: "", category: nil, pending: false + ) + } + } + await waitForReliableAdmissionBlock(bus) + + bus.drainForBackpressure() + + for delivery in deliveries { + let result = await delivery.value + XCTAssertEqual(result, .accepted) + } + let queuedTitles = bus.notificationQueueStateForTesting().1 + let reliableTitles = queuedTitles.filter { $0.hasPrefix("Reliable ") } + XCTAssertEqual(reliableTitles.count, 3) + XCTAssertEqual(Set(reliableTitles), ["Reliable 0", "Reliable 1", "Reliable 2"]) + let identities = bus.notificationIdentityStateForTesting() + let reliableIdentities = queuedTitles.enumerated().compactMap { index, title in + title.hasPrefix("Reliable ") ? identities[index] : nil + } + XCTAssertEqual(Set(reliableIdentities.map(\.0)).count, 3) + XCTAssertEqual(reliableIdentities.map(\.1), reliableIdentities.map(\.1).sorted()) + } + + func testCannotCrossClearBoundary() async { + let bus = TerminalMutationBus.shared + let tabId = UUID() + let surfaceId = UUID() + bus.discardPendingNotifications() + bus.setDrainsSuspendedForTesting(true) + defer { reset(bus) } + for index in 0..<TerminalMutationBus.maximumPendingMutationCount { + XCTAssertTrue(bus.enqueueNotification( + tabId: tabId, surfaceId: surfaceId, title: "Seed \(index)", subtitle: "", body: "" + )) + } + let preClear = Task { @MainActor in + await AgentNotificationDelivery().enqueueReliably( + workspaceID: tabId, surfaceID: surfaceId, title: "Must not survive clear", + subtitle: "", body: "", category: nil, pending: false + ) + } + await waitForReliableAdmissionBlock(bus) + + bus.enqueueClearNotifications(forTabId: tabId, surfaceId: surfaceId) + + let preClearResult = await preClear.value + XCTAssertEqual(preClearResult, .cancelled) + XCTAssertFalse(bus.notificationQueueStateForTesting().1.contains("Must not survive clear")) + let postClearResult = await AgentNotificationDelivery().enqueueReliably( + workspaceID: tabId, surfaceID: surfaceId, title: "Accepted after clear", + subtitle: "", body: "", category: nil, pending: false + ) + XCTAssertEqual(postClearResult, .accepted) + XCTAssertEqual(bus.notificationQueueStateForTesting().1, ["Accepted after clear"]) + } + + func testSurfaceAddressedAdmissionCannotCrossWorkspaceClearBoundary() async { + let bus = TerminalMutationBus.shared + let tabId = UUID() + let surfaceId = UUID() + bus.discardPendingNotifications() + bus.setDrainsSuspendedForTesting(true) + defer { reset(bus) } + for index in 0..<TerminalMutationBus.maximumPendingMutationCount { + XCTAssertTrue(bus.enqueueNotification( + tabId: tabId, surfaceId: surfaceId, title: "Seed \(index)", subtitle: "", body: "" + )) + } + let preClear = Task { @MainActor in + await AgentNotificationDelivery().enqueueReliably( + workspaceID: tabId, surfaceID: surfaceId, title: "Must not resurrect after workspace clear", + subtitle: "", body: "", category: nil, pending: false + ) + } + await waitForReliableAdmissionBlock(bus) + + bus.enqueueClearNotifications(forTabId: tabId) + bus.drainForBackpressure() + + let preClearResult = await preClear.value + XCTAssertEqual(preClearResult, .cancelled) + XCTAssertFalse(bus.notificationQueueStateForTesting().1.contains("Must not resurrect after workspace clear")) + } + + func testFallbackCapableAdmissionCannotResurrectAfterWorkspaceClear() async { + let bus = TerminalMutationBus.shared + let tabId = UUID() + let disappearedSurfaceId = UUID() + bus.discardPendingNotifications() + bus.setDrainsSuspendedForTesting(true) + defer { reset(bus) } + for index in 0..<TerminalMutationBus.maximumPendingMutationCount { + XCTAssertTrue(bus.enqueueNotification( + tabId: UUID(), surfaceId: nil, title: "Seed \(index)", subtitle: "", body: "" + )) + } + let preClear = Task { @MainActor in + await AgentNotificationDelivery().enqueueReliably( + workspaceID: tabId, + surfaceID: disappearedSurfaceId, + title: "Must not fallback after clear", + subtitle: "", + body: "", + category: nil, + pending: false, + allowWorkspaceFallbackForValidatedSurface: true + ) + } + await waitForReliableAdmissionBlock(bus) + + bus.discardPendingNotificationsResolvingLiveOwner(forTabId: tabId) + bus.drainForBackpressure() + + let preClearResult = await preClear.value + XCTAssertEqual(preClearResult, .cancelled) + XCTAssertFalse(bus.notificationQueueStateForTesting().1.contains("Must not fallback after clear")) + } + + func testMigratesAcrossSessionTransfer() async { + let bus = TerminalMutationBus.shared + let oldTabId = UUID() + let newTabId = UUID() + let oldSurfaceId = UUID() + let newSurfaceId = UUID() + bus.discardPendingNotifications() + bus.setDrainsSuspendedForTesting(true) + defer { reset(bus) } + for index in 0..<TerminalMutationBus.maximumPendingMutationCount { + XCTAssertTrue(bus.enqueueNotification( + tabId: oldTabId, surfaceId: oldSurfaceId, title: "Seed \(index)", subtitle: "", body: "" + )) + } + let preTransfer = Task { @MainActor in + await AgentNotificationDelivery().enqueueReliably( + workspaceID: oldTabId, surfaceID: oldSurfaceId, title: "Must survive transfer once", + subtitle: "", body: "", category: nil, pending: false + ) + } + await waitForReliableAdmissionBlock(bus) + let transferTime = Date() + + bus.transferPendingNotifications( + fromTabId: oldTabId, toTabId: newTabId, panelIdMap: [oldSurfaceId: newSurfaceId] + ) + bus.drainForBackpressure() + + let preTransferResult = await preTransfer.value + XCTAssertEqual(preTransferResult, .accepted) + let titles = bus.notificationQueueStateForTesting().1 + XCTAssertEqual(titles.filter { $0 == "Must survive transfer once" }.count, 1) + guard let reliableIndex = titles.firstIndex(of: "Must survive transfer once") else { + return XCTFail("Transferred reliable notification was not admitted") + } + let identity = bus.notificationIdentityStateForTesting()[reliableIndex] + XCTAssertEqual(identity.2, newTabId) + XCTAssertEqual(identity.3, newSurfaceId) + XCTAssertLessThanOrEqual(identity.1, transferTime) + } + + func testUnmappedSurfaceSurvivesReliableAdmissionSessionTransfer() async { + let bus = TerminalMutationBus.shared + let oldTabId = UUID() + let newTabId = UUID() + let unmappedSurfaceId = UUID() + bus.discardPendingNotifications() + bus.setDrainsSuspendedForTesting(true) + defer { reset(bus) } + for index in 0..<TerminalMutationBus.maximumPendingMutationCount { + XCTAssertTrue(bus.enqueueNotification( + tabId: oldTabId, surfaceId: unmappedSurfaceId, title: "Seed \(index)", subtitle: "", body: "" + )) + } + let preTransfer = Task { @MainActor in + await AgentNotificationDelivery().enqueueReliably( + workspaceID: oldTabId, surfaceID: unmappedSurfaceId, title: "Must retain unmapped surface", + subtitle: "", body: "", category: nil, pending: false + ) + } + await waitForReliableAdmissionBlock(bus) + + bus.transferPendingNotifications(fromTabId: oldTabId, toTabId: newTabId, panelIdMap: [:]) + bus.drainForBackpressure() + + let result = await preTransfer.value + XCTAssertEqual(result, .accepted) + let titles = bus.notificationQueueStateForTesting().1 + guard let index = titles.firstIndex(of: "Must retain unmapped surface") else { + return XCTFail("Transferred reliable notification was not admitted") + } + let identity = bus.notificationIdentityStateForTesting()[index] + XCTAssertEqual(identity.2, newTabId) + XCTAssertEqual(identity.3, unmappedSurfaceId) + } + + func testReliableAdmissionStartedAfterSessionTransferUsesReplacementRoute() async { + let bus = TerminalMutationBus.shared + let oldTabId = UUID() + let newTabId = UUID() + let oldSurfaceId = UUID() + let newSurfaceId = UUID() + bus.discardPendingNotifications() + bus.setDrainsSuspendedForTesting(true) + defer { reset(bus) } + + bus.transferPendingNotifications( + fromTabId: oldTabId, + toTabId: newTabId, + panelIdMap: [oldSurfaceId: newSurfaceId] + ) + + let result = await AgentNotificationDelivery().enqueueReliably( + workspaceID: oldTabId, + surfaceID: oldSurfaceId, + title: "Accepted after replacement scan", + subtitle: "", + body: "", + category: nil, + pending: false + ) + + XCTAssertEqual(result, .accepted) + let titles = bus.notificationQueueStateForTesting().1 + guard let index = titles.firstIndex(of: "Accepted after replacement scan") else { + return XCTFail("Replacement-routed reliable notification was not admitted") + } + let identity = bus.notificationIdentityStateForTesting()[index] + XCTAssertEqual(identity.2, newTabId) + XCTAssertEqual(identity.3, newSurfaceId) + } + + func testValidatedReliableAdmissionFallsBackToWorkspaceIfSurfaceDisappearsBeforeDrain() async throws { + let bus = TerminalMutationBus.shared + let store = TerminalNotificationStore.shared + let appDelegate = AppDelegate.shared ?? AppDelegate() + let manager = TabManager() + let originalTabManager = appDelegate.tabManager + let originalNotificationStore = appDelegate.notificationStore + let originalAppFocusOverride = AppFocusState.overrideIsFocused + + bus.discardPendingNotifications() + bus.setDrainsSuspendedForTesting(true) + store.replaceNotificationsForTesting([]) + store.configureNotificationDeliveryHandlerForTesting { _, _ in } + store.configureSuppressedNotificationFeedbackHandlerForTesting { _, _ in } + appDelegate.tabManager = manager + appDelegate.notificationStore = store + AppFocusState.overrideIsFocused = false + let workspace = manager.addWorkspace(select: true) + let surfaceId = UUID() + defer { + reset(bus) + if manager.tabs.contains(where: { $0.id == workspace.id }) { + manager.closeWorkspace(workspace) + } + store.replaceNotificationsForTesting([]) + store.resetNotificationDeliveryHandlerForTesting() + store.resetSuppressedNotificationFeedbackHandlerForTesting() + appDelegate.tabManager = originalTabManager + appDelegate.notificationStore = originalNotificationStore + AppFocusState.overrideIsFocused = originalAppFocusOverride + } + + let result = await AgentNotificationDelivery().enqueueReliably( + workspaceID: workspace.id, + surfaceID: surfaceId, + title: "Validated completion", + subtitle: "", + body: "", + category: nil, + pending: false, + allowWorkspaceFallbackForValidatedSurface: true + ) + XCTAssertEqual(result, .accepted) + + bus.drainForTesting() + + let recorded = Array(store.notifications) + XCTAssertEqual(recorded.map(\.title), ["Validated completion"]) + XCTAssertEqual(recorded.first?.tabId, workspace.id) + XCTAssertNil(recorded.first?.surfaceId) + XCTAssertNil(recorded.first?.panelId) + } + + func testValidatedPolicyRequestFallsBackToOpenableWorkspaceRow() { + let store = TerminalNotificationStore.shared + let appDelegate = AppDelegate.shared ?? AppDelegate() + let manager = TabManager() + let originalTabManager = appDelegate.tabManager + let originalNotificationStore = appDelegate.notificationStore + store.replaceNotificationsForTesting([]) + appDelegate.tabManager = manager + appDelegate.notificationStore = store + let workspace = manager.addWorkspace(select: true) + let stalePanelId = UUID() + defer { + if manager.tabs.contains(where: { $0.id == workspace.id }) { + manager.closeWorkspace(workspace) + } + store.replaceNotificationsForTesting([]) + appDelegate.tabManager = originalTabManager + appDelegate.notificationStore = originalNotificationStore + } + + let request = TerminalNotificationPolicyRequest( + tabId: workspace.id, + surfaceId: stalePanelId, + panelId: stalePanelId, + retargetsToLiveSurfaceOwner: true, + title: "Validated completion", + subtitle: "", + body: "", + cwd: nil, + isAppFocused: false, + isFocusedPanel: false + ) + + let retargeted = store.notificationPolicyRequestAtLiveOwner(request) + XCTAssertEqual(retargeted?.tabId, workspace.id) + XCTAssertNil(retargeted?.surfaceId) + XCTAssertNil(retargeted?.panelId) + } + + func testClearStartedAfterSessionTransferUsesReplacementRoute() { + let bus = TerminalMutationBus.shared + let oldTabId = UUID() + let newTabId = UUID() + let oldSurfaceId = UUID() + let newSurfaceId = UUID() + bus.discardPendingNotifications() + bus.setDrainsSuspendedForTesting(true) + defer { reset(bus) } + + bus.transferPendingNotifications( + fromTabId: oldTabId, + toTabId: newTabId, + panelIdMap: [oldSurfaceId: newSurfaceId] + ) + XCTAssertTrue(bus.enqueueNotification( + tabId: oldTabId, + surfaceId: oldSurfaceId, + title: "Must be cleared through replacement route", + subtitle: "", + body: "" + )) + + bus.enqueueClearNotifications(forTabId: oldTabId, surfaceId: oldSurfaceId) + + XCTAssertFalse( + bus.notificationQueueStateForTesting().1.contains("Must be cleared through replacement route") + ) + } + + func testReliableAdmissionBacklogIsBounded() async { + let bus = TerminalMutationBus.shared + bus.discardPendingNotifications() + bus.setDrainsSuspendedForTesting(true) + defer { reset(bus) } + for index in 0..<TerminalMutationBus.maximumPendingMutationCount { + XCTAssertTrue(bus.enqueueNotification( + tabId: UUID(), surfaceId: nil, title: "Seed \(index)", subtitle: "", body: "" + )) + } + + let deliveries = (0...TerminalMutationBus.maximumWaitingNotificationProducerCount).map { index in + Task { @MainActor in + await AgentNotificationDelivery().enqueueReliably( + workspaceID: UUID(), surfaceID: UUID(), title: "Bounded \(index)", + subtitle: "", body: "", category: nil, pending: false + ) + } + } + await waitForReliableAdmissionBlock(bus) + for _ in 0..<10_000 { await Task.yield() } + + XCTAssertEqual(bus.reliablyWaitingNotificationProducerCountForTesting(), 1) + + bus.drainForBackpressure() + bus.drainForBackpressure() + var results: [AgentNotificationDeliveryResult] = [] + for delivery in deliveries { results.append(await delivery.value) } + XCTAssertEqual(results.filter { $0 == .accepted }.count, deliveries.count) + XCTAssertEqual(results.filter { $0 == .saturated }.count, 0) + XCTAssertEqual(results.filter { $0 == .cancelled }.count, 0) + } + + func testReliableAdmissionTruncatesPayloadBeforeQueueing() async { + let bus = TerminalMutationBus.shared + let title = String(repeating: "é", count: TerminalNotificationStore.maximumNotificationTitleBytes) + let subtitle = String(repeating: "s", count: TerminalNotificationStore.maximumNotificationSubtitleBytes + 128) + let body = String(repeating: "b", count: TerminalNotificationStore.maximumNotificationBodyBytes + 128) + bus.discardPendingNotifications() + bus.setDrainsSuspendedForTesting(true) + defer { reset(bus) } + + let result = await AgentNotificationDelivery().enqueueReliably( + workspaceID: UUID(), + surfaceID: UUID(), + title: title, + subtitle: subtitle, + body: body, + category: nil, + pending: false + ) + + XCTAssertEqual(result, .accepted) + let queuedTitles = bus.notificationQueueStateForTesting().1 + XCTAssertEqual(queuedTitles.count, 1) + XCTAssertLessThanOrEqual(queuedTitles[0].utf8.count, TerminalNotificationStore.maximumNotificationTitleBytes) + } + + func testReliableAdmissionContentByteBudgetRejectsBeforeRetainingBacklog() { + let bus = TerminalMutationBus.shared + let body = String(repeating: "b", count: TerminalNotificationStore.maximumNotificationBodyBytes) + let byteLimitedCount = TerminalMutationBus.maximumQueuedNotificationContentBytes / body.utf8.count + bus.discardPendingNotifications() + bus.setDrainsSuspendedForTesting(true) + defer { reset(bus) } + + for _ in 0..<byteLimitedCount { + XCTAssertTrue(bus.enqueueNotification( + tabId: UUID(), + surfaceId: nil, + title: "", + subtitle: "", + body: body + )) + } + + let result = AgentNotificationDelivery().enqueueReliablySynchronously( + workspaceID: UUID(), + surfaceID: UUID(), + title: "", + subtitle: "", + body: body, + category: nil, + pending: false + ) + XCTAssertEqual(result, .saturated) + XCTAssertEqual(bus.reliablyWaitingNotificationProducerCountForTesting(), 0) + } + + func testReliableAdmissionDeadlineStartsAtCaptureForBackloggedSubmitters() async { + let bus = TerminalMutationBus.shared + bus.discardPendingNotifications() + bus.setDrainsSuspendedForTesting(true) + defer { reset(bus) } + for index in 0..<TerminalMutationBus.maximumPendingMutationCount { + XCTAssertTrue(bus.enqueueNotification( + tabId: UUID(), + surfaceId: nil, + title: "Seed \(index)", + subtitle: "", + body: "" + )) + } + + let startedAt = Date() + let deliveries = (0..<4).map { index in + Task { @MainActor in + await AgentNotificationDelivery().enqueueReliably( + workspaceID: UUID(), + surfaceID: UUID(), + title: "Deadline \(index)", + subtitle: "", + body: "", + category: nil, + pending: false + ) + } + } + + var results: [AgentNotificationDeliveryResult] = [] + for delivery in deliveries { + results.append(await delivery.value) + } + + XCTAssertEqual(results, Array(repeating: .saturated, count: deliveries.count)) + XCTAssertLessThan(Date().timeIntervalSince(startedAt), TerminalMutationBus.notificationCapacityWaitTimeout + 1.5) + XCTAssertEqual(bus.reliablyWaitingNotificationProducerCountForTesting(), 0) + XCTAssertFalse(bus.notificationQueueStateForTesting().1.contains { $0.hasPrefix("Deadline ") }) + } + + func testLiveOwnerRoutesAreBoundedAndRemovable() { + let bus = TerminalMutationBus.shared + let staleTabId = UUID() + let firstSurfaceId = UUID() + let firstOwnerTabId = UUID() + var installedSurfaceIds = [firstSurfaceId] + defer { + for surfaceId in installedSurfaceIds { + bus.removeNotificationLiveOwnerRoute(surfaceId: surfaceId) + } + } + + bus.rebindPendingNotifications( + fromTabId: staleTabId, + toTabId: firstOwnerTabId, + surfaceId: firstSurfaceId + ) + XCTAssertEqual( + bus.routedNotificationKey(tabId: staleTabId, surfaceId: firstSurfaceId).tabId, + firstOwnerTabId + ) + + let maxRoutes = TerminalMutationBus.maximumNotificationLiveOwnerRouteCount + let newestSurfaceIds = (0..<maxRoutes).map { _ in UUID() } + let newestOwnerTabIds = newestSurfaceIds.map { _ in UUID() } + installedSurfaceIds.append(contentsOf: newestSurfaceIds) + for (surfaceId, ownerTabId) in zip(newestSurfaceIds, newestOwnerTabIds) { + bus.rebindPendingNotifications( + fromTabId: staleTabId, + toTabId: ownerTabId, + surfaceId: surfaceId + ) + } + + XCTAssertEqual( + bus.routedNotificationKey(tabId: staleTabId, surfaceId: firstSurfaceId).tabId, + staleTabId + ) + let newestSurfaceId = newestSurfaceIds[maxRoutes - 1] + let newestOwnerTabId = newestOwnerTabIds[maxRoutes - 1] + XCTAssertEqual( + bus.routedNotificationKey(tabId: staleTabId, surfaceId: newestSurfaceId).tabId, + newestOwnerTabId + ) + + bus.removeNotificationLiveOwnerRoute(surfaceId: newestSurfaceId) + XCTAssertEqual( + bus.routedNotificationKey(tabId: staleTabId, surfaceId: newestSurfaceId).tabId, + staleTabId + ) + } + + func testLiveOwnerRouteContinuesThroughLaterSessionReplacementRoute() { + let bus = TerminalMutationBus.shared + let staleTabId = UUID() + let liveOwnerTabId = UUID() + let restoredTabId = UUID() + let oldSurfaceId = UUID() + let restoredSurfaceId = UUID() + defer { + bus.removeNotificationLiveOwnerRoute(surfaceId: oldSurfaceId) + bus.removeNotificationLiveOwnerRoute(surfaceId: restoredSurfaceId) + } + + bus.rebindPendingNotifications( + fromTabId: staleTabId, + toTabId: liveOwnerTabId, + surfaceId: oldSurfaceId + ) + bus.transferPendingNotifications( + fromTabId: liveOwnerTabId, + toTabId: restoredTabId, + panelIdMap: [oldSurfaceId: restoredSurfaceId] + ) + + let routed = bus.routedNotificationKey(tabId: staleTabId, surfaceId: oldSurfaceId) + XCTAssertEqual(routed.tabId, restoredTabId) + XCTAssertEqual(routed.surfaceId, restoredSurfaceId) + } + + private func waitForReliableAdmissionBlock(_ bus: TerminalMutationBus) async { + let deadline = Date(timeIntervalSinceNow: 2) + while Date() < deadline { + if bus.reliablyWaitingNotificationProducerCountForTesting() == 1 { return } + await Task.yield() + } + XCTFail("Reliable admission worker did not reach the capacity wait") + } + + private func reset(_ bus: TerminalMutationBus) { + bus.discardPendingNotifications() + bus.drainForTesting() + bus.setDrainsSuspendedForTesting(false) + } +} diff --git a/cmuxTests/AgentResumeLivenessTests.swift b/cmuxTests/AgentResumeLivenessTests.swift index 9d5d4827e0ce..acb002d3cdd1 100644 --- a/cmuxTests/AgentResumeLivenessTests.swift +++ b/cmuxTests/AgentResumeLivenessTests.swift @@ -23,6 +23,7 @@ struct AgentResumeLivenessTests { snapshot: SessionRestorableAgentSnapshot(kind: kind, sessionId: sessionId), lifecycle: nil, updatedAt: 0, + processLiveness: processIDs.isEmpty ? .exited : .running, processIDs: processIDs, agentProcessIDs: processIDs, agentProcessIdentities: [:] diff --git a/cmuxTests/CmuxEventBusTests.swift b/cmuxTests/CmuxEventBusTests.swift index 3cc55f2b613f..5cf1eadde613 100644 --- a/cmuxTests/CmuxEventBusTests.swift +++ b/cmuxTests/CmuxEventBusTests.swift @@ -373,6 +373,59 @@ final class CmuxEventBusTests: XCTestCase { ) } + func testBulkNotificationRemovalPublishesPerRowEventsAsynchronously() throws { + let bus = CmuxEventBus(retainedEventLimit: 256) + let workspaceId = UUID() + let surfaceId = UUID() + let notifications = (0..<150).map { index in + TerminalNotification( + id: UUID(), + tabId: workspaceId, + surfaceId: surfaceId, + title: "Removed \(index)", + subtitle: "", + body: "", + createdAt: Date(timeIntervalSince1970: TimeInterval(index)), + isRead: false + ) + } + let created = TerminalNotification( + id: UUID(), + tabId: workspaceId, + surfaceId: surfaceId, + title: "Created after removals", + subtitle: "", + body: "", + createdAt: Date(timeIntervalSince1970: 999), + isRead: false + ) + + bus.publishNotificationsRemoved(notifications + [notifications[0], notifications[1]]) { + bus.publishNotificationCreated(created, delivery: "store", replacedNotificationIds: []) + } + + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + bus.retainedSnapshot().count == notifications.count + 1 + }, + object: NSObject() + ) + XCTAssertEqual(XCTWaiter().wait(for: [expectation], timeout: 2), .completed) + let events = bus.retainedSnapshot() + XCTAssertEqual( + events.compactMap { $0["name"] as? String }, + Array(repeating: "notification.removed", count: notifications.count) + + ["notification.created"] + ) + let notificationIDs = events.dropLast().compactMap { + ($0["payload"] as? [String: Any])?["notification_id"] as? String + } + XCTAssertEqual(notificationIDs.count, notifications.count) + XCTAssertEqual(Set(notificationIDs).count, notifications.count) + let createdPayload = try XCTUnwrap(events.last?["payload"] as? [String: Any]) + XCTAssertEqual(createdPayload["notification_id"] as? String, created.id.uuidString) + } + @MainActor func testBulkNotificationClearPublishesClearedWithoutRemovedDuplicates() throws { let store = TerminalNotificationStore.shared diff --git a/cmuxTests/NotificationChronologyTests.swift b/cmuxTests/NotificationChronologyTests.swift new file mode 100644 index 000000000000..a2c4cd29ad08 --- /dev/null +++ b/cmuxTests/NotificationChronologyTests.swift @@ -0,0 +1,516 @@ +import Foundation +import Testing +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif +@Suite(.serialized) +@MainActor +struct NotificationChronologyTests { + @Test + func acceptedIdentityAndTimestampDriveRecordOrderAndReplayIsIdempotent() { + let store = TerminalNotificationStore.shared + let tabId = UUID() + let surfaceId = UUID() + let earlierId = UUID(uuidString: "10000000-0000-0000-0000-000000000000")! + let laterId = UUID(uuidString: "20000000-0000-0000-0000-000000000000")! + + store.replaceNotificationsForTesting([]) + store.configureNotificationDeliveryHandlerForTesting { _, _ in } + store.configureSuppressedNotificationFeedbackHandlerForTesting { _, _ in } + defer { + store.replaceNotificationsForTesting([]) + store.resetNotificationDeliveryHandlerForTesting() + store.resetSuppressedNotificationFeedbackHandlerForTesting() + } + + store.addNotification( + id: earlierId, + acceptedAt: Date(timeIntervalSince1970: 10), + tabId: tabId, + surfaceId: surfaceId, + title: "Earlier", + subtitle: "", + body: "same payload", + retargetsToLiveSurfaceOwner: false + ) + store.addNotification( + id: laterId, + acceptedAt: Date(timeIntervalSince1970: 20), + tabId: tabId, + surfaceId: surfaceId, + title: "Later", + subtitle: "", + body: "same payload", + retargetsToLiveSurfaceOwner: false + ) + store.addNotification( + id: earlierId, + acceptedAt: Date(timeIntervalSince1970: 30), + tabId: tabId, + surfaceId: surfaceId, + title: "Replay must not replace", + subtitle: "", + body: "same payload", + retargetsToLiveSurfaceOwner: false + ) + + #expect(store.notifications.map(\.id) == [laterId, earlierId]) + #expect(store.notifications.first(where: { $0.id == earlierId })?.title == "Earlier") + #expect(store.notifications.first(where: { $0.id == earlierId })?.createdAt == Date(timeIntervalSince1970: 10)) + } + + @Test + func lateOlderRecordDoesNotReplaceNewerExternalDelivery() { + let store = TerminalNotificationStore.shared + let tabId = UUID() + let surfaceId = UUID() + let olderId = UUID() + let newerId = UUID() + var deliveredIds: [UUID] = [] + var suppressedIds: [UUID] = [] + let originalAppFocusOverride = AppFocusState.overrideIsFocused + + store.replaceNotificationsForTesting([]) + AppFocusState.overrideIsFocused = false + store.configureNotificationDeliveryHandlerForTesting { _, notification in + deliveredIds.append(notification.id) + } + store.configureSuppressedNotificationFeedbackHandlerForTesting { _, notification in + suppressedIds.append(notification.id) + } + defer { + store.replaceNotificationsForTesting([]) + store.resetNotificationDeliveryHandlerForTesting() + store.resetSuppressedNotificationFeedbackHandlerForTesting() + AppFocusState.overrideIsFocused = originalAppFocusOverride + } + store.addNotification( + id: newerId, + acceptedAt: Date(timeIntervalSince1970: 20), + tabId: tabId, + surfaceId: surfaceId, + title: "Newer", + subtitle: "", + body: "", + retargetsToLiveSurfaceOwner: false + ) + store.addNotification( + id: olderId, + acceptedAt: Date(timeIntervalSince1970: 10), + tabId: tabId, + surfaceId: surfaceId, + title: "Older", + subtitle: "", + body: "", + retargetsToLiveSurfaceOwner: false + ) + + #expect(store.notifications.map(\.id) == [newerId, olderId]) + #expect(deliveredIds == [newerId]) + #expect(suppressedIds == [olderId]) + #expect(store.externalBannerOwnerIDForTesting(tabId: tabId, surfaceId: surfaceId) == newerId) + } + + @Test + func longHistorySupersedesOnlyImmediateExternalBannerOwner() { + let tabId = UUID() + let surfaceId = UUID() + let history = (0..<70).map { offset in + notification( + id: UUID(), + tabId: tabId, + surfaceId: surfaceId, + title: "History \(offset)", + createdAt: Date(timeIntervalSince1970: TimeInterval(offset)) + ) + }.sorted(by: TerminalNotificationStore.notificationSortPrecedes) + let latestExisting = history[0] + let incoming = notification( + id: UUID(), + tabId: tabId, + surfaceId: surfaceId, + title: "Incoming", + createdAt: Date(timeIntervalSince1970: 100) + ) + + let transition = TerminalNotificationStore.externalBannerTransition( + incoming: incoming, + latestExisting: latestExisting + ) + + #expect(transition.supersededId == latestExisting.id.uuidString) + #expect(!transition.suppressIncoming) + } + + @Test + func boundedQueueBackpressuresWithoutDroppingAcceptedNotifications() throws { + let store = TerminalNotificationStore.shared + let appDelegate = AppDelegate.shared ?? AppDelegate() + let manager = appDelegate.tabManager ?? TabManager() + let originalTabManager = appDelegate.tabManager + let originalNotificationStore = appDelegate.notificationStore + let originalAppFocusOverride = AppFocusState.overrideIsFocused + + store.replaceNotificationsForTesting([]) + store.configureNotificationDeliveryHandlerForTesting { _, _ in } + store.configureSuppressedNotificationFeedbackHandlerForTesting { _, _ in } + appDelegate.tabManager = manager + appDelegate.notificationStore = store + AppFocusState.overrideIsFocused = false + + let workspace = manager.addWorkspace(select: true) + defer { + if manager.tabs.contains(where: { $0.id == workspace.id }) { + manager.closeWorkspace(workspace) + } + store.replaceNotificationsForTesting([]) + store.resetNotificationDeliveryHandlerForTesting() + store.resetSuppressedNotificationFeedbackHandlerForTesting() + appDelegate.tabManager = originalTabManager + appDelegate.notificationStore = originalNotificationStore + AppFocusState.overrideIsFocused = originalAppFocusOverride + } + let surfaceId = try #require(workspace.focusedPanelId) + let notificationCount = TerminalMutationBus.maximumPendingMutationCount + 1 + var backpressureCount = 0 + + for offset in 0..<notificationCount { + TerminalMutationBus.shared.enqueueNotification( + tabId: workspace.id, + surfaceId: surfaceId, + title: "Queued \(offset)", + subtitle: "", + body: "", + saturationHandler: { + backpressureCount += 1 + TerminalMutationBus.shared.drainForBackpressure() + } + ) + } + TerminalMutationBus.shared.drainForTesting() + + #expect(backpressureCount == 1) + #expect(store.notifications.count == notificationCount) + #expect(Set(store.notifications.map(\.title)).count == notificationCount) + } + + @Test + func restoreMergesLiveAndRestoredNotificationsExactlyOnceInStableOrder() { + let store = TerminalNotificationStore.shared + let tabId = UUID(uuidString: "10000000-0000-0000-0000-000000000000")! + let surfaceId = UUID(uuidString: "20000000-0000-0000-0000-000000000000")! + let replayedId = UUID(uuidString: "30000000-0000-0000-0000-000000000000")! + let earlierId = UUID(uuidString: "40000000-0000-0000-0000-000000000000")! + let equalLaterId = UUID(uuidString: "50000000-0000-0000-0000-000000000000")! + let equalEarlierId = UUID(uuidString: "01000000-0000-0000-0000-000000000000")! + let timestamp = Date(timeIntervalSince1970: 1_700_000_000) + let live = notification( + id: replayedId, + tabId: tabId, + surfaceId: surfaceId, + title: "Live", + createdAt: timestamp + ) + + defer { store.replaceNotificationsForTesting([]) } + + let restored = [ + notification( + id: earlierId, + tabId: tabId, + surfaceId: surfaceId, + title: "Earlier", + createdAt: timestamp.addingTimeInterval(-1) + ), + notification( + id: earlierId, + tabId: tabId, + surfaceId: surfaceId, + title: "Replay must not replace first occurrence", + createdAt: timestamp.addingTimeInterval(100) + ), + notification( + id: equalLaterId, + tabId: tabId, + surfaceId: surfaceId, + title: "Equal later UUID", + createdAt: timestamp + ), + notification( + id: equalEarlierId, + tabId: tabId, + surfaceId: surfaceId, + title: "Equal earlier UUID", + createdAt: timestamp + ), + notification( + id: replayedId, + tabId: tabId, + surfaceId: surfaceId, + title: "Stale replay", + createdAt: timestamp.addingTimeInterval(10) + ), + ] + + store.replaceNotificationsForTesting([live]) + store.restoreSessionNotifications(restored, forTabId: tabId) + let forward = store.notifications + + store.replaceNotificationsForTesting([live]) + store.restoreSessionNotifications(Array(restored.reversed()), forTabId: tabId) + let reversed = store.notifications + + #expect(forward.map(\.id) == [equalEarlierId, replayedId, equalLaterId, earlierId]) + #expect(forward.first(where: { $0.id == replayedId })?.title == "Live") + #expect(forward.first(where: { $0.id == earlierId })?.title == "Earlier") + #expect(Set(forward.map(\.id)).count == forward.count) + #expect(reversed == forward) + } + + @Test + func repeatedMultiWorkspaceRestoreIsBatchOrderIndependent() { + let store = TerminalNotificationStore.shared + let tabA = UUID(uuidString: "A0000000-0000-0000-0000-000000000000")! + let tabB = UUID(uuidString: "B0000000-0000-0000-0000-000000000000")! + let a1 = notification( + id: UUID(uuidString: "A1000000-0000-0000-0000-000000000000")!, + tabId: tabA, + surfaceId: nil, + title: "A1", + createdAt: Date(timeIntervalSince1970: 10) + ) + let a2 = notification( + id: UUID(uuidString: "A2000000-0000-0000-0000-000000000000")!, + tabId: tabA, + surfaceId: nil, + title: "A2", + createdAt: Date(timeIntervalSince1970: 30) + ) + let b1 = notification( + id: UUID(uuidString: "B1000000-0000-0000-0000-000000000000")!, + tabId: tabB, + surfaceId: nil, + title: "B1", + createdAt: Date(timeIntervalSince1970: 20) + ) + + store.replaceNotificationsForTesting([]) + defer { store.replaceNotificationsForTesting([]) } + store.restoreSessionNotifications([a1, a2], forTabId: tabA) + store.restoreSessionNotifications([b1], forTabId: tabB) + store.restoreSessionNotifications([a2, a1], forTabId: tabA) + let forwardOrder = store.notifications.map(\.id) + + store.replaceNotificationsForTesting([]) + store.restoreSessionNotifications([b1], forTabId: tabB) + store.restoreSessionNotifications([a2, a1], forTabId: tabA) + let reverseOrder = store.notifications.map(\.id) + + #expect(forwardOrder == [a2.id, b1.id, a1.id]) + #expect(reverseOrder == forwardOrder) + } + + @Test + func restoreClearsStaleFocusedReadIndicatorEvenForReplayOnlySnapshot() { + let store = TerminalNotificationStore.shared + let tabId = UUID() + let surfaceId = UUID() + let existing = notification( + id: UUID(), + tabId: tabId, + surfaceId: surfaceId, + title: "Existing", + createdAt: Date(timeIntervalSince1970: 1) + ) + + store.replaceNotificationsForTesting([existing]) + store.setFocusedReadIndicator(forTabId: tabId, surfaceId: surfaceId) + defer { store.replaceNotificationsForTesting([]) } + + store.restoreSessionNotifications([existing], forTabId: tabId) + + #expect(store.focusedReadIndicatorSurfaceId(forTabId: tabId) == nil) + #expect(store.notifications.map(\.id) == [existing.id]) + } + + @Test + func readMutationsAndEveryProjectionPreserveChronologicalOrder() { + let store = TerminalNotificationStore.shared + let tabId = UUID() + let newest = notification(id: UUID(), tabId: tabId, surfaceId: nil, title: "Newest", createdAt: Date(timeIntervalSince1970: 3)) + let middle = notification(id: UUID(), tabId: tabId, surfaceId: nil, title: "Middle", createdAt: Date(timeIntervalSince1970: 2)) + let oldest = notification(id: UUID(), tabId: tabId, surfaceId: nil, title: "Oldest", createdAt: Date(timeIntervalSince1970: 1)) + + store.replaceNotificationsForTesting([newest, middle, oldest]) + defer { store.replaceNotificationsForTesting([]) } + + store.markRead(id: newest.id) + store.markUnread(id: newest.id) + _ = store.markLatestNotificationAsOldestUnread(forTabId: tabId, surfaceId: nil) + + let expected = [newest.id, middle.id, oldest.id] + #expect(store.notifications.map(\.id) == expected) + #expect(store.notifications(forTabIds: [tabId]).map(\.id) == expected) + #expect(store.notificationMenuSnapshot.recentNotifications.map(\.id) == expected) + #expect(store.latestNotification(forTabId: tabId)?.id == newest.id) + } + + @Test + func oldestUnreadNavigationDefersWithoutReorderingFeed() { + let store = TerminalNotificationStore.shared + let newestTabId = UUID() + let nextTabId = UUID() + let newest = notification( + id: UUID(), + tabId: newestTabId, + surfaceId: nil, + title: "Newest", + createdAt: Date(timeIntervalSince1970: 2) + ) + let next = notification( + id: UUID(), + tabId: nextTabId, + surfaceId: nil, + title: "Next", + createdAt: Date(timeIntervalSince1970: 1) + ) + + store.replaceNotificationsForTesting([newest, next]) + defer { store.replaceNotificationsForTesting([]) } + + #expect(store.markLatestNotificationAsOldestUnread(forTabId: newestTabId, surfaceId: nil) == newest.id) + #expect(store.notifications.map(\.id) == [newest.id, next.id]) + #expect(store.notificationsForUnreadNavigation.map(\.id) == [next.id, newest.id]) + + store.markRead(id: newest.id) + store.markUnread(id: newest.id) + #expect(store.notificationsForUnreadNavigation.map(\.id) == [newest.id, next.id]) + } + + @Test + func sessionSnapshotRoundTripPreservesIdentityAndTimestamp() throws { + let source = notification( + id: UUID(), + tabId: UUID(), + surfaceId: UUID(), + title: "Persisted", + createdAt: Date(timeIntervalSince1970: 1_700_000_000.125) + ) + let encoded = try JSONEncoder().encode(SessionNotificationSnapshot(notification: source)) + let decoded = try JSONDecoder().decode(SessionNotificationSnapshot.self, from: encoded) + let restored = decoded.terminalNotification(tabId: source.tabId, surfaceId: source.surfaceId, panelId: source.panelId) + + #expect(restored.id == source.id) + #expect(restored.createdAt == source.createdAt) + } + + @Test + func provisionalCooldownDoesNotPublishCommittedAdmissionDate() throws { + var reservations = NotificationCooldownReservations() + var dates: [String: Date] = [:] + let key = "agent-finished" + let reservation = try #require(reservations.reserve( + key: key, + interval: 60, + acceptedAt: Date(timeIntervalSince1970: 100), + dates: &dates + )) + + #expect(dates[key] == nil) + + reservations.restore(reservation, dates: &dates) + } + + @Test + func reversedCooldownCompletionKeepsNewestReservationMonotonic() throws { + var reservations = NotificationCooldownReservations() + var dates: [String: Date] = [:] + let key = "agent-finished" + let earlierDate = Date(timeIntervalSince1970: 10) + let laterDate = Date(timeIntervalSince1970: 20) + + let earlier = try #require(reservations.reserve( + key: key, + interval: 5, + acceptedAt: earlierDate, + dates: &dates + )) + let later = try #require(reservations.reserve( + key: key, + interval: 5, + acceptedAt: laterDate, + dates: &dates + )) + + reservations.commit(later, at: laterDate, dates: &dates) + #expect(dates[key] == laterDate) + + reservations.commit(earlier, at: earlierDate, dates: &dates) + #expect(dates[key] == laterDate) + } + + @Test + func reversedCooldownFailuresRemoveEveryProvisionalReservation() throws { + var reservations = NotificationCooldownReservations() + var dates: [String: Date] = [:] + let key = "agent-finished" + let earlier = try #require(reservations.reserve( + key: key, + interval: 5, + acceptedAt: Date(timeIntervalSince1970: 10), + dates: &dates + )) + let later = try #require(reservations.reserve( + key: key, + interval: 5, + acceptedAt: Date(timeIntervalSince1970: 20), + dates: &dates + )) + + reservations.restore(later, dates: &dates) + #expect(dates[key] == nil) + + reservations.restore(earlier, dates: &dates) + #expect(dates[key] == nil) + } + + private func notification( + id: UUID, + tabId: UUID, + surfaceId: UUID?, + title: String, + createdAt: Date + ) -> TerminalNotification { + TerminalNotification( + id: id, + tabId: tabId, + surfaceId: surfaceId, + title: title, + subtitle: "", + body: "same payload", + createdAt: createdAt, + isRead: false + ) + } +} +@MainActor +extension TerminalMutationBus { + func enqueueNotificationForTesting( + tabId: UUID, + surfaceId: UUID?, + title: String, + subtitle: String, + body: String + ) { + enqueueNotification( + tabId: tabId, + surfaceId: surfaceId, + title: title, + subtitle: subtitle, + body: body, + saturationHandler: { self.drainForBackpressure() } + ) + } +} diff --git a/cmuxTests/NotificationDismissSyncTests.swift b/cmuxTests/NotificationDismissSyncTests.swift index ca0491e5573c..35d503a32636 100644 --- a/cmuxTests/NotificationDismissSyncTests.swift +++ b/cmuxTests/NotificationDismissSyncTests.swift @@ -287,6 +287,219 @@ struct NotificationDismissSyncTests { #expect(store.reconcileHandledNotificationIDs(deliveredIDs: [notification.id]) == []) } + /// Superseding a desktop/phone banner keeps the older unread row in the + /// chronological feed, but the phone must still learn that its old banner + /// was handled. Explicitly marking that row unread later resurrects it. + @Test func reconcileHandlesRetainedSupersededRowUntilExplicitlyMarkedUnread() throws { + let store = TerminalNotificationStore.shared + let previousNotifications = store.notifications + let tombstoneKey = TerminalNotificationStore.dismissedTombstoneDefaultsKey + let previousTombstones = UserDefaults.standard.stringArray(forKey: tombstoneKey) + let supersededKey = TerminalNotificationStore.retainedSupersededBannerDefaultsKey + let previousSuperseded = UserDefaults.standard.stringArray(forKey: supersededKey) + let supersededDeltaKey = TerminalNotificationStore.retainedSupersededBannerDeltaDefaultsKey + let previousSupersededDeltas = UserDefaults.standard.stringArray(forKey: supersededDeltaKey) + let originalAppFocusOverride = AppFocusState.overrideIsFocused + let tabId = UUID() + let surfaceId = UUID() + let older = TerminalNotification( + id: UUID(), tabId: tabId, surfaceId: surfaceId, + title: "Older", subtitle: "", body: "body", + createdAt: Date(timeIntervalSince1970: 1_778_000_000), isRead: false + ) + defer { + store.replaceNotificationsForTesting(previousNotifications) + store.resetNotificationDeliveryHandlerForTesting() + AppFocusState.overrideIsFocused = originalAppFocusOverride + if let previousTombstones { + UserDefaults.standard.set(previousTombstones, forKey: tombstoneKey) + } else { + UserDefaults.standard.removeObject(forKey: tombstoneKey) + } + if let previousSuperseded { + UserDefaults.standard.set(previousSuperseded, forKey: supersededKey) + } else { + UserDefaults.standard.removeObject(forKey: supersededKey) + } + if let previousSupersededDeltas { + UserDefaults.standard.set(previousSupersededDeltas, forKey: supersededDeltaKey) + } else { + UserDefaults.standard.removeObject(forKey: supersededDeltaKey) + } + store.reloadDismissedTombstonesForTesting() + } + + UserDefaults.standard.removeObject(forKey: tombstoneKey) + UserDefaults.standard.removeObject(forKey: supersededKey) + UserDefaults.standard.removeObject(forKey: supersededDeltaKey) + store.reloadDismissedTombstonesForTesting() + store.replaceNotificationsForTesting([older]) + store.configureNotificationDeliveryHandlerForTesting { _, _ in } + AppFocusState.overrideIsFocused = false + + store.addNotification( + acceptedAt: Date(timeIntervalSince1970: 1_778_000_001), + tabId: tabId, + surfaceId: surfaceId, + title: "Replacement", + subtitle: "", + body: "body", + retargetsToLiveSurfaceOwner: false + ) + + #expect(store.notifications.contains { $0.id == older.id && !$0.isRead }) + #expect( + store.reconcileHandledNotificationIDs(deliveredIDs: [older.id]) + == [older.id.uuidString] + ) + + store.markRead(id: older.id) + store.markUnread(id: older.id) + #expect(store.reconcileHandledNotificationIDs(deliveredIDs: [older.id]) == []) + } + + /// The short dismissed-ID ring is only a reconnect aid for removed rows. + /// Superseded state for retained unread rows must cover the whole retained + /// feed, or older retained phone banners resurrect after enough newer + /// supersessions overflow the ring. + @Test func reconcileHandlesRetainedSupersededRowAfterTombstoneOverflow() throws { + let store = TerminalNotificationStore.shared + let previousNotifications = store.notifications + let tombstoneKey = TerminalNotificationStore.dismissedTombstoneDefaultsKey + let previousTombstones = UserDefaults.standard.stringArray(forKey: tombstoneKey) + let supersededKey = TerminalNotificationStore.retainedSupersededBannerDefaultsKey + let previousSuperseded = UserDefaults.standard.stringArray(forKey: supersededKey) + let supersededDeltaKey = TerminalNotificationStore.retainedSupersededBannerDeltaDefaultsKey + let previousSupersededDeltas = UserDefaults.standard.stringArray(forKey: supersededDeltaKey) + let originalAppFocusOverride = AppFocusState.overrideIsFocused + let rows = (0..<520).map { index in + TerminalNotification( + id: UUID(), + tabId: UUID(), + surfaceId: UUID(), + title: "Older \(index)", + subtitle: "", + body: "body", + createdAt: Date(timeIntervalSince1970: TimeInterval(index)), + isRead: false + ) + } + let oldestSuperseded = rows[0] + defer { + store.replaceNotificationsForTesting(previousNotifications) + store.resetNotificationDeliveryHandlerForTesting() + AppFocusState.overrideIsFocused = originalAppFocusOverride + if let previousTombstones { + UserDefaults.standard.set(previousTombstones, forKey: tombstoneKey) + } else { + UserDefaults.standard.removeObject(forKey: tombstoneKey) + } + if let previousSuperseded { + UserDefaults.standard.set(previousSuperseded, forKey: supersededKey) + } else { + UserDefaults.standard.removeObject(forKey: supersededKey) + } + if let previousSupersededDeltas { + UserDefaults.standard.set(previousSupersededDeltas, forKey: supersededDeltaKey) + } else { + UserDefaults.standard.removeObject(forKey: supersededDeltaKey) + } + store.reloadDismissedTombstonesForTesting() + } + + UserDefaults.standard.removeObject(forKey: tombstoneKey) + UserDefaults.standard.removeObject(forKey: supersededKey) + UserDefaults.standard.removeObject(forKey: supersededDeltaKey) + store.reloadDismissedTombstonesForTesting() + store.replaceNotificationsForTesting(rows) + store.configureNotificationDeliveryHandlerForTesting { _, _ in } + AppFocusState.overrideIsFocused = false + + for (index, row) in rows.enumerated() { + store.addNotification( + acceptedAt: Date(timeIntervalSince1970: TimeInterval(10_000 + index)), + tabId: row.tabId, + surfaceId: row.surfaceId, + title: "Replacement \(index)", + subtitle: "", + body: "body", + retargetsToLiveSurfaceOwner: false + ) + } + + #expect(store.notifications.contains { $0.id == oldestSuperseded.id && !$0.isRead }) + #expect( + store.reconcileHandledNotificationIDs(deliveredIDs: [oldestSuperseded.id]) + == [oldestSuperseded.id.uuidString] + ) + + store.markRead(id: oldestSuperseded.id) + store.markUnread(id: oldestSuperseded.id) + #expect(store.reconcileHandledNotificationIDs(deliveredIDs: [oldestSuperseded.id]) == []) + } + + /// The retained superseded-banner restart state is stored as a compact base + /// snapshot plus bounded add/remove deltas. Loading must apply both, and must + /// still understand the old snapshot-only key from earlier builds. + @Test func retainedSupersededBannerDeltaPersistenceSurvivesStoreReload() throws { + let store = TerminalNotificationStore.shared + let previousNotifications = store.notifications + let supersededKey = TerminalNotificationStore.retainedSupersededBannerDefaultsKey + let previousSuperseded = UserDefaults.standard.stringArray(forKey: supersededKey) + let supersededDeltaKey = TerminalNotificationStore.retainedSupersededBannerDeltaDefaultsKey + let previousSupersededDeltas = UserDefaults.standard.stringArray(forKey: supersededDeltaKey) + let deltaAdded = TerminalNotification( + id: UUID(), tabId: UUID(), surfaceId: UUID(), + title: "Delta added", subtitle: "", body: "body", + createdAt: Date(timeIntervalSince1970: 1_778_000_000), isRead: false + ) + let snapshotOnly = TerminalNotification( + id: UUID(), tabId: UUID(), surfaceId: UUID(), + title: "Snapshot only", subtitle: "", body: "body", + createdAt: Date(timeIntervalSince1970: 1_778_000_001), isRead: false + ) + let deltaRemoved = TerminalNotification( + id: UUID(), tabId: UUID(), surfaceId: UUID(), + title: "Delta removed", subtitle: "", body: "body", + createdAt: Date(timeIntervalSince1970: 1_778_000_002), isRead: false + ) + defer { + store.replaceNotificationsForTesting(previousNotifications) + if let previousSuperseded { + UserDefaults.standard.set(previousSuperseded, forKey: supersededKey) + } else { + UserDefaults.standard.removeObject(forKey: supersededKey) + } + if let previousSupersededDeltas { + UserDefaults.standard.set(previousSupersededDeltas, forKey: supersededDeltaKey) + } else { + UserDefaults.standard.removeObject(forKey: supersededDeltaKey) + } + store.reloadDismissedTombstonesForTesting() + } + + UserDefaults.standard.set( + [snapshotOnly.id.uuidString, deltaRemoved.id.uuidString], + forKey: supersededKey + ) + UserDefaults.standard.set( + ["+\(deltaAdded.id.uuidString)", "-\(deltaRemoved.id.uuidString)"], + forKey: supersededDeltaKey + ) + store.reloadDismissedTombstonesForTesting() + store.replaceNotificationsForTesting([deltaAdded, snapshotOnly, deltaRemoved]) + + #expect( + store.reconcileHandledNotificationIDs(deliveredIDs: [deltaAdded.id]) + == [deltaAdded.id.uuidString] + ) + #expect( + store.reconcileHandledNotificationIDs(deliveredIDs: [snapshotOnly.id]) + == [snapshotOnly.id.uuidString] + ) + #expect(store.reconcileHandledNotificationIDs(deliveredIDs: [deltaRemoved.id]) == []) + } + /// Dismiss tombstones are write-through persisted: a notification dismissed /// and fully removed before a Mac relaunch must still reconcile as handled /// afterwards, or a phone whose silent dismiss push was dropped would keep @@ -373,6 +586,81 @@ struct NotificationDismissSyncTests { #expect(buffer.flushAll() == []) } + @Test func supersededPhoneDismissBufferTransfersKeysAcrossSessionReplacement() { + var buffer = SupersededPhoneDismissBuffer() + let oldTabId = UUID() + let newTabId = UUID() + let oldSurfaceId = UUID() + let newSurfaceId = UUID() + let unmappedSurfaceId = UUID() + buffer.stash( + ids: ["old"], + forKey: SupersededPhoneDismissBuffer.key(tabId: oldTabId, surfaceId: oldSurfaceId) + ) + buffer.stash( + ids: ["unmapped"], + forKey: SupersededPhoneDismissBuffer.key(tabId: oldTabId, surfaceId: unmappedSurfaceId) + ) + buffer.stash( + ids: ["existing"], + forKey: SupersededPhoneDismissBuffer.key(tabId: newTabId, surfaceId: newSurfaceId) + ) + + buffer.transfer( + fromTabId: oldTabId, + toTabId: newTabId, + panelIdMap: [oldSurfaceId: newSurfaceId] + ) + + #expect(buffer.flush(matchingTabId: oldTabId) == []) + #expect(buffer.flush( + forKey: SupersededPhoneDismissBuffer.key(tabId: newTabId, surfaceId: newSurfaceId) + ) == ["existing", "old"]) + #expect(buffer.flush( + forKey: SupersededPhoneDismissBuffer.key(tabId: newTabId, surfaceId: unmappedSurfaceId) + ) == ["unmapped"]) + } + + @Test func rowActionsFlushPhoneReplacementOnlyForNewestNotification() { + let store = TerminalNotificationStore.shared + let previousNotifications = store.notifications + let tabId = UUID() + let surfaceId = UUID() + let older = TerminalNotification( + id: UUID(), tabId: tabId, surfaceId: surfaceId, + title: "Older", subtitle: "", body: "", createdAt: .distantPast, isRead: false + ) + let newer = TerminalNotification( + id: UUID(), tabId: tabId, surfaceId: surfaceId, + title: "Newer", subtitle: "", body: "", createdAt: .distantFuture, isRead: false + ) + defer { + _ = store.flushSupersededPhoneDismissIDsForTesting(tabId: tabId, surfaceId: surfaceId) + store.replaceNotificationsForTesting(previousNotifications) + } + store.replaceNotificationsForTesting([newer, older]) + store.stashSupersededPhoneDismissIDsForTesting( + ["pending-newer-banner"], tabId: tabId, surfaceId: surfaceId + ) + + store.markRead(id: older.id) + store.remove(id: older.id) + + #expect( + store.flushSupersededPhoneDismissIDsForTesting(tabId: tabId, surfaceId: surfaceId) + == ["pending-newer-banner"] + ) + + store.stashSupersededPhoneDismissIDsForTesting( + ["flush-with-newest"], tabId: tabId, surfaceId: surfaceId + ) + store.markRead(id: newer.id) + + #expect(store.flushSupersededPhoneDismissIDsForTesting(tabId: tabId, surfaceId: surfaceId) == []) + store.markRead(id: newer.id) + #expect(store.flushSupersededPhoneDismissIDsForTesting(tabId: tabId, surfaceId: surfaceId) == []) + } + /// The phone badge counts unread notification entries only. Workspace-level /// manual unread indicators feed the Mac Dock badge but have no phone banner, /// so they must not inflate the phone count. diff --git a/cmuxTests/NotificationEffectsOnlyReplayTests.swift b/cmuxTests/NotificationEffectsOnlyReplayTests.swift new file mode 100644 index 000000000000..01f678e30cc0 --- /dev/null +++ b/cmuxTests/NotificationEffectsOnlyReplayTests.swift @@ -0,0 +1,108 @@ +import Darwin +import Foundation +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +final class NotificationEffectsOnlyReplayTests: XCTestCase { + func testRecordedIdReplayCannotReachEffectsOnlyDelivery() async throws { + try await AppContextSerialGate.withExclusiveAppContext { + let fileManager = FileManager.default + let temporaryDirectory = fileManager.temporaryDirectory + .appendingPathComponent("cmux-notification-replay-\(UUID().uuidString)", isDirectory: true) + try fileManager.createDirectory(at: temporaryDirectory, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: temporaryDirectory) } + + let markerURL = temporaryDirectory.appendingPathComponent("hook-finished") + let configURL = temporaryDirectory.appendingPathComponent("cmux.json") + let hookCommand = "sed 's/\"record\":true/\"record\":false/' ; touch '\(markerURL.path)'" + let config: [String: Any] = [ + "notifications": [ + "hooks": [["id": "effects-only-replay", "command": hookCommand]], + ], + ] + try JSONSerialization.data(withJSONObject: config).write(to: configURL) + + let markerObserved = expectation(description: "effects-only hook completed") + let directoryDescriptor = open(temporaryDirectory.path, O_EVTONLY) + XCTAssertGreaterThanOrEqual(directoryDescriptor, 0) + let markerSource = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: directoryDescriptor, + eventMask: .write, + queue: .global(qos: .userInitiated) + ) + markerSource.setEventHandler { + guard fileManager.fileExists(atPath: markerURL.path) else { return } + markerObserved.fulfill() + markerSource.cancel() + } + markerSource.setCancelHandler { close(directoryDescriptor) } + markerSource.resume() + defer { markerSource.cancel() } + + let appDelegate = AppDelegate.shared ?? AppDelegate() + let manager = TabManager() + let workspace = manager.addWorkspace(select: true) + let configStore = CmuxConfigStore( + globalConfigPath: configURL.path, + startFileWatchers: false + ) + configStore.loadAll() + let windowId = appDelegate.registerMainWindowContextForTesting( + tabManager: manager, + cmuxConfigStore: configStore + ) + let store = TerminalNotificationStore.shared + let originalNotificationStore = appDelegate.notificationStore + let originalAppFocusOverride = AppFocusState.overrideIsFocused + let replayId = UUID() + let original = TerminalNotification( + id: replayId, + tabId: workspace.id, + surfaceId: nil, + title: "Original", + subtitle: "", + body: "", + createdAt: Date(timeIntervalSince1970: 1), + isRead: false + ) + defer { + store.replaceNotificationsForTesting([]) + store.resetNotificationDeliveryHandlerForTesting() + store.resetSuppressedNotificationFeedbackHandlerForTesting() + appDelegate.notificationStore = originalNotificationStore + AppFocusState.overrideIsFocused = originalAppFocusOverride + appDelegate.unregisterMainWindowContextForTesting(windowId: windowId) + } + + appDelegate.notificationStore = store + AppFocusState.overrideIsFocused = false + store.replaceNotificationsForTesting([original]) + let replayDelivered = expectation(description: "duplicate id must not deliver effects again") + replayDelivered.isInverted = true + store.configureNotificationDeliveryHandlerForTesting { _, notification in + guard notification.id == replayId else { return } + replayDelivered.fulfill() + } + store.configureSuppressedNotificationFeedbackHandlerForTesting { _, _ in } + + store.addNotification( + id: replayId, + acceptedAt: Date(timeIntervalSince1970: 2), + tabId: workspace.id, + surfaceId: nil, + title: "Replay", + subtitle: "", + body: "" + ) + + await fulfillment(of: [markerObserved, replayDelivered], timeout: 3) + XCTAssertEqual(store.notifications, [original]) + } + } +} diff --git a/cmuxTests/NotificationFeedHistoryTests.swift b/cmuxTests/NotificationFeedHistoryTests.swift index 30c304a82624..40f402ca3482 100644 --- a/cmuxTests/NotificationFeedHistoryTests.swift +++ b/cmuxTests/NotificationFeedHistoryTests.swift @@ -10,7 +10,7 @@ import Testing @Suite(.serialized) @MainActor struct NotificationFeedHistoryTests { - @Test func repeatedSurfaceNotificationsRemainChronologicalAndSupersededEntryBecomesRead() { + @Test func repeatedSurfaceNotificationsRemainChronologicalAndUnread() { let store = TerminalNotificationStore.shared store.replaceNotificationsForTesting([]) store.configureNotificationDeliveryHandlerForTesting { _, _ in } @@ -38,12 +38,12 @@ struct NotificationFeedHistoryTests { retargetsToLiveSurfaceOwner: false ) - #expect(store.notifications.count == 1) - #expect(store.notifications.first?.title == "Second") + #expect(store.notifications.count == 2) + #expect(store.notifications.map(\.title) == ["Second", "First"]) let history = store.notificationFeedHistory.notifications #expect(history.count == 2) #expect(history.map(\.title) == ["Second", "First"]) - #expect(history.map(\.isRead) == [false, true]) + #expect(history.map(\.isRead) == [false, false]) } @Test func retentionKeepsEveryUnreadRecordAndOnlyNewestReadRecords() { diff --git a/cmuxTests/NotificationFeedScaleTests.swift b/cmuxTests/NotificationFeedScaleTests.swift new file mode 100644 index 000000000000..1f2b47b4dd75 --- /dev/null +++ b/cmuxTests/NotificationFeedScaleTests.swift @@ -0,0 +1,650 @@ +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@Suite(.serialized) +@MainActor +struct NotificationFeedScaleTests { + @Test + func liveInsertionsAvoidFullHistoryRebuildsAndPublishOnlyCreatedEvents() { + verifyLiveInsertions(restoredCount: 2_000, liveCount: 500) + } + + @Test + func liveInsertionsRemainIncrementalWithTenThousandRestoredNotifications() { + verifyLiveInsertions(restoredCount: 10_000, liveCount: 500) + } + + @Test + func retainedFeedEvictsOldestRowAtDefaultCapacityBoundary() { + let store = TerminalNotificationStore.shared + let eventBus = CmuxEventBus.shared + let tabId = UUID() + let surfaceId = UUID() + let limit = TerminalNotificationStore.maximumNotificationFeedCount + + store.replaceNotificationsForTesting([]) + store.configureNotificationDeliveryHandlerForTesting { _, _ in } + store.configureSuppressedNotificationFeedbackHandlerForTesting { _, _ in } + eventBus.resetForTesting() + defer { + store.replaceNotificationsForTesting([]) + store.resetNotificationDeliveryHandlerForTesting() + store.resetSuppressedNotificationFeedbackHandlerForTesting() + eventBus.resetForTesting() + } + + let notifications = (0...limit).reversed().map { index in + TerminalNotification( + id: UUID(), + tabId: tabId, + surfaceId: surfaceId, + retargetsToLiveSurfaceOwner: false, + title: "History \(index)", + subtitle: "", + body: "", + createdAt: Date(timeIntervalSince1970: TimeInterval(index)), + isRead: false + ) + } + store.replaceNotificationsForTesting(notifications) + + #expect(store.notifications.count == limit) + #expect(store.unreadNotificationCount == limit) + #expect(store.notifications.first?.title == "History \(limit)") + #expect(store.notifications.last?.title == "History 1") + #expect(!store.notifications.contains { $0.title == "History 0" }) + #expect(Set(store.notifications.map(\.id)).count == limit) + } + + @Test + func liveAppendAtCapacityEvictsOldestRowIncrementally() { + let store = TerminalNotificationStore.shared + let eventBus = CmuxEventBus.shared + let tabId = UUID() + let surfaceId = UUID() + let limit = TerminalNotificationStore.maximumNotificationFeedCount + + let notifications = (0..<limit).map { index in + TerminalNotification( + id: UUID(), + tabId: tabId, + surfaceId: surfaceId, + retargetsToLiveSurfaceOwner: false, + title: "History \(index)", + subtitle: "", + body: "", + createdAt: Date(timeIntervalSince1970: TimeInterval(index)), + isRead: false + ) + }.sorted(by: TerminalNotificationStore.notificationSortPrecedes) + + store.replaceNotificationsForTesting(notifications) + store.configureNotificationDeliveryHandlerForTesting { _, _ in } + store.configureSuppressedNotificationFeedbackHandlerForTesting { _, _ in } + eventBus.resetForTesting() + TerminalNotificationStore.resetFullIndexRebuildCountForTesting() + defer { + store.replaceNotificationsForTesting([]) + store.resetNotificationDeliveryHandlerForTesting() + store.resetSuppressedNotificationFeedbackHandlerForTesting() + eventBus.resetForTesting() + } + + store.addNotification( + id: UUID(), + acceptedAt: Date(timeIntervalSince1970: TimeInterval(limit + 1)), + tabId: tabId, + surfaceId: surfaceId, + title: "Live at cap", + subtitle: "", + body: "", + retargetsToLiveSurfaceOwner: false + ) + + #expect(store.notifications.count == limit) + #expect(store.notifications.first?.title == "Live at cap") + #expect(store.notifications.last?.title == "History 1") + #expect(!store.notifications.contains { $0.title == "History 0" }) + #expect(store.unreadNotificationCount == limit) + #expect(store.latestNotification(forTabId: tabId)?.title == "Live at cap") + #expect(TerminalNotificationStore.fullIndexRebuildCountForTesting == 0) + let lifecycleNames = eventBus.retainedSnapshot().compactMap { $0["name"] as? String } + #expect(lifecycleNames == ["notification.removed", "notification.created"]) + } + + @Test + func cappedAppendWithOneRowPerSurfaceDropsEvictedLatestWithoutHistoryRecovery() throws { + let store = TerminalNotificationStore.shared + let eventBus = CmuxEventBus.shared + let limit = TerminalNotificationStore.maximumNotificationFeedCount + + let notifications = (0..<limit).map { index in + TerminalNotification( + id: UUID(), + tabId: UUID(), + surfaceId: UUID(), + retargetsToLiveSurfaceOwner: false, + title: "Distinct \(index)", + subtitle: "", + body: "", + createdAt: Date(timeIntervalSince1970: TimeInterval(index)), + isRead: false + ) + }.sorted(by: TerminalNotificationStore.notificationSortPrecedes) + let evicted = try #require(notifications.last) + + store.replaceNotificationsForTesting(notifications) + store.configureNotificationDeliveryHandlerForTesting { _, _ in } + store.configureSuppressedNotificationFeedbackHandlerForTesting { _, _ in } + eventBus.resetForTesting() + TerminalNotificationStore.resetFullIndexRebuildCountForTesting() + defer { + store.replaceNotificationsForTesting([]) + store.resetNotificationDeliveryHandlerForTesting() + store.resetSuppressedNotificationFeedbackHandlerForTesting() + eventBus.resetForTesting() + } + + store.addNotification( + id: UUID(), + acceptedAt: Date(timeIntervalSince1970: TimeInterval(limit + 1)), + tabId: UUID(), + surfaceId: UUID(), + title: "Distinct live at cap", + subtitle: "", + body: "", + retargetsToLiveSurfaceOwner: false + ) + + #expect(store.notifications.count == limit) + #expect(!store.notifications.contains { $0.id == evicted.id }) + #expect(store.latestNotification(forTabId: evicted.tabId) == nil) + #expect(!store.hasUnreadNotification(forTabId: evicted.tabId, surfaceId: evicted.surfaceId)) + #expect(TerminalNotificationStore.fullIndexRebuildCountForTesting == 0) + } + + @Test + func retainedFeedEvictsOldestRowsByAggregateContentBytes() throws { + let store = TerminalNotificationStore.shared + let tabId = UUID() + let surfaceId = UUID() + let body = String(repeating: "x", count: TerminalNotificationStore.maximumNotificationBodyBytes) + let rowBytes = body.utf8.count + let retainedByBytes = TerminalNotificationStore.maximumNotificationFeedContentBytes / rowBytes + let totalRows = retainedByBytes + 3 + let notifications = (0..<totalRows).map { index in + TerminalNotification( + id: UUID(), + tabId: tabId, + surfaceId: surfaceId, + retargetsToLiveSurfaceOwner: false, + title: "", + subtitle: "", + body: body, + createdAt: Date(timeIntervalSince1970: TimeInterval(index)), + isRead: false + ) + }.sorted(by: TerminalNotificationStore.notificationSortPrecedes) + + store.replaceNotificationsForTesting(notifications) + defer { store.replaceNotificationsForTesting([]) } + + #expect(store.notifications.count == retainedByBytes) + #expect(store.notifications.first?.createdAt == notifications.first?.createdAt) + #expect(store.notifications.last?.createdAt == notifications[retainedByBytes - 1].createdAt) + let retainedBytes = store.notifications.reduce(0) { + $0 + $1.title.utf8.count + $1.subtitle.utf8.count + $1.body.utf8.count + } + #expect(retainedBytes <= TerminalNotificationStore.maximumNotificationFeedContentBytes) + let evictedIDs = Set(notifications.dropFirst(retainedByBytes).map(\.id)) + #expect(store.notifications.allSatisfy { !evictedIDs.contains($0.id) }) + } + + @Test + func retainedFeedByteLimitEvictsChronologicalSuffix() throws { + let store = TerminalNotificationStore.shared + let body = String(repeating: "x", count: TerminalNotificationStore.maximumNotificationBodyBytes) + let retainedByBytes = TerminalNotificationStore.maximumNotificationFeedContentBytes / body.utf8.count + let retained = (0..<retainedByBytes).map { index in + TerminalNotification( + id: UUID(), + tabId: UUID(), + surfaceId: UUID(), + retargetsToLiveSurfaceOwner: false, + title: "", + subtitle: "", + body: body, + createdAt: Date(timeIntervalSince1970: TimeInterval(retainedByBytes + 2 - index)), + isRead: false + ) + } + let firstEvicted = TerminalNotification( + id: UUID(), + tabId: UUID(), + surfaceId: UUID(), + retargetsToLiveSurfaceOwner: false, + title: "first evicted", + subtitle: "", + body: "x", + createdAt: Date(timeIntervalSince1970: 1), + isRead: false + ) + let olderSmall = TerminalNotification( + id: UUID(), + tabId: UUID(), + surfaceId: UUID(), + retargetsToLiveSurfaceOwner: false, + title: "", + subtitle: "", + body: "", + createdAt: Date(timeIntervalSince1970: 0), + isRead: false + ) + let notifications = (retained + [firstEvicted, olderSmall]) + .sorted(by: TerminalNotificationStore.notificationSortPrecedes) + + store.replaceNotificationsForTesting(notifications) + defer { store.replaceNotificationsForTesting([]) } + + #expect(store.notifications.count == retainedByBytes) + #expect(!store.notifications.contains { $0.id == firstEvicted.id }) + #expect(!store.notifications.contains { $0.id == olderSmall.id }) + } + + @Test + func liveAppendAtByteCapacityEvictsOldestRowsIncrementally() throws { + let store = TerminalNotificationStore.shared + let eventBus = CmuxEventBus.shared + let body = String(repeating: "x", count: TerminalNotificationStore.maximumNotificationBodyBytes / 2) + let oldRowBytes = body.utf8.count + let retainedByBytes = TerminalNotificationStore.maximumNotificationFeedContentBytes / oldRowBytes + let liveBody = String(repeating: "y", count: TerminalNotificationStore.maximumNotificationBodyBytes) + let expectedEvictionCount = liveBody.utf8.count / oldRowBytes + let existing = (0..<retainedByBytes).map { index in + TerminalNotification( + id: UUID(), + tabId: UUID(), + surfaceId: UUID(), + retargetsToLiveSurfaceOwner: false, + title: "", + subtitle: "", + body: body, + createdAt: Date(timeIntervalSince1970: TimeInterval(index)), + isRead: false + ) + }.sorted(by: TerminalNotificationStore.notificationSortPrecedes) + let evictedIDs = Set(existing.suffix(expectedEvictionCount).map(\.id)) + let liveId = UUID() + let liveTabId = UUID() + let liveSurfaceId = UUID() + + store.replaceNotificationsForTesting(existing) + store.configureNotificationDeliveryHandlerForTesting { _, _ in } + store.configureSuppressedNotificationFeedbackHandlerForTesting { _, _ in } + eventBus.resetForTesting() + TerminalNotificationStore.resetFullIndexRebuildCountForTesting() + defer { + store.replaceNotificationsForTesting([]) + store.resetNotificationDeliveryHandlerForTesting() + store.resetSuppressedNotificationFeedbackHandlerForTesting() + eventBus.resetForTesting() + } + + store.addNotification( + id: liveId, + acceptedAt: Date(timeIntervalSince1970: TimeInterval(retainedByBytes + 1)), + tabId: liveTabId, + surfaceId: liveSurfaceId, + title: "", + subtitle: "", + body: liveBody, + retargetsToLiveSurfaceOwner: false + ) + + #expect(store.notifications.first?.id == liveId) + #expect(store.notifications.count == retainedByBytes - expectedEvictionCount + 1) + #expect(store.notifications.allSatisfy { !evictedIDs.contains($0.id) }) + #expect(store.latestNotification(forTabId: liveTabId)?.id == liveId) + #expect(TerminalNotificationStore.fullIndexRebuildCountForTesting == 0) + let lifecycleNames = eventBus.retainedSnapshot().compactMap { $0["name"] as? String } + #expect( + lifecycleNames + == Array(repeating: "notification.removed", count: expectedEvictionCount) + + ["notification.created"] + ) + } + + @Test + func largeByteCapacityEvictionPreservesPerRowRemovalEvents() throws { + let store = TerminalNotificationStore.shared + let eventBus = CmuxEventBus.shared + let oldBody = String(repeating: "x", count: 512) + let oldRowBytes = oldBody.utf8.count + let retainedByBytes = TerminalNotificationStore.maximumNotificationFeedContentBytes / oldRowBytes + let liveBody = String(repeating: "y", count: TerminalNotificationStore.maximumNotificationBodyBytes) + let expectedEvictionCount = liveBody.utf8.count / oldRowBytes + let existing = (0..<retainedByBytes).map { index in + TerminalNotification( + id: UUID(), + tabId: UUID(), + surfaceId: UUID(), + retargetsToLiveSurfaceOwner: false, + title: "", + subtitle: "", + body: oldBody, + createdAt: Date(timeIntervalSince1970: TimeInterval(index)), + isRead: false + ) + }.sorted(by: TerminalNotificationStore.notificationSortPrecedes) + + store.replaceNotificationsForTesting(existing) + store.configureNotificationDeliveryHandlerForTesting { _, _ in } + store.configureSuppressedNotificationFeedbackHandlerForTesting { _, _ in } + eventBus.resetForTesting() + TerminalNotificationStore.resetFullIndexRebuildCountForTesting() + defer { + store.replaceNotificationsForTesting([]) + store.resetNotificationDeliveryHandlerForTesting() + store.resetSuppressedNotificationFeedbackHandlerForTesting() + eventBus.resetForTesting() + } + + store.addNotification( + id: UUID(), + acceptedAt: Date(timeIntervalSince1970: TimeInterval(retainedByBytes + 1)), + tabId: UUID(), + surfaceId: UUID(), + title: "", + subtitle: "", + body: liveBody, + retargetsToLiveSurfaceOwner: false + ) + + let events = eventBus.retainedSnapshot() + #expect(TerminalNotificationStore.fullIndexRebuildCountForTesting == 0) + #expect( + events.compactMap { $0["name"] as? String } + == Array(repeating: "notification.removed", count: expectedEvictionCount) + + ["notification.created"] + ) + let removedPayloads = try events.dropLast().map { event in + try #require(event["payload"] as? [String: Any]) + } + #expect(removedPayloads.count == expectedEvictionCount) + #expect(removedPayloads.allSatisfy { $0["notification_id"] is String }) + } + + @Test + func incrementalReadStateUpdatesPanelAliasUnreadProjection() throws { + let store = TerminalNotificationStore.shared + let workspaceId = UUID() + let surfaceId = UUID() + let panelId = UUID() + let notification = TerminalNotification( + id: UUID(), + tabId: workspaceId, + surfaceId: surfaceId, + panelId: panelId, + retargetsToLiveSurfaceOwner: false, + title: "Panel alias", + subtitle: "", + body: "", + createdAt: Date(timeIntervalSince1970: 1), + isRead: false + ) + + store.replaceNotificationsForTesting([notification]) + defer { store.replaceNotificationsForTesting([]) } + + #expect(store.sidebarUnread.hasUnreadNotification(forWorkspaceId: workspaceId, surfaceId: surfaceId)) + #expect(store.sidebarUnread.hasUnreadNotification(forWorkspaceId: workspaceId, surfaceId: panelId)) + + store.markRead(id: notification.id) + + #expect(!store.sidebarUnread.hasUnreadNotification(forWorkspaceId: workspaceId, surfaceId: surfaceId)) + #expect(!store.sidebarUnread.hasUnreadNotification(forWorkspaceId: workspaceId, surfaceId: panelId)) + } + + @Test + func notificationAdmissionTruncatesOversizedTextFields() throws { + let store = TerminalNotificationStore.shared + let eventBus = CmuxEventBus.shared + let tabId = UUID() + let surfaceId = UUID() + let title = String(repeating: "é", count: TerminalNotificationStore.maximumNotificationTitleBytes) + let subtitle = String(repeating: "s", count: TerminalNotificationStore.maximumNotificationSubtitleBytes + 128) + let body = String(repeating: "b", count: TerminalNotificationStore.maximumNotificationBodyBytes + 128) + store.replaceNotificationsForTesting([]) + store.configureNotificationDeliveryHandlerForTesting { _, _ in } + store.configureSuppressedNotificationFeedbackHandlerForTesting { _, _ in } + eventBus.resetForTesting() + defer { + store.replaceNotificationsForTesting([]) + store.resetNotificationDeliveryHandlerForTesting() + store.resetSuppressedNotificationFeedbackHandlerForTesting() + eventBus.resetForTesting() + } + + store.addNotification( + id: UUID(), + acceptedAt: Date(timeIntervalSince1970: 1), + tabId: tabId, + surfaceId: surfaceId, + title: title, + subtitle: subtitle, + body: body, + retargetsToLiveSurfaceOwner: false + ) + + let notification = try #require(store.notifications.first) + #expect(notification.title.utf8.count <= TerminalNotificationStore.maximumNotificationTitleBytes) + #expect(notification.subtitle.utf8.count == TerminalNotificationStore.maximumNotificationSubtitleBytes) + #expect(notification.body.utf8.count == TerminalNotificationStore.maximumNotificationBodyBytes) + #expect(String(notification.title.last ?? "x").utf8.count == 2) + } + + @Test + func frozenFeedSnapshotSurvivesStorageCompaction() { + let store = TerminalNotificationStore.shared + let eventBus = CmuxEventBus.shared + let tabId = UUID() + let surfaceId = UUID() + let limit = TerminalNotificationStore.maximumNotificationFeedCount + + let notifications = (0..<limit).map { index in + TerminalNotification( + id: UUID(), + tabId: tabId, + surfaceId: surfaceId, + retargetsToLiveSurfaceOwner: false, + title: "History \(index)", + subtitle: "", + body: "", + createdAt: Date(timeIntervalSince1970: TimeInterval(index)), + isRead: false + ) + }.sorted(by: TerminalNotificationStore.notificationSortPrecedes) + + store.replaceNotificationsForTesting(notifications) + store.configureNotificationDeliveryHandlerForTesting { _, _ in } + store.configureSuppressedNotificationFeedbackHandlerForTesting { _, _ in } + TerminalNotificationStore.notificationFeedCompactionOffsetForTesting = 8 + eventBus.resetForTesting() + defer { + store.replaceNotificationsForTesting([]) + TerminalNotificationStore.notificationFeedCompactionOffsetForTesting = nil + store.resetNotificationDeliveryHandlerForTesting() + store.resetSuppressedNotificationFeedbackHandlerForTesting() + eventBus.resetForTesting() + } + + let frozen = store.notifications + let frozenFirst = frozen.first?.id + let frozenLast = frozen.last?.id + + for index in 0..<8 { + store.addNotification( + id: UUID(), + acceptedAt: Date(timeIntervalSince1970: TimeInterval(limit + index + 1)), + tabId: tabId, + surfaceId: surfaceId, + title: "Live \(index)", + subtitle: "", + body: "", + retargetsToLiveSurfaceOwner: false + ) + } + + #expect(frozen.count == limit) + #expect(frozen.first?.id == frozenFirst) + #expect(frozen.last?.id == frozenLast) + #expect(store.notifications.count == limit) + #expect(store.notifications.first?.title == "Live 7") + } + + @Test + func inconsistentIncrementalIndexesRebuildInsteadOfTrapping() { + let tabId = UUID() + let existing = TerminalNotification( + id: UUID(), + tabId: tabId, + surfaceId: UUID(), + title: "Existing", + subtitle: "", + body: "", + createdAt: Date(timeIntervalSince1970: 1), + isRead: false + ) + let incoming = TerminalNotification( + id: UUID(), + tabId: tabId, + surfaceId: UUID(), + title: "Incoming", + subtitle: "", + body: "", + createdAt: Date(timeIntervalSince1970: 2), + isRead: false + ) + let notifications = [incoming, existing] + var indexes = TerminalNotificationStore.buildIndexes(for: [existing]) + indexes.ids.insert(incoming.id) + TerminalNotificationStore.resetFullIndexRebuildCountForTesting() + + TerminalNotificationStore.insertNotification( + incoming, + into: &indexes, + notifications: notifications + ) + + #expect(TerminalNotificationStore.fullIndexRebuildCountForTesting == 1) + #expect(indexes.ids == Set(notifications.map(\.id))) + #expect(indexes.unreadCount == notifications.count) + #expect(indexes.latestByTabId[tabId]?.id == incoming.id) + } + + @Test + func singleRowReadStateChangesAvoidFullHistoryRebuildAndDiff() throws { + let store = TerminalNotificationStore.shared + let eventBus = CmuxEventBus.shared + let tabId = UUID() + let surfaceId = UUID() + let notifications = (0..<10_000).map { index in + TerminalNotification( + id: UUID(), + tabId: tabId, + surfaceId: surfaceId, + title: "History \(index)", + subtitle: "", + body: "", + createdAt: Date(timeIntervalSince1970: TimeInterval(index)), + isRead: false + ) + }.sorted(by: TerminalNotificationStore.notificationSortPrecedes) + let target = try #require(notifications.first) + + store.replaceNotificationsForTesting(notifications) + eventBus.resetForTesting() + TerminalNotificationStore.resetFullIndexRebuildCountForTesting() + defer { + store.replaceNotificationsForTesting([]) + eventBus.resetForTesting() + } + + store.markRead(id: target.id) + #expect(TerminalNotificationStore.fullIndexRebuildCountForTesting == 0) + #expect(store.unreadNotificationCount == notifications.count - 1) + #expect(store.notifications.first(where: { !$0.isRead && $0.tabId == tabId })?.id == notifications[1].id) + #expect(store.hasUnreadNotification(forTabId: tabId, surfaceId: surfaceId)) + let readEvents = eventBus.retainedSnapshot().compactMap { $0["name"] as? String } + #expect(readEvents == ["notification.read"]) + + eventBus.resetForTesting() + store.markUnread(id: target.id) + #expect(TerminalNotificationStore.fullIndexRebuildCountForTesting == 0) + #expect(store.unreadNotificationCount == notifications.count) + #expect(store.notifications.first(where: { !$0.isRead && $0.tabId == tabId })?.id == target.id) + #expect(eventBus.retainedSnapshot().isEmpty) + } + + private func verifyLiveInsertions(restoredCount: Int, liveCount: Int) { + let store = TerminalNotificationStore.shared + let eventBus = CmuxEventBus.shared + let tabId = UUID() + let surfaceId = UUID() + let restored = (0..<restoredCount).map { index in + TerminalNotification( + id: UUID(), + tabId: tabId, + surfaceId: surfaceId, + title: "Restored \(index)", + subtitle: "", + body: "", + createdAt: Date(timeIntervalSince1970: TimeInterval(index)), + isRead: false + ) + }.sorted(by: TerminalNotificationStore.notificationSortPrecedes) + + store.replaceNotificationsForTesting(restored) + store.configureNotificationDeliveryHandlerForTesting { _, _ in } + store.configureSuppressedNotificationFeedbackHandlerForTesting { _, _ in } + eventBus.resetForTesting() + TerminalNotificationStore.resetFullIndexRebuildCountForTesting() + defer { + store.replaceNotificationsForTesting([]) + store.resetNotificationDeliveryHandlerForTesting() + store.resetSuppressedNotificationFeedbackHandlerForTesting() + eventBus.resetForTesting() + } + + for index in 0..<liveCount { + store.addNotification( + id: UUID(), + acceptedAt: Date(timeIntervalSince1970: TimeInterval(restoredCount + index)), + tabId: tabId, + surfaceId: surfaceId, + title: "Live \(index)", + subtitle: "", + body: "", + retargetsToLiveSurfaceOwner: false + ) + } + + #expect(store.notifications.count == restoredCount + liveCount) + #expect(Set(store.notifications.map(\.id)).count == restoredCount + liveCount) + #expect(zip(store.notifications, store.notifications.dropFirst()).allSatisfy { + TerminalNotificationStore.notificationSortPrecedes($0.0, $0.1) + }) + #expect(store.notificationMenuSnapshot.unreadCount == restoredCount + liveCount) + #expect(TerminalNotificationStore.fullIndexRebuildCountForTesting == 0) + + let lifecycleNames = eventBus.retainedSnapshot().compactMap { $0["name"] as? String } + #expect(lifecycleNames.filter { $0 == "notification.created" }.count == liveCount) + #expect(!lifecycleNames.contains("notification.removed")) + } +} diff --git a/cmuxTests/NotificationRestoreBannerOwnershipTests.swift b/cmuxTests/NotificationRestoreBannerOwnershipTests.swift new file mode 100644 index 000000000000..a659df006122 --- /dev/null +++ b/cmuxTests/NotificationRestoreBannerOwnershipTests.swift @@ -0,0 +1,998 @@ +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@Suite(.serialized) +@MainActor +struct NotificationRestoreBannerOwnershipTests { + @Test func duplicateIdentityDegradesToFirstRowInUnreadNavigation() { + let store = TerminalNotificationStore.shared + let previousNotifications = store.notifications + let tabId = UUID() + let surfaceId = UUID() + let duplicateId = UUID() + let first = notification( + id: duplicateId, tabId: tabId, surfaceId: surfaceId, + title: "First canonical row", createdAt: Date(timeIntervalSince1970: 20) + ) + let duplicate = notification( + id: duplicateId, tabId: tabId, surfaceId: surfaceId, + title: "Corrupt duplicate", createdAt: Date(timeIntervalSince1970: 10) + ) + defer { store.replaceNotificationsForTesting(previousNotifications) } + + store.replaceNotificationsForTesting([first, duplicate]) + #expect(store.markLatestNotificationAsOldestUnread(forTabId: tabId, surfaceId: surfaceId) == duplicateId) + #expect(store.notificationsForUnreadNavigation.map(\.title) == ["First canonical row"]) + } + + @Test func coldRestoreDoesNotInferBannerOwnershipFromChronology() { + let tabId = UUID() + let surfaceId = UUID() + let duplicateId = UUID() + let first = notification( + id: duplicateId, tabId: tabId, surfaceId: surfaceId, + title: "First canonical row", createdAt: Date(timeIntervalSince1970: 20) + ) + let duplicate = notification( + id: duplicateId, tabId: tabId, surfaceId: surfaceId, + title: "Corrupt duplicate", createdAt: Date(timeIntervalSince1970: 10) + ) + var ownership = ExternalNotificationBannerOwnership() + + ownership.reconcile(previous: [], merged: [first, duplicate]) + + #expect(ownership.owner(tabId: tabId, surfaceId: surfaceId) == nil) + } + + @Test func legacyColdRestoreInfersLatestUnreadBannerOwnerPerSurface() { + let store = TerminalNotificationStore.shared + let previousNotifications = store.notifications + let tabId = UUID() + let firstSurfaceId = UUID() + let secondSurfaceId = UUID() + let older = notification( + id: UUID(), tabId: tabId, surfaceId: firstSurfaceId, + title: "Older unread", createdAt: Date(timeIntervalSince1970: 10) + ) + let latestUnread = notification( + id: UUID(), tabId: tabId, surfaceId: firstSurfaceId, + title: "Latest unread", createdAt: Date(timeIntervalSince1970: 20) + ) + let newerRead = notification( + id: UUID(), tabId: tabId, surfaceId: firstSurfaceId, + title: "Newer read", createdAt: Date(timeIntervalSince1970: 30), + isRead: true + ) + let secondSurface = notification( + id: UUID(), tabId: tabId, surfaceId: secondSurfaceId, + title: "Second surface", createdAt: Date(timeIntervalSince1970: 15) + ) + defer { store.replaceNotificationsForTesting(previousNotifications) } + + store.replaceNotificationsForTesting([]) + store.restoreSessionNotifications( + [older, latestUnread, newerRead, secondSurface], + forTabId: tabId, + inferLegacyExternalBannerOwners: true + ) + + #expect(store.externalBannerOwnerIDForTesting(tabId: tabId, surfaceId: firstSurfaceId) == latestUnread.id) + #expect(store.externalBannerOwnerIDForTesting(tabId: tabId, surfaceId: secondSurfaceId) == secondSurface.id) + + store.replaceNotificationsForTesting([]) + store.restoreSessionNotifications([latestUnread], forTabId: tabId) + + #expect(store.externalBannerOwnerIDForTesting(tabId: tabId, surfaceId: firstSurfaceId) == nil) + } + + @Test func olderAcceptedNotificationPreservesVisibleBannerOwner() { + let store = TerminalNotificationStore.shared + let previousNotifications = store.notifications + let originalAppFocusOverride = AppFocusState.overrideIsFocused + let tabId = UUID() + let surfaceId = UUID() + let visibleOwner = notification( + id: UUID(), tabId: tabId, surfaceId: surfaceId, + title: "Visible owner", createdAt: Date(timeIntervalSince1970: 20) + ) + let olderId = UUID() + var deliveredIds: [UUID] = [] + defer { + store.replaceNotificationsForTesting(previousNotifications) + store.resetNotificationDeliveryHandlerForTesting() + AppFocusState.overrideIsFocused = originalAppFocusOverride + } + + store.replaceNotificationsForTesting([visibleOwner]) + AppFocusState.overrideIsFocused = false + store.configureNotificationDeliveryHandlerForTesting { _, notification in + deliveredIds.append(notification.id) + } + + store.addNotification( + id: olderId, + acceptedAt: Date(timeIntervalSince1970: 10), + tabId: tabId, + surfaceId: surfaceId, + title: "Older incoming", + subtitle: "", + body: "", + retargetsToLiveSurfaceOwner: false + ) + + #expect(store.notifications.map(\.id) == [visibleOwner.id, olderId]) + #expect(store.externalBannerOwnerIDForTesting(tabId: tabId, surfaceId: surfaceId) == visibleOwner.id) + #expect(deliveredIds.isEmpty) + } + + @Test func clearingBannerOwnerByIDPreservesOtherOwners() throws { + let tabId = UUID() + let owners = (0..<512).map { index in + notification( + id: UUID(), + tabId: tabId, + surfaceId: UUID(), + title: "Owner \(index)", + createdAt: Date(timeIntervalSince1970: TimeInterval(index)) + ) + } + var ownership = ExternalNotificationBannerOwnership() + + for owner in owners { + ownership.setOwner(owner) + } + + ownership.clear(id: UUID()) + #expect(Set(ownership.ownerIDs(tabId: tabId)) == Set(owners.map(\.id))) + + let removed = try #require(owners.dropFirst(257).first) + ownership.clear(id: removed.id) + + #expect(ownership.owner(tabId: tabId, surfaceId: removed.surfaceId) == nil) + #expect(Set(ownership.ownerIDs(tabId: tabId)) == Set(owners.map(\.id)).subtracting([removed.id])) + } + + @Test func rebindFindsAndClearsBannerOwnerThroughPanelAlias() { + let sourceTabId = UUID() + let destinationTabId = UUID() + let sourceSurfaceId = UUID() + let destinationSurfaceId = UUID() + let panelId = UUID() + let sourceOwner = TerminalNotification( + id: UUID(), + tabId: sourceTabId, + surfaceId: sourceSurfaceId, + panelId: panelId, + title: "Newer source owner", + subtitle: "", + body: "", + createdAt: Date(timeIntervalSince1970: 30), + isRead: false + ) + let destinationOwner = TerminalNotification( + id: UUID(), + tabId: destinationTabId, + surfaceId: destinationSurfaceId, + panelId: panelId, + title: "Older destination owner", + subtitle: "", + body: "", + createdAt: Date(timeIntervalSince1970: 20), + isRead: false + ) + var ownership = ExternalNotificationBannerOwnership() + ownership.setOwner(sourceOwner) + ownership.setOwner(destinationOwner) + + let displaced = ownership.rebind( + surfaceId: panelId, + fromTabId: sourceTabId, + toTabId: destinationTabId + ) + + #expect(displaced?.id == destinationOwner.id) + #expect(ownership.ownerIDs(tabId: sourceTabId).isEmpty) + #expect(ownership.ownerIDs(tabId: destinationTabId) == [sourceOwner.id]) + #expect(ownership.owner(tabId: destinationTabId, surfaceId: sourceSurfaceId)?.id == sourceOwner.id) + #expect(ownership.owner(tabId: destinationTabId, surfaceId: destinationSurfaceId) == nil) + } + + @Test func unaffectedSessionRestoreDoesNotResortGlobalFeed() { + let targetTabId = UUID() + let otherTabId = UUID() + let older = notification( + id: UUID(), + tabId: otherTabId, + surfaceId: nil, + title: "Older first by caller order", + createdAt: Date(timeIntervalSince1970: 1) + ) + let newer = notification( + id: UUID(), + tabId: otherTabId, + surfaceId: nil, + title: "Newer second by caller order", + createdAt: Date(timeIntervalSince1970: 2) + ) + + let merged = TerminalNotificationStore.mergeRestoredSessionNotifications( + existing: [older, newer], + restored: [], + tabId: targetTabId, + replacingTabId: nil, + panelIdMap: [:] + ) + + #expect(merged.map(\.id) == [older.id, newer.id]) + } + + @Test func transferCollisionDismissesDisplacedBannerOwner() { + let store = TerminalNotificationStore.shared + let previousNotifications = store.notifications + let tombstoneKey = TerminalNotificationStore.dismissedTombstoneDefaultsKey + let previousTombstones = UserDefaults.standard.object(forKey: tombstoneKey) + let sourceTabId = UUID() + let destinationTabId = UUID() + let sourceSurfaceId = UUID() + let destinationSurfaceId = UUID() + let sourceSupersededId = UUID() + let destinationSupersededId = UUID() + let sourceOwner = notification( + id: UUID(), tabId: sourceTabId, surfaceId: sourceSurfaceId, + title: "Source owner", createdAt: Date(timeIntervalSince1970: 10) + ) + let destinationOwner = notification( + id: UUID(), tabId: destinationTabId, surfaceId: destinationSurfaceId, + title: "Destination owner", createdAt: Date(timeIntervalSince1970: 20) + ) + defer { + if let previousTombstones { + UserDefaults.standard.set(previousTombstones, forKey: tombstoneKey) + } else { + UserDefaults.standard.removeObject(forKey: tombstoneKey) + } + _ = store.flushSupersededPhoneDismissIDsForTesting( + tabId: sourceTabId, + surfaceId: sourceSurfaceId + ) + _ = store.flushSupersededPhoneDismissIDsForTesting( + tabId: destinationTabId, + surfaceId: destinationSurfaceId + ) + store.reloadDismissedTombstonesForTesting() + store.replaceNotificationsForTesting(previousNotifications) + } + + UserDefaults.standard.removeObject(forKey: tombstoneKey) + store.reloadDismissedTombstonesForTesting() + store.replaceNotificationsForTesting([destinationOwner, sourceOwner]) + store.stashSupersededPhoneDismissIDsForTesting( + [sourceSupersededId.uuidString], + tabId: sourceTabId, + surfaceId: sourceSurfaceId + ) + store.stashSupersededPhoneDismissIDsForTesting( + [destinationSupersededId.uuidString], + tabId: destinationTabId, + surfaceId: destinationSurfaceId + ) + + store.transferSessionNotificationState( + fromTabId: sourceTabId, + toTabId: destinationTabId, + panelIdMap: [sourceSurfaceId: destinationSurfaceId] + ) + + #expect( + store.externalBannerOwnerIDForTesting( + tabId: destinationTabId, + surfaceId: destinationSurfaceId + ) == destinationOwner.id + ) + let tombstones = UserDefaults.standard.stringArray(forKey: tombstoneKey) ?? [] + #expect(tombstones.contains(sourceOwner.id.uuidString)) + #expect(tombstones.contains(sourceSupersededId.uuidString)) + #expect(!tombstones.contains(destinationOwner.id.uuidString)) + #expect(!tombstones.contains(destinationSupersededId.uuidString)) + #expect( + store.flushSupersededPhoneDismissIDsForTesting( + tabId: destinationTabId, + surfaceId: destinationSurfaceId + ) == [destinationSupersededId.uuidString] + ) + } + + @Test func rebindCollisionDismissesDisplacedBannerOwner() { + let store = TerminalNotificationStore.shared + let previousNotifications = store.notifications + let tombstoneKey = TerminalNotificationStore.dismissedTombstoneDefaultsKey + let previousTombstones = UserDefaults.standard.object(forKey: tombstoneKey) + let sourceTabId = UUID() + let destinationTabId = UUID() + let surfaceId = UUID() + let sourceSupersededId = UUID() + let destinationSupersededId = UUID() + let sourceOwner = notification( + id: UUID(), tabId: sourceTabId, surfaceId: surfaceId, + title: "Source owner", createdAt: Date(timeIntervalSince1970: 10) + ) + let destinationOwner = notification( + id: UUID(), tabId: destinationTabId, surfaceId: surfaceId, + title: "Destination owner", createdAt: Date(timeIntervalSince1970: 20) + ) + defer { + if let previousTombstones { + UserDefaults.standard.set(previousTombstones, forKey: tombstoneKey) + } else { + UserDefaults.standard.removeObject(forKey: tombstoneKey) + } + _ = store.flushSupersededPhoneDismissIDsForTesting(tabId: sourceTabId, surfaceId: surfaceId) + _ = store.flushSupersededPhoneDismissIDsForTesting(tabId: destinationTabId, surfaceId: surfaceId) + store.reloadDismissedTombstonesForTesting() + store.replaceNotificationsForTesting(previousNotifications) + } + + UserDefaults.standard.removeObject(forKey: tombstoneKey) + store.reloadDismissedTombstonesForTesting() + store.replaceNotificationsForTesting([destinationOwner, sourceOwner]) + store.stashSupersededPhoneDismissIDsForTesting( + [sourceSupersededId.uuidString], + tabId: sourceTabId, + surfaceId: surfaceId + ) + store.stashSupersededPhoneDismissIDsForTesting( + [destinationSupersededId.uuidString], + tabId: destinationTabId, + surfaceId: surfaceId + ) + + store.rebindSurfaceNotifications( + fromTabId: sourceTabId, + toTabId: destinationTabId, + surfaceId: surfaceId + ) + + #expect( + store.externalBannerOwnerIDForTesting( + tabId: destinationTabId, + surfaceId: surfaceId + ) == destinationOwner.id + ) + let tombstones = UserDefaults.standard.stringArray(forKey: tombstoneKey) ?? [] + #expect(tombstones.contains(sourceOwner.id.uuidString)) + #expect(tombstones.contains(sourceSupersededId.uuidString)) + #expect(!tombstones.contains(destinationOwner.id.uuidString)) + #expect(!tombstones.contains(destinationSupersededId.uuidString)) + #expect( + store.flushSupersededPhoneDismissIDsForTesting( + tabId: destinationTabId, + surfaceId: surfaceId + ) == [destinationSupersededId.uuidString] + ) + } + + @Test func transferCollisionDrainsDestinationBacklogWhenDestinationOwnerLoses() { + let store = TerminalNotificationStore.shared + let previousNotifications = store.notifications + let tombstoneKey = TerminalNotificationStore.dismissedTombstoneDefaultsKey + let previousTombstones = UserDefaults.standard.object(forKey: tombstoneKey) + let sourceTabId = UUID() + let destinationTabId = UUID() + let sourceSurfaceId = UUID() + let destinationSurfaceId = UUID() + let sourceSupersededId = UUID() + let destinationSupersededId = UUID() + let sourceOwner = notification( + id: UUID(), tabId: sourceTabId, surfaceId: sourceSurfaceId, + title: "Newer source owner", createdAt: Date(timeIntervalSince1970: 30) + ) + let destinationOwner = notification( + id: UUID(), tabId: destinationTabId, surfaceId: destinationSurfaceId, + title: "Older destination owner", createdAt: Date(timeIntervalSince1970: 20) + ) + defer { + if let previousTombstones { + UserDefaults.standard.set(previousTombstones, forKey: tombstoneKey) + } else { + UserDefaults.standard.removeObject(forKey: tombstoneKey) + } + _ = store.flushSupersededPhoneDismissIDsForTesting( + tabId: sourceTabId, + surfaceId: sourceSurfaceId + ) + _ = store.flushSupersededPhoneDismissIDsForTesting( + tabId: destinationTabId, + surfaceId: destinationSurfaceId + ) + store.reloadDismissedTombstonesForTesting() + store.replaceNotificationsForTesting(previousNotifications) + } + + UserDefaults.standard.removeObject(forKey: tombstoneKey) + store.reloadDismissedTombstonesForTesting() + store.replaceNotificationsForTesting([sourceOwner, destinationOwner]) + store.stashSupersededPhoneDismissIDsForTesting( + [sourceSupersededId.uuidString], + tabId: sourceTabId, + surfaceId: sourceSurfaceId + ) + store.stashSupersededPhoneDismissIDsForTesting( + [destinationSupersededId.uuidString], + tabId: destinationTabId, + surfaceId: destinationSurfaceId + ) + + store.transferSessionNotificationState( + fromTabId: sourceTabId, + toTabId: destinationTabId, + panelIdMap: [sourceSurfaceId: destinationSurfaceId] + ) + + #expect( + store.externalBannerOwnerIDForTesting( + tabId: destinationTabId, + surfaceId: destinationSurfaceId + ) == sourceOwner.id + ) + let tombstones = UserDefaults.standard.stringArray(forKey: tombstoneKey) ?? [] + #expect(tombstones.contains(destinationOwner.id.uuidString)) + #expect(tombstones.contains(destinationSupersededId.uuidString)) + #expect(!tombstones.contains(sourceOwner.id.uuidString)) + #expect(!tombstones.contains(sourceSupersededId.uuidString)) + #expect( + store.flushSupersededPhoneDismissIDsForTesting( + tabId: destinationTabId, + surfaceId: destinationSurfaceId + ) == [sourceSupersededId.uuidString] + ) + } + + @Test func rebindCollisionDrainsDestinationBacklogWhenDestinationOwnerLoses() { + let store = TerminalNotificationStore.shared + let previousNotifications = store.notifications + let tombstoneKey = TerminalNotificationStore.dismissedTombstoneDefaultsKey + let previousTombstones = UserDefaults.standard.object(forKey: tombstoneKey) + let sourceTabId = UUID() + let destinationTabId = UUID() + let surfaceId = UUID() + let sourceSupersededId = UUID() + let destinationSupersededId = UUID() + let sourceOwner = notification( + id: UUID(), tabId: sourceTabId, surfaceId: surfaceId, + title: "Newer source owner", createdAt: Date(timeIntervalSince1970: 30) + ) + let destinationOwner = notification( + id: UUID(), tabId: destinationTabId, surfaceId: surfaceId, + title: "Older destination owner", createdAt: Date(timeIntervalSince1970: 20) + ) + defer { + if let previousTombstones { + UserDefaults.standard.set(previousTombstones, forKey: tombstoneKey) + } else { + UserDefaults.standard.removeObject(forKey: tombstoneKey) + } + _ = store.flushSupersededPhoneDismissIDsForTesting(tabId: sourceTabId, surfaceId: surfaceId) + _ = store.flushSupersededPhoneDismissIDsForTesting(tabId: destinationTabId, surfaceId: surfaceId) + store.reloadDismissedTombstonesForTesting() + store.replaceNotificationsForTesting(previousNotifications) + } + + UserDefaults.standard.removeObject(forKey: tombstoneKey) + store.reloadDismissedTombstonesForTesting() + store.replaceNotificationsForTesting([sourceOwner, destinationOwner]) + store.stashSupersededPhoneDismissIDsForTesting( + [sourceSupersededId.uuidString], + tabId: sourceTabId, + surfaceId: surfaceId + ) + store.stashSupersededPhoneDismissIDsForTesting( + [destinationSupersededId.uuidString], + tabId: destinationTabId, + surfaceId: surfaceId + ) + + store.rebindSurfaceNotifications( + fromTabId: sourceTabId, + toTabId: destinationTabId, + surfaceId: surfaceId + ) + + #expect( + store.externalBannerOwnerIDForTesting( + tabId: destinationTabId, + surfaceId: surfaceId + ) == sourceOwner.id + ) + let tombstones = UserDefaults.standard.stringArray(forKey: tombstoneKey) ?? [] + #expect(tombstones.contains(destinationOwner.id.uuidString)) + #expect(tombstones.contains(destinationSupersededId.uuidString)) + #expect(!tombstones.contains(sourceOwner.id.uuidString)) + #expect(!tombstones.contains(sourceSupersededId.uuidString)) + #expect( + store.flushSupersededPhoneDismissIDsForTesting( + tabId: destinationTabId, + surfaceId: surfaceId + ) == [sourceSupersededId.uuidString] + ) + } + + @Test func transferPreservesSourceConfinedBannerOwner() throws { + let sourceTabId = UUID() + let destinationTabId = UUID() + let surfaceId = UUID() + let owner = notification( + id: UUID(), tabId: sourceTabId, surfaceId: surfaceId, + title: "Source-confined owner", createdAt: Date(timeIntervalSince1970: 10), + retargetsToLiveSurfaceOwner: false + ) + var ownership = ExternalNotificationBannerOwnership() + ownership.setOwner(owner) + + #expect(ownership.transfer( + fromTabId: sourceTabId, + toTabId: destinationTabId, + panelIdMap: [:] + ).isEmpty) + + let moved = try #require(ownership.owner(tabId: destinationTabId, surfaceId: surfaceId)) + #expect(!moved.retargetsToLiveSurfaceOwner) + } + + @Test func transferCollisionKeepsNewerMovedBannerOwner() throws { + let sourceTabId = UUID() + let destinationTabId = UUID() + let sourceSurfaceId = UUID() + let destinationSurfaceId = UUID() + let sourceOwner = notification( + id: UUID(), tabId: sourceTabId, surfaceId: sourceSurfaceId, + title: "Newer source owner", createdAt: Date(timeIntervalSince1970: 30) + ) + let destinationOwner = notification( + id: UUID(), tabId: destinationTabId, surfaceId: destinationSurfaceId, + title: "Older destination owner", createdAt: Date(timeIntervalSince1970: 20) + ) + var ownership = ExternalNotificationBannerOwnership() + ownership.setOwner(sourceOwner) + ownership.setOwner(destinationOwner) + + let displaced = ownership.transfer( + fromTabId: sourceTabId, + toTabId: destinationTabId, + panelIdMap: [sourceSurfaceId: destinationSurfaceId] + ) + + #expect(ownership.owner(tabId: destinationTabId, surfaceId: destinationSurfaceId)?.id == sourceOwner.id) + #expect(displaced.map(\.id) == [destinationOwner.id]) + } + + @Test func rebindKeepsSourceConfinedBannerOwnerAtSource() throws { + let sourceTabId = UUID() + let destinationTabId = UUID() + let surfaceId = UUID() + let owner = notification( + id: UUID(), tabId: sourceTabId, surfaceId: surfaceId, + title: "Source-confined owner", createdAt: Date(timeIntervalSince1970: 10), + retargetsToLiveSurfaceOwner: false + ) + var ownership = ExternalNotificationBannerOwnership() + ownership.setOwner(owner) + + #expect(ownership.rebind( + surfaceId: surfaceId, + fromTabId: sourceTabId, + toTabId: destinationTabId + ) == nil) + + let preserved = try #require(ownership.owner(tabId: sourceTabId, surfaceId: surfaceId)) + #expect(!preserved.retargetsToLiveSurfaceOwner) + #expect(ownership.owner(tabId: destinationTabId, surfaceId: surfaceId) == nil) + } + + @Test func rebindCollisionKeepsNewerMovedBannerOwner() throws { + let sourceTabId = UUID() + let destinationTabId = UUID() + let surfaceId = UUID() + let sourceOwner = notification( + id: UUID(), tabId: sourceTabId, surfaceId: surfaceId, + title: "Newer source owner", createdAt: Date(timeIntervalSince1970: 30) + ) + let destinationOwner = notification( + id: UUID(), tabId: destinationTabId, surfaceId: surfaceId, + title: "Older destination owner", createdAt: Date(timeIntervalSince1970: 20) + ) + var ownership = ExternalNotificationBannerOwnership() + ownership.setOwner(sourceOwner) + ownership.setOwner(destinationOwner) + + let displaced = ownership.rebind( + surfaceId: surfaceId, + fromTabId: sourceTabId, + toTabId: destinationTabId + ) + + #expect(ownership.owner(tabId: destinationTabId, surfaceId: surfaceId)?.id == sourceOwner.id) + #expect(displaced?.id == destinationOwner.id) + } + + @Test func sourceConfinedBannerOwnerStaysWithSourceWhenOtherRowsRebind() throws { + let store = TerminalNotificationStore.shared + let previousNotifications = store.notifications + let sourceTabId = UUID() + let destinationTabId = UUID() + let surfaceId = UUID() + let sourceConfinedOwner = notification( + id: UUID(), tabId: sourceTabId, surfaceId: surfaceId, + title: "Source-confined owner", createdAt: Date(timeIntervalSince1970: 20), + retargetsToLiveSurfaceOwner: false + ) + let retargetingRow = notification( + id: UUID(), tabId: sourceTabId, surfaceId: surfaceId, + title: "Retargeting row", createdAt: Date(timeIntervalSince1970: 10) + ) + let supersededId = UUID().uuidString + defer { + _ = store.flushSupersededPhoneDismissIDsForTesting(tabId: sourceTabId, surfaceId: surfaceId) + _ = store.flushSupersededPhoneDismissIDsForTesting(tabId: destinationTabId, surfaceId: surfaceId) + store.replaceNotificationsForTesting(previousNotifications) + } + + store.replaceNotificationsForTesting([sourceConfinedOwner, retargetingRow]) + store.stashSupersededPhoneDismissIDsForTesting( + [supersededId], tabId: sourceTabId, surfaceId: surfaceId + ) + store.rebindSurfaceNotifications( + fromTabId: sourceTabId, + toTabId: destinationTabId, + surfaceId: surfaceId + ) + + #expect( + store.externalBannerOwnerIDForTesting(tabId: sourceTabId, surfaceId: surfaceId) + == sourceConfinedOwner.id + ) + #expect(store.externalBannerOwnerIDForTesting(tabId: destinationTabId, surfaceId: surfaceId) == nil) + #expect(store.notifications.first(where: { $0.id == sourceConfinedOwner.id })?.tabId == sourceTabId) + #expect(store.notifications.first(where: { $0.id == retargetingRow.id })?.tabId == destinationTabId) + #expect( + store.flushSupersededPhoneDismissIDsForTesting(tabId: sourceTabId, surfaceId: surfaceId) + == [supersededId] + ) + #expect( + store.flushSupersededPhoneDismissIDsForTesting(tabId: destinationTabId, surfaceId: surfaceId) + == [] + ) + } + + @Test func readSourceConfinedHistoryDoesNotPinFocusedIndicatorAtSource() { + let store = TerminalNotificationStore.shared + let previousNotifications = store.notifications + let sourceTabId = UUID() + let destinationTabId = UUID() + let surfaceId = UUID() + let readSourceConfinedHistory = notification( + id: UUID(), tabId: sourceTabId, surfaceId: surfaceId, + title: "Read source history", createdAt: Date(timeIntervalSince1970: 10), + retargetsToLiveSurfaceOwner: false, + isRead: true + ) + let movingUnread = notification( + id: UUID(), tabId: sourceTabId, surfaceId: surfaceId, + title: "Moving unread", createdAt: Date(timeIntervalSince1970: 20) + ) + defer { store.replaceNotificationsForTesting(previousNotifications) } + + store.replaceNotificationsForTesting([movingUnread, readSourceConfinedHistory]) + store.setFocusedReadIndicator(forTabId: sourceTabId, surfaceId: surfaceId) + store.rebindSurfaceNotifications( + fromTabId: sourceTabId, + toTabId: destinationTabId, + surfaceId: surfaceId + ) + + #expect(store.focusedReadIndicatorSurfaceId(forTabId: sourceTabId) == nil) + #expect(store.focusedReadIndicatorSurfaceId(forTabId: destinationTabId) == surfaceId) + } + + @Test func restoredNewerRowDoesNotOwnLiveBannerRowActions() { + let store = TerminalNotificationStore.shared + let previousNotifications = store.notifications + let tabId = UUID() + let surfaceId = UUID() + let live = notification( + id: UUID(), tabId: tabId, surfaceId: surfaceId, + title: "Live banner", createdAt: Date(timeIntervalSince1970: 10) + ) + let restored = notification( + id: UUID(), tabId: tabId, surfaceId: surfaceId, + title: "Newer restored row", createdAt: Date(timeIntervalSince1970: 20) + ) + defer { + _ = store.flushSupersededPhoneDismissIDsForTesting(tabId: tabId, surfaceId: surfaceId) + store.replaceNotificationsForTesting(previousNotifications) + } + + store.replaceNotificationsForTesting([live]) + store.restoreSessionNotifications([restored], forTabId: tabId) + #expect(store.externalBannerOwnerIDForTesting(tabId: tabId, surfaceId: surfaceId) == live.id) + store.stashSupersededPhoneDismissIDsForTesting( + ["preserve-for-live-owner"], tabId: tabId, surfaceId: surfaceId + ) + + store.markRead(id: restored.id) + #expect(store.externalBannerOwnerIDForTesting(tabId: tabId, surfaceId: surfaceId) == live.id) + #expect( + store.flushSupersededPhoneDismissIDsForTesting(tabId: tabId, surfaceId: surfaceId) + == ["preserve-for-live-owner"] + ) + + store.stashSupersededPhoneDismissIDsForTesting( + ["drain-with-live-owner"], tabId: tabId, surfaceId: surfaceId + ) + store.markRead(id: live.id) + #expect(store.externalBannerOwnerIDForTesting(tabId: tabId, surfaceId: surfaceId) == nil) + #expect(store.flushSupersededPhoneDismissIDsForTesting(tabId: tabId, surfaceId: surfaceId) == []) + } + + @Test func explicitlyRestoredReadBannerOwnerOwnsRowActions() { + let store = TerminalNotificationStore.shared + let previousNotifications = store.notifications + let tabId = UUID() + let surfaceId = UUID() + let restoredOwner = notification( + id: UUID(), tabId: tabId, surfaceId: surfaceId, + title: "Read restored owner", + createdAt: Date(timeIntervalSince1970: 20), + isRead: true + ) + defer { + _ = store.flushSupersededPhoneDismissIDsForTesting(tabId: tabId, surfaceId: surfaceId) + store.replaceNotificationsForTesting(previousNotifications) + } + + store.replaceNotificationsForTesting([]) + store.applySessionNotificationMerge( + [restoredOwner], + restoredExternalBannerOwnerIDs: [restoredOwner.id] + ) + + #expect(store.externalBannerOwnerIDForTesting(tabId: tabId, surfaceId: surfaceId) == restoredOwner.id) + store.stashSupersededPhoneDismissIDsForTesting( + ["drain-with-restored-read-owner"], tabId: tabId, surfaceId: surfaceId + ) + store.remove(id: restoredOwner.id) + #expect(store.externalBannerOwnerIDForTesting(tabId: tabId, surfaceId: surfaceId) == nil) + #expect(store.flushSupersededPhoneDismissIDsForTesting(tabId: tabId, surfaceId: surfaceId) == []) + } + + @Test func nextLiveNotificationSupersedesLiveOwnerInsteadOfNewerRestoredRow() { + let store = TerminalNotificationStore.shared + let tabId = UUID() + let surfaceId = UUID() + let live = notification( + id: UUID(), tabId: tabId, surfaceId: surfaceId, + title: "Live owner", createdAt: Date(timeIntervalSince1970: 10) + ) + let restored = notification( + id: UUID(), tabId: tabId, surfaceId: surfaceId, + title: "Restored row", createdAt: Date(timeIntervalSince1970: 20) + ) + let replacementId = UUID() + let originalAppFocusOverride = AppFocusState.overrideIsFocused + + store.replaceNotificationsForTesting([live]) + AppFocusState.overrideIsFocused = false + store.configureNotificationDeliveryHandlerForTesting { _, _ in } + defer { + store.replaceNotificationsForTesting([]) + store.resetNotificationDeliveryHandlerForTesting() + AppFocusState.overrideIsFocused = originalAppFocusOverride + } + + store.restoreSessionNotifications([restored], forTabId: tabId) + #expect(store.externalBannerOwnerIDForTesting(tabId: tabId, surfaceId: surfaceId) == live.id) + + store.addNotification( + id: replacementId, + acceptedAt: Date(timeIntervalSince1970: 30), + tabId: tabId, + surfaceId: surfaceId, + title: "Replacement", + subtitle: "", + body: "", + retargetsToLiveSurfaceOwner: false + ) + + #expect(store.notifications.map(\.id) == [replacementId, restored.id, live.id]) + #expect(store.externalBannerOwnerIDForTesting(tabId: tabId, surfaceId: surfaceId) == replacementId) + } + + @Test func feedCapEvictionDismissesEvictedBannerOwner() { + let store = TerminalNotificationStore.shared + let previousNotifications = store.notifications + let tombstoneKey = TerminalNotificationStore.dismissedTombstoneDefaultsKey + let previousTombstones = UserDefaults.standard.object(forKey: tombstoneKey) + let originalAppFocusOverride = AppFocusState.overrideIsFocused + let evictedTabId = UUID() + let evictedSurfaceId = UUID() + let evictedId = UUID() + let evicted = notification( + id: evictedId, + tabId: evictedTabId, + surfaceId: evictedSurfaceId, + title: "Evicted owner", + createdAt: Date(timeIntervalSince1970: 0) + ) + let retained = (1..<TerminalNotificationStore.maximumNotificationFeedCount).map { index in + notification( + id: UUID(), + tabId: UUID(), + surfaceId: UUID(), + title: "Retained \(index)", + createdAt: Date(timeIntervalSince1970: TimeInterval(index)) + ) + } + defer { + if let previousTombstones { + UserDefaults.standard.set(previousTombstones, forKey: tombstoneKey) + } else { + UserDefaults.standard.removeObject(forKey: tombstoneKey) + } + store.reloadDismissedTombstonesForTesting() + store.replaceNotificationsForTesting(previousNotifications) + store.resetNotificationDeliveryHandlerForTesting() + AppFocusState.overrideIsFocused = originalAppFocusOverride + } + + UserDefaults.standard.removeObject(forKey: tombstoneKey) + store.reloadDismissedTombstonesForTesting() + AppFocusState.overrideIsFocused = false + store.configureNotificationDeliveryHandlerForTesting { _, _ in } + store.replaceNotificationsForTesting(([evicted] + retained).sorted(by: TerminalNotificationStore.notificationSortPrecedes)) + #expect(store.externalBannerOwnerIDForTesting(tabId: evictedTabId, surfaceId: evictedSurfaceId) == evictedId) + + store.addNotification( + id: UUID(), + acceptedAt: Date(timeIntervalSince1970: TimeInterval(TerminalNotificationStore.maximumNotificationFeedCount + 1)), + tabId: UUID(), + surfaceId: UUID(), + title: "Replacement at cap", + subtitle: "", + body: "", + retargetsToLiveSurfaceOwner: false + ) + + #expect(!store.notifications.contains(where: { $0.id == evictedId })) + #expect(store.externalBannerOwnerIDForTesting(tabId: evictedTabId, surfaceId: evictedSurfaceId) == nil) + let tombstones = UserDefaults.standard.stringArray(forKey: tombstoneKey) ?? [] + #expect(tombstones.contains(evictedId.uuidString)) + } + + @Test func feedCapMiddleInsertionDismissesEvictedBannerOwner() { + let store = TerminalNotificationStore.shared + let previousNotifications = store.notifications + let tombstoneKey = TerminalNotificationStore.dismissedTombstoneDefaultsKey + let previousTombstones = UserDefaults.standard.object(forKey: tombstoneKey) + let originalAppFocusOverride = AppFocusState.overrideIsFocused + let evictedTabId = UUID() + let evictedSurfaceId = UUID() + let evictedId = UUID() + let evicted = notification( + id: evictedId, + tabId: evictedTabId, + surfaceId: evictedSurfaceId, + title: "Evicted owner", + createdAt: Date(timeIntervalSince1970: 0) + ) + let retained = (1..<TerminalNotificationStore.maximumNotificationFeedCount).map { index in + notification( + id: UUID(), + tabId: UUID(), + surfaceId: UUID(), + title: "Retained \(index)", + createdAt: Date(timeIntervalSince1970: TimeInterval(index)) + ) + } + defer { + if let previousTombstones { + UserDefaults.standard.set(previousTombstones, forKey: tombstoneKey) + } else { + UserDefaults.standard.removeObject(forKey: tombstoneKey) + } + store.reloadDismissedTombstonesForTesting() + store.replaceNotificationsForTesting(previousNotifications) + store.resetNotificationDeliveryHandlerForTesting() + AppFocusState.overrideIsFocused = originalAppFocusOverride + } + + UserDefaults.standard.removeObject(forKey: tombstoneKey) + store.reloadDismissedTombstonesForTesting() + AppFocusState.overrideIsFocused = false + store.configureNotificationDeliveryHandlerForTesting { _, _ in } + store.replaceNotificationsForTesting(([evicted] + retained).sorted(by: TerminalNotificationStore.notificationSortPrecedes)) + #expect(store.externalBannerOwnerIDForTesting(tabId: evictedTabId, surfaceId: evictedSurfaceId) == evictedId) + + store.addNotification( + id: UUID(), + acceptedAt: Date(timeIntervalSince1970: 10_000.5), + tabId: UUID(), + surfaceId: UUID(), + title: "Middle retained", + subtitle: "", + body: "", + retargetsToLiveSurfaceOwner: false + ) + + #expect(!store.notifications.contains(where: { $0.id == evictedId })) + #expect(store.externalBannerOwnerIDForTesting(tabId: evictedTabId, surfaceId: evictedSurfaceId) == nil) + let tombstones = UserDefaults.standard.stringArray(forKey: tombstoneKey) ?? [] + #expect(tombstones.contains(evictedId.uuidString)) + } + + @Test func feedCapOlderInsertionDoesNotDeliverAbsentRow() { + let store = TerminalNotificationStore.shared + let previousNotifications = store.notifications + let originalAppFocusOverride = AppFocusState.overrideIsFocused + let oldId = UUID() + let oldTabId = UUID() + var deliveredIds: [UUID] = [] + let retained = (1...TerminalNotificationStore.maximumNotificationFeedCount).map { index in + notification( + id: UUID(), + tabId: UUID(), + surfaceId: UUID(), + title: "Retained \(index)", + createdAt: Date(timeIntervalSince1970: TimeInterval(index)) + ) + } + defer { + store.replaceNotificationsForTesting(previousNotifications) + store.resetNotificationDeliveryHandlerForTesting() + AppFocusState.overrideIsFocused = originalAppFocusOverride + } + + AppFocusState.overrideIsFocused = false + store.configureNotificationDeliveryHandlerForTesting { _, notification in + deliveredIds.append(notification.id) + } + store.replaceNotificationsForTesting(retained.sorted(by: TerminalNotificationStore.notificationSortPrecedes)) + store.markUnread(forTabId: oldTabId) + + store.addNotification( + id: oldId, + acceptedAt: Date(timeIntervalSince1970: 0), + tabId: oldTabId, + surfaceId: UUID(), + title: "Too old", + subtitle: "", + body: "", + retargetsToLiveSurfaceOwner: false + ) + + #expect(!store.notifications.contains(where: { $0.id == oldId })) + #expect(deliveredIds.isEmpty) + #expect(store.hasManualUnread(forTabId: oldTabId)) + } + + private func notification( + id: UUID, + tabId: UUID, + surfaceId: UUID?, + title: String, + createdAt: Date, + retargetsToLiveSurfaceOwner: Bool = true, + isRead: Bool = false + ) -> TerminalNotification { + TerminalNotification( + id: id, tabId: tabId, surfaceId: surfaceId, + retargetsToLiveSurfaceOwner: retargetsToLiveSurfaceOwner, + title: title, subtitle: "", body: "", + createdAt: createdAt, isRead: isRead + ) + } +} diff --git a/cmuxTests/NotificationSessionReplacementIdentityTests.swift b/cmuxTests/NotificationSessionReplacementIdentityTests.swift new file mode 100644 index 000000000000..c50497ce800e --- /dev/null +++ b/cmuxTests/NotificationSessionReplacementIdentityTests.swift @@ -0,0 +1,181 @@ +import Foundation +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +final class NotificationSessionReplacementIdentityTests: XCTestCase { + func testDuplicateRestoreCanonicalizationIncludesRoutingAndScrollFields() throws { + let tabId = UUID() + let notificationId = UUID() + let createdAt = Date(timeIntervalSince1970: 1) + + func notification( + retargetsToLiveSurfaceOwner: Bool = true, + rowSpaceRevision: UInt64? = nil + ) -> TerminalNotification { + TerminalNotification( + id: notificationId, + tabId: tabId, + surfaceId: nil, + retargetsToLiveSurfaceOwner: retargetsToLiveSurfaceOwner, + title: "Duplicate", + subtitle: "", + body: "", + createdAt: createdAt, + isRead: false, + scrollPosition: TerminalNotificationScrollPosition( + row: 1, + totalRows: 2, + rowSpaceRevision: rowSpaceRevision + ) + ) + } + + func canonical(_ restored: [TerminalNotification]) throws -> TerminalNotification { + try XCTUnwrap(TerminalNotificationStore.mergeRestoredSessionNotifications( + existing: [], + restored: restored, + tabId: tabId, + replacingTabId: nil, + panelIdMap: [:] + ).first) + } + + let confined = notification(retargetsToLiveSurfaceOwner: false) + let retargeting = notification(retargetsToLiveSurfaceOwner: true) + XCTAssertEqual( + try canonical([confined, retargeting]), + try canonical([retargeting, confined]) + ) + + let earlierRowSpace = notification(rowSpaceRevision: 1) + let laterRowSpace = notification(rowSpaceRevision: 2) + XCTAssertEqual( + try canonical([earlierRowSpace, laterRowSpace]), + try canonical([laterRowSpace, earlierRowSpace]) + ) + } + + func testReplacementRetainsUnmappedNotificationLocation() throws { + let oldTabId = UUID() + let newTabId = UUID() + let unmappedSurfaceId = UUID() + let unmappedPanelId = UUID() + let current = TerminalNotification( + id: UUID(), + tabId: oldTabId, + surfaceId: unmappedSurfaceId, + panelId: unmappedPanelId, + title: "Unmapped surface", + subtitle: "", + body: "", + createdAt: Date(timeIntervalSince1970: 1), + isRead: false + ) + + let merged = TerminalNotificationStore.mergeRestoredSessionNotifications( + existing: [current], + restored: [], + tabId: newTabId, + replacingTabId: oldTabId, + panelIdMap: [:] + ) + let transferred = try XCTUnwrap(merged.first) + + XCTAssertEqual(transferred.tabId, newTabId) + XCTAssertEqual(transferred.surfaceId, unmappedSurfaceId) + XCTAssertEqual(transferred.panelId, unmappedPanelId) + } + + func testReplacementRetainsUnmappedFocusedAndQueuedSurfaceIdentity() throws { + let store = TerminalNotificationStore.shared + let bus = TerminalMutationBus.shared + let oldTabId = UUID() + let newTabId = UUID() + let unmappedSurfaceId = UUID() + bus.discardPendingNotifications() + bus.setDrainsSuspendedForTesting(true) + store.replaceNotificationsForTesting([]) + defer { + store.replaceNotificationsForTesting([]) + bus.discardPendingNotifications() + bus.drainForTesting() + bus.setDrainsSuspendedForTesting(false) + } + + store.setFocusedReadIndicator(forTabId: oldTabId, surfaceId: unmappedSurfaceId) + XCTAssertTrue(bus.enqueueNotification( + tabId: oldTabId, + surfaceId: unmappedSurfaceId, + title: "Accepted before replacement", + subtitle: "", + body: "" + )) + + store.transferSessionNotifications( + fromTabId: oldTabId, + toTabId: newTabId, + panelIdMap: [:] + ) + + let queued = try XCTUnwrap(bus.notificationIdentityStateForTesting().first) + XCTAssertEqual(queued.2, newTabId) + XCTAssertEqual(queued.3, unmappedSurfaceId) + XCTAssertEqual(store.focusedReadIndicatorSurfaceId(forTabId: newTabId), unmappedSurfaceId) + } + + func testReplacementPersistsUnmappedNotificationLocationInNextSnapshot() throws { + let store = TerminalNotificationStore.shared + let appDelegate = AppDelegate.shared ?? AppDelegate() + let manager = TabManager() + let originalTabManager = appDelegate.tabManager + let originalNotificationStore = appDelegate.notificationStore + let previousNotifications = store.notifications + appDelegate.tabManager = manager + appDelegate.notificationStore = store + let source = manager.addWorkspace(select: true) + let destination = manager.addWorkspace(select: false) + let unmappedSurfaceId = UUID() + let unmappedPanelId = UUID() + let notification = TerminalNotification( + id: UUID(), + tabId: source.id, + surfaceId: unmappedSurfaceId, + panelId: unmappedPanelId, + title: "Unmapped persisted row", + subtitle: "", + body: "", + createdAt: Date(timeIntervalSince1970: 1), + isRead: false + ) + defer { + store.replaceNotificationsForTesting(previousNotifications) + appDelegate.tabManager = originalTabManager + appDelegate.notificationStore = originalNotificationStore + } + + store.replaceNotificationsForTesting([notification]) + store.transferSessionNotifications( + fromTabId: source.id, + toTabId: destination.id, + panelIdMap: [:] + ) + + let snapshot = destination.sessionSnapshot(includeScrollback: false) + let allSnapshots = (snapshot.notifications ?? []) + + snapshot.panels.flatMap { $0.notifications ?? [] } + let persisted = try XCTUnwrap(allSnapshots.first { $0.id == notification.id }) + let restored = persisted.terminalNotification( + tabId: destination.id, + surfaceId: nil, + panelId: nil + ) + XCTAssertEqual(restored.surfaceId, unmappedSurfaceId) + XCTAssertEqual(restored.panelId, unmappedPanelId) + } +} diff --git a/cmuxTests/SessionPersistenceTests.swift b/cmuxTests/SessionPersistenceTests.swift index 39857230b308..8de74bba1da4 100644 --- a/cmuxTests/SessionPersistenceTests.swift +++ b/cmuxTests/SessionPersistenceTests.swift @@ -148,7 +148,7 @@ final class SessionPersistenceTests: XCTestCase { } @MainActor - func testRestoreSessionNotificationsKeepsNotificationIdsUniqueWhenSnapshotIsDuplicated() throws { + func testRestoreSessionNotificationsTreatsDuplicateIdAsReplay() throws { let store = TerminalNotificationStore.shared store.replaceNotificationsForTesting([]) defer { store.replaceNotificationsForTesting([]) } @@ -180,10 +180,95 @@ final class SessionPersistenceTests: XCTestCase { store.replaceNotificationsForTesting([existing]) store.restoreSessionNotifications([restored], forTabId: restoredWorkspaceId) - XCTAssertEqual(store.notifications.count, 2) - XCTAssertEqual(Set(store.notifications.map(\.id)).count, 2) + XCTAssertEqual(store.notifications.count, 1) + XCTAssertEqual(Set(store.notifications.map(\.id)).count, 1) XCTAssertTrue(store.notifications.contains { $0.tabId == liveWorkspaceId && $0.id == duplicateId }) - XCTAssertTrue(store.notifications.contains { $0.tabId == restoredWorkspaceId && $0.id != duplicateId }) + XCTAssertFalse(store.notifications.contains { $0.tabId == restoredWorkspaceId }) + } + + @MainActor + func testRestoringDuplicateWorkspaceSnapshotDoesNotStealLiveNotificationRows() throws { + let store = TerminalNotificationStore.shared + let appDelegate = AppDelegate.shared ?? AppDelegate() + let originalNotificationStore = appDelegate.notificationStore + appDelegate.notificationStore = store + store.replaceNotificationsForTesting([]) + defer { + store.replaceNotificationsForTesting([]) + appDelegate.notificationStore = originalNotificationStore + } + + let source = Workspace() + let notification = TerminalNotification( + id: UUID(), + tabId: source.id, + surfaceId: nil, + title: "Live source row", + subtitle: "", + body: "", + createdAt: Date(timeIntervalSince1970: 1), + isRead: false + ) + store.replaceNotificationsForTesting([notification]) + let snapshot = source.sessionSnapshot(includeScrollback: false) + + let duplicate = Workspace() + duplicate.restoreSessionSnapshot(snapshot) + + XCTAssertEqual(store.notifications.count, 1) + XCTAssertEqual(store.notifications.first?.id, notification.id) + XCTAssertEqual(store.notifications.first?.tabId, source.id) + XCTAssertFalse(store.notifications.contains { $0.tabId == duplicate.id }) + } + + @MainActor + func testSessionRoundTripPersistsBannerOwnerWithoutUsingChronology() throws { + let store = TerminalNotificationStore.shared + let appDelegate = AppDelegate.shared ?? AppDelegate() + let originalNotificationStore = appDelegate.notificationStore + appDelegate.notificationStore = store + store.replaceNotificationsForTesting([]) + defer { + store.replaceNotificationsForTesting([]) + appDelegate.notificationStore = originalNotificationStore + } + + let source = Workspace() + let olderOwner = TerminalNotification( + id: UUID(), + tabId: source.id, + surfaceId: nil, + title: "Displayed older row", + subtitle: "", + body: "", + createdAt: Date(timeIntervalSince1970: 1), + isRead: false + ) + let newerHistory = TerminalNotification( + id: UUID(), + tabId: source.id, + surfaceId: nil, + title: "Newer restored history", + subtitle: "", + body: "", + createdAt: Date(timeIntervalSince1970: 2), + isRead: false + ) + store.replaceNotificationsForTesting([olderOwner]) + store.restoreSessionNotifications([newerHistory], forTabId: source.id) + + let snapshot = source.sessionSnapshot(includeScrollback: false) + XCTAssertEqual(snapshot.externalBannerOwnerNotificationIds, [olderOwner.id]) + store.replaceNotificationsForTesting([]) + + let restored = Workspace() + restored.restoreSessionSnapshot(snapshot) + + XCTAssertEqual( + store.externalBannerOwnerNotificationIDs(forTabId: restored.id), + [olderOwner.id] + ) + XCTAssertEqual(store.notifications.map(\.id), [newerHistory.id, olderOwner.id]) } func testSaveAndLoadRoundTripWithCustomSnapshotPath() throws { diff --git a/cmuxTests/TerminalNotificationBackpressureTests.swift b/cmuxTests/TerminalNotificationBackpressureTests.swift new file mode 100644 index 000000000000..a54a08c617a8 --- /dev/null +++ b/cmuxTests/TerminalNotificationBackpressureTests.swift @@ -0,0 +1,524 @@ +import Foundation +import XCTest +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +private final class NotificationEnqueueResults: @unchecked Sendable { + private let lock = NSLock() + private var values: [Bool] = [] + + func append(_ value: Bool) { + lock.lock() + values.append(value) + lock.unlock() + } + + func snapshot() -> [Bool] { + lock.lock() + defer { lock.unlock() } + return values + } +} + +@MainActor +final class TerminalNotificationBackpressureTests: XCTestCase { + func testSaturationCapsWaitersBeforeAcceptance() async { + let bus = TerminalMutationBus.shared + let tabId = UUID() + let surfaceId = UUID() + resetBackpressureQueueState(bus) + bus.setDrainsSuspendedForTesting(true) + defer { + resetBackpressureQueueState(bus) + } + + for index in 0..<TerminalMutationBus.maximumPendingMutationCount { + XCTAssertTrue(bus.enqueueNotification( + tabId: tabId, + surfaceId: surfaceId, + title: "", + subtitle: "", + body: "" + ), "Seed \(index) should be admitted before the count cap") + } + + let ready = DispatchGroup() + let releaseWaiters = DispatchSemaphore(value: 0) + let completed = expectation(description: "waiting producers completed") + completed.expectedFulfillmentCount = TerminalMutationBus.maximumWaitingNotificationProducerCount + let results = NotificationEnqueueResults() + let expectedWaiterTitles = Set( + (0..<TerminalMutationBus.maximumWaitingNotificationProducerCount).map { "Waiter \($0)" } + ) + + for index in 0..<TerminalMutationBus.maximumWaitingNotificationProducerCount { + ready.enter() + DispatchQueue.global(qos: .userInitiated).async { + ready.leave() + releaseWaiters.wait() + results.append(bus.enqueueNotification( + tabId: tabId, + surfaceId: surfaceId, + title: "Waiter \(index)", + subtitle: "", + body: "" + )) + completed.fulfill() + } + } + XCTAssertEqual(ready.wait(timeout: .now() + 2), .success) + for _ in 0..<TerminalMutationBus.maximumWaitingNotificationProducerCount { + releaseWaiters.signal() + } + XCTAssertTrue(waitForWaitingNotificationProducers(bus, count: TerminalMutationBus.maximumWaitingNotificationProducerCount)) + + let fullState = bus.notificationQueueStateForTesting() + XCTAssertEqual(fullState.0, TerminalMutationBus.maximumWaitingNotificationProducerCount) + XCTAssertEqual(fullState.1.count, TerminalMutationBus.maximumPendingMutationCount) + XCTAssertFalse(bus.enqueueNotification( + tabId: tabId, + surfaceId: surfaceId, + title: "Rejected before acceptance", + subtitle: "", + body: "" + )) + XCTAssertEqual(bus.notificationQueueStateForTesting().1, fullState.1) + + bus.discardPendingNotifications() + await fulfillment(of: [completed], timeout: 2) + + XCTAssertEqual(results.snapshot(), Array(repeating: true, count: expectedWaiterTitles.count)) + } + + private func waitForWaitingNotificationProducers( + _ bus: TerminalMutationBus, + count: Int + ) -> Bool { + let deadline = Date(timeIntervalSinceNow: TerminalMutationBus.notificationCapacityWaitTimeout / 2) + while Date() < deadline { + if bus.notificationQueueStateForTesting().0 == count { + return true + } + RunLoop.current.run(mode: .default, before: Date(timeIntervalSinceNow: 0.002)) + } + return bus.notificationQueueStateForTesting().0 == count + } + + func testCapacityWaitExpiresBeforeNotificationAcceptance() async { + let bus = TerminalMutationBus.shared + resetBackpressureQueueState(bus) + bus.setDrainsSuspendedForTesting(true) + defer { + resetBackpressureQueueState(bus) + } + for index in 0..<TerminalMutationBus.maximumPendingMutationCount { + XCTAssertTrue(bus.enqueueNotification( + tabId: UUID(), surfaceId: nil, title: "", subtitle: "", body: "" + ), "Seed \(index) should be admitted before the count cap") + } + + let completed = expectation(description: "capacity wait expired") + let results = NotificationEnqueueResults() + DispatchQueue.global(qos: .userInitiated).async { + results.append(bus.enqueueNotification( + tabId: UUID(), surfaceId: nil, title: "Timed out", subtitle: "", body: "" + )) + completed.fulfill() + } + XCTAssertTrue(waitForWaitingNotificationProducers(bus, count: 1)) + XCTAssertEqual(bus.notificationQueueStateForTesting().0, 1) + await fulfillment(of: [completed], timeout: TerminalMutationBus.notificationCapacityWaitTimeout + 1) + XCTAssertEqual(results.snapshot(), [false]) + XCTAssertEqual(bus.notificationQueueStateForTesting().0, 0) + XCTAssertFalse(bus.notificationQueueStateForTesting().1.contains("Timed out")) + } + + func testQueuedContentByteBudgetRejectsBeforePendingCountLimit() { + let bus = TerminalMutationBus.shared + let body = String(repeating: "b", count: TerminalNotificationStore.maximumNotificationBodyBytes) + let byteLimitedCount = TerminalMutationBus.maximumQueuedNotificationContentBytes / body.utf8.count + XCTAssertLessThan(byteLimitedCount, TerminalMutationBus.maximumPendingMutationCount) + resetBackpressureQueueState(bus) + bus.setDrainsSuspendedForTesting(true) + defer { + resetBackpressureQueueState(bus) + } + + for _ in 0..<byteLimitedCount { + XCTAssertTrue(bus.enqueueNotification( + tabId: UUID(), + surfaceId: nil, + title: "", + subtitle: "", + body: body + )) + } + + XCTAssertEqual(bus.notificationIdentityStateForTesting().count, byteLimitedCount) + XCTAssertFalse(bus.enqueueNotification( + tabId: UUID(), + surfaceId: nil, + title: "", + subtitle: "", + body: body + )) + XCTAssertEqual(bus.notificationIdentityStateForTesting().count, byteLimitedCount) + } + + func testOversizedQueuedTextIsTruncatedBeforePendingAdmission() { + let bus = TerminalMutationBus.shared + let title = String(repeating: "é", count: TerminalNotificationStore.maximumNotificationTitleBytes) + let subtitle = String(repeating: "s", count: TerminalNotificationStore.maximumNotificationSubtitleBytes + 128) + let body = String(repeating: "b", count: TerminalNotificationStore.maximumNotificationBodyBytes + 128) + resetBackpressureQueueState(bus) + bus.setDrainsSuspendedForTesting(true) + defer { + resetBackpressureQueueState(bus) + } + + XCTAssertTrue(bus.enqueueNotification( + tabId: UUID(), + surfaceId: nil, + title: title, + subtitle: subtitle, + body: body + )) + + let queuedTitles = bus.notificationQueueStateForTesting().1 + XCTAssertEqual(queuedTitles.count, 1) + XCTAssertLessThanOrEqual(queuedTitles[0].utf8.count, TerminalNotificationStore.maximumNotificationTitleBytes) + } + + func testSharedAgentDeliveryReportsSaturationBeforeAcceptance() { + let bus = TerminalMutationBus.shared + resetBackpressureQueueState(bus) + bus.setDrainsSuspendedForTesting(true) + defer { + resetBackpressureQueueState(bus) + } + for index in 0..<TerminalMutationBus.maximumPendingMutationCount { + XCTAssertTrue(bus.enqueueNotification( + tabId: UUID(), surfaceId: nil, title: "Seed \(index)", subtitle: "", body: "" + )) + } + + let result = AgentNotificationDelivery().enqueue( + workspaceID: UUID(), + surfaceID: UUID(), + title: "Rejected by shared delivery", + subtitle: "", + body: "", + category: nil, + pending: false + ) + + XCTAssertEqual(result, .saturated) + XCTAssertFalse(bus.notificationQueueStateForTesting().1.contains("Rejected by shared delivery")) + } + + func testNotifyTargetAsyncReturnsLiteralSaturationResponse() { + let bus = TerminalMutationBus.shared + resetBackpressureQueueState(bus) + bus.setDrainsSuspendedForTesting(true) + defer { + resetBackpressureQueueState(bus) + } + + for index in 0..<TerminalMutationBus.maximumPendingMutationCount { + XCTAssertTrue(bus.enqueueNotification( + tabId: UUID(), + surfaceId: nil, + title: "Seed \(index)", + subtitle: "", + body: "" + )) + } + + let response = TerminalController.debugNotifyTargetQueuedResponseForTesting( + "\(UUID().uuidString) \(UUID().uuidString) Saturated|Retry|Body" + ) + + XCTAssertEqual( + response, + ReliableTerminalNotificationEnqueueResult.saturatedSocketResponse + ) + } + + private func resetBackpressureQueueState(_ bus: TerminalMutationBus) { + bus.lock.lock() + bus.pending.removeAll(keepingCapacity: false) + bus.pendingHead = 0 + bus.reliableAdmissionsById.removeAll(keepingCapacity: false) + bus.notificationReplacementRoutesByTabId.removeAll() + bus.notificationReplacementRouteOrder.removeAll() + bus.notificationLiveOwnerTabIdBySurfaceId.removeAll() + bus.notificationLiveOwnerSurfaceOrder.removeAll() + bus.lock.broadcast() + bus.lock.unlock() + bus.drainForTesting() + bus.setDrainsSuspendedForTesting(false) + } +} + +@MainActor +final class TerminalNotificationSessionReplacementTests: XCTestCase { + func testReplacementTransfersLiveAndRestoredNotificationsToRebuiltTargets() { + let store = TerminalNotificationStore.shared + let bus = TerminalMutationBus.shared + let oldTabId = UUID() + let newTabId = UUID() + let oldSurfaceId = UUID() + let newSurfaceId = UUID() + let restoredId = UUID() + let lateId = UUID() + let duringReleaseId = UUID() + let unrelatedTabId = UUID() + let unrelatedSurfaceId = UUID() + let unrelatedId = UUID() + bus.discardPendingNotifications() + bus.setDrainsSuspendedForTesting(true) + defer { + store.replaceNotificationsForTesting([]) + bus.discardPendingNotifications() + bus.drainForTesting() + bus.setDrainsSuspendedForTesting(false) + } + + let liveCanonical = notification( + id: restoredId, + tabId: oldTabId, + surfaceId: oldSurfaceId, + title: "Live canonical", + createdAt: Date(timeIntervalSince1970: 20), + isRead: true + ) + let lateLive = notification( + id: lateId, + tabId: oldTabId, + surfaceId: oldSurfaceId, + title: "Late live", + createdAt: Date(timeIntervalSince1970: 30), + isRead: false + ) + let unrelated = notification( + id: unrelatedId, + tabId: unrelatedTabId, + surfaceId: unrelatedSurfaceId, + title: "Unrelated", + createdAt: Date(timeIntervalSince1970: 5), + isRead: false + ) + store.replaceNotificationsForTesting([liveCanonical, lateLive, unrelated]) + + let restoredReplay = notification( + id: restoredId, + tabId: newTabId, + surfaceId: newSurfaceId, + title: "Persisted replay", + createdAt: Date(timeIntervalSince1970: 10), + isRead: false + ) + store.restoreSessionNotifications( + [restoredReplay], + forTabId: newTabId, + replacingTabId: oldTabId, + panelIdMap: [oldSurfaceId: newSurfaceId] + ) + let duringRelease = notification( + id: duringReleaseId, + tabId: oldTabId, + surfaceId: oldSurfaceId, + title: "During release", + createdAt: Date(timeIntervalSince1970: 40), + isRead: false + ) + store.replaceNotificationsForTesting(Array(store.notifications) + [duringRelease]) + store.markUnread(forTabId: oldTabId) + store.setPanelDerivedUnread(true, forTabId: oldTabId) + store.restoreUnreadIndicator(forTabId: oldTabId) + store.setFocusedReadIndicator(forTabId: oldTabId, surfaceId: oldSurfaceId) + store.markUnread(forTabId: unrelatedTabId) + store.setFocusedReadIndicator(forTabId: unrelatedTabId, surfaceId: unrelatedSurfaceId) + XCTAssertTrue(bus.enqueueNotification( + tabId: oldTabId, + surfaceId: oldSurfaceId, + title: "Accepted during restore", + subtitle: "", + body: "" + )) + let queuedBeforeTransfer = bus.notificationIdentityStateForTesting() + store.transferSessionNotifications( + fromTabId: oldTabId, + toTabId: newTabId, + panelIdMap: [oldSurfaceId: newSurfaceId] + ) + let queuedAfterTransfer = bus.notificationIdentityStateForTesting() + + XCTAssertEqual(store.notifications.map(\.id), [duringReleaseId, lateId, restoredId, unrelatedId]) + XCTAssertEqual(store.notifications.first(where: { $0.id == restoredId })?.title, "Live canonical") + XCTAssertEqual(store.notifications.first(where: { $0.id == restoredId })?.isRead, true) + XCTAssertEqual(store.notifications.first(where: { $0.id == restoredId })?.tabId, newTabId) + XCTAssertEqual(store.notifications.first(where: { $0.id == restoredId })?.surfaceId, newSurfaceId) + XCTAssertEqual(store.notifications.first(where: { $0.id == lateId })?.tabId, newTabId) + XCTAssertEqual(store.notifications.first(where: { $0.id == lateId })?.surfaceId, newSurfaceId) + XCTAssertEqual(store.notifications.first(where: { $0.id == duringReleaseId })?.tabId, newTabId) + XCTAssertEqual(store.notifications.first(where: { $0.id == duringReleaseId })?.surfaceId, newSurfaceId) + XCTAssertEqual(store.notifications.first(where: { $0.id == unrelatedId }), unrelated) + XCTAssertEqual(Set(store.notifications.map(\.id)).count, store.notifications.count) + XCTAssertEqual(queuedBeforeTransfer.count, 1) + XCTAssertEqual(queuedAfterTransfer.count, 1) + XCTAssertEqual(queuedAfterTransfer[0].0, queuedBeforeTransfer[0].0) + XCTAssertEqual(queuedAfterTransfer[0].1, queuedBeforeTransfer[0].1) + XCTAssertEqual(queuedAfterTransfer[0].4, queuedBeforeTransfer[0].4) + XCTAssertEqual(queuedAfterTransfer[0].2, newTabId) + XCTAssertEqual(queuedAfterTransfer[0].3, newSurfaceId) + XCTAssertFalse(store.hasManualUnread(forTabId: oldTabId)) + XCTAssertFalse(store.hasPanelDerivedUnread(forTabId: oldTabId)) + XCTAssertFalse(store.hasRestoredUnreadIndicator(forTabId: oldTabId)) + XCTAssertNil(store.focusedReadIndicatorSurfaceId(forTabId: oldTabId)) + XCTAssertTrue(store.hasManualUnread(forTabId: newTabId)) + XCTAssertTrue(store.hasPanelDerivedUnread(forTabId: newTabId)) + XCTAssertTrue(store.hasRestoredUnreadIndicator(forTabId: newTabId)) + XCTAssertEqual(store.focusedReadIndicatorSurfaceId(forTabId: newTabId), newSurfaceId) + XCTAssertTrue(store.hasManualUnread(forTabId: unrelatedTabId)) + XCTAssertEqual(store.focusedReadIndicatorSurfaceId(forTabId: unrelatedTabId), unrelatedSurfaceId) + } + + func testReplacementRemapsQueuedSurfaceAndWorkspaceClears() { + let store = TerminalNotificationStore.shared + let bus = TerminalMutationBus.shared + let oldTabId = UUID() + let newTabId = UUID() + let oldSurfaceId = UUID() + let newSurfaceId = UUID() + let survivingSurfaceId = UUID() + let unrelatedTabId = UUID() + bus.discardPendingNotifications() + bus.setDrainsSuspendedForTesting(true) + defer { + store.replaceNotificationsForTesting([]) + bus.discardPendingNotifications() + bus.drainForTesting() + bus.setDrainsSuspendedForTesting(false) + } + + let clearedBySurface = notification( + id: UUID(), tabId: newTabId, surfaceId: newSurfaceId, + title: "Surface", createdAt: Date(timeIntervalSince1970: 3), isRead: false + ) + let clearedByWorkspace = notification( + id: UUID(), tabId: newTabId, surfaceId: survivingSurfaceId, + title: "Workspace", createdAt: Date(timeIntervalSince1970: 2), isRead: false + ) + let unrelated = notification( + id: UUID(), tabId: unrelatedTabId, surfaceId: UUID(), + title: "Unrelated", createdAt: Date(timeIntervalSince1970: 1), isRead: false + ) + store.replaceNotificationsForTesting([clearedBySurface, clearedByWorkspace, unrelated]) + + bus.enqueueClearNotifications(forTabId: oldTabId, surfaceId: oldSurfaceId) + store.transferSessionNotifications( + fromTabId: oldTabId, + toTabId: newTabId, + panelIdMap: [oldSurfaceId: newSurfaceId] + ) + bus.drainForTesting() + XCTAssertEqual(store.notifications.map(\.id), [clearedByWorkspace.id, unrelated.id]) + + bus.enqueueClearNotifications(forTabId: oldTabId) + store.transferSessionNotifications( + fromTabId: oldTabId, + toTabId: newTabId, + panelIdMap: [oldSurfaceId: newSurfaceId] + ) + bus.drainForTesting() + XCTAssertEqual(store.notifications, [unrelated]) + } + + func testReleaseRestoredAwayWorkspacePreservesTransferredNotificationsAndQueuedWork() { + let store = TerminalNotificationStore.shared + let bus = TerminalMutationBus.shared + let appDelegate = AppDelegate.shared ?? AppDelegate() + let manager = TabManager() + let originalTabManager = appDelegate.tabManager + let originalNotificationStore = appDelegate.notificationStore + + bus.discardPendingNotifications() + bus.setDrainsSuspendedForTesting(true) + store.replaceNotificationsForTesting([]) + appDelegate.tabManager = manager + appDelegate.notificationStore = store + let oldWorkspace = manager.addWorkspace(select: true) + let newWorkspace = manager.addWorkspace(select: true) + let oldPanelId = oldWorkspace.focusedPanelId! + let newPanelId = newWorkspace.focusedPanelId! + let notificationId = UUID() + defer { + bus.discardPendingNotifications() + bus.drainForTesting() + bus.setDrainsSuspendedForTesting(false) + store.replaceNotificationsForTesting([]) + for workspace in manager.tabs { + workspace.teardownAllPanels() + } + appDelegate.tabManager = originalTabManager + appDelegate.notificationStore = originalNotificationStore + } + + store.replaceNotificationsForTesting([ + notification( + id: notificationId, + tabId: oldWorkspace.id, + surfaceId: oldPanelId, + title: "Transferred history", + createdAt: Date(timeIntervalSince1970: 1), + isRead: false + ), + ]) + XCTAssertTrue(bus.enqueueNotification( + tabId: oldWorkspace.id, + surfaceId: oldPanelId, + title: "Queued during restore", + subtitle: "", + body: "" + )) + + manager.releaseRestoredAwayWorkspaces( + [oldWorkspace], + originalWorkspaceIds: [oldWorkspace.id], + replacements: [newWorkspace], + panelIdMaps: [[oldPanelId: newPanelId]] + ) + + XCTAssertEqual(store.notifications.map(\.id), [notificationId]) + XCTAssertEqual(store.notifications.first?.tabId, newWorkspace.id) + XCTAssertEqual(store.notifications.first?.surfaceId, newPanelId) + let queued = bus.notificationIdentityStateForTesting() + XCTAssertEqual(queued.count, 1) + XCTAssertEqual(queued.first?.2, newWorkspace.id) + XCTAssertEqual(queued.first?.3, newPanelId) + } + + private func notification( + id: UUID, + tabId: UUID, + surfaceId: UUID, + title: String, + createdAt: Date, + isRead: Bool + ) -> TerminalNotification { + TerminalNotification( + id: id, + tabId: tabId, + surfaceId: surfaceId, + title: title, + subtitle: "", + body: "", + createdAt: createdAt, + isRead: isRead + ) + } +} diff --git a/cmuxTests/TerminalNotificationCallerTests.swift b/cmuxTests/TerminalNotificationCallerTests.swift index 92d706cb9e18..72731db8280c 100644 --- a/cmuxTests/TerminalNotificationCallerTests.swift +++ b/cmuxTests/TerminalNotificationCallerTests.swift @@ -262,6 +262,14 @@ final class TerminalNotificationCallerTests: XCTestCase { let focusedPanelId = try XCTUnwrap(workspace.focusedPanelId) + TerminalMutationBus.shared.enqueueNotificationForTesting( + tabId: workspace.id, + surfaceId: focusedPanelId, + title: "Queued before sync", + subtitle: "Must survive", + body: "Body" + ) + TerminalController.shared.start( tabManager: manager, socketPath: socketPath, @@ -283,6 +291,8 @@ final class TerminalNotificationCallerTests: XCTestCase { XCTAssertEqual(responses, ["OK"]) XCTAssertTrue(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: focusedPanelId)) XCTAssertEqual(store.notifications.first?.title, "Sync") + TerminalMutationBus.shared.drainForTesting() + XCTAssertEqual(store.notifications.map(\.title), ["Sync", "Queued before sync"]) store.replaceNotificationsForTesting([]) let callerResponse = try await sendV2RequestAsync( diff --git a/cmuxTests/TerminalNotificationClearAllTests.swift b/cmuxTests/TerminalNotificationClearAllTests.swift index d52a56caf8c0..bbfb307eaa47 100644 --- a/cmuxTests/TerminalNotificationClearAllTests.swift +++ b/cmuxTests/TerminalNotificationClearAllTests.swift @@ -43,7 +43,7 @@ final class TerminalNotificationClearAllTests: XCTestCase { return } - TerminalMutationBus.shared.enqueueNotification( + TerminalMutationBus.shared.enqueueNotificationForTesting( tabId: workspace.id, surfaceId: focusedPanelId, title: "Delivered", @@ -92,14 +92,14 @@ final class TerminalNotificationClearAllTests: XCTestCase { workspace.newTerminalSplit(from: firstPanelId, orientation: .horizontal) ) - TerminalMutationBus.shared.enqueueNotification( + TerminalMutationBus.shared.enqueueNotificationForTesting( tabId: workspace.id, surfaceId: firstPanelId, title: "Grok", subtitle: "Waiting", body: "First" ) - TerminalMutationBus.shared.enqueueNotification( + TerminalMutationBus.shared.enqueueNotificationForTesting( tabId: workspace.id, surfaceId: secondPanel.id, title: "Grok", @@ -262,6 +262,55 @@ final class TerminalNotificationClearAllTests: XCTestCase { XCTAssertFalse(workspace.listeningPorts.contains(port)) } + func testStaleAgentPIDSweepPreservesNotificationHistory() throws { + let store = TerminalNotificationStore.shared + let appDelegate = AppDelegate.shared ?? AppDelegate() + let manager = TabManager() + let originalTabManager = appDelegate.tabManager + let originalNotificationStore = appDelegate.notificationStore + + store.replaceNotificationsForTesting([]) + store.configureNotificationDeliveryHandlerForTesting { _, _ in } + store.configureSuppressedNotificationFeedbackHandlerForTesting { _, _ in } + appDelegate.tabManager = manager + appDelegate.notificationStore = store + + let workspace = manager.addWorkspace(select: true) + defer { + if manager.tabs.contains(where: { $0.id == workspace.id }) { + manager.closeWorkspace(workspace) + } + store.replaceNotificationsForTesting([]) + store.resetNotificationDeliveryHandlerForTesting() + store.resetSuppressedNotificationFeedbackHandlerForTesting() + appDelegate.tabManager = originalTabManager + appDelegate.notificationStore = originalNotificationStore + } + + let panelId = try XCTUnwrap(workspace.focusedPanelId) + let notificationId = UUID() + store.addNotification( + id: notificationId, + acceptedAt: Date(timeIntervalSince1970: 1), + tabId: workspace.id, + surfaceId: panelId, + title: "Keep history", + subtitle: "", + body: "", + retargetsToLiveSurfaceOwner: false + ) + workspace.recordAgentPID( + key: "codex.dead-stale-sweep", + pid: pid_t(999_999), + panelId: panelId, + refreshPorts: false + ) + + XCTAssertTrue(workspace.clearStaleAgentPIDs(panelId: panelId, refreshPorts: false)) + XCTAssertEqual(store.notifications.map(\.id), [notificationId]) + XCTAssertNil(workspace.agentPIDs["codex.dead-stale-sweep"]) + } + func testClosingPanePreservesSharedAgentStatusForSiblingPanel() throws { let appDelegate = AppDelegate.shared ?? AppDelegate() let manager = TabManager() diff --git a/cmuxTests/TerminalNotificationQueueTests.swift b/cmuxTests/TerminalNotificationQueueTests.swift index 36a0e672a2df..2ef0ccedaaa9 100644 --- a/cmuxTests/TerminalNotificationQueueTests.swift +++ b/cmuxTests/TerminalNotificationQueueTests.swift @@ -93,6 +93,133 @@ final class TerminalNotificationQueueTests: XCTestCase { ) } + func testNotifyTargetAsyncWaitsForReliableCapacityInsteadOfDropping() async throws { + let socketPath = makeSocketPath("wait") + let bus = TerminalMutationBus.shared + let appDelegate = AppDelegate.shared ?? AppDelegate() + let manager = appDelegate.tabManager ?? TabManager() + + let originalTabManager = appDelegate.tabManager + appDelegate.tabManager = manager + let workspace = manager.addWorkspace(select: true) + guard let focusedPanelId = workspace.focusedPanelId else { + XCTFail("Expected selected workspace with a focused panel") + return + } + + TerminalController.shared.start( + tabManager: manager, + socketPath: socketPath, + accessMode: .allowAll + ) + try waitForSocket(at: socketPath) + + bus.discardPendingNotifications() + bus.setDrainsSuspendedForTesting(true) + defer { + bus.discardPendingNotifications() + bus.drainForTesting() + bus.setDrainsSuspendedForTesting(false) + TerminalController.shared.stop() + if manager.tabs.contains(where: { $0.id == workspace.id }) { + manager.closeWorkspace(workspace) + } + appDelegate.tabManager = originalTabManager + } + for index in 0..<TerminalMutationBus.maximumPendingMutationCount { + XCTAssertTrue(bus.enqueueNotification( + tabId: workspace.id, + surfaceId: focusedPanelId, + title: "Seed \(index)", + subtitle: "", + body: "" + )) + } + + let command = "notify_target_async \(workspace.id.uuidString) \(focusedPanelId.uuidString) Reliable|Queued|Body" + let responseTask = Task { + try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + do { + continuation.resume(returning: try self.sendCommands([command], to: socketPath)) + } catch { + continuation.resume(throwing: error) + } + } + } + } + await waitForReliableAdmissionBlock(bus) + + bus.drainForBackpressure() + + let responses = try await responseTask.value + XCTAssertEqual(responses, ["OK"]) + XCTAssertTrue(bus.notificationQueueStateForTesting().1.contains("Reliable")) + } + + func testNotifyTargetAsyncTimesOutReliableCapacityInsteadOfHanging() async throws { + let socketPath = makeSocketPath("sat") + let bus = TerminalMutationBus.shared + let appDelegate = AppDelegate.shared ?? AppDelegate() + let manager = appDelegate.tabManager ?? TabManager() + + let originalTabManager = appDelegate.tabManager + appDelegate.tabManager = manager + let workspace = manager.addWorkspace(select: true) + guard let focusedPanelId = workspace.focusedPanelId else { + XCTFail("Expected selected workspace with a focused panel") + return + } + + TerminalController.shared.start( + tabManager: manager, + socketPath: socketPath, + accessMode: .allowAll + ) + try waitForSocket(at: socketPath) + + bus.discardPendingNotifications() + bus.setDrainsSuspendedForTesting(true) + defer { + bus.discardPendingNotifications() + bus.drainForTesting() + bus.setDrainsSuspendedForTesting(false) + TerminalController.shared.stop() + if manager.tabs.contains(where: { $0.id == workspace.id }) { + manager.closeWorkspace(workspace) + } + appDelegate.tabManager = originalTabManager + } + for index in 0..<TerminalMutationBus.maximumPendingMutationCount { + XCTAssertTrue(bus.enqueueNotification( + tabId: workspace.id, + surfaceId: focusedPanelId, + title: "Seed \(index)", + subtitle: "", + body: "" + )) + } + + let command = "notify_target_async \(workspace.id.uuidString) \(focusedPanelId.uuidString) Timeout|Queued|Body" + let responseTask = Task { + try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + do { + continuation.resume(returning: try self.sendCommands([command], to: socketPath)) + } catch { + continuation.resume(throwing: error) + } + } + } + } + await waitForReliableAdmissionBlock(bus) + + let responses = try await responseTask.value + XCTAssertEqual(responses, [ReliableTerminalNotificationEnqueueResult.saturatedSocketResponse]) + XCTAssertFalse(bus.notificationQueueStateForTesting().1.contains("Timeout")) + XCTAssertEqual(bus.reliablyWaitingNotificationProducerCountForTesting(), 0) + } + func testClearNotificationsDropsQueuedNotifyBeforeDrain() throws { let store = TerminalNotificationStore.shared let appDelegate = AppDelegate.shared ?? AppDelegate() @@ -126,7 +253,7 @@ final class TerminalNotificationQueueTests: XCTestCase { return } - TerminalMutationBus.shared.enqueueNotification( + TerminalMutationBus.shared.enqueueNotificationForTesting( tabId: workspace.id, surfaceId: focusedPanelId, title: "Async", @@ -173,7 +300,7 @@ final class TerminalNotificationQueueTests: XCTestCase { return } - TerminalMutationBus.shared.enqueueNotification( + TerminalMutationBus.shared.enqueueNotificationForTesting( tabId: workspace.id, surfaceId: focusedPanelId, title: "Stale", @@ -181,7 +308,7 @@ final class TerminalNotificationQueueTests: XCTestCase { body: "Body" ) TerminalMutationBus.shared.enqueueClearNotifications(forTabId: workspace.id) - TerminalMutationBus.shared.enqueueNotification( + TerminalMutationBus.shared.enqueueNotificationForTesting( tabId: workspace.id, surfaceId: focusedPanelId, title: "Fresh", @@ -231,7 +358,7 @@ final class TerminalNotificationQueueTests: XCTestCase { return } - TerminalMutationBus.shared.enqueueNotification( + TerminalMutationBus.shared.enqueueNotificationForTesting( tabId: workspace.id, surfaceId: focusedPanelId, title: "Stale", @@ -239,7 +366,7 @@ final class TerminalNotificationQueueTests: XCTestCase { body: "Body" ) TerminalMutationBus.shared.enqueueClearAllNotifications() - TerminalMutationBus.shared.enqueueNotification( + TerminalMutationBus.shared.enqueueNotificationForTesting( tabId: workspace.id, surfaceId: focusedPanelId, title: "Fresh", @@ -254,7 +381,7 @@ final class TerminalNotificationQueueTests: XCTestCase { XCTAssertTrue(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: focusedPanelId)) } - func testQueuedNotificationCoalescesRepeatedEventsForSameSurface() throws { + func testQueuedNotificationRetainsRepeatedEventsForSameSurface() throws { let store = TerminalNotificationStore.shared let appDelegate = AppDelegate.shared ?? AppDelegate() let manager = appDelegate.tabManager ?? TabManager() @@ -292,7 +419,7 @@ final class TerminalNotificationQueueTests: XCTestCase { TerminalMutationBus.shared.setDrainsSuspendedForTesting(true) defer { TerminalMutationBus.shared.setDrainsSuspendedForTesting(false) } for title in ["First", "Second"] { - TerminalMutationBus.shared.enqueueNotification( + TerminalMutationBus.shared.enqueueNotificationForTesting( tabId: workspace.id, surfaceId: focusedPanelId, title: title, @@ -303,8 +430,8 @@ final class TerminalNotificationQueueTests: XCTestCase { } TerminalMutationBus.shared.drainForTesting() let workspaceNotifications = store.notifications.filter { $0.tabId == workspace.id } - XCTAssertEqual(workspaceNotifications.map(\.title), ["Second"]) - XCTAssertEqual(deliveredTitles, ["Second"]) + XCTAssertEqual(workspaceNotifications.map(\.title).sorted(), ["First", "Second"]) + XCTAssertEqual(deliveredTitles, ["First", "Second"]) } func testNotifyTargetAsyncCommandDoesNotCoalesceRepeatedEventsForSameSurface() throws { @@ -355,11 +482,11 @@ final class TerminalNotificationQueueTests: XCTestCase { TerminalMutationBus.shared.drainForTesting() let workspaceNotifications = store.notifications.filter { $0.tabId == workspace.id } - XCTAssertEqual(workspaceNotifications.map(\.title), ["Second"]) + XCTAssertEqual(workspaceNotifications.map(\.title).sorted(), ["First", "Second"]) XCTAssertEqual(deliveredTitles, ["First", "Second"]) } - func testScopedDiscardDoesNotSplitUnrelatedNotificationCoalescing() throws { + func testScopedDiscardPreservesUnrelatedNotificationsExactlyOnce() throws { let store = TerminalNotificationStore.shared let appDelegate = AppDelegate.shared ?? AppDelegate() let manager = appDelegate.tabManager ?? TabManager() @@ -402,7 +529,7 @@ final class TerminalNotificationQueueTests: XCTestCase { TerminalMutationBus.shared.setDrainsSuspendedForTesting(true) defer { TerminalMutationBus.shared.setDrainsSuspendedForTesting(false) } - TerminalMutationBus.shared.enqueueNotification( + TerminalMutationBus.shared.enqueueNotificationForTesting( tabId: unrelatedWorkspace.id, surfaceId: unrelatedPanelId, title: "Stale unrelated", @@ -413,7 +540,7 @@ final class TerminalNotificationQueueTests: XCTestCase { forTabId: clearedWorkspace.id, surfaceId: clearedPanelId ) - TerminalMutationBus.shared.enqueueNotification( + TerminalMutationBus.shared.enqueueNotificationForTesting( tabId: unrelatedWorkspace.id, surfaceId: unrelatedPanelId, title: "Fresh unrelated", @@ -424,8 +551,8 @@ final class TerminalNotificationQueueTests: XCTestCase { TerminalMutationBus.shared.drainForTesting() let unrelatedNotifications = store.notifications.filter { $0.tabId == unrelatedWorkspace.id } - XCTAssertEqual(unrelatedNotifications.map(\.title), ["Fresh unrelated"]) - XCTAssertEqual(deliveredTitles, ["Fresh unrelated"]) + XCTAssertEqual(unrelatedNotifications.map(\.title).sorted(), ["Fresh unrelated", "Stale unrelated"]) + XCTAssertEqual(deliveredTitles, ["Stale unrelated", "Fresh unrelated"]) } func testQueuedNotificationResolvesWorkspaceInRegisteredWindowContext() throws { @@ -465,7 +592,7 @@ final class TerminalNotificationQueueTests: XCTestCase { return } - TerminalMutationBus.shared.enqueueNotification( + TerminalMutationBus.shared.enqueueNotificationForTesting( tabId: workspace.id, surfaceId: focusedPanelId, title: "Async", @@ -560,6 +687,15 @@ final class TerminalNotificationQueueTests: XCTestCase { throw NSError(domain: NSPOSIXErrorDomain, code: Int(ETIMEDOUT)) } + private func waitForReliableAdmissionBlock(_ bus: TerminalMutationBus) async { + let deadline = Date(timeIntervalSinceNow: 2) + while Date() < deadline { + if bus.reliablyWaitingNotificationProducerCountForTesting() == 1 { return } + await Task.yield() + } + XCTFail("Reliable admission worker did not reach the capacity wait") + } + private nonisolated func sendCommands(_ commands: [String], to socketPath: String) throws -> [String] { let fd = Darwin.socket(AF_UNIX, SOCK_STREAM, 0) guard fd >= 0 else { diff --git a/cmuxTests/TerminalNotificationSocketActionTests.swift b/cmuxTests/TerminalNotificationSocketActionTests.swift index 6ef40948084e..0edca4ccfb19 100644 --- a/cmuxTests/TerminalNotificationSocketActionTests.swift +++ b/cmuxTests/TerminalNotificationSocketActionTests.swift @@ -44,10 +44,17 @@ final class TerminalNotificationSocketActionTests: XCTestCase { let fixture = try makeSocketFixture(name: "notif-dismiss-read") defer { fixture.cleanup() } - let firstRead = makeNotification(tabId: fixture.workspace.id, surfaceId: fixture.surfaceId, title: "Read 1", isRead: true) - let secondRead = makeNotification(tabId: fixture.workspace.id, surfaceId: fixture.surfaceId, title: "Read 2", isRead: true) + let readNotifications = (0..<128).map { index in + makeNotification( + tabId: fixture.workspace.id, + surfaceId: fixture.surfaceId, + title: "Read \(index)", + isRead: true + ) + } let unread = makeNotification(tabId: fixture.workspace.id, surfaceId: fixture.surfaceId, title: "Unread") - fixture.store.replaceNotificationsForTesting([firstRead, secondRead, unread]) + fixture.store.replaceNotificationsForTesting(readNotifications + [unread]) + TerminalNotificationStore.resetFullIndexRebuildCountForTesting() let response = try await sendV2RequestAsync( method: "notification.dismiss", @@ -57,10 +64,14 @@ final class TerminalNotificationSocketActionTests: XCTestCase { XCTAssertEqual(response["ok"] as? Bool, true, "\(response)") let result = try XCTUnwrap(response["result"] as? [String: Any]) - XCTAssertEqual(result["dismissed"] as? Int, 2) + XCTAssertEqual(result["dismissed"] as? Int, readNotifications.count) XCTAssertEqual(result["all_read"] as? Bool, true) - XCTAssertFalse(fixture.store.notifications.contains(where: { $0.id == firstRead.id })) - XCTAssertFalse(fixture.store.notifications.contains(where: { $0.id == secondRead.id })) + XCTAssertEqual(TerminalNotificationStore.fullIndexRebuildCountForTesting, 1) + XCTAssertFalse( + fixture.store.notifications.contains { notification in + readNotifications.contains { $0.id == notification.id } + } + ) XCTAssertTrue(fixture.store.notifications.contains(where: { $0.id == unread.id })) } diff --git a/cmuxTests/TypingHotPathRegressionTests.swift b/cmuxTests/TypingHotPathRegressionTests.swift index 3d1a0082768e..049e762b0c22 100644 --- a/cmuxTests/TypingHotPathRegressionTests.swift +++ b/cmuxTests/TypingHotPathRegressionTests.swift @@ -72,9 +72,9 @@ struct TerminalNotificationPolicyInFlightStoreTests { onDiscard: {} ) - store.complete(second) { applied.append("second") } + store.complete(second) { _ in applied.append("second") } #expect(applied.isEmpty) - store.complete(first) { applied.append("first") } + store.complete(first) { _ in applied.append("first") } #expect(applied == ["first", "second"]) } @@ -93,7 +93,7 @@ struct TerminalNotificationPolicyInFlightStoreTests { onDiscard: {} ) - store.complete(independent) { applied.append("independent") } + store.complete(independent) { _ in applied.append("independent") } #expect(applied == ["independent"]) } @@ -159,7 +159,7 @@ struct TerminalNotificationPolicyInFlightStoreTests { #expect(store.hasPendingRequest(forTabId: tabId)) #expect(store.hasPendingRequest(forTabId: tabId, surfaceId: surfaceId)) #expect(store.hasPendingRequest(forTabId: tabId, surfaceId: panelId)) - #expect(store.claim(id)) + #expect(store.claim(id, applying: makeRequest(tabId: tabId, surfaceId: surfaceId, panelId: panelId, title: "pending")) != nil) #expect(!store.hasPendingRequest(forTabId: tabId)) } diff --git a/cmuxTests/WorkspaceManualUnreadTests.swift b/cmuxTests/WorkspaceManualUnreadTests.swift index 68ca2beadcef..696a9039147a 100644 --- a/cmuxTests/WorkspaceManualUnreadTests.swift +++ b/cmuxTests/WorkspaceManualUnreadTests.swift @@ -522,7 +522,7 @@ final class WorkspaceManualUnreadTests: XCTestCase { XCTAssertEqual(store.unreadCount(forTabId: workspace.id), 1) } - func testMarkLatestNotificationAsOldestUnreadDefersCurrentNotificationBehindUnreadQueue() { + func testMarkLatestNotificationAsOldestUnreadPreservesChronologicalFeedOrder() { let store = TerminalNotificationStore.shared let currentWorkspaceId = UUID() let currentSurfaceId = UUID() @@ -570,11 +570,8 @@ final class WorkspaceManualUnreadTests: XCTestCase { store.markLatestNotificationAsOldestUnread(forTabId: currentWorkspaceId, surfaceId: currentSurfaceId), currentNotificationId ) - XCTAssertEqual( - store.notifications.map(\.id), - [nextNotificationId, oldestNotificationId, currentNotificationId] - ) - XCTAssertFalse(store.notifications.last?.isRead ?? true) + XCTAssertEqual(store.notifications.map(\.id), [currentNotificationId, nextNotificationId, oldestNotificationId]) + XCTAssertFalse(store.notifications.first?.isRead ?? true) } func testMarkLatestNotificationAsOldestUnreadFallsBackToManualWorkspaceUnreadWhenNoSurfaceExists() { @@ -1182,7 +1179,7 @@ final class WorkspaceManualUnreadTests: XCTestCase { XCTAssertEqual(unreadWorkspace.tmuxWorkspaceFlashReason, .unreadIndicatorDismiss) } - func testMarkLatestNotificationAsOldestUnreadAppendsWhenNoOtherUnreadNotificationsRemain() { + func testMarkLatestNotificationAsOldestUnreadPreservesOrderWhenNoOtherUnreadNotificationsRemain() { let store = TerminalNotificationStore.shared let currentWorkspaceId = UUID() let currentNotificationId = UUID() @@ -1217,8 +1214,8 @@ final class WorkspaceManualUnreadTests: XCTestCase { store.markLatestNotificationAsOldestUnread(forTabId: currentWorkspaceId, surfaceId: nil), currentNotificationId ) - XCTAssertEqual(store.notifications.map(\.id), [readNotificationId, currentNotificationId]) - XCTAssertFalse(store.notifications.last?.isRead ?? true) + XCTAssertEqual(store.notifications.map(\.id), [currentNotificationId, readNotificationId]) + XCTAssertFalse(store.notifications.first?.isRead ?? true) } func testManualPanelUnreadClearsOnDirectTerminalInteraction() { @@ -2079,6 +2076,39 @@ final class WorkspaceManualUnreadTests: XCTestCase { ]) XCTAssertNotEqual(notificationWithoutPanelIdFingerprint, manager.sessionAutosaveFingerprint()) + resetUnreadState() + let readOrphanFingerprint = manager.sessionAutosaveFingerprint() + store.replaceNotificationsForTesting([ + TerminalNotification( + id: UUID(), + tabId: workspace.id, + surfaceId: UUID(), + title: "Read orphan", + subtitle: "", + body: "", + createdAt: notificationCreatedAt, + isRead: true + ), + ]) + XCTAssertNotEqual(readOrphanFingerprint, manager.sessionAutosaveFingerprint()) + + resetUnreadState() + let aliasFingerprint = manager.sessionAutosaveFingerprint() + store.replaceNotificationsForTesting([ + TerminalNotification( + id: UUID(), + tabId: workspace.id, + surfaceId: UUID(), + panelId: panelId, + title: "Read alias", + subtitle: "", + body: "", + createdAt: notificationCreatedAt, + isRead: true + ), + ]) + XCTAssertNotEqual(aliasFingerprint, manager.sessionAutosaveFingerprint()) + resetUnreadState() store.setFocusedReadIndicator(forTabId: workspace.id, surfaceId: panelId) XCTAssertNotEqual(cleanFingerprint, manager.sessionAutosaveFingerprint()) diff --git a/docs/notifications.md b/docs/notifications.md index 387753a59340..109265211b0c 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -58,6 +58,10 @@ cmux notify --title "Done" --tab 0 --panel 1 Use `Cmd+Shift+U` to jump to the latest unread notification. Use `Ctrl+Cmd+U` to mark the current item as oldest unread and jump to the next latest unread. Both shortcuts are configurable in Settings > Keyboard Shortcuts and in `~/.config/cmux/cmux.json`. +## Feed ordering and identity + +The Notifications feed keeps every accepted event once, keyed by its notification UUID. It orders rows newest first by the event's initial acceptance time, with ascending UUID text as the stable tie break for equal timestamps. Late restored events are inserted at their chronological position. Reconnect and session restore merge unseen UUIDs without replacing live rows, and replaying a UUID does not add or update a row. Marking or opening a notification changes read state without changing feed order. Native-banner and phone-delivery throttling do not remove feed history. + ## Suppress only the focused surface By default cmux withdraws a delivered banner when its workspace becomes visible/active, which can retract a banner for a non-focused surface (e.g. a second agent in the same visible workspace) before you notice it. Set the opt-in flag below to `true` so the auto-withdraw fires **only** for the exact focused surface — matching the delivery gate. A banner for a non-focused surface then stays up until you focus that surface (or click/dismiss it). Workspace-visible-but-not-focused surfaces and surfaces in non-visible workspaces keep their banners; explicit "mark workspace read" and clicking/typing still clear notifications as before.