diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 7d29f8aaf16b..f99c04e11f27 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -5386,6 +5386,7 @@ struct CMUXCLI { let title = optionValue(commandArgs, name: "--title") ?? "Notification" let subtitle = optionValue(commandArgs, name: "--subtitle") ?? "" let body = optionValue(commandArgs, name: "--body") ?? "" + let allowsReply = hasFlag(commandArgs, name: "--reply") let explicitWorkspaceArg = optionValue(commandArgs, name: "--workspace") let windowRaw = windowFromArgsOrOverride(commandArgs, windowOverride: windowId) let windowHandle = try normalizeWindowHandle(windowRaw, client: client) @@ -5412,12 +5413,25 @@ struct CMUXCLI { targetSurface = try explicitSurfaceArg.map { try resolveSurfaceId($0, workspaceId: targetWorkspace, client: client) } ?? resolveSurfaceId(nil, workspaceId: targetWorkspace, client: client) } - let payload = notificationPayload(title: title, subtitle: subtitle, body: body) - let response = try sendV1Command("notify_target \(targetWorkspace) \(targetSurface) \(payload)", client: client) - print(response) + if allowsReply { + let payload = try client.sendV2(method: "notification.create_for_target", params: [ + "workspace_id": targetWorkspace, + "surface_id": targetSurface, + "title": title, + "subtitle": subtitle, + "body": body, + "reply_shape": "text", + ]) + printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: "OK") + } else { + let payload = notificationPayload(title: title, subtitle: subtitle, body: body) + let response = try sendV1Command("notify_target \(targetWorkspace) \(targetSurface) \(payload)", client: client) + print(response) + } return } var params: [String: Any] = ["title": title, "subtitle": subtitle, "body": body] + if allowsReply { params["reply_shape"] = "text" } let method: String if explicitSurfaceArg != nil { method = "notification.create" @@ -17218,6 +17232,10 @@ struct CMUXCLI { cmux send-key-panel --panel surface:2 ctrl+c """ case "notify": + let replyHelp = String( + localized: "cli.help.notify.reply", + defaultValue: "--reply Allow a free-text inline reply" + ) return """ Usage: cmux notify [flags] @@ -17227,6 +17245,7 @@ struct CMUXCLI { --title Notification title (default: "Notification") --subtitle Notification subtitle --body Notification body + \(replyHelp) --workspace Target workspace, except explicit surface UUIDs resolve globally --surface Target surface (refs/indexes use workspace/window context) --window Window context for workspace/surface refs and indexes @@ -36539,7 +36558,7 @@ export default CMUXSessionRestore; send-key [--workspace ] [--surface ] [--window ] send-panel --panel [--workspace ] [--window ] send-key-panel --panel [--workspace ] [--window ] - notify --title [--subtitle ] [--body ] [--workspace ] [--surface ] [--window ] + notify --title [--subtitle ] [--body ] [--reply] [--workspace ] [--surface ] [--window ] list-notifications dismiss-notification (--id | --all-read) mark-notification-read (--id | --workspace [--surface ] [--window ] | --all) diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ExplicitTerminalInput.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ExplicitTerminalInput.swift new file mode 100644 index 000000000000..0901a6a49790 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ExplicitTerminalInput.swift @@ -0,0 +1,66 @@ +internal import CmuxMobileRPC +public import CmuxMobileShellModel +internal import Foundation +internal import OSLog + +private let explicitTerminalInputLog = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "dev.cmux.ios", + category: "mobile-shell" +) + +extension MobileShellComposite { + /// Whether the Mac that owns `workspaceID` currently has an RPC channel able to accept terminal input. + /// + /// This check does not change the selected Mac, workspace, terminal, or navigation state. + /// + /// - Parameter workspaceID: The current UI row identifier for the target workspace. + /// - Returns: `true` when the workspace exists and its owning Mac has a live RPC client. + public func canSendTerminalInput(to workspaceID: MobileWorkspacePreview.ID) -> Bool { + guard workspaces.contains(where: { $0.id == workspaceID }) else { return false } + return workspaceMutationTarget(for: workspaceID).client != nil + } + + /// Sends text to an explicit workspace and terminal without changing UI selection or navigation. + /// + /// The workspace row selects the owning Mac's live RPC client, including a connected secondary Mac + /// in the aggregated workspace list, while the Mac-local workspace identifier is sent on the wire. + /// + /// - Parameters: + /// - text: The exact terminal input bytes represented as text. + /// - workspaceID: The current UI row identifier for the target workspace. + /// - terminalID: The terminal that must still belong to `workspaceID`. + /// - Returns: `true` after the owning Mac acknowledges the input; otherwise `false`. + @discardableResult + public func sendTerminalInput( + _ text: String, + workspaceID: MobileWorkspacePreview.ID, + terminalID: MobileTerminalPreview.ID + ) async -> Bool { + guard !text.isEmpty, + workspace(workspaceID, containsSurfaceID: terminalID.rawValue) else { + return false + } + let target = workspaceMutationTarget(for: workspaceID) + guard let client = target.client else { return false } + + do { + _ = try await client.sendRequest( + MobileCoreRPCClient.requestData( + method: "terminal.input", + params: [ + "workspace_id": remoteWorkspaceID(for: workspaceID).rawValue, + "surface_id": terminalID.rawValue, + "text": text, + "client_id": clientID, + ] + ) + ) + return true + } catch { + explicitTerminalInputLog.error( + "explicit terminal input failed workspace=\(workspaceID.rawValue, privacy: .private) surface=\(terminalID.rawValue, privacy: .private) error=\(String(describing: error), privacy: .private)" + ) + return false + } + } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift index c8f0206a5bac..9edaf595da6f 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift @@ -6,9 +6,15 @@ import CmuxMobileShell import CmuxMobileShellModel import Foundation import Observation +import OSLog import UIKit import UserNotifications +private let mobilePushLog = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "dev.cmux.ios", + category: "push" +) + /// Bridges APNs push between the app-target `AppDelegate` and the mobile shell /// store: drives opt-in registration, hands device tokens to the injected /// ``CmuxAuthRuntime/PushRegistrationService``, and routes foreground @@ -39,13 +45,17 @@ public final class MobilePushCoordinator { private static let enabledKey = "cmux.notifications.pushEnabled" private var enabledMirror: Bool - /// APNs `aps.category` the web sets on every cmux terminal push (see - /// `CMUX_APNS_CATEGORY` in `web/services/apns/payload.ts`). The matching - /// ``UNNotificationCategory`` registered below carries + /// Base APNs `aps.category` the web sets on non-replyable cmux terminal + /// pushes (see `CMUX_APNS_CATEGORY` in `web/services/apns/payload.ts`). The + /// matching ``UNNotificationCategory`` registered below carries /// `.customDismissAction`, so a swipe/clear delivers /// `UNNotificationDismissActionIdentifier` to the app and we can forward the /// dismiss to the Mac. Keep these two ids in sync. public static let dismissSyncCategoryIdentifier = "cmux.terminal" + /// APNs category for terminal notifications that accept text input. + public static let replyCategoryIdentifier = "cmux.terminal.reply" + /// Notification action identifier delivered for a submitted inline reply. + public static let replyActionIdentifier = "cmux.reply" @ObservationIgnored private weak var store: CMUXMobileShellStore? @@ -70,6 +80,16 @@ public final class MobilePushCoordinator { /// launch plus sign-in plus a slow attach. private static let pendingDeeplinkLifetime: TimeInterval = 120 @ObservationIgnored private let now: () -> Date + @ObservationIgnored private var pendingReplyState = PendingReplyState() + @ObservationIgnored private var replySendInFlight = false + /// One-shot delayed re-evaluation armed after a FAILED reply send: a + /// transient RPC failure with unchanged topology fires no store/channel + /// event, so without this the re-parked reply would sit until its 120 s + /// lifetime dropped it. Each retry re-arms on failure, so attempts stay + /// bounded by the reply lifetime; success or a fresh park cancels it. + @ObservationIgnored private var replyRetryTask: Task? + @ObservationIgnored private let replyRetrySleep: @Sendable (Duration) async throws -> Void + private static let replyRetryDelay: Duration = .seconds(5) /// The iOS API endpoint that accepted this installation's APNs token. public let phoneAPIOrigin: String /// Live OS authorization, refreshed at launch, on foreground, and when @@ -108,8 +128,8 @@ public final class MobilePushCoordinator { /// - pendingDismissQueue: The durable phone→Mac dismiss outbox shared (via /// `UserDefaults`) with the shell store, used when a swipe arrives before /// any store exists. Defaults to the standard-defaults-backed queue. - /// - now: Clock seam for the pending-deeplink expiry. Defaults to - /// `Date.init`. + /// - now: Clock seam for pending deep-link and inline-reply expiry. Defaults + /// to `Date.init`. public init( registration: any PushRegistering, analytics: any AnalyticsEmitting = NoopAnalytics(), @@ -129,9 +149,13 @@ public final class MobilePushCoordinator { }, unregisterForRemoteNotifications: @escaping @MainActor () -> Void = { UIApplication.shared.unregisterForRemoteNotifications() + }, + replyRetrySleep: @escaping @Sendable (Duration) async throws -> Void = { + try await ContinuousClock().sleep(for: $0) } ) { self.registration = registration + self.replyRetrySleep = replyRetrySleep self.analytics = analytics self.phoneAPIOrigin = phoneAPIOrigin self.defaults = defaults @@ -167,6 +191,9 @@ public final class MobilePushCoordinator { public func bind(store: CMUXMobileShellStore) { self.store = store applyPendingDeeplinkIfReady() + Task { @MainActor [weak self] in + await self?.applyPendingReplyIfReady() + } } /// Re-apply a parked notification tap once its target can exist. Called by @@ -174,12 +201,16 @@ public final class MobilePushCoordinator { /// empty until the Mac attach completes). public func workspacesDidChange() { applyPendingDeeplinkIfReady() + Task { @MainActor [weak self] in + await self?.applyPendingReplyIfReady() + } } - /// Install the notification-center delegate and dismiss-sync category, then - /// start live readiness observation. The workspace/foreground lifecycle - /// requests APNs registration after system authorization permits delivery. - /// Call once at launch from the AppDelegate. + /// Install the notification-center delegate and the terminal notification + /// categories (dismiss-sync + inline reply), then start live readiness + /// observation. The workspace/foreground lifecycle requests APNs + /// registration after system authorization permits delivery. Call once at + /// launch from the AppDelegate. public func configure(delegate: any UNUserNotificationCenterDelegate) { let center = UNUserNotificationCenter.current() center.delegate = delegate @@ -192,7 +223,24 @@ public final class MobilePushCoordinator { intentIdentifiers: [], options: [.customDismissAction] ) - center.setNotificationCategories([dismissSyncCategory]) + let replyAction = UNTextInputNotificationAction( + identifier: Self.replyActionIdentifier, + title: String(localized: "mobile.push.reply.action", defaultValue: "Reply", bundle: .module), + options: [], + textInputButtonTitle: String(localized: "mobile.push.reply.send", defaultValue: "Send", bundle: .module), + textInputPlaceholder: String( + localized: "mobile.push.reply.placeholder", + defaultValue: "Message the agent…", + bundle: .module + ) + ) + let replyCategory = UNNotificationCategory( + identifier: Self.replyCategoryIdentifier, + actions: [replyAction], + intentIdentifiers: [], + options: [.customDismissAction] + ) + center.setNotificationCategories([dismissSyncCategory, replyCategory]) startRegistrationSnapshotObservation() Task { await refreshReadiness() } } @@ -555,6 +603,35 @@ public final class MobilePushCoordinator { applyPendingDeeplinkIfReady() } + /// Parks an inline notification reply and sends it once its exact Mac, workspace, surface, and RPC channel are ready. + /// + /// This path never changes the selected Mac, workspace, terminal, or navigation state. + /// - Parameters: + /// - text: The user's reply text, without the submit Return. + /// - workspaceId: The Mac-local workspace claim carried by the push. + /// - surfaceId: The exact terminal claim carried by the push. + /// - macDeviceId: The Mac that owns the claimed ids. + /// - retargetsToLiveSurfaceOwner: Whether a moved terminal may resolve in a + /// workspace other than the explicit claim. + public func handleReply( + text: String, + workspaceId: String?, + surfaceId: String?, + macDeviceId: String?, + retargetsToLiveSurfaceOwner: Bool + ) async { + guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + pendingReplyState.park(PendingReply( + text: text, + workspaceId: workspaceId, + surfaceId: surfaceId, + macDeviceId: macDeviceId, + retargetsToLiveSurfaceOwner: retargetsToLiveSurfaceOwner, + createdAt: now() + )) + await applyPendingReplyIfReady() + } + /// Apply the parked tap if its target can be navigated to right now; /// otherwise keep it parked for the next ``bind(store:)`` or /// ``workspacesDidChange()``. @@ -651,6 +728,122 @@ public final class MobilePushCoordinator { ]) } + /// Applies the parked reply without mutating UI selection; later topology changes retry only unresolved prerequisites. + private func applyPendingReplyIfReady() async { + guard !replySendInFlight else { return } + let initialDecision = pendingReplyState.evaluate( + now: now(), + isStoreBound: store != nil, + isTargetReachable: false, + isChannelAvailable: false + ) + switch initialDecision { + case .noPending: + return + case .expired: + mobilePushLog.info("dropping expired inline reply") + return + case .waiting: + break + case .ready: + return + } + + guard let pending = pendingReplyState.pending, let store else { return } + guard let surfaceId = pending.surfaceId, !surfaceId.isEmpty else { + pendingReplyState.discard() + mobilePushLog.info("dropping inline reply without a surface id") + return + } + + var workspaceTarget: MobileWorkspacePreview.ID + if let workspaceId = pending.workspaceId { + guard let resolved = store.workspaceID( + matchingRemoteWorkspaceID: workspaceId, + macDeviceID: pending.macDeviceId + ) else { return } + workspaceTarget = resolved + } else if pending.retargetsToLiveSurfaceOwner { + guard let owner = store.workspaceID( + containingSurfaceID: surfaceId, + macDeviceID: pending.macDeviceId + ) else { return } + workspaceTarget = owner + } else { + pendingReplyState.discard() + mobilePushLog.info("dropping confined inline reply without a workspace id") + return + } + + if !store.workspace(workspaceTarget, containsSurfaceID: surfaceId) { + guard pending.retargetsToLiveSurfaceOwner, + let liveOwner = store.workspaceID( + containingSurfaceID: surfaceId, + macDeviceID: pending.macDeviceId + ) else { + pendingReplyState.discard() + mobilePushLog.info("dropping inline reply because the target surface has no permitted live owner") + return + } + workspaceTarget = liveOwner + } + + let decision = pendingReplyState.evaluate( + now: now(), + isStoreBound: true, + isTargetReachable: true, + isChannelAvailable: store.canSendTerminalInput(to: workspaceTarget) + ) + guard case .ready(let ready) = decision else { + if case .expired = decision { + mobilePushLog.info("dropping expired inline reply") + return + } + // Channel not ready. A store/channel event retries immediately, + // but a channel that recovers without one would otherwise strand + // the reply until its lifetime expires — keep the bounded retry + // ladder armed while parked. + scheduleReplyRetry() + return + } + + replySendInFlight = true + let sent = await store.sendTerminalInput( + ready.text + "\r", + workspaceID: workspaceTarget, + terminalID: MobileTerminalPreview.ID(rawValue: surfaceId) + ) + replySendInFlight = false + if !sent { + // A failed RPC send must not consume the reply: re-park it (with + // its original createdAt, so the 120 s lifetime still bounds the + // total retry window). A reply parked mid-send wins instead — + // latest user intent replaces the failed one. Store/channel + // readiness events retry immediately; the armed delay covers a + // transient failure whose topology never changes. + mobilePushLog.error("inline reply terminal input failed; re-parking for retry") + if pendingReplyState.pending == nil { + pendingReplyState.park(ready) + } + scheduleReplyRetry() + return + } + replyRetryTask?.cancel() + replyRetryTask = nil + await applyPendingReplyIfReady() + } + + /// Arms one delayed `applyPendingReplyIfReady` pass (see `replyRetryTask`). + private func scheduleReplyRetry() { + replyRetryTask?.cancel() + replyRetryTask = Task { @MainActor [weak self, replyRetrySleep] in + guard (try? await replyRetrySleep(Self.replyRetryDelay)) != nil else { return } + guard let self, !Task.isCancelled else { return } + self.replyRetryTask = nil + await self.applyPendingReplyIfReady() + } + } + /// Forward a phone-side notification dismissal to the paired Mac so it marks /// the notification read and clears its own banner. Fire-and-forget over the /// attach channel; carries only the opaque notification id, never content. @@ -692,5 +885,60 @@ public final class MobilePushCoordinator { guard !trimmed.isEmpty else { return } await deliveredNotificationClearer.removeDelivered(ids: trimmed) } + +#if DEBUG + /// Schedules a LOCAL notification carrying the same reply category and + /// `cmux` userInfo schema as a Mac-forwarded APNs push, addressed at the + /// currently selected workspace/terminal. The notification-center response + /// path cannot tell local from remote, so the inline-reply UX and its full + /// handling chain (action routing, reply parking, `terminal.input` RPC back + /// to the Mac) are verifiable on a device without any APNs transport — dev + /// web deployments have no push service configured. Fires after a short + /// delay so the tester can lock the phone or background the app first. + /// In this same file so it reaches the private `store` without widening + /// production visibility for a debug affordance. + public func debugScheduleLocalReplyNotification() async -> Bool { + guard let store, + let workspace = store.selectedWorkspace, + let surfaceId = store.selectedTerminalID?.rawValue else { + mobilePushLog.info("debug local reply skipped: no selected workspace/terminal") + return false + } + let content = UNMutableNotificationContent() + content.title = String( + localized: "mobile.push.debugReply.title", + defaultValue: "cmux reply test", + bundle: .module + ) + content.subtitle = workspace.name + content.body = String( + localized: "mobile.push.debugReply.body", + defaultValue: "Reply here; the text is typed into the selected Mac terminal.", + bundle: .module + ) + content.categoryIdentifier = Self.replyCategoryIdentifier + var cmux: [String: Any] = [ + "workspaceId": workspace.rpcWorkspaceID.rawValue, + "surfaceId": surfaceId, + "retargetsToLiveSurfaceOwner": true, + ] + if let macDeviceId = workspace.macDeviceID, !macDeviceId.isEmpty { + cmux["macDeviceId"] = macDeviceId + } + content.userInfo = ["cmux": cmux] + let request = UNNotificationRequest( + identifier: "cmux.debug.reply.\(UUID().uuidString)", + content: content, + trigger: UNTimeIntervalNotificationTrigger(timeInterval: 5, repeats: false) + ) + do { + try await UNUserNotificationCenter.current().add(request) + return true + } catch { + mobilePushLog.error("debug local reply schedule failed: \(error.localizedDescription)") + return false + } + } +#endif } #endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift index 100d2a88ff28..aab2f0b26b3d 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift @@ -41,6 +41,9 @@ struct MobileSettingsView: View { /// `isEnabled` as a non-observable `UserDefaults` read, so reading it /// directly in `body` would not re-render when it flips. @State private var notificationsEnabled = false +#if DEBUG + @State private var debugReplyScheduled: Bool? +#endif @State private var showingOnboarding = false @State private var showingSetupHelp = false #if DEBUG @@ -392,7 +395,12 @@ struct MobileSettingsView: View { .accessibilityIdentifier("MobileSettingsTerminalScrollback") } + // Release builds keep the section to the single agent-alerts + // toggle the app always had; the delivery-status diagnostics, + // Mac forwarding controls, and test actions are a dev surface + // and stay DEBUG-only. Section(L10n.string("mobile.settings.notifications", defaultValue: "Push Alerts")) { +#if DEBUG MobilePushSettingsContent( readiness: pushCoordinator.readiness( macStatus: store?.phonePushMacStatus, @@ -407,6 +415,47 @@ struct MobileSettingsView: View { onMacMutation: updateMacPhonePush, onSendTest: sendPhonePushTest ) + Button { + Task { @MainActor in + debugReplyScheduled = await pushCoordinator + .debugScheduleLocalReplyNotification() + } + } label: { + Text(L10n.string( + "mobile.settings.debugReplyTest", + defaultValue: "Test Inline Reply (Local)" + )) + } + .accessibilityIdentifier("MobileSettingsDebugReplyTestButton") + if let debugReplyScheduled { + Text(L10n.string( + debugReplyScheduled + ? "mobile.settings.debugReplyTest.scheduled" + : "mobile.settings.debugReplyTest.failed", + defaultValue: debugReplyScheduled + ? "Scheduled: lock the phone; the notification fires in 5 seconds." + : "Couldn't schedule: open a workspace and select a terminal first." + )) + .font(.footnote) + .foregroundStyle(.secondary) + } +#else + Toggle( + L10n.string( + "mobile.notifications.phoneEnabled", + defaultValue: "Allow Push Alerts on This iPhone" + ), + isOn: Binding( + get: { notificationsEnabled }, + set: { enabled in + Task { @MainActor in + notificationsEnabled = await updatePhonePushEnabled(enabled) + } + } + ) + ) + .accessibilityIdentifier("MobileSettingsNotifications") +#endif } Section { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PendingReply.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PendingReply.swift new file mode 100644 index 000000000000..c261865f706d --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PendingReply.swift @@ -0,0 +1,11 @@ +import Foundation + +/// A single inline notification reply parked until its target Mac can accept input. +struct PendingReply: Equatable, Sendable { + let text: String + let workspaceId: String? + let surfaceId: String? + let macDeviceId: String? + let retargetsToLiveSurfaceOwner: Bool + let createdAt: Date +} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PendingReplyDecision.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PendingReplyDecision.swift new file mode 100644 index 000000000000..074c95a55a25 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PendingReplyDecision.swift @@ -0,0 +1,7 @@ +/// The pure lifecycle decision for a parked inline notification reply. +enum PendingReplyDecision: Equatable, Sendable { + case noPending + case waiting + case expired + case ready(PendingReply) +} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PendingReplyState.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PendingReplyState.swift new file mode 100644 index 000000000000..e7a201b813c7 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PendingReplyState.swift @@ -0,0 +1,37 @@ +import Foundation + +/// Stores at most one reply and decides when it may leave the parking lane. +struct PendingReplyState: Sendable { + static let lifetime: TimeInterval = 120 + + private(set) var pending: PendingReply? + + /// Replaces any older parked reply so the most recent user intent wins. + mutating func park(_ reply: PendingReply) { + pending = reply + } + + /// Drops the current reply when topology proves it can no longer be delivered safely. + mutating func discard() { + pending = nil + } + + /// Evaluates expiry and all prerequisites without depending on UIKit or a live shell store. + mutating func evaluate( + now: Date, + isStoreBound: Bool, + isTargetReachable: Bool, + isChannelAvailable: Bool + ) -> PendingReplyDecision { + guard let pending else { return .noPending } + guard now.timeIntervalSince(pending.createdAt) < Self.lifetime else { + self.pending = nil + return .expired + } + guard isStoreBound, isTargetReachable, isChannelAvailable else { + return .waiting + } + self.pending = nil + return .ready(pending) + } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings index 66b0340395e1..fcc0cb4cbed4 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings @@ -831,6 +831,62 @@ "ja" : { "stringUnit" : { "state" : "translated", "value" : "保存" } } } }, + "mobile.push.reply.action" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Reply" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "返信" } } + } + }, + "mobile.push.reply.placeholder" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Message the agent…" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "エージェントにメッセージ…" } } + } + }, + "mobile.push.reply.send" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Send" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "送信" } } + } + }, + "mobile.push.debugReply.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "cmux reply test" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "cmux返信テスト" } } + } + }, + "mobile.push.debugReply.body" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Reply here; the text is typed into the selected Mac terminal." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "ここで返信すると、選択中のMacターミナルにテキストが入力されます。" } } + } + }, + "mobile.settings.debugReplyTest" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Test Inline Reply (Local)" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "インライン返信をテスト(ローカル)" } } + } + }, + "mobile.settings.debugReplyTest.scheduled" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Scheduled: lock the phone; the notification fires in 5 seconds." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "予約しました。iPhoneをロックしてください。5秒後に通知が届きます。" } } + } + }, + "mobile.settings.debugReplyTest.failed" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Couldn't schedule: open a workspace and select a terminal first." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "予約できませんでした。先にワークスペースを開いてターミナルを選択してください。" } } + } + }, "mobile.workspace.customize.description.tooLong" : { "extractionState" : "manual", "localizations" : { diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/PendingReplyStateTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/PendingReplyStateTests.swift new file mode 100644 index 000000000000..6cc9f5bb8ab5 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/PendingReplyStateTests.swift @@ -0,0 +1,84 @@ +import Foundation +import Testing +@testable import CmuxMobileShellUI + +@Suite struct PendingReplyStateTests { + private let now = Date(timeIntervalSince1970: 1_000) + + @Test func latestReplyReplacesThePreviouslyParkedReply() { + var state = PendingReplyState() + state.park(reply(text: "first")) + state.park(reply(text: "second")) + + #expect(state.pending?.text == "second") + } + + @Test func replyExpiresAtTheLifetimeBoundary() { + var state = PendingReplyState() + state.park(reply(text: "expired", createdAt: now.addingTimeInterval(-120))) + + #expect( + state.evaluate( + now: now, + isStoreBound: true, + isTargetReachable: true, + isChannelAvailable: true + ) == .expired + ) + #expect(state.pending == nil) + } + + @Test(arguments: [ + (false, true, true), + (true, false, true), + (true, true, false), + ]) + func replyWaitsUntilEveryApplyConditionIsReady( + isStoreBound: Bool, + isTargetReachable: Bool, + isChannelAvailable: Bool + ) { + var state = PendingReplyState() + state.park(reply(text: "waiting")) + + #expect( + state.evaluate( + now: now, + isStoreBound: isStoreBound, + isTargetReachable: isTargetReachable, + isChannelAvailable: isChannelAvailable + ) == .waiting + ) + #expect(state.pending?.text == "waiting") + } + + @Test func readyReplyIsReturnedAndRemovedFromParking() { + var state = PendingReplyState() + let pending = reply(text: "send") + state.park(pending) + + #expect( + state.evaluate( + now: now, + isStoreBound: true, + isTargetReachable: true, + isChannelAvailable: true + ) == .ready(pending) + ) + #expect(state.pending == nil) + } + + private func reply( + text: String, + createdAt: Date? = nil + ) -> PendingReply { + PendingReply( + text: text, + workspaceId: "workspace-1", + surfaceId: "surface-1", + macDeviceId: "mac-1", + retargetsToLiveSurfaceOwner: true, + createdAt: createdAt ?? now + ) + } +} diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamQuestionPrompt+Parsing.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamQuestionPrompt+Parsing.swift new file mode 100644 index 000000000000..2bebcb819aa1 --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamQuestionPrompt+Parsing.swift @@ -0,0 +1,52 @@ +import Foundation + +extension WorkstreamQuestionPrompt { + /// Parses Claude-style nested question input and the legacy flat question shape. + /// + /// - Parameter toolInputJSON: The serialized tool input, if present. + /// - Returns: Parsed prompts, or an empty array when the input is absent or invalid. + public static func parse(toolInputJSON: String?) -> [WorkstreamQuestionPrompt] { + guard let toolInputJSON, + let data = toolInputJSON.data(using: .utf8), + let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return [] } + + if let questions = root["questions"] as? [[String: Any]] { + return questions.enumerated().map { index, question in + makeParsedQuestion(from: question, fallbackId: "q\(index)") + } + } + return [makeParsedQuestion(from: root, fallbackId: "q0")] + } + + private static func makeParsedQuestion( + from dictionary: [String: Any], + fallbackId: String + ) -> WorkstreamQuestionPrompt { + let header = (dictionary["header"] as? String) ?? (dictionary["title"] as? String) + let prompt = (dictionary["question"] as? String) + ?? (dictionary["prompt"] as? String) + ?? "" + let multiSelect = (dictionary["multiSelect"] as? Bool) + ?? (dictionary["multi_select"] as? Bool) + ?? false + let rawOptions = dictionary["options"] as? [Any] ?? [] + let options = rawOptions.enumerated().compactMap { index, raw -> WorkstreamQuestionOption? in + if let label = raw as? String { + return WorkstreamQuestionOption(id: "opt\(index)", label: label) + } + guard let option = raw as? [String: Any] else { return nil } + let id = (option["id"] as? String) ?? "opt\(index)" + let label = (option["label"] as? String) ?? (option["title"] as? String) ?? id + let description = (option["description"] as? String) ?? (option["detail"] as? String) + return WorkstreamQuestionOption(id: id, label: label, description: description) + } + return WorkstreamQuestionPrompt( + id: (dictionary["id"] as? String) ?? fallbackId, + header: header, + prompt: prompt, + multiSelect: multiSelect, + options: options + ) + } +} diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamStore.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamStore.swift index afbd0337f8a0..90e780918c12 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamStore.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamStore.swift @@ -290,7 +290,7 @@ public final class WorkstreamStore { ) ) case .askUserQuestion: - let parsed = parseQuestions(fromToolInput: event.toolInputJSON) + let parsed = WorkstreamQuestionPrompt.parse(toolInputJSON: event.toolInputJSON) return ( .question, .question( @@ -354,57 +354,6 @@ public final class WorkstreamStore { return titleProvider(event) } - /// Parses Claude Code's `AskUserQuestion` tool input (or similar) - /// into an array of question prompts. Recognized shape: - /// { "questions": [{ "question": "…", "multiSelect": true, - /// "options": [{"id": "a", "label": "…"}] }] } - /// Also tolerates flat legacy shapes with a single prompt. - private func parseQuestions(fromToolInput json: String?) -> [WorkstreamQuestionPrompt] { - guard let json, let data = json.data(using: .utf8), - let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] - else { return [] } - - if let arr = root["questions"] as? [[String: Any]] { - return arr.enumerated().map { idx, q in - Self.makeQuestion(from: q, fallbackId: "q\(idx)") - } - } - // Flat shape: top-level { question, options, multiSelect }. - return [Self.makeQuestion(from: root, fallbackId: "q0")] - } - - private static func makeQuestion(from dict: [String: Any], fallbackId: String) -> WorkstreamQuestionPrompt { - let header = (dict["header"] as? String) - ?? (dict["title"] as? String) - let prompt = (dict["question"] as? String) - ?? (dict["prompt"] as? String) - ?? "" - let multi = (dict["multiSelect"] as? Bool) - ?? (dict["multi_select"] as? Bool) - ?? false - let rawOptions = dict["options"] as? [Any] ?? [] - var options: [WorkstreamQuestionOption] = [] - for (i, raw) in rawOptions.enumerated() { - if let s = raw as? String { - options.append(WorkstreamQuestionOption(id: "opt\(i)", label: s)) - } else if let d = raw as? [String: Any] { - let id = (d["id"] as? String) ?? "opt\(i)" - let label = (d["label"] as? String) ?? (d["title"] as? String) ?? id - let description = (d["description"] as? String) ?? (d["detail"] as? String) - options.append(WorkstreamQuestionOption( - id: id, label: label, description: description - )) - } - } - return WorkstreamQuestionPrompt( - id: (dict["id"] as? String) ?? fallbackId, - header: header, - prompt: prompt, - multiSelect: multi, - options: options - ) - } - private static func jsonObject(from json: String?) -> Any? { guard let json, let data = json.data(using: .utf8) else { return nil } return try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/Workstream/WorkstreamQuestionPromptParsingTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/Workstream/WorkstreamQuestionPromptParsingTests.swift new file mode 100644 index 000000000000..2b1013de4a2a --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/Workstream/WorkstreamQuestionPromptParsingTests.swift @@ -0,0 +1,46 @@ +import Testing +@testable import CMUXAgentLaunch + +@Suite +struct WorkstreamQuestionPromptParsingTests { + @Test("parses nested questions with rich options") + func parsesNestedQuestions() throws { + let parsed = WorkstreamQuestionPrompt.parse(toolInputJSON: #""" + { + "questions": [{ + "id": "q-choice", + "header": "Approach", + "question": "Which one?", + "multiSelect": true, + "options": [{"id":"fast","label":"Fast","description":"Ship now"}] + }] + } + """#) + + let question = try #require(parsed.first) + #expect(question.id == "q-choice") + #expect(question.header == "Approach") + #expect(question.prompt == "Which one?") + #expect(question.multiSelect) + #expect(question.options == [.init(id: "fast", label: "Fast", description: "Ship now")]) + } + + @Test("parses flat questions and defaults multi-select to false") + func parsesFlatQuestion() throws { + let parsed = WorkstreamQuestionPrompt.parse(toolInputJSON: #""" + { + "prompt": "Choose", + "options": ["Alpha", "Beta"] + } + """#) + + let question = try #require(parsed.first) + #expect(question.id == "q0") + #expect(question.prompt == "Choose") + #expect(!question.multiSelect) + #expect(question.options == [ + .init(id: "opt0", label: "Alpha"), + .init(id: "opt1", label: "Beta"), + ]) + } +} 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..e4826f7fb15c 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Notification/ControlCommandCoordinator+Notification.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Notification/ControlCommandCoordinator+Notification.swift @@ -50,7 +50,8 @@ extension ControlCommandCoordinator { explicitSurfaceID: uuid(params, "surface_id"), title: title, subtitle: subtitle, - body: body + body: body, + replyShapeWire: rawString(params, "reply_shape") ) ?? .tabManagerUnavailable switch resolution { @@ -86,7 +87,8 @@ extension ControlCommandCoordinator { surfaceID: surfaceID, title: title, subtitle: subtitle, - body: body + body: body, + replyShapeWire: rawString(params, "reply_shape") ) ?? .tabManagerUnavailable return targetedDeliveryResult(resolution) } @@ -109,7 +111,8 @@ extension ControlCommandCoordinator { surfaceID: surfaceID, title: title, subtitle: subtitle, - body: body + body: body, + replyShapeWire: rawString(params, "reply_shape") ) ?? .tabManagerUnavailable return targetedDeliveryResult(resolution) } diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Notification/ControlNotificationContext.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Notification/ControlNotificationContext.swift index a7468746ce73..95fffd326d2a 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Notification/ControlNotificationContext.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Notification/ControlNotificationContext.swift @@ -35,7 +35,8 @@ public protocol ControlNotificationContext: AnyObject { explicitSurfaceID: UUID?, title: String, subtitle: String, - body: String + body: String, + replyShapeWire: String? ) -> ControlNotificationCreateResolution /// Delivers a notification for `notification.create_for_surface`: resolves @@ -56,7 +57,8 @@ public protocol ControlNotificationContext: AnyObject { surfaceID: UUID, title: String, subtitle: String, - body: String + body: String, + replyShapeWire: String? ) -> ControlNotificationTargetedDeliveryResolution /// Delivers a notification for `notification.create_for_target`: resolves @@ -80,7 +82,8 @@ public protocol ControlNotificationContext: AnyObject { surfaceID: UUID, title: String, subtitle: String, - body: String + body: String, + replyShapeWire: String? ) -> ControlNotificationTargetedDeliveryResolution /// Snapshots every notification for `notification.list`, in store order, diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift index 026f35760b99..1541b3996ae3 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift @@ -140,7 +140,8 @@ extension ControlNotificationContext { explicitSurfaceID: UUID?, title: String, subtitle: String, - body: String + body: String, + replyShapeWire: String? ) -> ControlNotificationCreateResolution { .tabManagerUnavailable } func controlNotificationCreateForSurface( @@ -148,7 +149,8 @@ extension ControlNotificationContext { surfaceID: UUID, title: String, subtitle: String, - body: String + body: String, + replyShapeWire: String? ) -> ControlNotificationTargetedDeliveryResolution { .tabManagerUnavailable } func controlNotificationCreateForTarget( @@ -157,7 +159,8 @@ extension ControlNotificationContext { surfaceID: UUID, title: String, subtitle: String, - body: String + body: String, + replyShapeWire: String? ) -> ControlNotificationTargetedDeliveryResolution { .tabManagerUnavailable } func controlNotificationList() -> [ControlNotificationSnapshot] { [] } diff --git a/Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/NotificationDeliveryActionTitles.swift b/Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/NotificationDeliveryActionTitles.swift index c14f29bd569b..dae3c3c0b8b5 100644 --- a/Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/NotificationDeliveryActionTitles.swift +++ b/Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/NotificationDeliveryActionTitles.swift @@ -5,6 +5,15 @@ public struct NotificationDeliveryActionTitles: Sendable, Equatable { /// Title for opening a terminal notification. public let show: String + /// Title for starting a terminal notification reply. + public let reply: String + + /// Title for sending a terminal notification reply. + public let replySend: String + + /// Placeholder for terminal notification reply text. + public let replyPlaceholder: String + /// Title for allowing a permission request once. public let feedPermissionAllowOnce: String @@ -26,12 +35,21 @@ public struct NotificationDeliveryActionTitles: Sendable, Equatable { /// Title for accepting an exit plan automatically. public let feedExitPlanAutoAccept: String + /// Title for revising an exit plan with free-text feedback. + public let feedExitPlanRevise: String + /// Title for opening a feed question in the app. public let feedQuestionReply: String + /// Title for answering a feed question with free text. + public let feedQuestionOther: String + /// Creates the localized titles used by delivery-category actions. public init( show: String, + reply: String, + replySend: String, + replyPlaceholder: String, feedPermissionAllowOnce: String, feedPermissionAlways: String, feedPermissionAll: String, @@ -39,9 +57,14 @@ public struct NotificationDeliveryActionTitles: Sendable, Equatable { feedExitPlanUltraplan: String, feedExitPlanManual: String, feedExitPlanAutoAccept: String, - feedQuestionReply: String + feedExitPlanRevise: String, + feedQuestionReply: String, + feedQuestionOther: String ) { self.show = show + self.reply = reply + self.replySend = replySend + self.replyPlaceholder = replyPlaceholder self.feedPermissionAllowOnce = feedPermissionAllowOnce self.feedPermissionAlways = feedPermissionAlways self.feedPermissionAll = feedPermissionAll @@ -49,6 +72,8 @@ public struct NotificationDeliveryActionTitles: Sendable, Equatable { self.feedExitPlanUltraplan = feedExitPlanUltraplan self.feedExitPlanManual = feedExitPlanManual self.feedExitPlanAutoAccept = feedExitPlanAutoAccept + self.feedExitPlanRevise = feedExitPlanRevise self.feedQuestionReply = feedQuestionReply + self.feedQuestionOther = feedQuestionOther } } diff --git a/Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/NotificationDeliveryCoordinator.swift b/Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/NotificationDeliveryCoordinator.swift index a4f2ebebc73d..75a572aab621 100644 --- a/Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/NotificationDeliveryCoordinator.swift +++ b/Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/NotificationDeliveryCoordinator.swift @@ -16,6 +16,7 @@ public import UserNotifications public final class NotificationDeliveryCoordinator { private let center: any UserNotificationCenterConfiguring private let terminalNavigation: any NotificationDeliveryTerminalNavigating + private let terminalReplying: any NotificationTerminalReplying private let feedReplying: any NotificationFeedReplying private let applicationActivation: any NotificationApplicationActivating private let terminalIdentifiers: TerminalNotificationDeliveryIdentifiers @@ -30,6 +31,7 @@ public final class NotificationDeliveryCoordinator { public init( center: any UserNotificationCenterConfiguring, terminalNavigation: any NotificationDeliveryTerminalNavigating, + terminalReplying: any NotificationTerminalReplying, feedReplying: any NotificationFeedReplying, applicationActivation: any NotificationApplicationActivating, terminalIdentifiers: TerminalNotificationDeliveryIdentifiers, @@ -37,6 +39,7 @@ public final class NotificationDeliveryCoordinator { ) { self.center = center self.terminalNavigation = terminalNavigation + self.terminalReplying = terminalReplying self.feedReplying = feedReplying self.applicationActivation = applicationActivation self.terminalIdentifiers = terminalIdentifiers @@ -56,7 +59,18 @@ public final class NotificationDeliveryCoordinator { let categories = notificationCategories() categoryInstallationTask?.cancel() categoryInstallationTask = Task { [center] in - _ = await center.setNotificationCategories(categories) + // Preserve live per-request Feed question categories: this static + // install replaces the whole registered set, and a re-configure + // racing a minted `CMUXFeedQuestion.` category would silently strip + // that banner's option buttons. An unreadable daemon degrades to + // installing the static set alone. + var merged = categories + if case .success(let current) = await center.notificationCategories() { + merged.formUnion(current.filter { + $0.identifier.hasPrefix("CMUXFeedQuestion.") + }) + } + _ = await center.setNotificationCategories(merged) } } @@ -98,6 +112,19 @@ public final class NotificationDeliveryCoordinator { intentIdentifiers: [], options: [.customDismissAction] ) + let terminalReplyAction = UNTextInputNotificationAction( + identifier: terminalIdentifiers.replyActionIdentifier, + title: actionTitles.reply, + options: [], + textInputButtonTitle: actionTitles.replySend, + textInputPlaceholder: actionTitles.replyPlaceholder + ) + let terminalTextReplyCategory = UNNotificationCategory( + identifier: terminalIdentifiers.textReplyCategoryIdentifier, + actions: [terminalReplyAction, terminalShowAction], + intentIdentifiers: [], + options: [.customDismissAction] + ) let permissionOnceAction = UNNotificationAction( identifier: "feed.permission.once", @@ -151,6 +178,13 @@ public final class NotificationDeliveryCoordinator { identifier: "feed.exit_plan.autoAccept", title: actionTitles.feedExitPlanAutoAccept ), + UNTextInputNotificationAction( + identifier: "feed.exit_plan.revise", + title: actionTitles.feedExitPlanRevise, + options: [], + textInputButtonTitle: actionTitles.replySend, + textInputPlaceholder: actionTitles.replyPlaceholder + ), ], intentIdentifiers: [], options: [] @@ -168,7 +202,7 @@ public final class NotificationDeliveryCoordinator { options: [] ) - return Set([terminalCategory, exitPlanCategory, questionCategory] + permissionCategories) + return Set([terminalCategory, terminalTextReplyCategory, exitPlanCategory, questionCategory] + permissionCategories) } private func feedPermissionNotificationCategoryIds() -> [String] { @@ -190,9 +224,13 @@ public final class NotificationDeliveryCoordinator { guard categoryId.hasPrefix("CMUXFeedPermission") || categoryId == "CMUXFeedExitPlan" || categoryId == "CMUXFeedQuestion" + || categoryId.hasPrefix("CMUXFeedQuestion.") else { return false } guard let requestId = response.userInfo["requestId"] as? String else { + if categoryId.hasPrefix("CMUXFeedQuestion.") { + applicationActivation.activateApplication() + } return true } @@ -215,20 +253,51 @@ public final class NotificationDeliveryCoordinator { case "feed.permission.deny": feedReplying.deliverReply(requestId: requestId, decision: .permission(.deny)) case "feed.exit_plan.ultraplan": - feedReplying.deliverReply(requestId: requestId, decision: .exitPlan(.ultraplan)) + feedReplying.deliverReply(requestId: requestId, decision: .exitPlan(.ultraplan, feedback: nil)) case "feed.exit_plan.bypassPermissions": - feedReplying.deliverReply(requestId: requestId, decision: .exitPlan(.bypassPermissions)) + feedReplying.deliverReply(requestId: requestId, decision: .exitPlan(.bypassPermissions, feedback: nil)) case "feed.exit_plan.autoAccept": - feedReplying.deliverReply(requestId: requestId, decision: .exitPlan(.autoAccept)) + feedReplying.deliverReply(requestId: requestId, decision: .exitPlan(.autoAccept, feedback: nil)) case "feed.exit_plan.manual": - feedReplying.deliverReply(requestId: requestId, decision: .exitPlan(.manual)) + feedReplying.deliverReply(requestId: requestId, decision: .exitPlan(.manual, feedback: nil)) + case "feed.exit_plan.revise": + // Empty feedback must not degenerate into a bare manual approval: + // Revise… without text is not a decision. Open the app at the card + // instead, matching the empty `feed.question.other` fallback. + guard let feedback = response.userText? + .trimmingCharacters(in: .whitespacesAndNewlines), + !feedback.isEmpty else { + applicationActivation.activateApplication() + return true + } + feedReplying.deliverReply( + requestId: requestId, + decision: .exitPlan(.manual, feedback: feedback) + ) + case let action where action.hasPrefix("feed.question.option."): + guard let index = Int(action.dropFirst("feed.question.option.".count)), + let optionIds = response.userInfo["questionOptionIds"] as? [String], + optionIds.indices.contains(index) else { + applicationActivation.activateApplication() + return true + } + feedReplying.deliverReply(requestId: requestId, decision: .question(selections: [optionIds[index]])) + case "feed.question.other": + guard let text = response.userText, + !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + applicationActivation.activateApplication() + return true + } + feedReplying.deliverReply(requestId: requestId, decision: .question(selections: [text])) case "feed.question.open": applicationActivation.activateApplication() case UNNotificationDismissActionIdentifier, UNNotificationDefaultActionIdentifier: applicationActivation.activateApplication() default: - break + if categoryId.hasPrefix("CMUXFeedQuestion.") { + applicationActivation.activateApplication() + } } return true } @@ -267,21 +336,41 @@ public final class NotificationDeliveryCoordinator { private func handleTerminalNotificationResponse(_ response: NotificationDeliveryResponse) { switch response.actionIdentifier { - case UNNotificationDefaultActionIdentifier, terminalIdentifiers.showActionIdentifier: - guard let tabIdString = response.userInfo["tabId"] as? String, - let tabId = UUID(uuidString: tabIdString) else { + case terminalIdentifiers.replyActionIdentifier: + guard let target = terminalTarget(response) else { return } + let text = response.userText?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !text.isEmpty else { + openTerminalNotification(response, target: target) return } - let surfaceId: UUID? = { - guard let surfaceIdString = response.userInfo["surfaceId"] as? String else { - return nil - } - return UUID(uuidString: surfaceIdString) - }() + let didSend = terminalReplying.sendReply( + text: text, + tabId: target.tabId, + surfaceId: target.surfaceId, + retargetsToLiveSurfaceOwner: target.retargetsToLiveSurfaceOwner + ) + if didSend, let notificationId = notificationId(response) { + terminalNavigation.markNotificationRead(id: notificationId) + } else if !didSend { + openTerminalNotification(response, target: target) + } + case UNNotificationDefaultActionIdentifier, terminalIdentifiers.showActionIdentifier: + guard let target = terminalTarget(response) else { return } + openTerminalNotification(response, target: target) + case UNNotificationDismissActionIdentifier: + if let notificationId = notificationId(response) { + terminalNavigation.markNotificationRead(id: notificationId) + } + default: + break + } + } + + private func openTerminalNotification( + _ response: NotificationDeliveryResponse, + target: (tabId: UUID, surfaceId: UUID?, retargetsToLiveSurfaceOwner: Bool) + ) { let notificationId = notificationId(response) - let retargetsToLiveSurfaceOwner = response.userInfo[ - terminalIdentifiers.retargetsToLiveSurfaceOwnerUserInfoKey - ] as? Bool ?? true if let clickAction = NotificationNavClickAction(userInfo: response.userInfo) { let didPerform = terminalNavigation.performClickAction(clickAction) if didPerform, let notificationId { @@ -292,20 +381,25 @@ public final class NotificationDeliveryCoordinator { if let notificationId { _ = terminalNavigation.openNotification( id: notificationId, - fallbackTabId: tabId, - fallbackSurfaceId: surfaceId, - fallbackRetargetsToLiveSurfaceOwner: retargetsToLiveSurfaceOwner + fallbackTabId: target.tabId, + fallbackSurfaceId: target.surfaceId, + fallbackRetargetsToLiveSurfaceOwner: target.retargetsToLiveSurfaceOwner ) } else { - _ = terminalNavigation.open(tabId: tabId, surfaceId: surfaceId, notificationId: nil) - } - case UNNotificationDismissActionIdentifier: - if let notificationId = notificationId(response) { - terminalNavigation.markNotificationRead(id: notificationId) + _ = terminalNavigation.open(tabId: target.tabId, surfaceId: target.surfaceId, notificationId: nil) } - default: - break - } + } + + private func terminalTarget( + _ response: NotificationDeliveryResponse + ) -> (tabId: UUID, surfaceId: UUID?, retargetsToLiveSurfaceOwner: Bool)? { + guard let tabIdString = response.userInfo["tabId"] as? String, + let tabId = UUID(uuidString: tabIdString) else { return nil } + let surfaceId = (response.userInfo["surfaceId"] as? String).flatMap(UUID.init(uuidString:)) + let retargets = response.userInfo[ + terminalIdentifiers.retargetsToLiveSurfaceOwnerUserInfoKey + ] as? Bool ?? true + return (tabId, surfaceId, retargets) } private func notificationId(_ response: NotificationDeliveryResponse) -> UUID? { diff --git a/Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/NotificationDeliveryResponse.swift b/Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/NotificationDeliveryResponse.swift index 81a57ef5e601..80f214983224 100644 --- a/Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/NotificationDeliveryResponse.swift +++ b/Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/NotificationDeliveryResponse.swift @@ -6,17 +6,20 @@ struct NotificationDeliveryResponse { let actionIdentifier: String let requestIdentifier: String let userInfo: [AnyHashable: Any] + let userText: String? init( categoryIdentifier: String, actionIdentifier: String, requestIdentifier: String, - userInfo: [AnyHashable: Any] + userInfo: [AnyHashable: Any], + userText: String? = nil ) { self.categoryIdentifier = categoryIdentifier self.actionIdentifier = actionIdentifier self.requestIdentifier = requestIdentifier self.userInfo = userInfo + self.userText = userText } init(_ response: UNNotificationResponse) { @@ -24,7 +27,8 @@ struct NotificationDeliveryResponse { categoryIdentifier: response.notification.request.content.categoryIdentifier, actionIdentifier: response.actionIdentifier, requestIdentifier: response.notification.request.identifier, - userInfo: response.notification.request.content.userInfo + userInfo: response.notification.request.content.userInfo, + userText: (response as? UNTextInputNotificationResponse)?.userText ) } } diff --git a/Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/NotificationFeedDecision.swift b/Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/NotificationFeedDecision.swift index bee12bcd4522..7e30841dd631 100644 --- a/Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/NotificationFeedDecision.swift +++ b/Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/NotificationFeedDecision.swift @@ -4,5 +4,8 @@ public enum NotificationFeedDecision: Sendable, Equatable { case permission(NotificationFeedPermissionMode) /// An exit-plan decision. - case exitPlan(NotificationFeedExitPlanMode) + case exitPlan(NotificationFeedExitPlanMode, feedback: String?) + + /// A question decision carrying the selected option ids or a custom answer. + case question(selections: [String]) } diff --git a/Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/NotificationTerminalReplying.swift b/Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/NotificationTerminalReplying.swift new file mode 100644 index 000000000000..d018d1ac5c67 --- /dev/null +++ b/Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/NotificationTerminalReplying.swift @@ -0,0 +1,20 @@ +public import Foundation + +/// Sends an inline notification reply to the exact terminal surface that produced it. +@MainActor +public protocol NotificationTerminalReplying: AnyObject { + /// Sends `text` followed by Return to the notification's terminal surface. + /// + /// - Parameters: + /// - text: The non-empty reply text. + /// - tabId: The workspace stored on the notification. + /// - surfaceId: The exact terminal surface, when the notification declared one. + /// - retargetsToLiveSurfaceOwner: Whether the surface may follow its live owner. + /// - Returns: `true` only when the reply was sent. + func sendReply( + text: String, + tabId: UUID, + surfaceId: UUID?, + retargetsToLiveSurfaceOwner: Bool + ) -> Bool +} diff --git a/Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/TerminalNotificationDeliveryIdentifiers.swift b/Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/TerminalNotificationDeliveryIdentifiers.swift index afa8d8f8588e..8289701e6656 100644 --- a/Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/TerminalNotificationDeliveryIdentifiers.swift +++ b/Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/TerminalNotificationDeliveryIdentifiers.swift @@ -5,9 +5,15 @@ public struct TerminalNotificationDeliveryIdentifiers: Sendable, Equatable { /// The `UNNotificationCategory` identifier used by terminal notifications. public let categoryIdentifier: String + /// The `UNNotificationCategory` identifier used by text-reply notifications. + public let textReplyCategoryIdentifier: String + /// The explicit "show" action identifier used by terminal notifications. public let showActionIdentifier: String + /// The text-input reply action identifier. + public let replyActionIdentifier: String + /// The `userInfo` key carrying whether a notification may follow its live surface owner. public let retargetsToLiveSurfaceOwnerUserInfoKey: String @@ -16,15 +22,21 @@ public struct TerminalNotificationDeliveryIdentifiers: Sendable, Equatable { /// /// - Parameters: /// - categoryIdentifier: Category identifier for terminal notifications. + /// - textReplyCategoryIdentifier: Category identifier for text-reply notifications. /// - showActionIdentifier: Explicit show-action identifier. + /// - replyActionIdentifier: Text-input reply action identifier. /// - retargetsToLiveSurfaceOwnerUserInfoKey: `userInfo` key for routing provenance. public init( categoryIdentifier: String, + textReplyCategoryIdentifier: String, showActionIdentifier: String, + replyActionIdentifier: String, retargetsToLiveSurfaceOwnerUserInfoKey: String ) { self.categoryIdentifier = categoryIdentifier + self.textReplyCategoryIdentifier = textReplyCategoryIdentifier self.showActionIdentifier = showActionIdentifier + self.replyActionIdentifier = replyActionIdentifier self.retargetsToLiveSurfaceOwnerUserInfoKey = retargetsToLiveSurfaceOwnerUserInfoKey } } diff --git a/Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/TerminalNotificationReplyShape.swift b/Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/TerminalNotificationReplyShape.swift new file mode 100644 index 000000000000..496cdb826974 --- /dev/null +++ b/Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/TerminalNotificationReplyShape.swift @@ -0,0 +1,30 @@ +/// The inline-reply affordance declared by a terminal notification. +public enum TerminalNotificationReplyShape: String, Sendable, Equatable { + /// The notification is open-only and has no inline reply control. + case none + + /// The notification accepts a free-text reply. + case text + + /// Creates a reply shape from its socket wire value. + /// + /// Unknown and absent values deliberately fall back to ``none``. + /// + /// - Parameter wire: The optional wire value. + public init(wire: String?) { + self = wire.flatMap(Self.init(rawValue:)) ?? .none + } + + /// Derives the reply shape for an agent notification category wire value. + /// + /// - Parameter agentCategoryWire: The agent category, if supplied. + /// - Returns: Text reply for turn-complete and idle-reminder only. + public static func forAgentCategory(wire agentCategoryWire: String?) -> Self { + switch agentCategoryWire { + case "turn-complete", "idle-reminder": + return .text + default: + return .none + } + } +} diff --git a/Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/UserNotificationCenterCalling.swift b/Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/UserNotificationCenterCalling.swift index f78677b538b9..6ee0fef7b833 100644 --- a/Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/UserNotificationCenterCalling.swift +++ b/Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/UserNotificationCenterCalling.swift @@ -6,6 +6,10 @@ protocol UserNotificationCenterCalling: AnyObject { func setNotificationCategories(_ categories: Set) + func getNotificationCategories( + completionHandler: @escaping @Sendable (Set) -> Void + ) + func getNotificationSettings( completionHandler: @escaping @Sendable (UNNotificationSettings) -> Void ) diff --git a/Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/UserNotificationCenterConfiguring.swift b/Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/UserNotificationCenterConfiguring.swift index 18349d111e95..2f2e94100cdc 100644 --- a/Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/UserNotificationCenterConfiguring.swift +++ b/Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/UserNotificationCenterConfiguring.swift @@ -10,6 +10,14 @@ public protocol UserNotificationCenterConfiguring: Sendable { _ categories: Set ) async -> Result + /// Reads the currently registered notification categories, so installers + /// and owners of dynamic per-request categories can merge instead of + /// clobbering each other's registrations. + func notificationCategories() async -> Result< + Set, + UserNotificationCenterFailure + > + /// Installs the notification-center delegate that receives delivery and /// response callbacks. func setDelegate(_ delegate: (any UNUserNotificationCenterDelegate)?) diff --git a/Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/UserNotificationCenterService.swift b/Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/UserNotificationCenterService.swift index b0184d762ff2..28085cad7287 100644 --- a/Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/UserNotificationCenterService.swift +++ b/Packages/macOS/CmuxNotifications/Sources/CmuxNotifications/UserNotificationCenterService.swift @@ -85,6 +85,21 @@ public final class UserNotificationCenterService: @unchecked Sendable { } } + /// Reads the currently registered notification categories through the + /// bounded background boundary. Callers that maintain dynamic per-request + /// categories (the Feed's `CMUXFeedQuestion.` namespace) merge against + /// this snapshot inside their own serialized update chain. + public func notificationCategories() async -> Result< + Set, + UserNotificationCenterFailure + > { + await perform { [self] completion in + center.getNotificationCategories { categories in + completion(.success(categories)) + } + } + } + /// Returns the app's current notification authorization status. public func authorizationStatus() async -> Result< UserNotificationAuthorizationStatus, diff --git a/Packages/macOS/CmuxNotifications/Tests/CmuxNotificationsTests/NotificationDeliveryCoordinatorTests.swift b/Packages/macOS/CmuxNotifications/Tests/CmuxNotificationsTests/NotificationDeliveryCoordinatorTests.swift index 372e6fb4e66c..922901fc079b 100644 --- a/Packages/macOS/CmuxNotifications/Tests/CmuxNotificationsTests/NotificationDeliveryCoordinatorTests.swift +++ b/Packages/macOS/CmuxNotifications/Tests/CmuxNotificationsTests/NotificationDeliveryCoordinatorTests.swift @@ -5,9 +5,17 @@ import UserNotifications @MainActor private final class FakeNotificationCenter: UserNotificationCenterConfiguring { - private(set) var categories: Set = [] + var categories: Set = [] + private(set) var installCount = 0 private(set) var delegate: (any UNUserNotificationCenterDelegate)? + func notificationCategories() async -> Result< + Set, + UserNotificationCenterFailure + > { + .success(categories) + } + /// When true, category installation suspends until /// ``releaseCategoryInstall()``, modeling the framework's unbounded wait /// on a wedged daemon. @@ -22,6 +30,7 @@ private final class FakeNotificationCenter: UserNotificationCenterConfiguring { await withCheckedContinuation { stallWaiters.append($0) } } self.categories = categories + installCount += 1 let waiters = categoryInstallWaiters categoryInstallWaiters.removeAll() for waiter in waiters { @@ -40,6 +49,13 @@ private final class FakeNotificationCenter: UserNotificationCenterConfiguring { await withCheckedContinuation { categoryInstallWaiters.append($0) } } + /// Suspends until an install lands beyond `count`, for tests whose center + /// starts pre-seeded (the empty-set guard above would return immediately). + func waitForCategoryInstall(past count: Int) async { + guard installCount <= count else { return } + await withCheckedContinuation { categoryInstallWaiters.append($0) } + } + /// Lets a stalled category installation proceed. func releaseCategoryInstall() { stallCategoryInstall = false @@ -96,6 +112,34 @@ private final class FakeTerminalNavigation: NotificationDeliveryTerminalNavigati } } +@MainActor +private final class FakeTerminalReplying: NotificationTerminalReplying { + struct Reply: Equatable { + let text: String + let tabId: UUID + let surfaceId: UUID? + let retargetsToLiveSurfaceOwner: Bool + } + + var succeeds = true + private(set) var replies: [Reply] = [] + + func sendReply( + text: String, + tabId: UUID, + surfaceId: UUID?, + retargetsToLiveSurfaceOwner: Bool + ) -> Bool { + replies.append(.init( + text: text, + tabId: tabId, + surfaceId: surfaceId, + retargetsToLiveSurfaceOwner: retargetsToLiveSurfaceOwner + )) + return succeeds + } +} + @MainActor private final class FakeFeedReplying: NotificationFeedReplying { struct Reply: Equatable { @@ -129,6 +173,25 @@ private final class DummyNotificationDelegate: NSObject, UNUserNotificationCente @Suite(.serialized) @MainActor struct NotificationDeliveryCoordinatorTests { + @Test("configure preserves live minted question categories") + func configurePreservesMintedQuestionCategories() async throws { + let center = FakeNotificationCenter() + center.categories = [UNNotificationCategory( + identifier: "CMUXFeedQuestion.live-req", + actions: [], + intentIdentifiers: [], + options: [] + )] + let delegate = DummyNotificationDelegate() + let coordinator = makeCoordinator(center: center) + + coordinator.configureUserNotifications(delegate: delegate) + await center.waitForCategoryInstall(past: 0) + + #expect(center.categories.contains { $0.identifier == "CMUXFeedQuestion.live-req" }) + #expect(center.categories.contains { $0.identifier == "terminal.category" }) + } + @Test("configure installs terminal and Feed categories and delegate") func configureInstallsCategoriesAndDelegate() async throws { let center = FakeNotificationCenter() @@ -140,11 +203,31 @@ struct NotificationDeliveryCoordinatorTests { await center.waitForCategoryInstall() let categories = categoriesByIdentifier(center.categories) + #expect(Set(categories.keys) == [ + "terminal.category", + "terminal.textReply", + "CMUXFeedPermission", + "CMUXFeedPermissionDeny", + "CMUXFeedPermissionOnce", + "CMUXFeedPermissionAlways", + "CMUXFeedPermissionAll", + "CMUXFeedPermissionOnceAlways", + "CMUXFeedPermissionOnceAll", + "CMUXFeedPermissionAlwaysAll", + "CMUXFeedPermissionOnceAlwaysAll", + "CMUXFeedExitPlan", + "CMUXFeedQuestion", + ]) let terminalCategory = try #require(categories["terminal.category"]) #expect(terminalCategory.actions.map(\.identifier) == ["terminal.show"]) #expect(terminalCategory.actions.map(\.title) == ["Show"]) #expect(terminalCategory.options.contains(.customDismissAction)) + let textReplyCategory = try #require(categories["terminal.textReply"]) + #expect(textReplyCategory.actions.map(\.identifier) == ["terminal.reply", "terminal.show"]) + #expect(textReplyCategory.actions.first is UNTextInputNotificationAction) + #expect(textReplyCategory.options.contains(.customDismissAction)) + let fullPermission = try #require(categories["CMUXFeedPermissionOnceAlwaysAll"]) #expect(fullPermission.actions.map(\.identifier) == [ "feed.permission.once", @@ -162,7 +245,9 @@ struct NotificationDeliveryCoordinatorTests { "feed.exit_plan.ultraplan", "feed.exit_plan.manual", "feed.exit_plan.autoAccept", + "feed.exit_plan.revise", ]) + #expect(exitPlan.actions.last is UNTextInputNotificationAction) let question = try #require(categories["CMUXFeedQuestion"]) #expect(question.actions.map(\.identifier) == ["feed.question.open"]) @@ -276,6 +361,166 @@ struct NotificationDeliveryCoordinatorTests { #expect(activation.activationCount == 1) } + @Test("exit-plan revise sends manual mode with trimmed feedback") + func exitPlanReviseSendsFeedback() { + let feed = FakeFeedReplying() + let coordinator = makeCoordinator(feedReplying: feed) + + coordinator.handle(NotificationDeliveryResponse( + categoryIdentifier: "CMUXFeedExitPlan", + actionIdentifier: "feed.exit_plan.revise", + requestIdentifier: "feed.req-revise", + userInfo: ["requestId": "req-revise"], + userText: " revise the tests " + )) + + #expect(feed.replies == [ + .init(requestId: "req-revise", decision: .exitPlan(.manual, feedback: "revise the tests")), + ]) + } + + @Test("exit-plan revise with empty feedback opens the app, never approves") + func exitPlanReviseEmptyFeedbackDoesNotApprove() { + let feed = FakeFeedReplying() + let activation = FakeApplicationActivation() + let coordinator = makeCoordinator(feedReplying: feed, applicationActivation: activation) + + coordinator.handle(NotificationDeliveryResponse( + categoryIdentifier: "CMUXFeedExitPlan", + actionIdentifier: "feed.exit_plan.revise", + requestIdentifier: "feed.req-revise", + userInfo: ["requestId": "req-revise"], + userText: " " + )) + + #expect(feed.replies.isEmpty) + #expect(activation.activationCount == 1) + } + + @Test("dynamic question option sends the matching option id") + func dynamicQuestionOptionSendsSelection() { + let feed = FakeFeedReplying() + let coordinator = makeCoordinator(feedReplying: feed) + + coordinator.handle(NotificationDeliveryResponse( + categoryIdentifier: "CMUXFeedQuestion.req-question", + actionIdentifier: "feed.question.option.1", + requestIdentifier: "feed.req-question", + userInfo: [ + "requestId": "req-question", + "questionOptionIds": ["one", "two", "three"], + ] + )) + + #expect(feed.replies == [ + .init(requestId: "req-question", decision: .question(selections: ["two"])), + ]) + } + + @Test("dynamic question other sends raw user text") + func dynamicQuestionOtherSendsRawText() { + let feed = FakeFeedReplying() + let coordinator = makeCoordinator(feedReplying: feed) + + coordinator.handle(NotificationDeliveryResponse( + categoryIdentifier: "CMUXFeedQuestion.req-question", + actionIdentifier: "feed.question.other", + requestIdentifier: "feed.req-question", + userInfo: ["requestId": "req-question"], + userText: " keep these spaces " + )) + + #expect(feed.replies == [ + .init(requestId: "req-question", decision: .question(selections: [" keep these spaces "])), + ]) + } + + @Test("malformed dynamic question action activates the app without replying") + func malformedDynamicQuestionActivatesApp() { + let feed = FakeFeedReplying() + let activation = FakeApplicationActivation() + let coordinator = makeCoordinator( + feedReplying: feed, + applicationActivation: activation + ) + + coordinator.handle(NotificationDeliveryResponse( + categoryIdentifier: "CMUXFeedQuestion.req-question", + actionIdentifier: "feed.question.option.9", + requestIdentifier: "feed.req-question", + userInfo: [ + "requestId": "req-question", + "questionOptionIds": ["one", "two"], + ] + )) + + #expect(feed.replies.isEmpty) + #expect(activation.activationCount == 1) + } + + @Test("terminal text reply sends to exact surface and marks read") + func terminalTextReplySendsAndMarksRead() { + let terminal = FakeTerminalNavigation() + let replying = FakeTerminalReplying() + let tabId = UUID() + let surfaceId = UUID() + let notificationId = UUID() + let coordinator = makeCoordinator(terminalNavigation: terminal, terminalReplying: replying) + + coordinator.handle(NotificationDeliveryResponse( + categoryIdentifier: "terminal.textReply", + actionIdentifier: "terminal.reply", + requestIdentifier: notificationId.uuidString, + userInfo: [ + "tabId": tabId.uuidString, + "surfaceId": surfaceId.uuidString, + "retargetsToLiveSurfaceOwner": false, + ], + userText: " continue " + )) + + #expect(replying.replies == [.init( + text: "continue", + tabId: tabId, + surfaceId: surfaceId, + retargetsToLiveSurfaceOwner: false + )]) + #expect(terminal.markedReadIds == [notificationId]) + #expect(terminal.storedOpens.isEmpty) + } + + @Test("empty terminal text reply falls back to opening") + func emptyTerminalTextReplyOpens() { + let terminal = FakeTerminalNavigation() + let replying = FakeTerminalReplying() + let tabId = UUID() + let notificationId = UUID() + let coordinator = makeCoordinator(terminalNavigation: terminal, terminalReplying: replying) + + coordinator.handle(NotificationDeliveryResponse( + categoryIdentifier: "terminal.textReply", + actionIdentifier: "terminal.reply", + requestIdentifier: notificationId.uuidString, + userInfo: ["tabId": tabId.uuidString], + userText: " \n " + )) + + #expect(replying.replies.isEmpty) + #expect(terminal.storedOpens.count == 1) + } + + @Test("reply shape wire and agent category mappings fail closed") + func replyShapeMappings() { + #expect(TerminalNotificationReplyShape(wire: nil) == .none) + #expect(TerminalNotificationReplyShape(wire: "unknown") == .none) + #expect(TerminalNotificationReplyShape(wire: "text") == .text) + #expect(TerminalNotificationReplyShape.forAgentCategory(wire: "turn-complete") == .text) + #expect(TerminalNotificationReplyShape.forAgentCategory(wire: "idle-reminder") == .text) + #expect(TerminalNotificationReplyShape.forAgentCategory(wire: "needs-permission") == .none) + #expect(TerminalNotificationReplyShape.forAgentCategory(wire: "other") == .none) + #expect(TerminalNotificationReplyShape.forAgentCategory(wire: nil) == .none) + } + @Test("terminal default response with click action performs and marks read") func terminalDefaultClickActionPerformsAndMarksRead() { let terminal = FakeTerminalNavigation() @@ -400,21 +645,28 @@ struct NotificationDeliveryCoordinatorTests { private func makeCoordinator( center: FakeNotificationCenter = FakeNotificationCenter(), terminalNavigation: FakeTerminalNavigation = FakeTerminalNavigation(), + terminalReplying: FakeTerminalReplying = FakeTerminalReplying(), feedReplying: FakeFeedReplying = FakeFeedReplying(), applicationActivation: FakeApplicationActivation = FakeApplicationActivation() ) -> NotificationDeliveryCoordinator { NotificationDeliveryCoordinator( center: center, terminalNavigation: terminalNavigation, + terminalReplying: terminalReplying, feedReplying: feedReplying, applicationActivation: applicationActivation, terminalIdentifiers: TerminalNotificationDeliveryIdentifiers( categoryIdentifier: "terminal.category", + textReplyCategoryIdentifier: "terminal.textReply", showActionIdentifier: "terminal.show", + replyActionIdentifier: "terminal.reply", retargetsToLiveSurfaceOwnerUserInfoKey: "retargetsToLiveSurfaceOwner" ), actionTitles: NotificationDeliveryActionTitles( show: "Show", + reply: "Reply", + replySend: "Send", + replyPlaceholder: "Message the agent…", feedPermissionAllowOnce: "Allow Once", feedPermissionAlways: "Always", feedPermissionAll: "All tools", @@ -422,7 +674,9 @@ struct NotificationDeliveryCoordinatorTests { feedExitPlanUltraplan: "Ultraplan", feedExitPlanManual: "Manual", feedExitPlanAutoAccept: "Auto", - feedQuestionReply: "Reply" + feedExitPlanRevise: "Revise…", + feedQuestionReply: "Reply", + feedQuestionOther: "Other…" ) ) } diff --git a/Packages/macOS/CmuxNotifications/Tests/CmuxNotificationsTests/UserNotificationCenterServiceTests.swift b/Packages/macOS/CmuxNotifications/Tests/CmuxNotificationsTests/UserNotificationCenterServiceTests.swift index a44223d986c2..772451985744 100644 --- a/Packages/macOS/CmuxNotifications/Tests/CmuxNotificationsTests/UserNotificationCenterServiceTests.swift +++ b/Packages/macOS/CmuxNotifications/Tests/CmuxNotificationsTests/UserNotificationCenterServiceTests.swift @@ -56,6 +56,12 @@ private final class StubCallingCenter: UserNotificationCenterCalling, @unchecked func setNotificationCategories(_ categories: Set) {} + func getNotificationCategories( + completionHandler: @escaping @Sendable (Set) -> Void + ) { + completionHandler([]) + } + func getNotificationSettings( completionHandler: @escaping @Sendable (UNNotificationSettings) -> Void ) { diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 8a0e72551675..fd2674c80ebb 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -1,6 +1,230 @@ { "sourceLanguage": "en", "strings": { + "cli.help.notify.reply": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "--reply Allow a free-text inline reply" } }, + "ja": { "stringUnit": { "state": "translated", "value": "--reply 自由入力のインライン返信を許可" } } + } + }, + "debug.menu.notification.all": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Emit All" } }, + "ja": { "stringUnit": { "state": "translated", "value": "すべて送信" } } + } + }, + "debug.menu.notification.cli": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "CLI" } }, + "ja": { "stringUnit": { "state": "translated", "value": "CLI" } } + } + }, + "debug.menu.notification.cliReply": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "CLI Reply" } }, + "ja": { "stringUnit": { "state": "translated", "value": "CLI 返信" } } + } + }, + "debug.menu.notification.emit": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Emit Notification" } }, + "ja": { "stringUnit": { "state": "translated", "value": "通知を送信" } } + } + }, + "debug.menu.notification.error": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Error" } }, + "ja": { "stringUnit": { "state": "translated", "value": "エラー" } } + } + }, + "debug.menu.notification.feedExitPlan": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Feed Exit Plan" } }, + "ja": { "stringUnit": { "state": "translated", "value": "フィード終了プラン" } } + } + }, + "debug.menu.notification.feedPermission": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Feed Permission" } }, + "ja": { "stringUnit": { "state": "translated", "value": "フィード権限" } } + } + }, + "debug.menu.notification.feedQuestion": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Feed Question" } }, + "ja": { "stringUnit": { "state": "translated", "value": "フィード質問" } } + } + }, + "debug.menu.notification.feedQuestion4": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Feed Question (4 Options)" } }, + "ja": { "stringUnit": { "state": "translated", "value": "フィード質問(4つの選択肢)" } } + } + }, + "debug.menu.notification.feedQuestionMany": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Feed Question (Many Options)" } }, + "ja": { "stringUnit": { "state": "translated", "value": "フィード質問(多数の選択肢)" } } + } + }, + "debug.menu.notification.feedQuestionMultiple": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Feed Question (Multiple)" } }, + "ja": { "stringUnit": { "state": "translated", "value": "フィード質問(複数質問)" } } + } + }, + "debug.menu.notification.feedQuestionMultiSelect": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Feed Question (Multi-select)" } }, + "ja": { "stringUnit": { "state": "translated", "value": "フィード質問(複数選択)" } } + } + }, + "debug.menu.notification.hookFailed": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Hook Failed" } }, + "ja": { "stringUnit": { "state": "translated", "value": "フック失敗" } } + } + }, + "debug.menu.notification.idle": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Idle Reminder" } }, + "ja": { "stringUnit": { "state": "translated", "value": "アイドル通知" } } + } + }, + "debug.menu.notification.mode": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Notification Debug Mode" } }, + "ja": { "stringUnit": { "state": "translated", "value": "通知デバッグモード" } } + } + }, + "debug.menu.notification.needsPermission": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Needs Permission" } }, + "ja": { "stringUnit": { "state": "translated", "value": "権限が必要" } } + } + }, + "debug.menu.notification.osc": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "OSC" } }, + "ja": { "stringUnit": { "state": "translated", "value": "OSC" } } + } + }, + "debug.menu.notification.turnComplete": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Turn Complete" } }, + "ja": { "stringUnit": { "state": "translated", "value": "ターン完了" } } + } + }, + "debug.notification.error.invalidKindOrTarget": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Unknown kind or no notification target" } }, + "ja": { "stringUnit": { "state": "translated", "value": "不明な種類、または通知対象がありません" } } + } + }, + "debug.notification.error.missingEnabled": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Pass enabled=true or enabled=false to turn notification debug mode on or off." } }, + "ja": { "stringUnit": { "state": "translated", "value": "enabled=true または enabled=false を指定して、通知デバッグモードのオン/オフを切り替えてください。" } } + } + }, + "debug.notification.error.missingKind": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Pass kind= to choose which debug notification to emit." } }, + "ja": { "stringUnit": { "state": "translated", "value": "kind=<通知の種類> を指定して、発行するデバッグ通知を選んでください。" } } + } + }, + "debug.notification.synthetic.body": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Synthetic %@ notification" } }, + "ja": { "stringUnit": { "state": "translated", "value": "合成 %@ 通知" } } + } + }, + "debug.notification.synthetic.option": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Option %d" } }, + "ja": { "stringUnit": { "state": "translated", "value": "選択肢 %d" } } + } + }, + "debug.notification.synthetic.plan": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Inspect the notification and verify the actions." } }, + "ja": { "stringUnit": { "state": "translated", "value": "通知を確認し、アクションを検証します。" } } + } + }, + "debug.notification.synthetic.question": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Choose a notification debug answer" } }, + "ja": { "stringUnit": { "state": "translated", "value": "通知デバッグの回答を選択" } } + } + }, + "debug.notification.synthetic.title": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Notification Debug" } }, + "ja": { "stringUnit": { "state": "translated", "value": "通知デバッグ" } } + } + }, + "feed.notification.exitPlan.revise": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Revise…" } }, + "ja": { "stringUnit": { "state": "translated", "value": "修正…" } } + } + }, + "feed.notification.question.other": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Other…" } }, + "ja": { "stringUnit": { "state": "translated", "value": "その他…" } } + } + }, + "terminal.notification.action.reply": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Reply" } }, + "ja": { "stringUnit": { "state": "translated", "value": "返信" } } + } + }, + "terminal.notification.action.replyPlaceholder": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Message the agent…" } }, + "ja": { "stringUnit": { "state": "translated", "value": "エージェントにメッセージ…" } } + } + }, + "terminal.notification.action.replySend": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Send" } }, + "ja": { "stringUnit": { "state": "translated", "value": "送信" } } + } + }, "about.appName": { "extractionState": "manual", "localizations": { diff --git a/Sources/AgentNotificationDelivery.swift b/Sources/AgentNotificationDelivery.swift index cffc78a6ccff..347655c71adb 100644 --- a/Sources/AgentNotificationDelivery.swift +++ b/Sources/AgentNotificationDelivery.swift @@ -1,4 +1,5 @@ import CmuxSettings +import CmuxNotifications import Foundation /// Applies agent notification policy and publishes accepted events through the shared mutation bus. @@ -44,6 +45,7 @@ struct AgentNotificationDelivery: Sendable { title: title, subtitle: subtitle, body: body, + replyShape: TerminalNotificationReplyShape.forAgentCategory(wire: category?.rawValue), coalesces: coalesces ) return true diff --git a/Sources/AppDelegate+NotificationDeliverySeams.swift b/Sources/AppDelegate+NotificationDeliverySeams.swift index 610319eaf161..5e5ed2646820 100644 --- a/Sources/AppDelegate+NotificationDeliverySeams.swift +++ b/Sources/AppDelegate+NotificationDeliverySeams.swift @@ -8,7 +8,7 @@ import Foundation /// `AppDelegate -> NotificationDeliveryCoordinator -> adapter -> AppDelegate` /// cannot become a retain cycle. @MainActor -final class NotificationDeliverySeamAdapter: NotificationFeedReplying, NotificationApplicationActivating { +final class NotificationDeliverySeamAdapter: NotificationFeedReplying, NotificationTerminalReplying, NotificationApplicationActivating { weak var owner: AppDelegate? init(owner: AppDelegate) { @@ -26,6 +26,20 @@ final class NotificationDeliverySeamAdapter: NotificationFeedReplying, Notificat func activateApplication() { owner?.notificationDeliveryActivateApplication() } + + func sendReply( + text: String, + tabId: UUID, + surfaceId: UUID?, + retargetsToLiveSurfaceOwner: Bool + ) -> Bool { + owner?.notificationDeliverySendTerminalReply( + text: text, + tabId: tabId, + surfaceId: surfaceId, + retargetsToLiveSurfaceOwner: retargetsToLiveSurfaceOwner + ) ?? false + } } extension AppDelegate { @@ -67,12 +81,53 @@ extension AppDelegate { NSApp.activate(ignoringOtherApps: true) } + func notificationDeliverySendTerminalReply( + text: String, + tabId: UUID, + surfaceId: UUID?, + retargetsToLiveSurfaceOwner: Bool + ) -> Bool { + guard let surfaceId else { return false } + // A reply follows the surface to its CURRENT workspace exactly like + // banner-open delivery does: a moved pane keeps its surface identity + // but may live under another window's tab manager, and send_text + // routing needs the live workspace to select that manager. A gone + // target fails closed instead of typing into a stale claim. + let target: (tabId: UUID, surfaceId: UUID?) + if retargetsToLiveSurfaceOwner { + guard let liveTarget = agentNotificationDeliveryTarget( + claimedTabId: tabId, + surfaceId: surfaceId + ) else { return false } + target = liveTarget + } else { + target = (tabId, surfaceId) + } + let payload: [String: Any] = [ + "id": UUID().uuidString, + "method": "surface.send_text", + "params": [ + "workspace_id": target.tabId.uuidString, + "surface_id": surfaceId.uuidString, + "text": text + "\r", + ], + ] + guard let data = try? JSONSerialization.data(withJSONObject: payload), + let line = String(data: data, encoding: .utf8), + let responseData = TerminalController.shared.handleSocketLine(line).data(using: .utf8), + let response = try? JSONSerialization.jsonObject(with: responseData) as? [String: Any] + else { return false } + return response["ok"] as? Bool == true + } + private static func workstreamDecision(from decision: NotificationFeedDecision) -> WorkstreamDecision { switch decision { case .permission(let mode): return .permission(workstreamPermissionMode(from: mode)) - case .exitPlan(let mode): - return .exitPlan(workstreamExitPlanMode(from: mode)) + case .exitPlan(let mode, let feedback): + return .exitPlan(workstreamExitPlanMode(from: mode), feedback: feedback) + case .question(let selections): + return .question(selections: selections) } } diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 16e9dfb48371..81eb5b221776 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -707,11 +707,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent lazy var notificationDelivery = NotificationDeliveryCoordinator( center: TerminalNotificationStore.shared.userNotificationCenter, terminalNavigation: notificationNavigation, + terminalReplying: notificationDeliverySeams, feedReplying: notificationDeliverySeams, applicationActivation: notificationDeliverySeams, terminalIdentifiers: TerminalNotificationDeliveryIdentifiers( categoryIdentifier: TerminalNotificationStore.categoryIdentifier, + textReplyCategoryIdentifier: TerminalNotificationStore.textReplyCategoryIdentifier, showActionIdentifier: TerminalNotificationStore.actionShowIdentifier, + replyActionIdentifier: TerminalNotificationStore.actionReplyIdentifier, retargetsToLiveSurfaceOwnerUserInfoKey: TerminalNotificationStore.retargetsToLiveSurfaceOwnerUserInfoKey ), actionTitles: notificationDeliveryActionTitles @@ -723,6 +726,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent localized: "terminal.notification.action.show", defaultValue: "Show" ), + reply: String( + localized: "terminal.notification.action.reply", + defaultValue: "Reply" + ), + replySend: String( + localized: "terminal.notification.action.replySend", + defaultValue: "Send" + ), + replyPlaceholder: String( + localized: "terminal.notification.action.replyPlaceholder", + defaultValue: "Message the agent…" + ), feedPermissionAllowOnce: String( localized: "feed.notification.permission.allowOnce", defaultValue: "Allow Once" @@ -751,9 +766,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent localized: "feed.notification.exitPlan.autoAccept", defaultValue: "Auto" ), + feedExitPlanRevise: String( + localized: "feed.notification.exitPlan.revise", + defaultValue: "Revise…" + ), feedQuestionReply: String( localized: "feed.notification.question.reply", defaultValue: "Reply" + ), + feedQuestionOther: String( + localized: "feed.notification.question.other", + defaultValue: "Other…" ) ) } diff --git a/Sources/Auth/AuthEnvironment.swift b/Sources/Auth/AuthEnvironment.swift index cdb667367e43..53f88ebdd587 100644 --- a/Sources/Auth/AuthEnvironment.swift +++ b/Sources/Auth/AuthEnvironment.swift @@ -232,6 +232,36 @@ enum AuthEnvironment { return canonicalizedLoopbackURL(URL(string: defaultVMAPIOrigin)!) } + /// Base URL for the phone-push relay (`/api/notifications/*`). + /// + /// Dev iPhones register their APNs tokens with the shared staging + /// deployment (the device rig's default origin), so a Debug Mac must post + /// pushes there too — a tag-local localhost port has no token registry and + /// every forward would die queued. The tag rig BAKES a localhost + /// `CMUX_VM_API_BASE_URL` into every Debug bundle, so that knob must not + /// steer the push lane; a deliberately local push rig sets + /// `CMUX_PUSH_API_BASE_URL` (env or `~/.cmux-dev.env`) instead. Debug + /// defaults to shared staging (mirroring `irohBrokerBaseURL`); Release + /// keeps the production VM-API origin. + static var pushAPIBaseURL: URL { + let environment = ProcessInfo.processInfo.environment + if let overridden = environment["CMUX_PUSH_API_BASE_URL"]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !overridden.isEmpty, + let url = URL(string: overridden) { + return canonicalizedLoopbackURL(url) + } + #if DEBUG + if let override = devOverride(key: "CMUX_PUSH_API_BASE_URL"), + let url = URL(string: override) { + return canonicalizedLoopbackURL(url) + } + return URL(string: "https://cmux-staging.vercel.app")! + #else + return vmAPIBaseURL + #endif + } + /// Authenticated route broker shared by matching tagged Mac and iOS builds. /// /// General tagged APIs remain on their isolated localhost origin. Iroh uses diff --git a/Sources/Cloud/PhonePushClient.swift b/Sources/Cloud/PhonePushClient.swift index e1107f86252c..5daba17cd902 100644 --- a/Sources/Cloud/PhonePushClient.swift +++ b/Sources/Cloud/PhonePushClient.swift @@ -273,6 +273,7 @@ final class PhonePushClient { localized: "push.test.body", defaultValue: "Your Mac sent a test alert to cmux." ), + replyShape: "", workspaceId: nil, surfaceId: nil, retargetsToLiveSurfaceOwner: false, @@ -352,6 +353,7 @@ final class PhonePushClient { title: "", subtitle: "", body: "", + replyShape: "", workspaceId: nil, surfaceId: nil, retargetsToLiveSurfaceOwner: false, @@ -715,13 +717,21 @@ final class PhonePushClient { guard await auth.isAuthenticatedSessionCurrent(sessionSnapshot) else { return (.staleSession, nil) } guard let http = response as? HTTPURLResponse else { + phonePushLog.error("delivery attempt got a non-HTTP response") return (.invalidResponse, nil) } + let decoded = PhonePushHTTPResult.decode( + statusCode: http.statusCode, + data: data + ) + // Status/host/byte-count only — never response content. This is + // the one place the queue can attribute an outcome to what the + // server actually said, so keep it at info alongside outcomes. + phonePushLog.info( + "delivery attempt host=\(url.host ?? "-", privacy: .public) status=\(http.statusCode, privacy: .public) bytes=\(data.count, privacy: .public) outcome=\(Self.logValue(decoded), privacy: .public)" + ) return ( - PhonePushHTTPResult.decode( - statusCode: http.statusCode, - data: data - ), + decoded, PhonePushHTTPResult.retryAfterSeconds( response: http, data: data @@ -729,15 +739,20 @@ final class PhonePushClient { ) } catch { if redirectDelegate.refusedRedirect { + phonePushLog.error("delivery attempt refused a redirect") return (.invalidResponse, nil) } + let urlErrorCode = (error as? URLError)?.code.rawValue ?? 0 + phonePushLog.info( + "delivery attempt host=\(url.host ?? "-", privacy: .public) transport error code=\(urlErrorCode, privacy: .public)" + ) return (PhonePushHTTPResult.classifyTransportError(error), nil) } } nonisolated private static func pushURL() -> URL? { guard var components = URLComponents( - url: AuthEnvironment.vmAPIBaseURL, + url: AuthEnvironment.pushAPIBaseURL, resolvingAgainstBaseURL: false ), let scheme = components.scheme?.lowercased(), ["http", "https"].contains(scheme), @@ -763,7 +778,7 @@ final class PhonePushClient { ) } - private static func logValue(_ result: PhonePushHTTPResult) -> String { + nonisolated private static func logValue(_ result: PhonePushHTTPResult) -> String { switch result { case .accepted: "accepted" case .partial: "partial" diff --git a/Sources/Cloud/PhonePushPayload.swift b/Sources/Cloud/PhonePushPayload.swift index 02e33327c850..0d6954ad7598 100644 --- a/Sources/Cloud/PhonePushPayload.swift +++ b/Sources/Cloud/PhonePushPayload.swift @@ -5,6 +5,8 @@ struct PhonePushPayload: Sendable { let title: String let subtitle: String let body: String + /// Inline-reply affordance requested by the Mac notification (`none` or `text`). + let replyShape: String let workspaceId: String? let surfaceId: String? /// Whether iOS may resolve `surfaceId` outside the explicit workspace. @@ -36,6 +38,7 @@ extension PhonePushPayload { title: notification.title, subtitle: notification.subtitle, body: notification.body, + replyShape: notification.replyShape.rawValue, workspaceId: notification.tabId.uuidString, surfaceId: notification.surfaceId?.uuidString, retargetsToLiveSurfaceOwner: notification.retargetsToLiveSurfaceOwner, diff --git a/Sources/Cloud/PhonePushRequestEnvelope.swift b/Sources/Cloud/PhonePushRequestEnvelope.swift index 3b5d05883e32..027dcc2355e2 100644 --- a/Sources/Cloud/PhonePushRequestEnvelope.swift +++ b/Sources/Cloud/PhonePushRequestEnvelope.swift @@ -81,6 +81,8 @@ struct PhonePushRequestEnvelope: Codable, Equatable, Sendable, ) object["retargetsToLiveSurfaceOwner"] = payload.retargetsToLiveSurfaceOwner + // Reply shape is a schema enum, not content: safe under hideContent. + object["replyShape"] = payload.replyShape if let value = try Self.boundedIdentifier(payload.workspaceId) { object["workspaceId"] = value } diff --git a/Sources/Feed/FeedCoordinator.swift b/Sources/Feed/FeedCoordinator.swift index 6192ed215db1..e00c11df7b01 100644 --- a/Sources/Feed/FeedCoordinator.swift +++ b/Sources/Feed/FeedCoordinator.swift @@ -65,6 +65,15 @@ final class FeedCoordinator: @unchecked Sendable { /// methods. @MainActor private var pendingAttentionStates: [AttentionTarget: AttentionOverlayState] = [:] + /// Tail of the serialized `CMUXFeedQuestion.` category mutation chain. + /// `UNUserNotificationCenter` has no atomic category merge, so every + /// mutation is a get→filter→set round trip; two concurrent round trips + /// (mint racing mint, or mint racing cancel) each capture a stale snapshot + /// and the later `set` silently drops the earlier write. The coordinator + /// is the sole owner of this category namespace, and every mutation + /// appends here so round trips never interleave. + @MainActor private var questionCategoryUpdates: Task? + private init() {} /// Must be called once at app launch to install the store. @@ -1023,7 +1032,9 @@ private extension FeedCoordinator { defaultValue: "Review and approve the plan" ) case .askUserQuestion: - categoryId = "CMUXFeedQuestion" + categoryId = Self.inlineQuestionOptions(for: event) == nil + ? "CMUXFeedQuestion" + : "CMUXFeedQuestion.\(requestId)" title = String( localized: "feed.notification.question.title", defaultValue: "\(event.source.capitalized) question" @@ -1113,6 +1124,17 @@ private extension FeedCoordinator { return suffix.isEmpty ? "CMUXFeedPermissionDeny" : "CMUXFeedPermission\(suffix)" } + private static func inlineQuestionOptions( + for event: WorkstreamEvent + ) -> [WorkstreamQuestionOption]? { + let questions = WorkstreamQuestionPrompt.parse(toolInputJSON: event.toolInputJSON) + guard questions.count == 1, + let question = questions.first, + !question.multiSelect, + (1...4).contains(question.options.count) else { return nil } + return question.options + } + @MainActor func deliverFeedNotificationIfStillAwaiting( requestId: String, @@ -1149,6 +1171,9 @@ private extension FeedCoordinator { "requestId": requestId, "workstreamId": event.sessionId, ] + if let options = Self.inlineQuestionOptions(for: event) { + content.userInfo["questionOptionIds"] = options.map(\.id) + } let request = UNNotificationRequest( identifier: "feed.\(requestId)", @@ -1182,8 +1207,9 @@ private extension FeedCoordinator { } switch status { case .authorized, .provisional: - self.addNotificationIfStillAwaiting( + self.registerQuestionCategoryAndAddIfStillAwaiting( request: request, + event: event, requestId: requestId, effects: effects ) @@ -1191,8 +1217,9 @@ private extension FeedCoordinator { let authorization = await center.requestAuthorization(options: [.alert, .sound]) guard self.isAwaitingDecision(requestId: requestId) else { return } if case .success(true) = authorization { - self.addNotificationIfStillAwaiting( + self.registerQuestionCategoryAndAddIfStillAwaiting( request: request, + event: event, requestId: requestId, effects: effects ) @@ -1235,6 +1262,104 @@ private extension FeedCoordinator { } } + @MainActor + func registerQuestionCategoryAndAddIfStillAwaiting( + request: UNNotificationRequest, + event: WorkstreamEvent, + requestId: String, + effects: TerminalNotificationPolicyEffects + ) { + guard request.content.categoryIdentifier.hasPrefix("CMUXFeedQuestion."), + let options = Self.inlineQuestionOptions(for: event) else { + addNotificationIfStillAwaiting( + request: request, + requestId: requestId, + effects: effects + ) + return + } + + let optionActions = options.enumerated().map { index, option in + UNNotificationAction( + identifier: "feed.question.option.\(index)", + title: option.label + ) + } + var actions = optionActions + if options.count <= 3 { + actions.append(UNTextInputNotificationAction( + identifier: "feed.question.other", + title: String( + localized: "feed.notification.question.other", + defaultValue: "Other…" + ), + options: [], + textInputButtonTitle: String( + localized: "terminal.notification.action.replySend", + defaultValue: "Send" + ), + textInputPlaceholder: String( + localized: "terminal.notification.action.replyPlaceholder", + defaultValue: "Message the agent…" + ) + )) + } + let minted = UNNotificationCategory( + identifier: request.content.categoryIdentifier, + actions: actions, + intentIdentifiers: [], + options: [] + ) + enqueueQuestionCategoryUpdate { [weak self] in + guard let self, self.isAwaitingDecision(requestId: requestId) else { return } + let center = self.resolvedUserNotificationCenter + guard case .success(let current) = await center.notificationCategories() else { + // Unresponsive daemon: deliver without inline options instead + // of dropping — the plain banner still opens the Feed card. + self.addNotificationIfStillAwaiting( + request: request, + requestId: requestId, + effects: effects + ) + return + } + let liveCategoryIds = self.liveWaiterRequestIds().map { "CMUXFeedQuestion.\($0)" } + var categories = Set(current.filter { category in + !category.identifier.hasPrefix("CMUXFeedQuestion.") + || liveCategoryIds.contains(category.identifier) + }) + categories.insert(minted) + _ = await center.setNotificationCategories(categories) + self.addNotificationIfStillAwaiting( + request: request, + requestId: requestId, + effects: effects + ) + } + } + + /// Appends one `CMUXFeedQuestion.` category round trip to the serialized + /// chain (see `questionCategoryUpdates`). Order between distinct requests + /// is irrelevant — a mint whose waiter already resolved aborts on its + /// `isAwaitingDecision` guard, and every update prunes dead categories — + /// but no two round trips may interleave. + @MainActor + private func enqueueQuestionCategoryUpdate(_ update: @escaping @MainActor () async -> Void) { + let previous = questionCategoryUpdates + questionCategoryUpdates = Task { @MainActor in + await previous?.value + await update() + } + } + + func liveWaiterRequestIds() -> Set { + waiterLock.lock() + defer { waiterLock.unlock() } + return Set(waiters.compactMap { requestId, waiter in + waiter.decision == nil ? requestId : nil + }) + } + @MainActor func addNotificationIfStillAwaiting( request: UNNotificationRequest, @@ -1299,6 +1424,12 @@ private extension FeedCoordinator { let center = self.resolvedUserNotificationCenter _ = await center.removePendingNotificationRequests(withIdentifiers: [identifier]) _ = await center.removeDeliveredNotifications(withIdentifiers: [identifier]) + let categoryId = "CMUXFeedQuestion.\(requestId)" + self.enqueueQuestionCategoryUpdate { + guard case .success(let current) = await center.notificationCategories() else { return } + let categories = Set(current.filter { $0.identifier != categoryId }) + _ = await center.setNotificationCategories(categories) + } } } } diff --git a/Sources/IrohTransportDebugMenuButtons.swift b/Sources/IrohTransportDebugMenuButtons.swift index 9a04018dd22a..6786726647d7 100644 --- a/Sources/IrohTransportDebugMenuButtons.swift +++ b/Sources/IrohTransportDebugMenuButtons.swift @@ -13,6 +13,7 @@ struct IrohAndAgentSessionDebugMenuButtons: View { openReact: openReact, openSolid: openSolid ) + NotificationDebugMenuButtons() } } diff --git a/Sources/NotificationDebugEmitter.swift b/Sources/NotificationDebugEmitter.swift new file mode 100644 index 000000000000..bdfa3ae1f12f --- /dev/null +++ b/Sources/NotificationDebugEmitter.swift @@ -0,0 +1,315 @@ +import AppKit +import CMUXAgentLaunch +import CmuxNotifications +import Foundation + +#if DEBUG +@MainActor +final class NotificationDebugEmitter { + static let shared = NotificationDebugEmitter() + + static let kinds = [ + "turn-complete", + "idle", + "needs-permission", + "error", + "cli", + "cli-reply", + "osc", + "hook-failed", + "feed-permission", + "feed-exit-plan", + "feed-question", + "feed-question-4opts", + "feed-question-multiselect", + "feed-question-multi", + "feed-question-many-options", + ] + + /// Debug-mode switch that forces Feed notifications through the inactive-app banner path. + var isModeEnabled = false { + didSet { + if isModeEnabled { + FeedCoordinatorTestHooks.isAppActiveOverride = { false } + } else { + FeedCoordinatorTestHooks.isAppActiveOverride = nil + } + } + } + + private let desktopIngress = GhosttyDesktopNotificationIngress() + + private init() {} + + @discardableResult + func emit(kind: String, forceBanner: Bool) -> Bool { + emit(kind: kind, forceBanner: forceBanner, target: focusedTarget()) + } + + @discardableResult + func emit(kind: String, forceBanner: Bool, target: NotificationDebugTarget?) -> Bool { + if forceBanner { + isModeEnabled = true + } + if kind == "all" { + var emittedAll = true + for childKind in Self.kinds { + if !emit(kind: childKind, forceBanner: false, target: target) { + emittedAll = false + } + } + return emittedAll + } + guard Self.kinds.contains(kind) else { return false } + + // Fail closed without a resolved target: a real hook event always + // carries workspace identity, so a synthetic one without it would + // exercise a path production never takes. + if kind.hasPrefix("feed-") { + guard let target else { return false } + return emitFeed(kind: kind, target: target) + } + guard let target, let surfaceId = target.surfaceId else { return false } + + let title = String( + localized: "debug.notification.synthetic.title", + defaultValue: "Notification Debug" + ) + let bodyFormat = String( + localized: "debug.notification.synthetic.body", + defaultValue: "Synthetic %@ notification" + ) + let displayName = displayName(for: kind) + let body = String(format: bodyFormat, displayName) + switch kind { + case "turn-complete": + return emitAgent( + target: target, + surfaceId: surfaceId, + title: title, + subtitle: displayName, + body: body, + category: .turnComplete + ) + case "idle": + return emitAgent( + target: target, + surfaceId: surfaceId, + title: title, + subtitle: displayName, + body: body, + category: .idleReminder + ) + case "needs-permission": + return emitAgent( + target: target, + surfaceId: surfaceId, + title: title, + subtitle: displayName, + body: body, + category: .needsPermission + ) + case "error": + return emitAgent( + target: target, + surfaceId: surfaceId, + title: title, + subtitle: displayName, + body: body, + category: .other + ) + case "cli", "cli-reply": + TerminalController.shared.deliverNotificationSynchronously( + tabId: target.workspaceId, + surfaceId: surfaceId, + title: title, + subtitle: displayName, + body: body, + replyShape: kind == "cli-reply" ? .text : .none + ) + return true + case "osc": + return desktopIngress.submit(GhosttyDesktopNotificationRequest( + tabId: target.workspaceId, + surfaceId: surfaceId, + hookDirectory: nil, + title: title, + body: body + )) + case "hook-failed": + TerminalNotificationStore.shared.reportNotificationHookFailure( + TerminalNotificationPolicyFailure( + hookId: "debug.synthetic", + sourcePath: nil, + message: body + ) + ) + return true + default: + return false + } + } + + private func emitAgent( + target: NotificationDebugTarget, + surfaceId: UUID, + title: String, + subtitle: String, + body: String, + category: AgentNotifyCategory + ) -> Bool { + AgentNotificationDelivery().enqueue( + workspaceID: target.workspaceId, + surfaceID: surfaceId, + title: title, + subtitle: subtitle, + body: body, + category: category, + pending: false, + coalesces: false + ) + } + + private func emitFeed(kind: String, target: NotificationDebugTarget) -> Bool { + let suffix = UUID().uuidString.prefix(8).lowercased() + let requestId = "debug-\(suffix)" + let event = feedEvent(kind: kind, requestId: requestId, target: target) + guard let event else { return false } + + Thread.detachNewThread { + _ = FeedCoordinator.shared.ingestBlocking(event: event, waitTimeout: 300) + } + return true + } + + private func feedEvent( + kind: String, + requestId: String, + target: NotificationDebugTarget + ) -> WorkstreamEvent? { + let common = ( + workspaceId: target.workspaceId.uuidString, + surfaceId: target.surfaceId?.uuidString + ) + switch kind { + case "feed-permission": + return WorkstreamEvent( + sessionId: requestId, + hookEventName: .permissionRequest, + source: "claude", + workspaceId: common.workspaceId, + surfaceId: common.surfaceId, + toolName: "Bash", + toolInputJSON: #"{"command":"echo notification-debug","pattern":"echo *"}"#, + requestId: requestId + ) + case "feed-exit-plan": + return WorkstreamEvent( + sessionId: requestId, + hookEventName: .exitPlanMode, + source: "claude", + workspaceId: common.workspaceId, + surfaceId: common.surfaceId, + toolInputJSON: String( + localized: "debug.notification.synthetic.plan", + defaultValue: "Inspect the notification and verify the actions." + ), + requestId: requestId + ) + case "feed-question", "feed-question-4opts", "feed-question-multiselect", + "feed-question-multi", "feed-question-many-options": + return WorkstreamEvent( + sessionId: requestId, + hookEventName: .askUserQuestion, + source: "claude", + workspaceId: common.workspaceId, + surfaceId: common.surfaceId, + toolName: "AskUserQuestion", + toolInputJSON: questionJSON(kind: kind), + requestId: requestId + ) + default: + return nil + } + } + + private func questionJSON(kind: String) -> String { + let optionCount = kind == "feed-question-4opts" ? 4 + : (kind == "feed-question-many-options" ? 6 : 3) + let multiSelect = kind == "feed-question-multiselect" + let questionCount = kind == "feed-question-multi" ? 2 : 1 + let questions: [[String: Any]] = (0.. NotificationDebugTarget? { + guard let tabManager = AppDelegate.shared?.tabManager, + let workspaceId = tabManager.selectedTabId else { return nil } + return NotificationDebugTarget( + workspaceId: workspaceId, + surfaceId: tabManager.focusedSurfaceId(for: workspaceId) + ) + } + + private func displayName(for kind: String) -> String { + switch kind { + case "turn-complete": + return String(localized: "debug.menu.notification.turnComplete", defaultValue: "Turn Complete") + case "idle": + return String(localized: "debug.menu.notification.idle", defaultValue: "Idle Reminder") + case "needs-permission": + return String(localized: "debug.menu.notification.needsPermission", defaultValue: "Needs Permission") + case "error": + return String(localized: "debug.menu.notification.error", defaultValue: "Error") + case "cli": + return String(localized: "debug.menu.notification.cli", defaultValue: "CLI") + case "cli-reply": + return String(localized: "debug.menu.notification.cliReply", defaultValue: "CLI Reply") + case "osc": + return String(localized: "debug.menu.notification.osc", defaultValue: "OSC") + case "hook-failed": + return String(localized: "debug.menu.notification.hookFailed", defaultValue: "Hook Failed") + case "feed-permission": + return String(localized: "debug.menu.notification.feedPermission", defaultValue: "Feed Permission") + case "feed-exit-plan": + return String(localized: "debug.menu.notification.feedExitPlan", defaultValue: "Feed Exit Plan") + case "feed-question": + return String(localized: "debug.menu.notification.feedQuestion", defaultValue: "Feed Question") + case "feed-question-4opts": + return String(localized: "debug.menu.notification.feedQuestion4", defaultValue: "Feed Question (4 Options)") + case "feed-question-multiselect": + return String(localized: "debug.menu.notification.feedQuestionMultiSelect", defaultValue: "Feed Question (Multi-select)") + case "feed-question-multi": + return String(localized: "debug.menu.notification.feedQuestionMultiple", defaultValue: "Feed Question (Multiple)") + case "feed-question-many-options": + return String(localized: "debug.menu.notification.feedQuestionMany", defaultValue: "Feed Question (Many Options)") + default: + return kind + } + } +} +#endif diff --git a/Sources/NotificationDebugMenuButtons.swift b/Sources/NotificationDebugMenuButtons.swift new file mode 100644 index 000000000000..9e29e956e438 --- /dev/null +++ b/Sources/NotificationDebugMenuButtons.swift @@ -0,0 +1,91 @@ +import SwiftUI + +#if DEBUG +struct NotificationDebugMenuButtons: View { + var body: some View { + Toggle( + String( + localized: "debug.menu.notification.mode", + defaultValue: "Notification Debug Mode" + ), + isOn: Binding( + get: { NotificationDebugEmitter.shared.isModeEnabled }, + set: { NotificationDebugEmitter.shared.isModeEnabled = $0 } + ) + ) + + Menu( + String( + localized: "debug.menu.notification.emit", + defaultValue: "Emit Notification" + ) + ) { + debugButton( + "turn-complete", + title: String(localized: "debug.menu.notification.turnComplete", defaultValue: "Turn Complete") + ) + debugButton( + "idle", + title: String(localized: "debug.menu.notification.idle", defaultValue: "Idle Reminder") + ) + debugButton( + "needs-permission", + title: String(localized: "debug.menu.notification.needsPermission", defaultValue: "Needs Permission") + ) + debugButton( + "error", + title: String(localized: "debug.menu.notification.error", defaultValue: "Error") + ) + debugButton("cli", title: String(localized: "debug.menu.notification.cli", defaultValue: "CLI")) + debugButton( + "cli-reply", + title: String(localized: "debug.menu.notification.cliReply", defaultValue: "CLI Reply") + ) + debugButton("osc", title: String(localized: "debug.menu.notification.osc", defaultValue: "OSC")) + debugButton( + "hook-failed", + title: String(localized: "debug.menu.notification.hookFailed", defaultValue: "Hook Failed") + ) + debugButton( + "feed-permission", + title: String(localized: "debug.menu.notification.feedPermission", defaultValue: "Feed Permission") + ) + debugButton( + "feed-exit-plan", + title: String(localized: "debug.menu.notification.feedExitPlan", defaultValue: "Feed Exit Plan") + ) + debugButton( + "feed-question", + title: String(localized: "debug.menu.notification.feedQuestion", defaultValue: "Feed Question") + ) + debugButton( + "feed-question-4opts", + title: String(localized: "debug.menu.notification.feedQuestion4", defaultValue: "Feed Question (4 Options)") + ) + debugButton( + "feed-question-multiselect", + title: String(localized: "debug.menu.notification.feedQuestionMultiSelect", defaultValue: "Feed Question (Multi-select)") + ) + debugButton( + "feed-question-multi", + title: String(localized: "debug.menu.notification.feedQuestionMultiple", defaultValue: "Feed Question (Multiple)") + ) + debugButton( + "feed-question-many-options", + title: String(localized: "debug.menu.notification.feedQuestionMany", defaultValue: "Feed Question (Many Options)") + ) + Divider() + debugButton( + "all", + title: String(localized: "debug.menu.notification.all", defaultValue: "Emit All") + ) + } + } + + private func debugButton(_ kind: String, title: String) -> some View { + Button(title) { + _ = NotificationDebugEmitter.shared.emit(kind: kind, forceBanner: false) + } + } +} +#endif diff --git a/Sources/NotificationDebugTarget.swift b/Sources/NotificationDebugTarget.swift new file mode 100644 index 000000000000..b72e9833defd --- /dev/null +++ b/Sources/NotificationDebugTarget.swift @@ -0,0 +1,93 @@ +import Foundation +import UserNotifications + +#if DEBUG +struct NotificationDebugTarget: Sendable { + let workspaceId: UUID + let surfaceId: UUID? +} + +/// DEBUG-only socket adapters for `debug.notification.*` verbs, kept out of +/// the production caller resolver so debug parsing never widens production +/// visibility. Target resolution goes through the shared production seam +/// (`resolvedCallerNotificationTarget`) so the debug emitter lands on the +/// same workspace/surface a real `notification.create_for_caller` would. +@MainActor +extension TerminalController { + func notificationDebugCallerTarget(params: [String: Any]) -> NotificationDebugTarget? { + guard let target = resolvedCallerNotificationTarget( + preferredWorkspaceId: v2UUID(params, "preferred_workspace_id"), + preferredSurfaceId: v2UUID(params, "preferred_surface_id"), + callerTTY: notificationDebugStringParam(params, "caller_tty"), + preferTTY: notificationDebugBoolParam(params, "prefer_tty") ?? false + ) else { return nil } + return NotificationDebugTarget( + workspaceId: target.workspaceId, + surfaceId: target.surfaceId + ) + } + + func notificationDebugStringParam(_ params: [String: Any], _ key: String) -> String? { + guard let raw = params[key] as? String else { return nil } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + func notificationDebugBoolParam(_ params: [String: Any], _ key: String) -> Bool? { + if let value = params[key] as? Bool { return value } + if let value = params[key] as? NSNumber { return value.boolValue } + switch notificationDebugStringParam(params, key)?.lowercased() { + case "1", "true", "yes", "on": return true + case "0", "false", "no", "off": return false + default: return nil + } + } + + /// `debug.notification.status` — the system's actual notification settings + /// for this bundle id, so authorization/style problems are diagnosable from + /// the socket instead of screenshot archaeology. Blocks the socket worker + /// on the settings callback (bounded; DEBUG-only diagnostic). + nonisolated func notificationDebugStatus() -> [String: Any] { + let semaphore = DispatchSemaphore(value: 0) + var payload: [String: Any] = ["available": false] + UNUserNotificationCenter.current().getNotificationSettings { settings in + let authorization: String + switch settings.authorizationStatus { + case .notDetermined: authorization = "notDetermined" + case .denied: authorization = "denied" + case .authorized: authorization = "authorized" + case .provisional: authorization = "provisional" + @unknown default: authorization = "unknown" + } + let style: String + switch settings.alertStyle { + case .none: style = "none" + case .banner: style = "banner" + case .alert: style = "alert" + @unknown default: style = "unknown" + } + func setting(_ value: UNNotificationSetting) -> String { + switch value { + case .notSupported: return "notSupported" + case .disabled: return "disabled" + case .enabled: return "enabled" + @unknown default: return "unknown" + } + } + payload = [ + "available": true, + "authorization_status": authorization, + "alert_style": style, + "alert_setting": setting(settings.alertSetting), + "sound_setting": setting(settings.soundSetting), + "badge_setting": setting(settings.badgeSetting), + "notification_center_setting": setting(settings.notificationCenterSetting), + "lock_screen_setting": setting(settings.lockScreenSetting), + ] + semaphore.signal() + } + _ = semaphore.wait(timeout: .now() + 3) + return payload + } +} +#endif diff --git a/Sources/TerminalController+ControlNotificationContext.swift b/Sources/TerminalController+ControlNotificationContext.swift index 0f6d151267ca..71a0ec30a191 100644 --- a/Sources/TerminalController+ControlNotificationContext.swift +++ b/Sources/TerminalController+ControlNotificationContext.swift @@ -1,4 +1,5 @@ import CmuxControlSocket +import CmuxNotifications import Foundation /// The notification-domain witnesses are the byte-faithful bodies of the former @@ -16,7 +17,8 @@ extension TerminalController: ControlNotificationContext { explicitSurfaceID: UUID?, title: String, subtitle: String, - body: String + body: String, + replyShapeWire: String? = nil ) -> ControlNotificationCreateResolution { guard let tabManager = resolveTabManager(routing: routing) else { return .tabManagerUnavailable @@ -24,7 +26,8 @@ extension TerminalController: ControlNotificationContext { guard let ws = resolveWorkspace(routing: routing, tabManager: tabManager) else { if let explicitSurfaceID, let rehomed = controlNotificationRehomedDelivery( - surfaceID: explicitSurfaceID, title: title, subtitle: subtitle, body: body + surfaceID: explicitSurfaceID, title: title, subtitle: subtitle, body: body, + replyShapeWire: replyShapeWire ) { return .delivered(workspaceID: rehomed.workspaceID, surfaceID: rehomed.surfaceID) } @@ -32,7 +35,8 @@ extension TerminalController: ControlNotificationContext { } if let explicitSurfaceID, !notificationWorkspace(ws, contains: explicitSurfaceID) { if let rehomed = controlNotificationRehomedDelivery( - surfaceID: explicitSurfaceID, title: title, subtitle: subtitle, body: body + surfaceID: explicitSurfaceID, title: title, subtitle: subtitle, body: body, + replyShapeWire: replyShapeWire ) { return .delivered(workspaceID: rehomed.workspaceID, surfaceID: rehomed.surfaceID) } @@ -46,7 +50,8 @@ extension TerminalController: ControlNotificationContext { surfaceId: surfaceId, title: title, subtitle: subtitle, - body: body + body: body, + replyShape: TerminalNotificationReplyShape(wire: replyShapeWire) ) return .delivered(workspaceID: ws.id, surfaceID: surfaceId) } @@ -56,7 +61,8 @@ extension TerminalController: ControlNotificationContext { surfaceID: UUID, title: String, subtitle: String, - body: String + body: String, + replyShapeWire: String? = nil ) -> ControlNotificationTargetedDeliveryResolution { guard let tabManager = resolveTabManager(routing: routing) else { return .tabManagerUnavailable @@ -70,7 +76,8 @@ extension TerminalController: ControlNotificationContext { // `create_for_target` path before they reach this trusted local path. guard let ws = resolveWorkspace(routing: routing, tabManager: tabManager) else { if let rehomed = controlNotificationRehomedDelivery( - surfaceID: surfaceID, title: title, subtitle: subtitle, body: body + surfaceID: surfaceID, title: title, subtitle: subtitle, body: body, + replyShapeWire: replyShapeWire ) { return .delivered( workspaceID: rehomed.workspaceID, @@ -82,7 +89,8 @@ extension TerminalController: ControlNotificationContext { } guard notificationWorkspace(ws, contains: surfaceID) else { if let rehomed = controlNotificationRehomedDelivery( - surfaceID: surfaceID, title: title, subtitle: subtitle, body: body + surfaceID: surfaceID, title: title, subtitle: subtitle, body: body, + replyShapeWire: replyShapeWire ) { return .delivered( workspaceID: rehomed.workspaceID, @@ -98,7 +106,8 @@ extension TerminalController: ControlNotificationContext { surfaceId: targetSurfaceID, title: title, subtitle: subtitle, - body: body + body: body, + replyShape: TerminalNotificationReplyShape(wire: replyShapeWire) ) return .delivered( workspaceID: ws.id, @@ -114,7 +123,8 @@ extension TerminalController: ControlNotificationContext { surfaceID: UUID, title: String, subtitle: String, - body: String + body: String, + replyShapeWire: String? = nil ) -> (workspaceID: UUID, surfaceID: UUID, windowID: UUID?)? { guard let owner = AppDelegate.shared?.notificationSurfaceOwner(surfaceID: surfaceID) else { return nil @@ -124,7 +134,8 @@ extension TerminalController: ControlNotificationContext { surfaceId: owner.surfaceID, title: title, subtitle: subtitle, - body: body + body: body, + replyShape: TerminalNotificationReplyShape(wire: replyShapeWire) ) return ( owner.tabID, @@ -139,7 +150,8 @@ extension TerminalController: ControlNotificationContext { surfaceID: UUID, title: String, subtitle: String, - body: String + body: String, + replyShapeWire: String? = nil ) -> ControlNotificationTargetedDeliveryResolution { guard let tabManager = resolveTabManager(routing: routing) else { return .tabManagerUnavailable @@ -167,6 +179,7 @@ extension TerminalController: ControlNotificationContext { title: title, subtitle: subtitle, body: body, + replyShape: TerminalNotificationReplyShape(wire: replyShapeWire), retargetsToLiveSurfaceOwner: false ) return .delivered( diff --git a/Sources/TerminalController+DebugMethodNames.swift b/Sources/TerminalController+DebugMethodNames.swift index 8571346e2fc7..3c042d28295e 100644 --- a/Sources/TerminalController+DebugMethodNames.swift +++ b/Sources/TerminalController+DebugMethodNames.swift @@ -36,6 +36,8 @@ extension TerminalController { "debug.empty_panel.count", "debug.empty_panel.reset", "debug.notification.focus", + "debug.notification.mode", + "debug.notification.emit", "debug.flash.count", "debug.flash.reset", "debug.canvas.command_scroll_hint", diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 14575bb9ffe7..62408bbe02d1 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -23,6 +23,7 @@ import Bonsplit import WebKit import CmuxSidebar import CmuxWorkspaces +import CmuxNotifications import CmuxSimulator extension Notification.Name { @@ -2424,6 +2425,50 @@ class TerminalController { case "notification.create_for_caller": return v2Result(id: id, self.v2NotificationCreateForCaller(params: params)) case "agent.resolve_delivery_target": return v2Result(id: id, self.v2AgentResolveDeliveryTarget(params: params)) + #if DEBUG + case "debug.notification.status": + return v2Ok(id: id, result: notificationDebugStatus()) + case "debug.notification.mode": + guard let enabled = notificationDebugBoolParam(params, "enabled") else { + return v2Error( + id: id, + code: "invalid_params", + message: String( + localized: "debug.notification.error.missingEnabled", + defaultValue: "Pass enabled=true or enabled=false to turn notification debug mode on or off." + ) + ) + } + NotificationDebugEmitter.shared.isModeEnabled = enabled + return v2Ok(id: id, result: ["enabled": enabled]) + case "debug.notification.emit": + guard let kind = notificationDebugStringParam(params, "kind"), !kind.isEmpty else { + return v2Error( + id: id, + code: "invalid_params", + message: String( + localized: "debug.notification.error.missingKind", + defaultValue: "Pass kind= to choose which debug notification to emit." + ) + ) + } + let emitted = NotificationDebugEmitter.shared.emit( + kind: kind, + forceBanner: notificationDebugBoolParam(params, "force_banner") ?? false, + target: notificationDebugCallerTarget(params: params) + ) + guard emitted else { + return v2Error( + id: id, + code: "invalid_params", + message: String( + localized: "debug.notification.error.invalidKindOrTarget", + defaultValue: "Unknown kind or no notification target" + ) + ) + } + return v2Ok(id: id, result: ["kind": kind]) + #endif // Diff review comments case "comments.list": return v2Result(id: id, self.v2CommentsList(params: params)) @@ -12291,7 +12336,8 @@ class TerminalController { surfaceId: surfaceId, title: title, subtitle: subtitle, - body: body + body: body, + replyShape: TerminalNotificationReplyShape.forAgentCategory(wire: meta?.category.rawValue) ) return "OK" } @@ -12327,7 +12373,8 @@ class TerminalController { surfaceId: surfaceId, title: title, subtitle: subtitle, - body: body + body: body, + replyShape: TerminalNotificationReplyShape.forAgentCategory(wire: meta?.category.rawValue) ) return "OK" } @@ -12373,7 +12420,8 @@ class TerminalController { surfaceId: fastPath.panelId, title: title, subtitle: subtitle, - body: body + body: body, + replyShape: TerminalNotificationReplyShape.forAgentCategory(wire: meta?.category.rawValue) ) return "OK" } @@ -12396,7 +12444,8 @@ class TerminalController { surfaceId: panelId, title: title, subtitle: subtitle, - body: body + body: body, + replyShape: TerminalNotificationReplyShape.forAgentCategory(wire: meta?.category.rawValue) ) return "OK" } diff --git a/Sources/TerminalNotification.swift b/Sources/TerminalNotification.swift index e997cba63758..895b16a2d573 100644 --- a/Sources/TerminalNotification.swift +++ b/Sources/TerminalNotification.swift @@ -1,3 +1,4 @@ +import CmuxNotifications import Foundation struct TerminalNotification: Identifiable, Hashable, Sendable { @@ -15,6 +16,7 @@ struct TerminalNotification: Identifiable, Hashable, Sendable { var paneFlash: Bool = true var scrollPosition: TerminalNotificationScrollPosition? var clickAction: TerminalNotificationClickAction? + var replyShape: TerminalNotificationReplyShape = .none init( id: UUID, @@ -30,7 +32,8 @@ struct TerminalNotification: Identifiable, Hashable, Sendable { isRead: Bool, paneFlash: Bool = true, scrollPosition: TerminalNotificationScrollPosition? = nil, - clickAction: TerminalNotificationClickAction? = nil + clickAction: TerminalNotificationClickAction? = nil, + replyShape: TerminalNotificationReplyShape = .none ) { self.id = id self.tabId = tabId @@ -46,6 +49,7 @@ struct TerminalNotification: Identifiable, Hashable, Sendable { self.paneFlash = paneFlash self.scrollPosition = scrollPosition self.clickAction = clickAction + self.replyShape = replyShape } func matches(tabId targetTabId: UUID, surfaceId targetSurfaceId: UUID?) -> Bool { diff --git a/Sources/TerminalNotificationCallerResolver.swift b/Sources/TerminalNotificationCallerResolver.swift index be6cde435b4d..5352a98bb3c5 100644 --- a/Sources/TerminalNotificationCallerResolver.swift +++ b/Sources/TerminalNotificationCallerResolver.swift @@ -1,3 +1,4 @@ +import CmuxNotifications import Foundation @MainActor @@ -6,6 +7,14 @@ private struct TerminalCallerTarget { let surfaceId: UUID? } +/// Resolved caller-notification address, shared across the entrypoints of +/// `resolvedCallerNotificationTarget`. Carries identities only so callers +/// outside this file never hold live `Workspace` references. +struct TerminalCallerNotificationTarget: Sendable { + let workspaceId: UUID + let surfaceId: UUID? +} + @MainActor extension TerminalController { func v2IdentifyCallerPayload( @@ -67,22 +76,22 @@ extension TerminalController { } func v2NotificationCreateForCaller(params: [String: Any]) -> V2CallResult { - guard let fallbackTabManager = activeTabManagerForCallerNotification() else { + guard activeTabManagerForCallerNotification() != nil else { return .err(code: "unavailable", message: "TabManager not available", data: nil) } let preferredWorkspaceId = v2UUID(params, "preferred_workspace_id") let preferredSurfaceId = v2UUID(params, "preferred_surface_id") - let callerTTY = Self.normalizedTTYName(stringParam(params, "caller_tty")) + let callerTTY = stringParam(params, "caller_tty") let preferTTY = boolParam(params, "prefer_tty") ?? false let title = stringParam(params, "title") ?? "Notification" let subtitle = stringParam(params, "subtitle") ?? "" let body = stringParam(params, "body") ?? "" + let replyShape = TerminalNotificationReplyShape(wire: stringParam(params, "reply_shape")) var result: V2CallResult = .err(code: "internal_error", message: "Failed to notify", data: nil) runOnMain { - let target = Self.callerNotificationTarget( - fallback: fallbackTabManager, + let target = self.resolvedCallerNotificationTarget( preferredWorkspaceId: preferredWorkspaceId, preferredSurfaceId: preferredSurfaceId, callerTTY: callerTTY, @@ -93,21 +102,47 @@ extension TerminalController { return } self.deliverNotificationSynchronously( - tabId: target.workspace.id, + tabId: target.workspaceId, surfaceId: target.surfaceId, title: title, subtitle: subtitle, - body: body + body: body, + replyShape: replyShape ) let surfaceId: Any = target.surfaceId?.uuidString ?? NSNull() result = .ok([ - "workspace_id": target.workspace.id.uuidString, + "workspace_id": target.workspaceId.uuidString, "surface_id": surfaceId ]) } return result } + /// Shared caller-target resolution entrypoint: the workspace/surface a + /// caller-addressed notification should land on, resolved with the same + /// TTY/preference/moved-pane rules for every entrypoint that needs it + /// (`notification.create_for_caller` above; the notification debug + /// emitter's target resolution in DEBUG builds). + func resolvedCallerNotificationTarget( + preferredWorkspaceId: UUID?, + preferredSurfaceId: UUID?, + callerTTY: String?, + preferTTY: Bool + ) -> TerminalCallerNotificationTarget? { + guard let fallback = activeTabManagerForCallerNotification() else { return nil } + guard let target = Self.callerNotificationTarget( + fallback: fallback, + preferredWorkspaceId: preferredWorkspaceId, + preferredSurfaceId: preferredSurfaceId, + callerTTY: Self.normalizedTTYName(callerTTY), + preferTTY: preferTTY + ) else { return nil } + return TerminalCallerNotificationTarget( + workspaceId: target.workspace.id, + surfaceId: target.surfaceId + ) + } + private static func callerNotificationTarget( fallback: TabManager, preferredWorkspaceId: UUID?, diff --git a/Sources/TerminalNotificationLiveRetargetDelivery.swift b/Sources/TerminalNotificationLiveRetargetDelivery.swift index 2a25e3bb7948..a5962a5fabc7 100644 --- a/Sources/TerminalNotificationLiveRetargetDelivery.swift +++ b/Sources/TerminalNotificationLiveRetargetDelivery.swift @@ -1,3 +1,4 @@ +import CmuxNotifications import Foundation /// Live-retargeting delivery and clear semantics for agent notifications @@ -14,6 +15,7 @@ extension TerminalController { title: String, subtitle: String, body: String, + replyShape: TerminalNotificationReplyShape = .none, retargetsToLiveSurfaceOwner: Bool = true ) { let target: (tabId: UUID, surfaceId: UUID?) @@ -55,6 +57,7 @@ extension TerminalController { title: title, subtitle: subtitle, body: body, + replyShape: replyShape, retargetsToLiveSurfaceOwner: retargetsToLiveSurfaceOwner ) } @@ -72,6 +75,7 @@ extension TerminalNotificationStore { title: String, subtitle: String, body: String, + replyShape: TerminalNotificationReplyShape, notificationGeneration: UInt64 ) { guard let target = AppDelegate.shared?.agentNotificationDeliveryTarget( @@ -96,6 +100,7 @@ extension TerminalNotificationStore { title: title, subtitle: subtitle, body: body, + replyShape: replyShape, retargetsToLiveSurfaceOwner: true, notificationGeneration: notificationGeneration ) @@ -120,6 +125,7 @@ extension TerminalNotificationStore { title: request.title, subtitle: request.subtitle, body: request.body, + replyShape: request.replyShape, cwd: request.cwd, isAppFocused: request.isAppFocused, isFocusedPanel: request.isFocusedPanel diff --git a/Sources/TerminalNotificationPolicy.swift b/Sources/TerminalNotificationPolicy.swift index db5011c1dc27..072c85891fec 100644 --- a/Sources/TerminalNotificationPolicy.swift +++ b/Sources/TerminalNotificationPolicy.swift @@ -1,4 +1,5 @@ import AppKit +import CmuxNotifications import Darwin import Foundation @@ -193,6 +194,7 @@ struct TerminalNotificationPolicyRequest: Sendable { let title: String let subtitle: String let body: String + let replyShape: TerminalNotificationReplyShape let cwd: String? let isAppFocused: Bool let isFocusedPanel: Bool @@ -205,6 +207,7 @@ struct TerminalNotificationPolicyRequest: Sendable { title: String, subtitle: String, body: String, + replyShape: TerminalNotificationReplyShape = .none, cwd: String?, isAppFocused: Bool, isFocusedPanel: Bool @@ -217,6 +220,7 @@ struct TerminalNotificationPolicyRequest: Sendable { self.title = title self.subtitle = subtitle self.body = body + self.replyShape = replyShape self.cwd = cwd self.isAppFocused = isAppFocused self.isFocusedPanel = isFocusedPanel diff --git a/Sources/TerminalNotificationQueue.swift b/Sources/TerminalNotificationQueue.swift index b0c56eb308b0..32d8cef84142 100644 --- a/Sources/TerminalNotificationQueue.swift +++ b/Sources/TerminalNotificationQueue.swift @@ -1,4 +1,5 @@ import CmuxRemoteSession +import CmuxNotifications import Foundation fileprivate struct QueuedTerminalNotificationKey: Hashable, Sendable { @@ -11,6 +12,7 @@ fileprivate struct QueuedTerminalNotification: Sendable { let title: String let subtitle: String let body: String + let replyShape: TerminalNotificationReplyShape } fileprivate enum TerminalSocketMutation { @@ -67,13 +69,15 @@ final class TerminalMutationBus: @unchecked Sendable { title: String, subtitle: String, body: String, + replyShape: TerminalNotificationReplyShape = .none, coalesces: Bool = true ) { enqueueNotification(QueuedTerminalNotification( key: QueuedTerminalNotificationKey(tabId: tabId, surfaceId: surfaceId), title: title, subtitle: subtitle, - body: body + body: body, + replyShape: replyShape ), coalesces: coalesces) } @@ -439,6 +443,7 @@ final class TerminalMutationBus: @unchecked Sendable { title: notification.title, subtitle: notification.subtitle, body: notification.body, + replyShape: notification.replyShape, notificationGeneration: entry.notificationGeneration ?? 0 ) case .clearAllNotifications(let boundary): diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index 0fa04df2d2bc..e8a7bee3cb98 100644 --- a/Sources/TerminalNotificationStore.swift +++ b/Sources/TerminalNotificationStore.swift @@ -7,6 +7,7 @@ import os import UserNotifications import Bonsplit import CmuxSettings +import CmuxNotifications nonisolated private let terminalNotificationLogger = Logger( subsystem: "com.cmuxterm.app", @@ -152,7 +153,9 @@ final class TerminalNotificationStore: ObservableObject { static let authorizationStatusDidChangeNotification = Notification.Name("cmux.terminalNotificationAuthorizationStatusDidChange") static let categoryIdentifier = "com.cmuxterm.app.userNotification" + static let textReplyCategoryIdentifier = "com.cmuxterm.app.userNotification.textReply" static let actionShowIdentifier = "com.cmuxterm.app.userNotification.show" + static let actionReplyIdentifier = "terminal.reply" 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 @@ -889,6 +892,7 @@ final class TerminalNotificationStore: ObservableObject { title: String, subtitle: String, body: String, + replyShape: TerminalNotificationReplyShape = .none, retargetsToLiveSurfaceOwner: Bool = true, cooldownKey: String? = nil, cooldownInterval: TimeInterval? = nil, @@ -935,6 +939,7 @@ final class TerminalNotificationStore: ObservableObject { title: title, subtitle: subtitle, body: body, + replyShape: replyShape, retargetsToLiveSurfaceOwner: retargetsToLiveSurfaceOwner, correlationKey: cooldownKey, resolvedHooks: resolvedHooks @@ -1102,6 +1107,7 @@ final class TerminalNotificationStore: ObservableObject { title: String, subtitle: String, body: String, + replyShape: TerminalNotificationReplyShape = .none, retargetsToLiveSurfaceOwner: Bool, correlationKey: String?, resolvedHooks: [CmuxResolvedNotificationHook]? @@ -1143,6 +1149,7 @@ final class TerminalNotificationStore: ObservableObject { title: title, subtitle: subtitle, body: body, + replyShape: replyShape, cwd: cwd, isAppFocused: isAppFocused, isFocusedPanel: isFocusedPanel @@ -1173,6 +1180,7 @@ final class TerminalNotificationStore: ObservableObject { title: payload.title, subtitle: payload.subtitle, body: payload.body, + replyShape: request.replyShape, cwd: request.cwd, isAppFocused: request.isAppFocused, isFocusedPanel: request.isFocusedPanel @@ -1213,7 +1221,8 @@ final class TerminalNotificationStore: ObservableObject { isRead: !effects.markUnread, paneFlash: effects.paneFlash, scrollPosition: scrollPosition, - clickAction: clickAction + clickAction: clickAction, + replyShape: request.replyShape ) if effects.record { recordNotification( @@ -1726,7 +1735,8 @@ final class TerminalNotificationStore: ObservableObject { isRead: notification.isRead, paneFlash: notification.paneFlash, scrollPosition: notification.scrollPosition, - clickAction: notification.clickAction + clickAction: notification.clickAction, + replyShape: notification.replyShape ) } @@ -1831,7 +1841,8 @@ final class TerminalNotificationStore: ObservableObject { isRead: notification.isRead, paneFlash: notification.paneFlash, scrollPosition: notification.scrollPosition, - clickAction: notification.clickAction + clickAction: notification.clickAction, + replyShape: notification.replyShape ) } if didMoveNotification { @@ -1911,7 +1922,9 @@ final class TerminalNotificationStore: ObservableObject { let notificationSurfaceId = notification.surfaceId let retargetsToLiveSurfaceOwner = notification.retargetsToLiveSurfaceOwner let clickActionUserInfo = notification.clickAction?.userInfo ?? [:] - let categoryIdentifier = Self.categoryIdentifier + let categoryIdentifier = notification.replyShape == .text + ? Self.textReplyCategoryIdentifier + : Self.categoryIdentifier let handleAuthorization: NativeNotificationDeliveryHooks.AuthorizationCompletion = { authorized, effectiveAuthorizationState in let content = UNMutableNotificationContent() content.title = notificationTitle diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index bc91bfa7809f..de14077befe9 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -1445,6 +1445,9 @@ 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 */; }; + A11E00010000000000000001 /* NotificationDebugEmitter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11E00010000000000000004 /* NotificationDebugEmitter.swift */; }; + A11E00010000000000000002 /* NotificationDebugMenuButtons.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11E00010000000000000005 /* NotificationDebugMenuButtons.swift */; }; + A11E00010000000000000003 /* NotificationDebugTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11E00010000000000000006 /* NotificationDebugTarget.swift */; }; D7AB00000000000000B021 /* NotificationDismissSyncTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB00000000000000B020 /* NotificationDismissSyncTests.swift */; }; C0DEFEF00000000000000001 /* NotificationFeedHistoryInsertionChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEFEF00000000000000002 /* NotificationFeedHistoryInsertionChange.swift */; }; C0DEFEF10000000000000001 /* NotificationFeedHistoryMutation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEFEF10000000000000002 /* NotificationFeedHistoryMutation.swift */; }; @@ -4099,6 +4102,9 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = D2C075029771815DD5DA1332 /* NotificationAndMenuBarTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationAndMenuBarTests.swift; sourceTree = ""; }; C06552010000000000000004 /* NotificationBurstCoalescer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationBurstCoalescer.swift; sourceTree = ""; }; 7490D00D7490D00D7490D00D /* NotificationCacheMemoryPressureResponder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/NotificationCacheMemoryPressureResponder.swift; sourceTree = ""; }; + A11E00010000000000000004 /* NotificationDebugEmitter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationDebugEmitter.swift; sourceTree = ""; }; + A11E00010000000000000005 /* NotificationDebugMenuButtons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationDebugMenuButtons.swift; sourceTree = ""; }; + A11E00010000000000000006 /* NotificationDebugTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationDebugTarget.swift; sourceTree = ""; }; D7AB00000000000000B020 /* NotificationDismissSyncTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationDismissSyncTests.swift"; sourceTree = ""; }; C0DEFEF00000000000000002 /* NotificationFeedHistoryInsertionChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationFeedHistoryInsertionChange.swift; sourceTree = ""; }; C0DEFEF10000000000000002 /* NotificationFeedHistoryMutation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationFeedHistoryMutation.swift; sourceTree = ""; }; @@ -6813,6 +6819,9 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A9F100000000000000000002 /* AgentExecutableResolverError.swift */, A9F100000000000000000003 /* AgentSessionDebugMenuButtons.swift */, E7B100000000000000000001 /* IrohTransportDebugMenuButtons.swift */, + A11E00010000000000000004 /* NotificationDebugEmitter.swift */, + A11E00010000000000000005 /* NotificationDebugMenuButtons.swift */, + A11E00010000000000000006 /* NotificationDebugTarget.swift */, A9F100000000000000000004 /* AgentSessionLaunchPlan.swift */, A9F100000000000000000005 /* CmuxRuntimeDebugCapture.swift */, A9F100000000000000000006 /* CmuxRuntimeDebugCaptureConfiguration.swift */, @@ -9426,6 +9435,9 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C0DE77060000000000000003 /* NewWorkspaceMenuModel.swift in Sources */, C06552010000000000000003 /* NotificationBurstCoalescer.swift in Sources */, 7490C00D7490C00D7490C00D /* NotificationCacheMemoryPressureResponder.swift in Sources */, + A11E00010000000000000001 /* NotificationDebugEmitter.swift in Sources */, + A11E00010000000000000002 /* NotificationDebugMenuButtons.swift in Sources */, + A11E00010000000000000003 /* NotificationDebugTarget.swift in Sources */, C0DEFEF00000000000000001 /* NotificationFeedHistoryInsertionChange.swift in Sources */, C0DEFEF10000000000000001 /* NotificationFeedHistoryMutation.swift in Sources */, C0DEFEF20000000000000001 /* NotificationFeedHistoryMutationResult.swift in Sources */, diff --git a/cmuxTests/PhonePushPresenceGateTests.swift b/cmuxTests/PhonePushPresenceGateTests.swift index 3387ba22713d..35f94d15c3c6 100644 --- a/cmuxTests/PhonePushPresenceGateTests.swift +++ b/cmuxTests/PhonePushPresenceGateTests.swift @@ -149,7 +149,8 @@ import Testing subtitle: "Completed", body: "May follow its live surface", createdAt: Self.now, - isRead: false + isRead: false, + replyShape: .text ) let confinedPayload = PhonePushPayload( @@ -168,9 +169,11 @@ import Testing #expect(confinedPayload.workspaceId == workspaceId.uuidString) #expect(confinedPayload.surfaceId == surfaceId.uuidString) #expect(!confinedPayload.retargetsToLiveSurfaceOwner) + #expect(confinedPayload.replyShape == "none") #expect(trustedPayload.workspaceId == workspaceId.uuidString) #expect(trustedPayload.surfaceId == surfaceId.uuidString) #expect(trustedPayload.retargetsToLiveSurfaceOwner) + #expect(trustedPayload.replyShape == "text") } // MARK: - Heuristic details @@ -414,6 +417,7 @@ import Testing title: "secret title", subtitle: "secret subtitle", body: "secret terminal output", + replyShape: "", workspaceId: UUID().uuidString, surfaceId: UUID().uuidString, retargetsToLiveSurfaceOwner: true, @@ -462,6 +466,7 @@ import Testing title: " \(String(repeating: longCharacter, count: 100)) ", subtitle: String(repeating: longCharacter, count: 100), body: String(repeating: longCharacter, count: 300), + replyShape: "", workspaceId: UUID().uuidString, surfaceId: UUID().uuidString, retargetsToLiveSurfaceOwner: true, @@ -499,6 +504,7 @@ import Testing title: "agent", subtitle: "", body: "done", + replyShape: "", workspaceId: String(repeating: "a", count: 201), surfaceId: nil, retargetsToLiveSurfaceOwner: false, @@ -524,6 +530,7 @@ import Testing title: "", subtitle: "", body: "", + replyShape: "", workspaceId: nil, surfaceId: nil, retargetsToLiveSurfaceOwner: false, diff --git a/ghostty b/ghostty index 3fbdd078dfc4..19d03fa4d016 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit 3fbdd078dfc499134710d3cf9ce2c5e06fa101aa +Subproject commit 19d03fa4d0161e60e02de2e42601992be0c001c3 diff --git a/ios/cmux/CmuxAppDelegate.swift b/ios/cmux/CmuxAppDelegate.swift index c487b5c4d8f5..7fb99fa3d9ae 100644 --- a/ios/cmux/CmuxAppDelegate.swift +++ b/ios/cmux/CmuxAppDelegate.swift @@ -69,7 +69,7 @@ final class CmuxAppDelegate: NSObject, @preconcurrency UIApplicationDelegate, UN ) async { let request = response.notification.request // A swipe/clear of a cmux banner delivers the custom dismiss action - // (enabled via the `cmux.terminal` category's `.customDismissAction`). + // (enabled on both cmux terminal categories via `.customDismissAction`). // Forward it to the Mac so the desktop banner + store entry clear too. let ids = Self.cmuxIDs(from: request.content.userInfo) if response.actionIdentifier == UNNotificationDismissActionIdentifier { @@ -79,6 +79,29 @@ final class CmuxAppDelegate: NSObject, @preconcurrency UIApplicationDelegate, UN ) return } + if response.actionIdentifier == MobilePushCoordinator.replyActionIdentifier, + let replyText = (response as? UNTextInputNotificationResponse)?.userText, + !replyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + let notificationId = Self.notificationID(from: request) + await analytics?.capture("ios_push_inline_reply", [ + "has_workspace_id": .bool(ids.workspaceId != nil), + "has_surface_id": .bool(ids.surfaceId != nil), + "has_mac_device_id": .bool(ids.macDeviceId != nil), + "has_notification_id": .bool(notificationId != nil), + ]) + await pushCoordinator?.handleReply( + text: replyText, + workspaceId: ids.workspaceId, + surfaceId: ids.surfaceId, + macDeviceId: ids.macDeviceId, + retargetsToLiveSurfaceOwner: ids.retargetsToLiveSurfaceOwner + ) + await pushCoordinator?.handleDismiss( + notificationId: notificationId, + macDeviceId: ids.macDeviceId + ) + return + } // A tap (default action) deep-links to the workspace/terminal AND marks // the notification read on the Mac, mirroring the Mac's own tap path // (which opens + marks read). The two compose: deep-link locally, clear diff --git a/vendor/bonsplit b/vendor/bonsplit index 4da56abb6d64..529913b7a49c 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit 4da56abb6d64595970af2081726591889ca54982 +Subproject commit 529913b7a49c39669414578f1f32ccf2df47f0f8 diff --git a/web/services/apns/payload.ts b/web/services/apns/payload.ts index c0fce01517a5..14cc66fbf86b 100644 --- a/web/services/apns/payload.ts +++ b/web/services/apns/payload.ts @@ -25,6 +25,8 @@ export interface ApnsNotificationInput { readonly title: string; readonly subtitle?: string | null; readonly body: string; + /** Inline-reply affordance requested by the Mac notification. */ + readonly replyShape?: "none" | "text"; readonly workspaceId?: string | null; readonly surfaceId?: string | null; /** Whether a tap may resolve the surface outside `workspaceId`. */ @@ -55,20 +57,22 @@ export interface ApnsNotificationInput { } /** - * APNs `aps.category` set on every cmux terminal push. iOS registers a - * matching ``UNNotificationCategory`` with `customDismissAction` so a - * swipe/clear delivers `UNNotificationDismissActionIdentifier` to the app, - * which forwards the dismiss to the Mac. Keep this in sync with the iOS - * category id. + * Base APNs `aps.category` for non-replyable cmux terminal pushes. iOS + * registers this and the reply category with `customDismissAction` so a + * swipe/clear delivers `UNNotificationDismissActionIdentifier` to the app. + * Keep both identifiers in sync with iOS. */ export const CMUX_APNS_CATEGORY = "cmux.terminal"; +/** APNs category for terminal pushes that accept an inline text reply. */ +export const CMUX_APNS_REPLY_CATEGORY = "cmux.terminal.reply"; + /** * Build the APNs JSON payload. Adds the workspace/surface ids, live-owner * retargeting provenance, Mac id, and notification id under `cmux` so a tap * can deep-link without crossing a confined workspace boundary and a swipe can - * be dismiss-synced. Also sets the dismiss-action `category` and marks the - * alert time-sensitive (the app holds that entitlement). + * be dismiss-synced. Also selects the plain or inline-reply dismiss-action + * category and marks the alert time-sensitive (the app holds that entitlement). */ export function buildApnsPayload(input: ApnsNotificationInput): Record { if (input.kind === "dismiss") return buildDismissPayload(input); @@ -90,7 +94,7 @@ export function buildApnsPayload(input: ApnsNotificationInput): Record): PushPayloadResu const surfaceId = body.surfaceId == null ? "" : boundedString(body.surfaceId, MAX_PUSH_ID_CHARS); const macDeviceId = body.macDeviceId == null ? "" : boundedString(body.macDeviceId, MAX_PUSH_ID_CHARS); const notificationId = body.notificationId == null ? "" : boundedString(body.notificationId, MAX_PUSH_ID_CHARS); + const replyShape = body.replyShape === "none" || body.replyShape === "text" ? body.replyShape : undefined; const correlationId = body.correlationId == null ? "" @@ -162,6 +164,7 @@ export function parsePushPayload(body: Record): PushPayloadResu title, subtitle: subtitle || null, body: text, + ...(kind === "notify" && replyShape ? { replyShape } : {}), workspaceId: workspaceId || null, surfaceId: surfaceId || null, macDeviceId: macDeviceId || null, diff --git a/web/tests/apns.test.ts b/web/tests/apns.test.ts index e9eefcdde23e..ad2bb57b84ff 100644 --- a/web/tests/apns.test.ts +++ b/web/tests/apns.test.ts @@ -6,6 +6,7 @@ import { apnsHostForEnvironment, buildApnsPayload, CMUX_APNS_CATEGORY, + CMUX_APNS_REPLY_CATEGORY, shouldPruneToken, } from "../services/apns/payload"; import { resolveApnsProviderConfiguration } from "../services/apns/config"; @@ -78,6 +79,22 @@ describe("apns payload", () => { expect(payload.cmux).toEqual({ workspaceId: "ws-1", notificationId: "n-42" }); }); + test("selects reply and fallback categories from replyShape", () => { + const category = (replyShape: unknown) => { + const payload = buildApnsPayload({ + title: "claude", + body: "Agent finished", + replyShape, + } as Parameters[0]) as { aps: Record }; + return payload.aps.category; + }; + + expect(category("text")).toBe(CMUX_APNS_REPLY_CATEGORY); + expect(category("none")).toBe(CMUX_APNS_CATEGORY); + expect(category(undefined)).toBe(CMUX_APNS_CATEGORY); + expect(category("unknown")).toBe(CMUX_APNS_CATEGORY); + }); + test("keeps the notification id even when content is hidden (id is not content)", () => { const payload = buildApnsPayload({ title: "secret", @@ -574,6 +591,19 @@ describe("apns route policy", () => { }); }); + test("passes through known reply shapes and ignores unknown values", () => { + const value = (replyShape: unknown) => { + const parsed = parsePushPayload({ title: "agent", body: "done", replyShape }); + if (!parsed.ok) throw new Error(parsed.error); + return parsed.value.replyShape; + }; + + expect(value("text")).toBe("text"); + expect(value("none")).toBe("none"); + expect(value(undefined)).toBeUndefined(); + expect(value("future-shape")).toBeUndefined(); + }); + test("parses a dismiss push: text-free, requires ids, carries the badge", () => { const parsed = parsePushPayload({ kind: "dismiss",