From 77bbab710c4237696c1af5e65ea3d5028fd670d1 Mon Sep 17 00:00:00 2001 From: Anthoni Rivera Date: Tue, 3 Nov 2026 23:09:44 -0500 Subject: [PATCH] feat: notify Vercel deployments passively and only when asked A finished deployment is ambient news, not the user's turn, so it no longer asks for sound or an interrupting banner: notifications use interruptionLevel .passive and the authorization drops .sound. Add a Notify when a Vercel deployment finishes switch next to the sound items, off by default and hidden until the integration is configured. The monitor takes the policy as an injected closure so Infrastructure never reads preferences. --- Sources/AgentNotch/App/AppDelegate.swift | 2 +- Sources/AgentNotch/App/Preferences.swift | 5 +++++ Sources/AgentNotch/App/StatusMenu.swift | 8 +++++++- Sources/AgentNotch/Infrastructure/VercelMonitor.swift | 10 ++++++++-- packaging/locales/de.lproj/Localizable.strings | 1 + packaging/locales/en.lproj/Localizable.strings | 1 + packaging/locales/es.lproj/Localizable.strings | 1 + 7 files changed, 24 insertions(+), 4 deletions(-) diff --git a/Sources/AgentNotch/App/AppDelegate.swift b/Sources/AgentNotch/App/AppDelegate.swift index dede9fc..24c9351 100644 --- a/Sources/AgentNotch/App/AppDelegate.swift +++ b/Sources/AgentNotch/App/AppDelegate.swift @@ -4,7 +4,7 @@ import AppKit final class AppDelegate: NSObject, NSApplicationDelegate { private let store = SessionStore() private let viewModel = NotchViewModel() - private let vercel = VercelMonitor() + private let vercel = VercelMonitor(notificationsEnabled: { Preferences.vercelNotificationsEnabled }) private var server: HookServer? private var panelController: NotchPanelController? private var statusMenu: StatusMenu? diff --git a/Sources/AgentNotch/App/Preferences.swift b/Sources/AgentNotch/App/Preferences.swift index 29a08cc..3b0a88c 100644 --- a/Sources/AgentNotch/App/Preferences.swift +++ b/Sources/AgentNotch/App/Preferences.swift @@ -13,6 +13,11 @@ enum Preferences { get { bool("soundOnApproval", true) } set { save(newValue, "soundOnApproval") } } + /// Notify when a Vercel deployment reaches a terminal state. Off by default: a build finishing is not your turn. + static var vercelNotificationsEnabled: Bool { + get { bool("vercelNotificationsEnabled", false) } + set { save(newValue, "vercelNotificationsEnabled") } + } /// 0...1, applied on top of the system volume. static var soundVolume: Float { get { Float(number("soundVolume", 0.3)) } diff --git a/Sources/AgentNotch/App/StatusMenu.swift b/Sources/AgentNotch/App/StatusMenu.swift index 170a7a4..1ed7add 100644 --- a/Sources/AgentNotch/App/StatusMenu.swift +++ b/Sources/AgentNotch/App/StatusMenu.swift @@ -18,6 +18,7 @@ final class StatusMenu: NSObject { private let languageItem = NSMenuItem(title: L("Language"), action: nil, keyEquivalent: "") private let vercelItem = NSMenuItem(title: L("Connect Vercel…"), action: nil, keyEquivalent: "") private let vercelStatusItem = NSMenuItem(title: "", action: nil, keyEquivalent: "") + private let vercelNotifyItem = NSMenuItem(title: L("Notify when a Vercel deployment finishes"), action: nil, keyEquivalent: "") private static let volumeChoices: [(title: String, value: Double)] = [(L("Quiet"), 0.15), (L("Normal"), 0.3), (L("Loud"), 0.6)] private static let sleepChoices: [(title: String, value: Double)] = [(L("30 seconds"), 30), (L("1 minute"), 60), (L("3 minutes"), 180)] @@ -50,7 +51,8 @@ final class StatusMenu: NSObject { let connect = NSMenuItem(title: L("Connect agents…"), action: #selector(connectAgents), keyEquivalent: "") connect.target = self vercelItem.target = self; vercelItem.action = #selector(connectVercel) - [connect, vercelItem, vercelStatusItem, .separator(), pauseItem, .separator(), soundItem, approvalSoundItem, volumeItem, .separator(), idleItem, sleepItem, + vercelNotifyItem.target = self; vercelNotifyItem.action = #selector(toggleVercelNotifications) + [connect, vercelItem, vercelStatusItem, .separator(), pauseItem, .separator(), soundItem, approvalSoundItem, volumeItem, vercelNotifyItem, .separator(), idleItem, sleepItem, yourTurnItem, .separator(), languageItem, loginItem, .separator(), quit].forEach(menu.addItem) menu.delegate = self item.menu = menu @@ -110,6 +112,8 @@ final class StatusMenu: NSObject { @objc private func toggleSound() { Preferences.soundEnabled.toggle() } + @objc private func toggleVercelNotifications() { Preferences.vercelNotificationsEnabled.toggle() } + @objc private func toggleLogin() { do { if SMAppService.mainApp.status == .enabled { @@ -171,6 +175,8 @@ extension StatusMenu: NSMenuDelegate { vercelItem.isHidden = !vercel.configured vercelStatusItem.isHidden = !vercel.configured || vercel.error == nil vercelStatusItem.title = vercel.error ?? "" + vercelNotifyItem.isHidden = !vercel.configured + vercelNotifyItem.state = Preferences.vercelNotificationsEnabled ? .on : .off soundItem.state = Preferences.soundEnabled ? .on : .off approvalSoundItem.state = Preferences.approvalSoundEnabled ? .on : .off idleItem.state = Preferences.showIdleMascot ? .on : .off diff --git a/Sources/AgentNotch/Infrastructure/VercelMonitor.swift b/Sources/AgentNotch/Infrastructure/VercelMonitor.swift index 5c3b049..8f993c9 100644 --- a/Sources/AgentNotch/Infrastructure/VercelMonitor.swift +++ b/Sources/AgentNotch/Infrastructure/VercelMonitor.swift @@ -22,8 +22,11 @@ final class VercelMonitor: ObservableObject { private let worker: URL? private let slug: String private let session = URLSession(configuration: .ephemeral, delegate: NoRedirects(), delegateQueue: nil) + /// Whether a finished deployment may post a notification. Injected by the app layer: this monitor reads no preferences. + private let notificationsEnabled: () -> Bool - init() { + init(notificationsEnabled: @escaping () -> Bool = { false }) { + self.notificationsEnabled = notificationsEnabled if let raw = Bundle.main.object(forInfoDictionaryKey: "VercelWorkerURL") as? String, let url = URL(string: raw), url.scheme == "https", url.host != nil { worker = url } else { worker = nil } @@ -114,11 +117,14 @@ final class VercelMonitor: ObservableObject { private func notify(_ deployment: Deployment) async { guard Bundle.main.bundleURL.pathExtension == "app" else { return } + guard notificationsEnabled() else { return } let center = UNUserNotificationCenter.current() - guard (try? await center.requestAuthorization(options: [.alert, .sound])) == true else { return } + guard (try? await center.requestAuthorization(options: [.alert])) == true else { return } let content = UNMutableNotificationContent() content.title = "Vercel · \(deployment.project)" content.body = [deployment.status.label, deployment.repository, deployment.commit, deployment.author].compactMap { $0 }.joined(separator: " · ") + // A build finishing is ambient news, not the user's turn: deliver it passively, with no sound or banner that demands attention. + content.interruptionLevel = .passive try? await center.add(UNNotificationRequest(identifier: "vercel-\(deployment.id)", content: content, trigger: nil)) } diff --git a/packaging/locales/de.lproj/Localizable.strings b/packaging/locales/de.lproj/Localizable.strings index 88dbf26..836596d 100644 --- a/packaging/locales/de.lproj/Localizable.strings +++ b/packaging/locales/de.lproj/Localizable.strings @@ -88,3 +88,4 @@ "Ready" = "Bereit"; "Canceled" = "Abgebrochen"; "Unknown" = "Unbekannt"; +"Notify when a Vercel deployment finishes" = "Benachrichtigen, wenn ein Vercel-Deployment fertig ist"; diff --git a/packaging/locales/en.lproj/Localizable.strings b/packaging/locales/en.lproj/Localizable.strings index 2421a2d..2811ea5 100644 --- a/packaging/locales/en.lproj/Localizable.strings +++ b/packaging/locales/en.lproj/Localizable.strings @@ -88,3 +88,4 @@ "Ready" = "Ready"; "Canceled" = "Canceled"; "Unknown" = "Unknown"; +"Notify when a Vercel deployment finishes" = "Notify when a Vercel deployment finishes"; diff --git a/packaging/locales/es.lproj/Localizable.strings b/packaging/locales/es.lproj/Localizable.strings index 1f5b4f8..be21440 100644 --- a/packaging/locales/es.lproj/Localizable.strings +++ b/packaging/locales/es.lproj/Localizable.strings @@ -88,3 +88,4 @@ "Ready" = "Listo"; "Canceled" = "Cancelado"; "Unknown" = "Desconocido"; +"Notify when a Vercel deployment finishes" = "Notificarme cuando termine un despliegue de Vercel";