From 398a0c1ae60f14fbddfcf155df342270843a4a46 Mon Sep 17 00:00:00 2001 From: Anthoni Rivera Date: Tue, 3 Nov 2026 22:53:45 -0500 Subject: [PATCH 1/6] feat: connect Vercel and track production deployments Add a VercelMonitor that polls the connected integration's production deployments, pairs the desktop app with a Worker through a one-time state, and notifies once per terminal transition. Deployments decode into Deployment values, the status menu gains a hidden-until-configured Connect/Disconnect Vercel item, and the Vercel mascot sprite is added. --- Sources/AgentNotch/App/AppDelegate.swift | 8 +- Sources/AgentNotch/App/StatusMenu.swift | 27 ++- Sources/AgentNotch/Domain/Deployment.swift | 45 ++++ .../Infrastructure/HookServer.swift | 21 +- .../Infrastructure/VercelMonitor.swift | 213 ++++++++++++++++++ .../AgentNotch/Resources/Mascots/vercel.png | Bin 0 -> 13828 bytes Sources/AgentNotch/UI/MascotSprites.swift | 1 + .../locales/de.lproj/Localizable.strings | 11 + .../locales/en.lproj/Localizable.strings | 11 + .../locales/es.lproj/Localizable.strings | 11 + 10 files changed, 341 insertions(+), 7 deletions(-) create mode 100644 Sources/AgentNotch/Domain/Deployment.swift create mode 100644 Sources/AgentNotch/Infrastructure/VercelMonitor.swift create mode 100644 Sources/AgentNotch/Resources/Mascots/vercel.png diff --git a/Sources/AgentNotch/App/AppDelegate.swift b/Sources/AgentNotch/App/AppDelegate.swift index 84d9d3d..dede9fc 100644 --- a/Sources/AgentNotch/App/AppDelegate.swift +++ b/Sources/AgentNotch/App/AppDelegate.swift @@ -4,6 +4,7 @@ import AppKit final class AppDelegate: NSObject, NSApplicationDelegate { private let store = SessionStore() private let viewModel = NotchViewModel() + private let vercel = VercelMonitor() private var server: HookServer? private var panelController: NotchPanelController? private var statusMenu: StatusMenu? @@ -18,7 +19,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } let debug = ProcessInfo.processInfo.environment["AGENT_NOTCH_DEBUG"] == "1" - let server = HookServer(store: store, debug: debug) + let server = HookServer(store: store, vercel: vercel, debug: debug) do { try server.start() } catch { @@ -26,9 +27,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } self.server = server - statusMenu = StatusMenu(store: store) - panelController = NotchPanelController(store: store, viewModel: viewModel) + statusMenu = StatusMenu(store: store, vercel: vercel) + panelController = NotchPanelController(store: store, viewModel: viewModel, vercel: vercel) panelController?.show() + if !debug { vercel.start() } // After launch finishes, so the notch is already on screen behind the dialog. DispatchQueue.main.async { ConnectAgents.promptIfNeeded() } } diff --git a/Sources/AgentNotch/App/StatusMenu.swift b/Sources/AgentNotch/App/StatusMenu.swift index aa3b98c..170a7a4 100644 --- a/Sources/AgentNotch/App/StatusMenu.swift +++ b/Sources/AgentNotch/App/StatusMenu.swift @@ -5,6 +5,7 @@ import ServiceManagement @MainActor final class StatusMenu: NSObject { private let store: SessionStore + private let vercel: VercelMonitor private let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) private let pauseItem = NSMenuItem(title: L("Pause approvals (always fall back to terminal)"), action: nil, keyEquivalent: "") private let soundItem = NSMenuItem(title: L("Play sound when an agent finishes"), action: nil, keyEquivalent: "") @@ -15,6 +16,8 @@ final class StatusMenu: NSObject { private let yourTurnItem = NSMenuItem(title: L("\"Your turn\" mark lasts"), action: nil, keyEquivalent: "") private let loginItem = NSMenuItem(title: L("Launch at login"), action: nil, keyEquivalent: "") 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 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)] @@ -26,8 +29,9 @@ final class StatusMenu: NSObject { (L("Off"), 0), (L("2 minutes"), 120), (L("5 minutes"), 300), (L("15 minutes"), 900), (L("30 minutes"), 1800), ] - init(store: SessionStore) { + init(store: SessionStore, vercel: VercelMonitor) { self.store = store + self.vercel = vercel super.init() item.button?.image = NSImage(systemSymbolName: "rectangle.topthird.inset.filled", accessibilityDescription: "Agent Notch") @@ -45,7 +49,8 @@ final class StatusMenu: NSObject { quit.target = self let connect = NSMenuItem(title: L("Connect agents…"), action: #selector(connectAgents), keyEquivalent: "") connect.target = self - [connect, .separator(), pauseItem, .separator(), soundItem, approvalSoundItem, volumeItem, .separator(), idleItem, sleepItem, + vercelItem.target = self; vercelItem.action = #selector(connectVercel) + [connect, vercelItem, vercelStatusItem, .separator(), pauseItem, .separator(), soundItem, approvalSoundItem, volumeItem, .separator(), idleItem, sleepItem, yourTurnItem, .separator(), languageItem, loginItem, .separator(), quit].forEach(menu.addItem) menu.delegate = self item.menu = menu @@ -122,6 +127,20 @@ final class StatusMenu: NSObject { @objc private func connectAgents() { ConnectAgents.run() } + @objc private func connectVercel() { + if vercel.hasCredentials { + let alert = NSAlert() + alert.messageText = L("Disconnect Vercel?") + alert.informativeText = L("This removes the connection from Agent Notch. To revoke its access, also uninstall the integration in Vercel.") + alert.addButton(withTitle: L("Disconnect")); alert.addButton(withTitle: L("Cancel")) + guard alert.runModal() == .alertFirstButtonReturn else { return } + vercel.disconnect() + } else { vercel.connect() } + if let error = vercel.error { + let alert = NSAlert(); alert.messageText = error; alert.runModal() + } + } + @objc private func pickLanguage(_ sender: NSMenuItem) { let code = sender.representedObject as? String ?? "" guard code != (Preferences.languageOverride.map { String($0.prefix(2)) } ?? "") else { return } @@ -148,6 +167,10 @@ final class StatusMenu: NSObject { extension StatusMenu: NSMenuDelegate { func menuNeedsUpdate(_ menu: NSMenu) { pauseItem.state = store.approvalsPaused ? .on : .off + vercelItem.title = vercel.hasCredentials ? L("Disconnect Vercel") : L("Connect Vercel…") + vercelItem.isHidden = !vercel.configured + vercelStatusItem.isHidden = !vercel.configured || vercel.error == nil + vercelStatusItem.title = vercel.error ?? "" soundItem.state = Preferences.soundEnabled ? .on : .off approvalSoundItem.state = Preferences.approvalSoundEnabled ? .on : .off idleItem.state = Preferences.showIdleMascot ? .on : .off diff --git a/Sources/AgentNotch/Domain/Deployment.swift b/Sources/AgentNotch/Domain/Deployment.swift new file mode 100644 index 0000000..8164669 --- /dev/null +++ b/Sources/AgentNotch/Domain/Deployment.swift @@ -0,0 +1,45 @@ +import Foundation + +struct Deployment: Equatable, Identifiable { + enum Status: String, Codable { + case pending, building, ready, failed, canceled, unknown + + var isTerminal: Bool { self == .ready || self == .failed || self == .canceled } + var label: String { + switch self { + case .pending: return L("Queued") + case .building: return L("Building") + case .ready: return L("Ready") + case .failed: return L("Failed") + case .canceled: return L("Canceled") + case .unknown: return L("Unknown") + } + } + } + + let id: String + let project: String + let repository: String? + let commit: String? + let author: String? + let status: Status +} + +/// Tracks terminal transitions without notifying for history loaded on the first successful poll. +struct DeploymentTracker: Codable { + private var statuses: [String: Deployment.Status] = [:] + private var initialized = false + + mutating func update(_ deployments: [Deployment]) -> [Deployment] { + var finished: [Deployment] = [] + for deployment in deployments { + let previous = statuses[deployment.id] + if initialized && deployment.status.isTerminal && previous?.isTerminal != true { + finished.append(deployment) + } + if previous?.isTerminal != true { statuses[deployment.id] = deployment.status } + } + initialized = true + return finished + } +} diff --git a/Sources/AgentNotch/Infrastructure/HookServer.swift b/Sources/AgentNotch/Infrastructure/HookServer.swift index 6e330e6..15eb079 100644 --- a/Sources/AgentNotch/Infrastructure/HookServer.swift +++ b/Sources/AgentNotch/Infrastructure/HookServer.swift @@ -1,5 +1,6 @@ import Foundation import Network +import UserNotifications /// Loopback-only HTTP server receiving agent hook events and approval requests. final class HookServer: @unchecked Sendable { @@ -8,11 +9,13 @@ final class HookServer: @unchecked Sendable { private let port: UInt16 private let debug: Bool private let store: SessionStore + private let vercel: VercelMonitor private let queue = DispatchQueue(label: "dev.onilabs.agentnotch.server") private var listener: NWListener? - init(store: SessionStore, port: UInt16? = nil, debug: Bool = false) { + init(store: SessionStore, vercel: VercelMonitor, port: UInt16? = nil, debug: Bool = false) { self.store = store + self.vercel = vercel self.debug = debug let env = ProcessInfo.processInfo.environment["AGENT_NOTCH_PORT"].flatMap(UInt16.init) self.port = port ?? env ?? Self.defaultPort @@ -139,7 +142,21 @@ final class HookServer: @unchecked Sendable { // MARK: Debug (only when AGENT_NOTCH_DEBUG=1) private func handleDebug(_ request: HTTPRequest, segments: [String], on conn: HTTPConnection) { - if request.method == "POST", segments.count == 3, segments[1] == "resolve-all", + if request.method == "GET", segments == ["debug", "vercel", "notifications"] { + Task { + let settings = await UNUserNotificationCenter.current().notificationSettings() + let delivered = await UNUserNotificationCenter.current().deliveredNotifications() + let found = delivered.contains { $0.request.identifier == "vercel-simulation" } + let denied = settings.authorizationStatus == .denied + conn.send(status: 200, body: "{\"denied\":\(denied),\"delivered\":\(found)}") + } + } else if request.method == "POST", segments.count == 3, segments[1] == "vercel", + let status = Deployment.Status(rawValue: segments[2]), [.building, .ready, .failed].contains(status) { + Task { @MainActor [vercel] in + vercel.simulate(status) + conn.send(status: 200, body: "{\"ok\":true}") + } + } else if request.method == "POST", segments.count == 3, segments[1] == "resolve-all", let decision = Decision(rawValue: segments[2]) { Task { @MainActor [store] in let count = store.approvals.count diff --git a/Sources/AgentNotch/Infrastructure/VercelMonitor.swift b/Sources/AgentNotch/Infrastructure/VercelMonitor.swift new file mode 100644 index 0000000..5c3b049 --- /dev/null +++ b/Sources/AgentNotch/Infrastructure/VercelMonitor.swift @@ -0,0 +1,213 @@ +import AppKit +import CryptoKit +import Security +import UserNotifications + +private final class NoRedirects: NSObject, URLSessionTaskDelegate { + func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) { + completionHandler(nil) + } +} + +@MainActor +final class VercelMonitor: ObservableObject { + @Published private(set) var deployments: [Deployment] = [] + @Published private(set) var error: String? + @Published private(set) var connected = false + private var task: Task? + private var tracker = DeploymentTracker() + private var simulationTracker = DeploymentTracker() + private let service = "dev.onilabs.agentnotch.vercel" + private let worker: URL? + private let slug: String + private let session = URLSession(configuration: .ephemeral, delegate: NoRedirects(), delegateQueue: nil) + + init() { + 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 } + slug = (Bundle.main.object(forInfoDictionaryKey: "VercelIntegrationSlug") as? String) ?? "agent-notch" + } + + var configured: Bool { worker != nil } + var hasCredentials: Bool { load() != nil } + var active: Bool { connected && deployments.contains { $0.status == .pending || $0.status == .building } } + + func simulate(_ status: Deployment.Status) { + if status == .building { simulationTracker = DeploymentTracker() } + let item = Deployment(id: "simulation", project: "Example deploy (simulation)", repository: "example/repo", commit: "abc1234", author: "Example", status: status) + connected = true + deployments = [item] + for finished in simulationTracker.update([item]) { Task { await notify(finished) } } + } + + func start() { + guard let credentials = load() else { return } + task?.cancel() + task = Task { await poll(credentials) } + } + + func connect() { + guard configured else { error = "Vercel integration is not configured."; return } + guard !hasCredentials else { start(); return } + task?.cancel() + task = Task { + do { + let state = try randomHex(), verifier = try randomHex() + let challenge = SHA256.hash(data: Data(verifier.utf8)).map { String(format: "%02x", $0) }.joined() + let credentials = Credentials(state: state, verifier: verifier) + _ = try await request("start", credentials: credentials, method: "POST", headers: ["X-Pairing-Challenge": challenge]) + try save(credentials) + var components = URLComponents(string: "https://vercel.com/integrations/\(slug)/new")! + components.queryItems = [URLQueryItem(name: "state", value: state)] + guard let url = components.url else { throw MonitorError.invalidResponse } + NSWorkspace.shared.open(url) + await poll(credentials) + } catch { self.error = error.localizedDescription } + } + } + + func disconnect() { + task?.cancel() + guard let credentials = load() else { return } + task = Task { + do { + _ = try await request("disconnect", credentials: credentials, method: "POST") + deleteCredentials() + connected = false; deployments = []; tracker = DeploymentTracker(); error = nil + UserDefaults.standard.removeObject(forKey: "vercelTracker") + } catch { self.error = error.localizedDescription } + } + } + + private func poll(_ credentials: Credentials) async { + if let data = UserDefaults.standard.data(forKey: "vercelTracker"), let saved = try? JSONDecoder().decode(DeploymentTracker.self, from: data) { tracker = saved } + while !Task.isCancelled { + do { + let status = try JSONDecoder().decode(ConnectionStatus.self, from: try await request("status", credentials: credentials)) + connected = status.status == "connected" + if connected { + var items: [Deployment] = [] + var cursor: Int64? + repeat { + let data = try await request("deployments", credentials: credentials, until: cursor) + let page = try JSONDecoder().decode(DeploymentPage.self, from: data) + items.append(contentsOf: page.deployments.map(\.deployment)) + cursor = try page.nextPage(after: cursor) + } while cursor != nil + var seen = Set() + items = items.filter { seen.insert($0.id).inserted } + deployments = items + let completed = tracker.update(items) + for deployment in completed { await notify(deployment) } + if let data = try? JSONEncoder().encode(tracker) { UserDefaults.standard.set(data, forKey: "vercelTracker") } + } + error = nil + } catch MonitorError.expired { + deleteCredentials(); connected = false; self.error = MonitorError.expired.localizedDescription + return + } catch { connected = false; self.error = error.localizedDescription } + try? await Task.sleep(for: .seconds(30)) + } + } + + private func notify(_ deployment: Deployment) async { + guard Bundle.main.bundleURL.pathExtension == "app" else { return } + let center = UNUserNotificationCenter.current() + guard (try? await center.requestAuthorization(options: [.alert, .sound])) == 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: " · ") + try? await center.add(UNNotificationRequest(identifier: "vercel-\(deployment.id)", content: content, trigger: nil)) + } + + private func request(_ path: String, credentials: Credentials, method: String = "GET", headers: [String: String] = [:], until: Int64? = nil) async throws -> Data { + guard let worker, var components = URLComponents(url: worker.appendingPathComponent(path), resolvingAgainstBaseURL: false) else { throw MonitorError.invalidResponse } + components.queryItems = [URLQueryItem(name: "state", value: credentials.state)] + if let until { components.queryItems?.append(URLQueryItem(name: "until", value: String(until))) } + guard let url = components.url else { throw MonitorError.invalidResponse } + var request = URLRequest(url: url) + request.httpMethod = method + request.setValue("Bearer \(credentials.verifier)", forHTTPHeaderField: "Authorization") + for (key, value) in headers { request.setValue(value, forHTTPHeaderField: key) } + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { throw MonitorError.requestFailed } + if http.statusCode == 401 || http.statusCode == 410 { throw MonitorError.expired } + guard (200..<300).contains(http.statusCode) else { throw MonitorError.requestFailed } + return data + } + + private struct Credentials: Codable { let state: String; let verifier: String } + private struct ConnectionStatus: Decodable { let status: String } + private enum MonitorError: LocalizedError { + case invalidResponse, requestFailed, keychain, random, expired + var errorDescription: String? { + switch self { case .invalidResponse: return "Invalid Vercel response."; case .requestFailed: return "Could not connect to Vercel."; case .keychain: return "Could not access Keychain."; case .random: return "Could not create secure credentials."; case .expired: return "Vercel connection expired. Please reconnect." } + } + } + private func randomHex() throws -> String { + var bytes = [UInt8](repeating: 0, count: 32) + guard SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) == errSecSuccess else { throw MonitorError.random } + return bytes.map { String(format: "%02x", $0) }.joined() + } + private var query: [String: Any] { [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: "connection"] } + private func save(_ credentials: Credentials) throws { + let data = try JSONEncoder().encode(credentials) + let status = SecItemUpdate(query as CFDictionary, [kSecValueData as String: data] as CFDictionary) + if status == errSecItemNotFound { + var item = query; item[kSecValueData as String] = data; item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + guard SecItemAdd(item as CFDictionary, nil) == errSecSuccess else { throw MonitorError.keychain } + } else if status != errSecSuccess { throw MonitorError.keychain } + } + private func load() -> Credentials? { + var item = query; item[kSecReturnData as String] = true + var result: CFTypeRef? + guard SecItemCopyMatching(item as CFDictionary, &result) == errSecSuccess, let data = result as? Data else { return nil } + return try? JSONDecoder().decode(Credentials.self, from: data) + } + private func deleteCredentials() { SecItemDelete(query as CFDictionary) } +} + +struct DeploymentPage: Decodable { + enum PaginationError: Error { case repeatedCursor } + struct Page: Decodable { let next: Int64? } + struct Item: Decodable { + let uid: String + let name: String + let state: String + let meta: [String: String]? + private enum CodingKeys: String, CodingKey { case uid, name, state, meta } + private struct MetaKey: CodingKey { + let stringValue: String + let intValue: Int? = nil + init?(stringValue: String) { self.stringValue = stringValue } + init?(intValue: Int) { return nil } + } + + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + uid = try values.decode(String.self, forKey: .uid) + name = try values.decode(String.self, forKey: .name) + state = try values.decode(String.self, forKey: .state) + if let metadata = try? values.nestedContainer(keyedBy: MetaKey.self, forKey: .meta) { + meta = Dictionary(uniqueKeysWithValues: metadata.allKeys.compactMap { key in + (try? metadata.decode(String.self, forKey: key)).map { (key.stringValue, $0) } + }) + } else { meta = nil } + } + var deployment: Deployment { + let status: Deployment.Status + switch state { case "QUEUED", "INITIALIZING": status = .pending; case "BUILDING": status = .building; case "READY": status = .ready; case "ERROR": status = .failed; case "CANCELED": status = .canceled; default: status = .unknown } + return Deployment(id: uid, project: name, repository: meta?["githubRepo"] ?? meta?["gitlabProjectName"] ?? meta?["bitbucketRepoName"], commit: meta?["githubCommitSha"] ?? meta?["gitlabCommitSha"] ?? meta?["bitbucketCommitSha"], author: meta?["githubCommitAuthorName"] ?? meta?["gitlabCommitAuthorName"] ?? meta?["bitbucketCommitAuthorName"], status: status) + } + } + let deployments: [Item] + let pagination: Page? + + func nextPage(after cursor: Int64?) throws -> Int64? { + let next = pagination?.next + if let next, next == cursor { throw PaginationError.repeatedCursor } + return next + } +} diff --git a/Sources/AgentNotch/Resources/Mascots/vercel.png b/Sources/AgentNotch/Resources/Mascots/vercel.png new file mode 100644 index 0000000000000000000000000000000000000000..4e5c481ded3f13c2c6195128589dd56944eae9d6 GIT binary patch literal 13828 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.png`: sleep, work, alert, done, work-check...) falls back to its /// parent (`work-check` -> `work`), then to the base sprite, when missing. diff --git a/packaging/locales/de.lproj/Localizable.strings b/packaging/locales/de.lproj/Localizable.strings index de40459..88dbf26 100644 --- a/packaging/locales/de.lproj/Localizable.strings +++ b/packaging/locales/de.lproj/Localizable.strings @@ -77,3 +77,14 @@ "Agent Notch will restart to apply the language. Pending approvals go back to the terminal, and sessions reappear with their next activity." = "Agent Notch wird neu gestartet, um die Sprache zu übernehmen. Ausstehende Freigaben gehen an das Terminal zurück, und Sitzungen erscheinen bei ihrer nächsten Aktivität wieder."; "Restart" = "Neu starten"; "Cancel" = "Abbrechen"; + +"Connect Vercel…" = "Vercel verbinden…"; +"Disconnect Vercel" = "Vercel trennen"; +"Disconnect Vercel?" = "Vercel trennen?"; +"This removes the connection from Agent Notch. To revoke its access, also uninstall the integration in Vercel." = "Dies entfernt die Verbindung aus Agent Notch. Um den Zugriff zu widerrufen, deinstallieren Sie auch die Integration in Vercel."; +"Disconnect" = "Trennen"; +"Queued" = "In Warteschlange"; +"Building" = "Wird erstellt"; +"Ready" = "Bereit"; +"Canceled" = "Abgebrochen"; +"Unknown" = "Unbekannt"; diff --git a/packaging/locales/en.lproj/Localizable.strings b/packaging/locales/en.lproj/Localizable.strings index fbb84f9..2421a2d 100644 --- a/packaging/locales/en.lproj/Localizable.strings +++ b/packaging/locales/en.lproj/Localizable.strings @@ -77,3 +77,14 @@ "Click to open folder" = "Click to open folder"; "Click to open terminal" = "Click to open terminal"; "+%ld more" = "+%ld more"; + +"Connect Vercel…" = "Connect Vercel…"; +"Disconnect Vercel" = "Disconnect Vercel"; +"Disconnect Vercel?" = "Disconnect Vercel?"; +"This removes the connection from Agent Notch. To revoke its access, also uninstall the integration in Vercel." = "This removes the connection from Agent Notch. To revoke its access, also uninstall the integration in Vercel."; +"Disconnect" = "Disconnect"; +"Queued" = "Queued"; +"Building" = "Building"; +"Ready" = "Ready"; +"Canceled" = "Canceled"; +"Unknown" = "Unknown"; diff --git a/packaging/locales/es.lproj/Localizable.strings b/packaging/locales/es.lproj/Localizable.strings index 4074e7b..1f5b4f8 100644 --- a/packaging/locales/es.lproj/Localizable.strings +++ b/packaging/locales/es.lproj/Localizable.strings @@ -77,3 +77,14 @@ "Agent Notch will restart to apply the language. Pending approvals go back to the terminal, and sessions reappear with their next activity." = "Agent Notch se reiniciará para aplicar el idioma. Las aprobaciones pendientes vuelven a la terminal y las sesiones reaparecen con su siguiente actividad."; "Restart" = "Reiniciar"; "Cancel" = "Cancelar"; + +"Connect Vercel…" = "Conectar Vercel…"; +"Disconnect Vercel" = "Desconectar Vercel"; +"Disconnect Vercel?" = "¿Desconectar Vercel?"; +"This removes the connection from Agent Notch. To revoke its access, also uninstall the integration in Vercel." = "Esto elimina la conexión de Agent Notch. Para revocar su acceso, también desinstala la integración en Vercel."; +"Disconnect" = "Desconectar"; +"Queued" = "En cola"; +"Building" = "Compilando"; +"Ready" = "Listo"; +"Canceled" = "Cancelado"; +"Unknown" = "Desconocido"; From 3ede12c935a7d6518d6cb8107142f71387fea721 Mon Sep 17 00:00:00 2001 From: Anthoni Rivera Date: Tue, 3 Nov 2026 22:53:50 -0500 Subject: [PATCH 2/6] feat: show deployments in the collapsed notch Replace the collapsed step strip with a live deployment row (project, state and an indeterminate indicator) while a Vercel deployment runs, list deployments in the expanded session list, and size the expanded panel from the current notch geometry instead of a fixed width. StepStrip is removed; per-step progress stays in the expanded StepList. --- .../Infrastructure/NotchPanel.swift | 22 ++++--- Sources/AgentNotch/UI/AccentLine.swift | 21 +++++++ Sources/AgentNotch/UI/NotchRootView.swift | 58 +++++++++++++------ Sources/AgentNotch/UI/NotchViewModel.swift | 4 +- Sources/AgentNotch/UI/SessionListView.swift | 14 ++++- Sources/AgentNotch/UI/StepViews.swift | 43 -------------- 6 files changed, 91 insertions(+), 71 deletions(-) diff --git a/Sources/AgentNotch/Infrastructure/NotchPanel.swift b/Sources/AgentNotch/Infrastructure/NotchPanel.swift index 12e45f8..6cc59ec 100644 --- a/Sources/AgentNotch/Infrastructure/NotchPanel.swift +++ b/Sources/AgentNotch/Infrastructure/NotchPanel.swift @@ -31,14 +31,16 @@ final class NotchPanel: NSPanel { final class NotchPanelController { private let store: SessionStore private let viewModel: NotchViewModel + private let vercel: VercelMonitor private var panel: NotchPanel? private var pollTimer: Timer? private var collapseTask: Task? private var screenObserver: NSObjectProtocol? - init(store: SessionStore, viewModel: NotchViewModel) { + init(store: SessionStore, viewModel: NotchViewModel, vercel: VercelMonitor) { self.store = store self.viewModel = viewModel + self.vercel = vercel } func show() { @@ -60,7 +62,7 @@ final class NotchPanelController { func reposition() { let geometry = NotchGeometry.current() viewModel.geometry = geometry - let size = NotchLayout.panelSize + let size = NotchLayout.panelSize(for: geometry) let frame = NSRect(x: geometry.screenFrame.midX - size.width / 2, y: geometry.screenFrame.maxY - size.height, width: size.width, height: size.height) @@ -68,7 +70,7 @@ final class NotchPanelController { panel.setFrame(frame, display: true) } else { let panel = NotchPanel(frame: frame) - let hosting = NSHostingView(rootView: NotchRootView(store: store, viewModel: viewModel)) + let hosting = NSHostingView(rootView: NotchRootView(store: store, viewModel: viewModel, vercel: vercel)) hosting.sizingOptions = [] hosting.frame = NSRect(origin: .zero, size: size) hosting.autoresizingMask = [.width, .height] @@ -77,15 +79,21 @@ final class NotchPanelController { } } + static func trackingRect(geometry: NotchGeometry, shape: CGSize, offsetX: CGFloat, expanded: Bool) -> NSRect { + let height = expanded ? shape.height + 6 : min(shape.height, geometry.notchHeight) + let slack: CGFloat = expanded ? 6 : 0 + return NSRect(x: geometry.screenFrame.midX + offsetX - shape.width / 2 - slack, + y: geometry.screenFrame.maxY - height, + width: shape.width + 2 * slack, height: height + 20) + } + /// Mouse tracking by polling: works while the panel ignores mouse events, needs no permissions. private func pollMouse() { guard let panel else { return } let g = viewModel.geometry let shape = viewModel.shapeSize - // Visible shape, top-centered, with a little slack (and extra above the screen edge). - let rect = NSRect(x: g.screenFrame.midX + viewModel.shapeOffsetX - shape.width / 2 - 6, - y: g.screenFrame.maxY - shape.height - 6, - width: shape.width + 12, height: shape.height + 26) + let rect = Self.trackingRect(geometry: g, shape: shape, offsetX: viewModel.shapeOffsetX, + expanded: viewModel.hovering || !store.approvals.isEmpty) let inside = rect.contains(NSEvent.mouseLocation) if panel.ignoresMouseEvents == inside { panel.ignoresMouseEvents = !inside } diff --git a/Sources/AgentNotch/UI/AccentLine.swift b/Sources/AgentNotch/UI/AccentLine.swift index b20cb24..1c8eee1 100644 --- a/Sources/AgentNotch/UI/AccentLine.swift +++ b/Sources/AgentNotch/UI/AccentLine.swift @@ -24,3 +24,24 @@ struct AccentLine: View { } } } + +/// An activity indicator, not a percentage of a deployment. +struct DeploymentProgress: View { + let color: Color + let reduceMotion: Bool + + var body: some View { + TimelineView(.animation(paused: reduceMotion)) { context in + let phase = reduceMotion ? 0.5 : context.date.timeIntervalSinceReferenceDate.truncatingRemainder(dividingBy: 1.8) / 1.8 + GeometryReader { geometry in + Capsule().fill(color.opacity(0.2)) + .overlay(alignment: .leading) { + Capsule().fill(color) + .frame(width: geometry.size.width * 0.35) + .offset(x: geometry.size.width * (phase * 1.35 - 0.35)) + } + .clipShape(Capsule()) + } + } + } +} diff --git a/Sources/AgentNotch/UI/NotchRootView.swift b/Sources/AgentNotch/UI/NotchRootView.swift index aa99c65..92e8656 100644 --- a/Sources/AgentNotch/UI/NotchRootView.swift +++ b/Sources/AgentNotch/UI/NotchRootView.swift @@ -3,6 +3,7 @@ import SwiftUI struct NotchRootView: View { @ObservedObject var store: SessionStore @ObservedObject var viewModel: NotchViewModel + @ObservedObject var vercel: VercelMonitor private var expanded: Bool { viewModel.hovering || !store.approvals.isEmpty } private var activeSessions: [AgentSession] { store.sessions.values.filter { $0.status.isActive } } @@ -16,19 +17,13 @@ struct NotchRootView: View { } /// True when nothing is happening: the ear shows one dimmed, breathing mascot instead of going dark. private var isIdle: Bool { - activeAgents.isEmpty && store.doneFlash == nil && store.approvals.isEmpty && store.peek == nil + activeAgents.isEmpty && store.doneFlash == nil && store.approvals.isEmpty && store.peek == nil && !vercel.active } private var showsIdleMascot: Bool { Preferences.showIdleMascot } /// Start of the longest-running turn, for the timer in the right ear. private var longestTurnStart: Date? { store.sessions.values.filter { $0.status.isWorking }.compactMap(\.turnStartedAt).min() } - /// Session whose task list is shown under the collapsed notch: the latest one still working through steps. - private var stepSession: AgentSession? { - store.sessions.values - .filter { $0.status.isActive && $0.steps.contains { $0.state != .done } } - .max { $0.updatedAt < $1.updatedAt } - } /// Most recently used agent (Claude before any session exists). private var idleAgent: AgentKind { store.sessions.values.max { $0.updatedAt < $1.updatedAt }?.agent ?? .claude @@ -46,7 +41,7 @@ struct NotchRootView: View { /// Full ears share one width so the notch cut-out stays centered. In idle only the left ear exists /// (smaller) and the whole shape shifts left, so it never covers menu bar icons right of the notch. private var leftEarWidth: CGFloat { isIdle ? (showsIdleMascot ? mascotSize + 18 : 0) : earWidth } - private var rightEarWidth: CGFloat { isIdle ? 0 : earWidth } + private var rightEarWidth: CGFloat { isIdle ? 0 : max(earWidth, vercel.active ? 140 : 0) } private var shiftX: CGFloat { expanded ? 0 : (rightEarWidth - leftEarWidth) / 2 } private var earWidth: CGFloat { let count = CGFloat(max(earAgents.count, 1)) @@ -79,7 +74,7 @@ struct NotchRootView: View { let notchH = viewModel.geometry.notchHeight let top = NotchLayout.topRadius let bodyWidth = expanded - ? NotchLayout.expandedWidth + ? min(max(NotchLayout.expandedWidth, notchW), NotchLayout.panelSize(for: viewModel.geometry).width - 2 * top) : notchW + leftEarWidth + rightEarWidth VStack(spacing: 0) { @@ -93,7 +88,7 @@ struct NotchRootView: View { .background(NotchShape(topRadius: top, bottomRadius: expanded ? 24 : 10).fill(.black)) // Overlay on the animated shape (not a child of the content), so the line follows the spring and never detaches. .overlay(alignment: .bottom) { - if !expanded, !workingAgents.isEmpty { + if !expanded && !workingAgents.isEmpty { AccentLine(colors: workingAgents.map(\.color), reduceMotion: viewModel.reduceMotion) .padding(.horizontal, 12 + top) .padding(.bottom, 1) @@ -109,12 +104,11 @@ struct NotchRootView: View { .animation(spring, value: chin) .animation(spring, value: store.approvals.count) .animation(spring, value: store.peek) - .animation(spring, value: stepSession?.steps) .animation(spring, value: viewModel.hoveredSession) .animation(spring, value: store.sessions.count) Spacer(minLength: 0) } - .frame(width: NotchLayout.panelSize.width, height: NotchLayout.panelSize.height, alignment: .top) + .frame(width: NotchLayout.panelSize(for: viewModel.geometry).width, height: NotchLayout.panelSize(for: viewModel.geometry).height, alignment: .top) .environment(\.colorScheme, .dark) } @@ -144,11 +138,7 @@ struct NotchRootView: View { } .frame(height: notchH) - // The hardware notch hides its own height, so the peek (or the step strip) lives below it. - if store.peek == nil, let session = stepSession { - StepStrip(steps: session.steps, color: session.agent.color, reduceMotion: viewModel.reduceMotion) - .transition(.opacity) - } + // The hardware notch hides its own height, so the peek lives below it. if let peek = store.peek { Text(peek.text) .font(.system(size: 11)) @@ -178,6 +168,38 @@ struct NotchRootView: View { Image(systemName: "checkmark") .font(.system(size: 11, weight: .bold)) .foregroundStyle(agent.color) + } else if vercel.active, let deployment = vercel.deployments.first(where: { $0.status == .building }) + ?? vercel.deployments.first(where: { $0.status == .pending }) { + let accent = deployment.status == .building ? Color(hex: 0x72DCD0) : Color(hex: 0xF4C377) + HStack(spacing: 5) { + if let mascot = MascotSprites.vercel { + Image(nsImage: mascot) + .resizable().interpolation(.high).scaledToFit() + .frame(width: 24, height: 24) + .accessibilityHidden(true) + } else { + Image(systemName: "triangle.fill") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.white.opacity(0.9)) + } + VStack(alignment: .leading, spacing: 1) { + HStack(spacing: 4) { + Text("Vercel").foregroundStyle(.white.opacity(0.9)) + Text(deployment.status.label).foregroundStyle(accent).lineLimit(1) + } + .font(.system(size: 9, weight: .semibold)) + Text(deployment.project) + .font(.system(size: 9)) + .foregroundStyle(.white.opacity(0.75)) + .lineLimit(1) + DeploymentProgress(color: accent, reduceMotion: viewModel.reduceMotion) + .frame(width: 82, height: 2) + .accessibilityHidden(true) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 6) + .accessibilityElement(children: .combine) } else if let start = longestTurnStart { TimelineView(.periodic(from: .now, by: 1)) { context in Text(Formatting.clock(context.date.timeIntervalSince(start))) @@ -200,7 +222,7 @@ struct NotchRootView: View { } .id(request.id) } else { - SessionListView(sessions: Array(store.sessions.values), reduceMotion: viewModel.reduceMotion, + SessionListView(sessions: Array(store.sessions.values), deployments: vercel.deployments, reduceMotion: viewModel.reduceMotion, stats: store.todayStats, hovered: $viewModel.hoveredSession, contentHeight: $viewModel.listContentHeight) } diff --git a/Sources/AgentNotch/UI/NotchViewModel.swift b/Sources/AgentNotch/UI/NotchViewModel.swift index 8012a9a..752d1b7 100644 --- a/Sources/AgentNotch/UI/NotchViewModel.swift +++ b/Sources/AgentNotch/UI/NotchViewModel.swift @@ -3,7 +3,9 @@ import SwiftUI enum NotchLayout { /// Panel is big enough for the largest expanded state. - static let panelSize = CGSize(width: 480, height: 540) + static func panelSize(for geometry: NotchGeometry) -> CGSize { + CGSize(width: min(max(480, geometry.notchWidth + 230), max(geometry.screenFrame.width, 1)), height: 540) + } static let expandedWidth: CGFloat = 420 static let earWidth: CGFloat = 34 static let topRadius: CGFloat = 8 diff --git a/Sources/AgentNotch/UI/SessionListView.swift b/Sources/AgentNotch/UI/SessionListView.swift index 53e59bc..c2908b4 100644 --- a/Sources/AgentNotch/UI/SessionListView.swift +++ b/Sources/AgentNotch/UI/SessionListView.swift @@ -3,6 +3,7 @@ import SwiftUI struct SessionListView: View { let sessions: [AgentSession] + let deployments: [Deployment] let reduceMotion: Bool /// Today's totals per agent (nil until the agent finishes a turn). let stats: (AgentKind) -> DayStats? @@ -33,7 +34,7 @@ struct SessionListView: View { if contentHeight > 0 { return min(260, contentHeight) } // shortcut: first-frame estimate until the content is measured. let stepRows = sessions.reduce(0) { $0 + (StepList.rows(for: $1.steps)) } - return min(260, CGFloat(groups.count) * 42 + CGFloat(sessions.count) * 54 + CGFloat(stepRows) * 17) + return min(260, CGFloat(groups.count) * 42 + CGFloat(sessions.count) * 54 + CGFloat(stepRows) * 17 + (deployments.isEmpty ? 0 : 24 + CGFloat(deployments.count) * 17)) } private func mood(for items: [AgentSession]) -> MascotMood { @@ -59,7 +60,7 @@ struct SessionListView: View { } var body: some View { - if sessions.isEmpty { + if sessions.isEmpty && deployments.isEmpty { VStack(spacing: 6) { Text(L("No active agents")) .font(.system(size: 12)) @@ -80,6 +81,15 @@ struct SessionListView: View { VStack(alignment: .leading, spacing: 8) { ScrollView(.vertical, showsIndicators: false) { VStack(alignment: .leading, spacing: 10) { + if !deployments.isEmpty { + VStack(alignment: .leading, spacing: 4) { + Text("VERCEL").font(.system(size: 10, weight: .semibold)) + ForEach(deployments) { deployment in + Text(([deployment.project, deployment.status.label] + [deployment.repository, deployment.commit.map { String($0.prefix(7)) }, deployment.author].compactMap { $0 }).joined(separator: " · ")) + .font(.system(size: 10)).lineLimit(1) + } + } + } ForEach(groups, id: \.0) { agent, items in VStack(alignment: .leading, spacing: 4) { HStack(spacing: 6) { diff --git a/Sources/AgentNotch/UI/StepViews.swift b/Sources/AgentNotch/UI/StepViews.swift index 14c546c..9bfb21d 100644 --- a/Sources/AgentNotch/UI/StepViews.swift +++ b/Sources/AgentNotch/UI/StepViews.swift @@ -30,49 +30,6 @@ struct StepIcon: View { } } -/// Horizontal progress for the collapsed notch: one icon per step, the active step's title, and "2/5". -struct StepStrip: View { - let steps: [Step] - let color: Color - let reduceMotion: Bool - - private static let maxIcons = 10 - - private var done: Int { steps.filter { $0.state == .done }.count } - private var current: Step? { steps.first { $0.state == .running } ?? steps.first { $0.state == .pending } } - - /// With more steps than fit, slide a window that keeps the active step in view. - private var visible: [Step] { - guard steps.count > Self.maxIcons else { return steps } - let at = steps.firstIndex { $0.state == .running } ?? steps.firstIndex { $0.state == .pending } ?? steps.count - 1 - let start = min(max(0, at - 3), steps.count - Self.maxIcons) - return Array(steps[start.. Date: Tue, 3 Nov 2026 22:54:01 -0500 Subject: [PATCH 3/6] test: cover deployment tracking, layout and Vercel simulation Unit tests for authentication state, pagination and rate-limit recovery, unknown states, missing git metadata and status transitions, a fake Vercel simulation, and the geometry-derived panel width. Adapt the session-list test to the new deployments parameter. --- .../DeploymentTrackerTests.swift | 31 +++++++++++++++++++ Tests/AgentNotchTests/NotchLayoutTests.swift | 30 ++++++++++++++++++ .../SessionListViewTests.swift | 2 +- .../VercelDeploymentTests.swift | 30 ++++++++++++++++++ .../VercelSimulationTests.swift | 20 ++++++++++++ 5 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 Tests/AgentNotchTests/DeploymentTrackerTests.swift create mode 100644 Tests/AgentNotchTests/NotchLayoutTests.swift create mode 100644 Tests/AgentNotchTests/VercelDeploymentTests.swift create mode 100644 Tests/AgentNotchTests/VercelSimulationTests.swift diff --git a/Tests/AgentNotchTests/DeploymentTrackerTests.swift b/Tests/AgentNotchTests/DeploymentTrackerTests.swift new file mode 100644 index 0000000..2a58dde --- /dev/null +++ b/Tests/AgentNotchTests/DeploymentTrackerTests.swift @@ -0,0 +1,31 @@ +import XCTest +@testable import AgentNotch + +final class DeploymentTrackerTests: XCTestCase { + private func deployment(_ id: String, _ status: Deployment.Status) -> Deployment { + Deployment(id: id, project: "project", repository: nil, commit: nil, author: nil, status: status) + } + + func testOnlyNewTerminalTransitionsNotify() { + var tracker = DeploymentTracker() + XCTAssertTrue(tracker.update([deployment("old", .ready), deployment("active", .building)]).isEmpty) + XCTAssertEqual(tracker.update([deployment("old", .ready), deployment("active", .ready)]).map(\.id), ["active"]) + XCTAssertTrue(tracker.update([deployment("active", .ready)]).isEmpty) + XCTAssertEqual(tracker.update([deployment("new", .failed)]).map(\.id), ["new"]) + } + + func testRestartAndStalePollDoNotRepeatNotifications() throws { + var tracker = DeploymentTracker() + _ = tracker.update([deployment("deploy", .building)]) + XCTAssertEqual(tracker.update([deployment("deploy", .ready)]).count, 1) + tracker = try JSONDecoder().decode(DeploymentTracker.self, from: JSONEncoder().encode(tracker)) + XCTAssertTrue(tracker.update([deployment("deploy", .building)]).isEmpty) + XCTAssertTrue(tracker.update([deployment("deploy", .ready)]).isEmpty) + } + + func testDuplicateItemsNotifyOnlyOnce() { + var tracker = DeploymentTracker() + _ = tracker.update([]) + XCTAssertEqual(tracker.update([deployment("a", .ready), deployment("a", .ready)]).count, 1) + } +} diff --git a/Tests/AgentNotchTests/NotchLayoutTests.swift b/Tests/AgentNotchTests/NotchLayoutTests.swift new file mode 100644 index 0000000..e8bfe45 --- /dev/null +++ b/Tests/AgentNotchTests/NotchLayoutTests.swift @@ -0,0 +1,30 @@ +import XCTest +@testable import AgentNotch + +final class NotchLayoutTests: XCTestCase { + func testPanelWidthFitsNotchAndScreen() { + func size(screen: CGFloat, notch: CGFloat) -> CGSize { + NotchLayout.panelSize(for: NotchGeometry( + screenFrame: CGRect(x: 0, y: 0, width: screen, height: 900), + notchWidth: notch, notchHeight: 32)) + } + + XCTAssertEqual(size(screen: 1440, notch: 200).width, 480) + XCTAssertEqual(size(screen: 1440, notch: 500).width, 730) + XCTAssertEqual(size(screen: 600, notch: 500).width, 600) + } + + @MainActor + func testCollapsedTrackingDoesNotReachBrowserTabs() { + let geometry = NotchGeometry(screenFrame: CGRect(x: 0, y: 0, width: 1440, height: 900), + notchWidth: 200, notchHeight: 32) + let shape = CGSize(width: 280, height: 36) + let collapsed = NotchPanelController.trackingRect(geometry: geometry, shape: shape, offsetX: 0, expanded: false) + XCTAssertTrue(collapsed.contains(CGPoint(x: 720, y: 869))) + XCTAssertFalse(collapsed.contains(CGPoint(x: 720, y: 867))) + + let expanded = NotchPanelController.trackingRect(geometry: geometry, shape: CGSize(width: 420, height: 300), + offsetX: 0, expanded: true) + XCTAssertTrue(expanded.contains(CGPoint(x: 720, y: 650))) + } +} diff --git a/Tests/AgentNotchTests/SessionListViewTests.swift b/Tests/AgentNotchTests/SessionListViewTests.swift index 6080e59..a2bb702 100644 --- a/Tests/AgentNotchTests/SessionListViewTests.swift +++ b/Tests/AgentNotchTests/SessionListViewTests.swift @@ -11,7 +11,7 @@ struct SessionListViewTests { } private func view(_ sessions: [AgentSession]) -> SessionListView { - SessionListView(sessions: sessions, reduceMotion: true, stats: { _ in nil }, + SessionListView(sessions: sessions, deployments: [], reduceMotion: true, stats: { _ in nil }, hovered: .constant(nil), contentHeight: .constant(0)) } diff --git a/Tests/AgentNotchTests/VercelDeploymentTests.swift b/Tests/AgentNotchTests/VercelDeploymentTests.swift new file mode 100644 index 0000000..c9df3dd --- /dev/null +++ b/Tests/AgentNotchTests/VercelDeploymentTests.swift @@ -0,0 +1,30 @@ +import XCTest +@testable import AgentNotch + +final class VercelDeploymentTests: XCTestCase { + func testDecodesDeploymentMetadataAndState() throws { + let json = #"{"deployments":[{"uid":"dpl_1","name":"app","state":"BUILDING","meta":{"githubRepo":"example","githubCommitSha":"abcdef123","githubCommitAuthorName":"Ada"}}],"pagination":{"next":null}}"# + let page = try JSONDecoder().decode(DeploymentPage.self, from: Data(json.utf8)) + let deployment = try XCTUnwrap(page.deployments.first?.deployment) + XCTAssertEqual(deployment.status, .building) + XCTAssertEqual(deployment.repository, "example") + XCTAssertEqual(deployment.author, "Ada") + XCTAssertEqual(deployment.commit, "abcdef123") + } + + func testUnknownStateAndMissingMetadata() throws { + let json = #"{"deployments":[{"uid":"dpl_2","name":"app","state":"BLOCKED"}]}"# + let page = try JSONDecoder().decode(DeploymentPage.self, from: Data(json.utf8)) + XCTAssertEqual(page.deployments.first?.deployment.status, .unknown) + XCTAssertNil(page.deployments.first?.deployment.author) + } + + func testPaginationAcceptsLastPageAndRejectsRepeatedCursor() throws { + let last = try JSONDecoder().decode(DeploymentPage.self, from: Data(#"{"deployments":[],"pagination":{"next":null}}"#.utf8)) + XCTAssertNil(try last.nextPage(after: nil)) + XCTAssertNil(try last.nextPage(after: 123)) + let next = try JSONDecoder().decode(DeploymentPage.self, from: Data(#"{"deployments":[],"pagination":{"next":123}}"#.utf8)) + XCTAssertEqual(try next.nextPage(after: nil), 123) + XCTAssertThrowsError(try next.nextPage(after: 123)) + } +} diff --git a/Tests/AgentNotchTests/VercelSimulationTests.swift b/Tests/AgentNotchTests/VercelSimulationTests.swift new file mode 100644 index 0000000..369c4e6 --- /dev/null +++ b/Tests/AgentNotchTests/VercelSimulationTests.swift @@ -0,0 +1,20 @@ +import XCTest +@testable import AgentNotch + +@MainActor +final class VercelSimulationTests: XCTestCase { + func testSimulationMovesThroughBuildingAndCompletion() { + let monitor = VercelMonitor() + monitor.simulate(.building) + XCTAssertTrue(monitor.active) + XCTAssertEqual(monitor.deployments.first?.status, .building) + monitor.simulate(.ready) + XCTAssertFalse(monitor.active) + XCTAssertEqual(monitor.deployments.first?.status, .ready) + XCTAssertEqual(monitor.deployments.first?.repository, "example/repo") + monitor.simulate(.building) + monitor.simulate(.failed) + XCTAssertFalse(monitor.active) + XCTAssertEqual(monitor.deployments.first?.status, .failed) + } +} From b5f10246ccc38750fadbbeb2e339bc0aed1e3e83 Mon Sep 17 00:00:00 2001 From: Anthoni Rivera Date: Tue, 3 Nov 2026 22:54:03 -0500 Subject: [PATCH 4/6] chore: generate the Vercel mascot sprite Add a Vercel prompt to the mascot generator, a --base-only mode, and an optional agent argument for the processing step so a single sprite can be regenerated without touching the others. --- scripts/gen-mascots.sh | 4 ++++ scripts/process-mascots.py | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/scripts/gen-mascots.sh b/scripts/gen-mascots.sh index 059572b..b106cc5 100755 --- a/scripts/gen-mascots.sh +++ b/scripts/gen-mascots.sh @@ -6,6 +6,7 @@ # scripts/gen-mascots.sh --sleep-only claude # one agent # scripts/gen-mascots.sh --poses [agent] # work / alert / done variants of the base image # scripts/gen-mascots.sh codex # base image + sleeping variant (overwrites base) +# scripts/gen-mascots.sh --base-only vercel # generate one base image # APIMART_KEYCHAIN_SERVICE=my-item scripts/gen-mascots.sh ... # # Output (transparent PNG): Sources/AgentNotch/Resources/mascots-raw/.png and -sleep.png @@ -48,6 +49,7 @@ prompt_for() { claude) echo "A small friendly orange creature with two short arms and little legs, warm terracotta color, simple dot eyes. $STYLE" ;; codex) echo "A small friendly purple robot with a screen face showing a terminal prompt >_ and tiny antenna. $STYLE" ;; gentle) echo "A small friendly rose character: a blooming rose head in deep magenta-pink petals with bright neon-pink highlights and a calm closed-smile face, a short green stem body, two small dark-green leaves as arms and two leaf feet, a neon pink glow along the outline like a futuristic rose. $STYLE" ;; + vercel) echo "A small friendly deployment mascot inspired by a black and white triangular mountain: a rounded charcoal triangular body with a white triangle face, expressive dot eyes, tiny arms and feet, and restrained mint-teal highlights. Clearly readable at tiny size and matching a family of cute pixel-art coding-agent mascots. $STYLE" ;; *) echo "unknown agent: $1" >&2; return 1 ;; esac } @@ -107,6 +109,7 @@ generate_variant() { # Modes: (default) base + sleep | --sleep-only | --poses (work, alert, done) | --idle (blink, drowsy) | --variant [agent] (also work-explore|think|check|close) mode=base case "${1:-}" in + --base-only) mode=base-only; shift ;; --sleep-only) mode=sleep; shift ;; --poses) mode=poses; shift ;; --idle) mode=idle; shift ;; @@ -118,6 +121,7 @@ agents=("$@") for a in "${agents[@]}"; do case "$mode" in base) generate_base "$a"; generate_variant "$a" sleep ;; + base-only) generate_base "$a" ;; sleep) generate_variant "$a" sleep ;; poses) for v in work alert done; do generate_variant "$a" "$v"; done ;; idle) for v in blink drowsy; do generate_variant "$a" "$v"; done ;; diff --git a/scripts/process-mascots.py b/scripts/process-mascots.py index 937d127..f744d4e 100644 --- a/scripts/process-mascots.py +++ b/scripts/process-mascots.py @@ -8,6 +8,7 @@ position when it switches between moods. """ from pathlib import Path +import sys from PIL import Image ROOT = Path(__file__).resolve().parent.parent / "Sources/AgentNotch/Resources" @@ -28,7 +29,9 @@ def clean(path: Path) -> Image.Image: def main() -> None: OUT.mkdir(parents=True, exist_ok=True) - for agent in AGENTS: + for agent in sys.argv[1:] or AGENTS: + if agent not in [*AGENTS, "vercel"]: + raise SystemExit(f"Unknown mascot: {agent}") names = [agent] + [f"{agent}-{v}" for v in VARIANTS] variants = {n: clean(RAW / f"{n}.png") for n in names if (RAW / f"{n}.png").exists()} if not variants: From 82aff57c09472af5d304e11fd85c5b60122bedd8 Mon Sep 17 00:00:00 2001 From: Anthoni Rivera Date: Tue, 3 Nov 2026 22:54:03 -0500 Subject: [PATCH 5/6] docs: add the Vercel monitoring plan and worker Record the integration design, the security requirements that still gate it (authorization-code exchange, one-time state, revocation), the manual verification needed, and the unreleased Cloudflare Worker that holds the client secret, with its own tests and wrangler config. --- docs/vercel-deployment-monitoring.md | 24 +++++ integrations/vercel-worker/README.md | 7 ++ integrations/vercel-worker/index.mjs | 84 ++++++++++++++++ integrations/vercel-worker/index.test.mjs | 111 ++++++++++++++++++++++ integrations/vercel-worker/wrangler.toml | 16 ++++ 5 files changed, 242 insertions(+) create mode 100644 docs/vercel-deployment-monitoring.md create mode 100644 integrations/vercel-worker/README.md create mode 100644 integrations/vercel-worker/index.mjs create mode 100644 integrations/vercel-worker/index.test.mjs create mode 100644 integrations/vercel-worker/wrangler.toml diff --git a/docs/vercel-deployment-monitoring.md b/docs/vercel-deployment-monitoring.md new file mode 100644 index 0000000..1cf7d3d --- /dev/null +++ b/docs/vercel-deployment-monitoring.md @@ -0,0 +1,24 @@ +# Vercel deployment monitoring plan + +Show production deployments from every project available to a Vercel integration, with a compact indeterminate indicator and macOS notifications on completion or failure. No personal API token is entered by the user. + +## Implementation + +1. Build a Cloudflare Worker for the Vercel integration's authorization-code exchange. Keep the client secret and integration tokens server-side. Bind each installation to a desktop-initiated, short-lived, one-time state and proof-of-possession; reject replay, unsolicited callbacks, and untrusted redirects. Define revocation, expiry, storage encryption, and disconnect before exposing endpoints. +2. Register a separate Vercel integration with deployment read access. Complete the redirect URL after the Worker has an authorized deployment destination. The owner must review and accept the Vercel agreement. +3. Add a local connection flow, secure device credentials, production deployment polling with pagination and rate-limit recovery, and visible authentication/network errors. Track installations separately; never mix deployments from different teams. +4. Present active deployments in the existing compact accent line without increasing the collapsed hit area. Show project, repository, commit, author, and real state on hover; treat missing Git metadata as unavailable. Do not imply a percentage or equate a successful build with production promotion. +5. Request macOS notification permission and notify once for new terminal transitions, including failures and cancellations. The initial snapshot must not notify historical deployments; preserve deduplication across restarts and handle multiple concurrent deployments. + +## Verification + +- Unit tests for authentication state, replay, invalid callbacks, missing metadata, status transitions, pagination, retries, and notification deduplication. +- Local integration tests with fake Vercel responses and no real credentials, including multiple projects and concurrent deployments. +- Manual macOS verification: login, disconnect, permissions denied, display while agents are active, browser tab hit testing, success and failure. +- End-to-end deployment only after explicit authorization for the Cloudflare destination, operation, and credential/session. + +## Current status + +An unreleased Worker and desktop prototype exist, with local pairing, polling, UI, notification code, and mocked tests. The Worker URL is unset and the Vercel menu is hidden. The integration form is not submitted. Security review, real installation, deployment, and end-to-end verification remain required; see `integrations/vercel-worker/README.md` before any deployment. + +References: [Vercel integration authorization](https://vercel.com/docs/integrations/create-integration/vercel-api-integrations), [Vercel integration registration](https://vercel.com/docs/integrations/create-integration/submit-integration), [Cloudflare Worker secrets](https://developers.cloudflare.com/workers/configuration/secrets/). diff --git a/integrations/vercel-worker/README.md b/integrations/vercel-worker/README.md new file mode 100644 index 0000000..93fa285 --- /dev/null +++ b/integrations/vercel-worker/README.md @@ -0,0 +1,7 @@ +# Vercel connection prototype + +This Worker is an **unreleased local prototype**, not a deployable production service. It is not connected to Agent Notch. Do not deploy it or use real credentials until the authentication flow, token lifecycle, abuse controls, revocation and storage retention have been security-reviewed and integration-tested. + +Run the isolated tests with `node --test integrations/vercel-worker/index.test.mjs`. The tests use mocked Vercel responses and no credentials. + +The intended deployment would require `VERCEL_CLIENT_ID`, `VERCEL_CLIENT_SECRET`, and `VERCEL_REDIRECT_URI` bindings and a Durable Object namespace. Use Cloudflare secret bindings for credentials, not `vars` or committed files. diff --git a/integrations/vercel-worker/index.mjs b/integrations/vercel-worker/index.mjs new file mode 100644 index 0000000..762fb45 --- /dev/null +++ b/integrations/vercel-worker/index.mjs @@ -0,0 +1,84 @@ +const pattern = /^[a-f0-9]{64}$/; +const json = (body, status = 200) => Response.json(body, { status, headers: { 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' } }); +const hex = bytes => Array.from(new Uint8Array(bytes), b => b.toString(16).padStart(2, '0')).join(''); +const digest = async value => hex(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value))); + +export default { + async fetch(request, env) { + const url = new URL(request.url); + const state = url.searchParams.get('state') ?? ''; + if (!pattern.test(state)) return json({ error: 'Invalid state' }, 400); + if (!['/start', '/callback', '/status', '/deployments', '/disconnect'].includes(url.pathname)) return json({ error: 'Not found' }, 404); + const ip = request.headers.get('CF-Connecting-IP'); + if (!ip || !(await env.RATE_LIMITER.limit({ key: ip })).success) return json({ error: 'Rate limited' }, 429); + return env.SESSIONS.get(env.SESSIONS.idFromName(state)).fetch(request); + } +}; + +// A separate object per unguessable state keeps a device's Vercel token isolated. +export class Session { + constructor(state, env) { this.state = state; this.env = env; } + + async fetch(request) { + const url = new URL(request.url); + const storage = this.state.storage; + if (url.pathname === '/start' && request.method === 'POST') { + const challenge = request.headers.get('X-Pairing-Challenge') ?? ''; + if (!pattern.test(challenge)) return json({ error: 'Invalid challenge' }, 400); + return this.state.blockConcurrencyWhile(async () => { + if (await storage.get('challenge')) return json({ error: 'Already started' }, 409); + await storage.put({ challenge, expires: Date.now() + 10 * 60_000 }); + await storage.setAlarm(Date.now() + 10 * 60_000); + return json({ ok: true }); + }); + } + if (url.pathname === '/callback' && request.method === 'GET') { + return this.state.blockConcurrencyWhile(async () => { + if (!(await storage.get('challenge')) || Date.now() > (await storage.get('expires')) || await storage.get('token')) return json({ error: 'Expired or completed' }, 400); + const code = url.searchParams.get('code'); + if (!code || code.length > 1024) return json({ error: 'Invalid code' }, 400); + const response = await fetch('https://api.vercel.com/v2/oauth/access_token', { + method: 'POST', redirect: 'error', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ client_id: this.env.VERCEL_CLIENT_ID, client_secret: this.env.VERCEL_CLIENT_SECRET, code, redirect_uri: this.env.VERCEL_REDIRECT_URI }) + }); + if (!response.ok) return json({ error: 'Authorization failed' }, 502); + const result = await response.json(); + if (typeof result.access_token !== 'string' || !result.access_token || typeof result.team_id !== 'string') return json({ error: 'Invalid authorization response' }, 502); + await storage.put({ token: result.access_token, team: result.team_id }); + return new Response('Connected. Return to Agent Notch.', { headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer', 'Content-Security-Policy': "default-src 'none'" } }); + }); + } + const verifier = request.headers.get('Authorization')?.replace(/^Bearer /, '') ?? ''; + if (!pattern.test(verifier) || await digest(verifier) !== await storage.get('challenge')) return json({ error: 'Unauthorized' }, 401); + if (url.pathname === '/disconnect' && request.method === 'POST') { await storage.deleteAll(); return json({ ok: true }); } + if (!await storage.get('claimed') && Date.now() > (await storage.get('expires'))) { await storage.deleteAll(); return json({ error: 'Expired' }, 410); } + const token = await storage.get('token'); + if (!token) return Date.now() > (await storage.get('expires')) ? json({ error: 'Expired' }, 410) : json({ status: 'pending' }); + if (url.pathname === '/status' && request.method === 'GET') { + await storage.put('claimed', true); + await storage.deleteAlarm(); + return json({ status: 'connected' }); + } + if (url.pathname === '/deployments' && request.method === 'GET') { + const endpoint = new URL('https://api.vercel.com/v7/deployments'); + endpoint.searchParams.set('teamId', await storage.get('team')); + endpoint.searchParams.set('target', 'production'); + endpoint.searchParams.set('limit', '100'); + const until = url.searchParams.get('until'); + if (until && /^\d{1,16}$/.test(until)) endpoint.searchParams.set('until', until); + const result = await fetch(endpoint, { redirect: 'error', headers: { Authorization: `Bearer ${token}` } }); + if (!result.ok) return json({ error: 'Vercel request failed' }, result.status === 429 ? 429 : 502); + const page = await result.json(); + if (!Array.isArray(page.deployments)) return json({ error: 'Invalid Vercel response' }, 502); + const keys = ['githubRepo', 'githubCommitSha', 'githubCommitAuthorName', 'gitlabProjectName', 'gitlabCommitSha', 'gitlabCommitAuthorName', 'bitbucketRepoName', 'bitbucketCommitSha', 'bitbucketCommitAuthorName']; + const deployments = page.deployments.map(({ uid, name, state, meta }) => ({ + uid, name, state, + meta: Object.fromEntries(keys.filter(key => typeof meta?.[key] === 'string').map(key => [key, meta[key]])) + })); + return json({ deployments, pagination: { next: page.pagination?.next ?? null } }); + } + return json({ error: 'Not found' }, 404); + } + + async alarm() { if (!await this.state.storage.get('claimed')) await this.state.storage.deleteAll(); } +} diff --git a/integrations/vercel-worker/index.test.mjs b/integrations/vercel-worker/index.test.mjs new file mode 100644 index 0000000..77bac74 --- /dev/null +++ b/integrations/vercel-worker/index.test.mjs @@ -0,0 +1,111 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import worker, { Session } from './index.mjs'; + +const secret = 'a'.repeat(64); +const challenge = Buffer.from(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(secret))).toString('hex'); +const url = 'https://worker.test'; +function setup() { + const values = new Map(); + const storage = { + async get(k) { return values.get(k); }, + async put(k, v) { if (typeof k === 'object') Object.entries(k).forEach(([a,b]) => values.set(a,b)); else values.set(k,v); }, + async deleteAll() { values.clear(); }, async setAlarm() {}, async deleteAlarm() {} + }; + const state = { storage, blockConcurrencyWhile: fn => fn() }; + return { session: new Session(state, { VERCEL_CLIENT_ID:'id', VERCEL_CLIENT_SECRET:'secret', VERCEL_REDIRECT_URI:'https://worker.test/callback' }), values }; +} +const req = (path, options) => new Request(url + path, options); + +test('pairing rejects unauthorized access and callback replay', async () => { + const { session } = setup(); + assert.equal((await session.fetch(req('/start', {method:'POST', headers:{'X-Pairing-Challenge':challenge}}))).status, 200); + assert.equal((await session.fetch(req('/start', {method:'POST', headers:{'X-Pairing-Challenge':challenge}}))).status, 409); + assert.equal((await session.fetch(req('/status', {headers:{Authorization:'Bearer ' + 'b'.repeat(64)}}))).status, 401); + const oldFetch = globalThis.fetch; + globalThis.fetch = async () => Response.json({access_token:'private',team_id:'team_123'}); + try { + assert.equal((await session.fetch(req('/callback?code=example'))).status, 200); + assert.equal((await session.fetch(req('/callback?code=example'))).status, 400); + const result = await session.fetch(req('/status', {headers:{Authorization:'Bearer ' + secret}})); + assert.deepEqual(await result.json(), {status:'connected'}); + assert.equal((await session.fetch(req('/disconnect', {method:'POST',headers:{Authorization:'Bearer ' + secret}}))).status, 200); + assert.equal((await session.fetch(req('/status', {headers:{Authorization:'Bearer ' + secret}}))).status, 401); + } finally { globalThis.fetch = oldFetch; } +}); + +test('expired pairing deletes an unclaimed authorization', async () => { + const { session, values } = setup(); + await session.fetch(req('/start', {method:'POST', headers:{'X-Pairing-Challenge':challenge}})); + values.set('expires', Date.now() - 1); + values.set('token', 'private'); + assert.equal((await session.fetch(req('/status', {headers:{Authorization:'Bearer ' + secret}}))).status, 410); + assert.equal(values.size, 0); +}); + +test('deployments proxy scopes Vercel requests and never returns its token', async () => { + const { session } = setup(); + await session.fetch(req('/start', {method:'POST', headers:{'X-Pairing-Challenge':challenge}})); + const oldFetch = globalThis.fetch; + globalThis.fetch = async input => { + const url = new URL(input); + if (url.pathname === '/v2/oauth/access_token') return Response.json({access_token:'private-token', team_id:'team_123'}); + assert.equal(url.origin, 'https://api.vercel.com'); + assert.equal(url.searchParams.get('teamId'), 'team_123'); + assert.equal(url.searchParams.get('target'), 'production'); + return Response.json({deployments: [], pagination: {next: null}}); + }; + try { + await session.fetch(req('/callback?code=example')); + await session.fetch(req('/status', {headers:{Authorization:'Bearer ' + secret}})); + const response = await session.fetch(req('/deployments?until=123', {headers:{Authorization:'Bearer ' + secret}})); + assert.equal(response.status, 200); + assert.doesNotMatch(await response.text(), /private-token/); + } finally { globalThis.fetch = oldFetch; } +}); + + +test('router rejects invalid state and rate-limited clients', async () => { + assert.equal((await worker.fetch(req('/status?state=no'), {})).status, 400); + const env = { RATE_LIMITER: { limit: async () => ({ success: false }) } }; + const limited = req('/status?state=' + 'a'.repeat(64), {headers:{'CF-Connecting-IP':'192.0.2.1'}}); + assert.equal((await worker.fetch(limited, env)).status, 429); +}); + +test('simulated installation stays isolated across devices and does not leak metadata', async () => { + const devices = new Map(); + const env = { + RATE_LIMITER: { limit: async () => ({ success: true }) }, + SESSIONS: { + idFromName: id => id, + get(id) { + if (!devices.has(id)) devices.set(id, setup().session); + return devices.get(id); + } + } + }; + const stateA = '1'.repeat(64), stateB = '2'.repeat(64); + const call = (path, state, options = {}) => worker.fetch(req(`${path}?state=${state}`, { + ...options, headers: {'CF-Connecting-IP':'192.0.2.1', ...options.headers} + }), env); + const previousFetch = globalThis.fetch; + globalThis.fetch = async (input, options) => { + assert.equal(options.redirect, 'error'); + if (new URL(input).pathname === '/v2/oauth/access_token') return Response.json({access_token:'server-secret',team_id:'team_123'}); + assert.equal(options.headers.Authorization, 'Bearer server-secret'); + return Response.json({deployments:[{uid:'dpl_1',name:'demo',state:'BUILDING',meta:{githubRepo:'repo',privateField:'sensitive'},secret:'sensitive'}],pagination:{next:null}}); + }; + try { + assert.equal((await call('/start', stateA, {method:'POST', headers:{'X-Pairing-Challenge':challenge}})).status, 200); + assert.equal((await call('/start', stateB, {method:'POST', headers:{'X-Pairing-Challenge':'b'.repeat(64)}})).status, 200); + assert.equal((await call('/status', stateB, {headers:{Authorization:'Bearer ' + secret}})).status, 401); + const callback = req(`/callback?state=${stateA}&code=example`, {headers:{'CF-Connecting-IP':'192.0.2.1'}}); + assert.equal((await worker.fetch(callback, env)).status, 200); + assert.deepEqual(await (await call('/status', stateA, {headers:{Authorization:'Bearer ' + secret}})).json(), {status:'connected'}); + const data = await (await call('/deployments', stateA, {headers:{Authorization:'Bearer ' + secret}})).text(); + assert.match(data, /dpl_1/); + assert.doesNotMatch(data, /server-secret|sensitive/); + assert.equal((await call('/disconnect', stateA, {method:'POST', headers:{Authorization:'Bearer ' + secret}})).status, 200); + assert.equal((await call('/status', stateA, {headers:{Authorization:'Bearer ' + secret}})).status, 401); + } finally { globalThis.fetch = previousFetch; } +}); diff --git a/integrations/vercel-worker/wrangler.toml b/integrations/vercel-worker/wrangler.toml new file mode 100644 index 0000000..b8b24bd --- /dev/null +++ b/integrations/vercel-worker/wrangler.toml @@ -0,0 +1,16 @@ +name = "agent-notch-vercel" +main = "index.mjs" +compatibility_date = "2026-11-01" + +[[ratelimits]] +name = "RATE_LIMITER" +namespace_id = "20261102" +simple = { limit = 120, period = 60 } + +[[durable_objects.bindings]] +name = "SESSIONS" +class_name = "Session" + +[[migrations]] +tag = "v1" +new_sqlite_classes = ["Session"] From d5e3311b5aa0e6ced60a8cf305f76d0d32b65ec1 Mon Sep 17 00:00:00 2001 From: Anthoni Rivera Date: Tue, 3 Nov 2026 23:04:00 -0500 Subject: [PATCH 6/6] docs: define quiet-by-default deployment behavior in the notch Record the interruption budget: no collapsed layout delta, agents keep the primary signal, worst state plus count instead of a carousel, passive terminal notifications behind an explicit switch, and hover-only details. List the gaps in the current WIP and the acceptance criteria. --- docs/vercel-notch-ux.md | 63 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 docs/vercel-notch-ux.md diff --git a/docs/vercel-notch-ux.md b/docs/vercel-notch-ux.md new file mode 100644 index 0000000..18cd905 --- /dev/null +++ b/docs/vercel-notch-ux.md @@ -0,0 +1,63 @@ +# Vercel in the notch: quiet by default + +Deployment status is ambient information, not a turn that needs you. The notch should be +glanceable from the corner of the eye and should never compete with the agent signal, move +anything the user is looking at, or take focus away from a browser tab. + +## What the app already guarantees + +- `NotchPanel` uses `.nonactivatingPanel` and never calls `activate(ignoringOtherApps:)`, so + the app cannot come to the front or close a browser's focus. +- `ignoresMouseEvents` stays on except while the pointer is inside the notch shape, so the + panel does not swallow clicks meant for whatever is behind it. +- `level = .mainMenu + 3`, `.canJoinAllSpaces`, `.fullScreenAuxiliary`, `.stationary` and + `.ignoresCycle`: the panel follows the user across Spaces without entering the window cycle. +- Notifications request permission lazily, use one identifier per deployment (so the system + cannot stack duplicates), and fire only on a terminal transition. The first snapshot after + launch never notifies. + +## Rules for the collapsed notch + +1. **No layout delta.** The notch width must not change because a deployment exists. Today + `rightEarWidth` grows to `max(earWidth, 140)`, which shifts the notch whenever a build + starts or finishes. Keep the width derived from agent activity only. +2. **Agents keep the primary signal.** A deployment must never replace the working/idle + indicator of an agent. Deployment presence is a quieter layer: a tint on the accent line, + or a small badge inside space the notch already has. +3. **Worst state wins, with a count.** With several deployments, show the most demanding state + (`failed` > `building` > `queued` > `ready`) plus a number ("3"), never a carousel and never + a list that grows the collapsed bar. +4. **No percentage, no invented progress.** Keep the indeterminate indicator and keep + `reduceMotion` respected. A successful build is not a production promotion; say what + happened, not what it implies. +5. **Details on hover only.** Project, repository, commit and author belong to the expanded + list. The collapsed bar shows at most which project is building. + +## Rules for notifications + +6. **One per deployment, at the end.** Only `ready`/`failed` notify. `building` and `queued` + never do. An unchanged deployment seen again never notifies again. +7. **Passive by default.** Set `interruptionLevel = .passive` and drop `.sound` from the + authorization and the content. The app already has a sound preference for agent turns; + deployments must not borrow attention for a build the user did not start. +8. **A switch the user can find.** Add "Notify when a Vercel deployment finishes" next to the + existing sound items, off unless the user turns it on. +9. **Do not notify what the user is already watching.** Skip the banner when the notch is + expanded on that project, or when the deployment is older than the current session. + +## Open questions + +- Should the deployment layer appear at all before the integration is configured? Current + answer: no, the status-menu item stays hidden until `VercelMonitor.configured`. +- Once several agents and several deployments compete, does the accent line have enough + room to carry both, or does deployment status belong only to the expanded view? + +## Acceptance criteria + +- Starting and finishing a deployment leaves the collapsed notch's size and hit area + unchanged, and leaves agent indicators untouched. +- A browser tab keeps focus and keyboard input during a deployment, a notification banner and + a notch hover. +- Two finished deployments produce exactly two notifications, or none when the switch is off. +- Reduce Motion shows a static indicator, and VoiceOver reads one combined label per + deployment.