From 418c76b112bde774f147dd99e5cdf174d1592a5d Mon Sep 17 00:00:00 2001 From: Anthoni Rivera Date: Tue, 3 Nov 2026 23:20:47 -0500 Subject: [PATCH 1/3] feat: keep the collapsed notch size stable during deployments The notch width came from agent activity plus a 140pt reservation for the deployment row, so starting or finishing a build resized and shifted the notch. Width is now derived from agents only, and the deployment becomes a badge in the idle mascot's slot: Vercel's sprite plus a state dot, with no text and no extra width. The indeterminate indicator moves to the expanded list, where there is room for project, repository, commit and author. --- Sources/AgentNotch/UI/NotchRootView.swift | 82 ++++++++++----------- Sources/AgentNotch/UI/SessionListView.swift | 12 ++- 2 files changed, 50 insertions(+), 44 deletions(-) diff --git a/Sources/AgentNotch/UI/NotchRootView.swift b/Sources/AgentNotch/UI/NotchRootView.swift index 92e8656..47c3a51 100644 --- a/Sources/AgentNotch/UI/NotchRootView.swift +++ b/Sources/AgentNotch/UI/NotchRootView.swift @@ -16,8 +16,9 @@ struct NotchRootView: View { } } /// True when nothing is happening: the ear shows one dimmed, breathing mascot instead of going dark. + /// Deployments are deliberately not part of this: a running build must not resize or shift the notch. private var isIdle: Bool { - activeAgents.isEmpty && store.doneFlash == nil && store.approvals.isEmpty && store.peek == nil && !vercel.active + activeAgents.isEmpty && store.doneFlash == nil && store.approvals.isEmpty && store.peek == nil } private var showsIdleMascot: Bool { Preferences.showIdleMascot } /// Start of the longest-running turn, for the timer in the right ear. @@ -28,6 +29,10 @@ struct NotchRootView: View { private var idleAgent: AgentKind { store.sessions.values.max { $0.updatedAt < $1.updatedAt }?.agent ?? .claude } + /// The deployment worth a badge in the collapsed notch: what is running now, or what starts next. + private var runningDeployment: Deployment? { + vercel.deployments.first { $0.status == .building } ?? vercel.deployments.first { $0.status == .pending } + } /// Agents shown as mascots in the left ear: the active ones plus the one that just finished. private var earAgents: [AgentKind] { @@ -41,7 +46,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 : max(earWidth, vercel.active ? 140 : 0) } + private var rightEarWidth: CGFloat { isIdle ? 0 : earWidth } private var shiftX: CGFloat { expanded ? 0 : (rightEarWidth - leftEarWidth) / 2 } private var earWidth: CGFloat { let count = CGFloat(max(earAgents.count, 1)) @@ -118,15 +123,17 @@ struct NotchRootView: View { VStack(spacing: 0) { HStack(spacing: 0) { HStack(spacing: -mascotOverlap) { - ForEach(earAgents, id: \.self) { agent in - MascotView(agent: agent, mood: mood(for: agent), size: mascotSize, - reduceMotion: viewModel.reduceMotion) - .opacity(isIdle ? 0.75 : 1) - .overlay(alignment: .topTrailing) { - if awaitsUser(agent) { - Circle().fill(Color(hex: 0xFFB020)).frame(width: 7, height: 7).offset(x: 2, y: 1) + if isIdle, showsIdleMascot, vercel.active { vercelBadge } else { + ForEach(earAgents, id: \.self) { agent in + MascotView(agent: agent, mood: mood(for: agent), size: mascotSize, + reduceMotion: viewModel.reduceMotion) + .opacity(isIdle ? 0.75 : 1) + .overlay(alignment: .topTrailing) { + if awaitsUser(agent) { + Circle().fill(Color(hex: 0xFFB020)).frame(width: 7, height: 7).offset(x: 2, y: 1) + } } - } + } } } .frame(width: leftEarWidth) @@ -153,6 +160,29 @@ struct NotchRootView: View { } } + /// Vercel's mascot plus a state dot, drawn in the idle mascot's slot so a running build never changes the notch's size. + @ViewBuilder private var vercelBadge: some View { + if let deployment = runningDeployment { + let accent = deployment.status == .building ? Color(hex: 0x72DCD0) : Color(hex: 0xF4C377) + Group { + if let mascot = MascotSprites.vercel { + Image(nsImage: mascot).resizable().interpolation(.high).scaledToFit() + } else { + Image(systemName: "triangle.fill") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.white.opacity(0.9)) + } + } + .frame(width: mascotSize, height: mascotSize) + .overlay(alignment: .topTrailing) { + Circle().fill(accent).frame(width: 7, height: 7).offset(x: 2, y: 1) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text("Vercel")) + .accessibilityValue(Text(deployment.status.label)) + } + } + @ViewBuilder private var rightEar: some View { if !store.approvals.isEmpty { Text("\(store.approvals.count)") @@ -168,38 +198,6 @@ 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))) diff --git a/Sources/AgentNotch/UI/SessionListView.swift b/Sources/AgentNotch/UI/SessionListView.swift index c2908b4..1ab8258 100644 --- a/Sources/AgentNotch/UI/SessionListView.swift +++ b/Sources/AgentNotch/UI/SessionListView.swift @@ -85,8 +85,16 @@ struct SessionListView: View { 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) + HStack(spacing: 5) { + 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) + if deployment.status == .building || deployment.status == .pending { + DeploymentProgress(color: deployment.status == .building ? Color(hex: 0x72DCD0) : Color(hex: 0xF4C377), + reduceMotion: reduceMotion) + .frame(width: 40, height: 2) + .accessibilityHidden(true) + } + } } } } From 98df06b3132140494e9275a31b6298f06c37108d Mon Sep 17 00:00:00 2001 From: Anthoni Rivera Date: Tue, 3 Nov 2026 23:20:48 -0500 Subject: [PATCH 2/3] fix: never announce deployments from before this session The monitor persisted its tracker, so a deployment that finished while the app was closed notified on the next launch. The tracker now starts empty on every run: the first poll is a baseline and only transitions observed while the app runs are announced. Notification permission is also narrowed to the moments worth a banner: the monitor asks an injected policy, and the app answers with the preference, a collapsed notch, and no pending approval. --- Sources/AgentNotch/App/AppDelegate.swift | 6 +++++- Sources/AgentNotch/Domain/Deployment.swift | 4 ++-- .../AgentNotch/Infrastructure/VercelMonitor.swift | 14 ++++++-------- Tests/AgentNotchTests/DeploymentTrackerTests.swift | 12 ++++++------ 4 files changed, 19 insertions(+), 17 deletions(-) diff --git a/Sources/AgentNotch/App/AppDelegate.swift b/Sources/AgentNotch/App/AppDelegate.swift index 24c9351..42d3401 100644 --- a/Sources/AgentNotch/App/AppDelegate.swift +++ b/Sources/AgentNotch/App/AppDelegate.swift @@ -4,7 +4,11 @@ import AppKit final class AppDelegate: NSObject, NSApplicationDelegate { private let store = SessionStore() private let viewModel = NotchViewModel() - private let vercel = VercelMonitor(notificationsEnabled: { Preferences.vercelNotificationsEnabled }) + /// Notifications belong to the app layer: quiet unless the user asked for them and the notch is already open. + private lazy var vercel = VercelMonitor(shouldNotify: { [weak self] _ in + guard let self else { return false } + return Preferences.vercelNotificationsEnabled && !viewModel.hovering && store.approvals.isEmpty + }) private var server: HookServer? private var panelController: NotchPanelController? private var statusMenu: StatusMenu? diff --git a/Sources/AgentNotch/Domain/Deployment.swift b/Sources/AgentNotch/Domain/Deployment.swift index 8164669..0fdbb65 100644 --- a/Sources/AgentNotch/Domain/Deployment.swift +++ b/Sources/AgentNotch/Domain/Deployment.swift @@ -25,8 +25,8 @@ struct Deployment: Equatable, Identifiable { let status: Status } -/// Tracks terminal transitions without notifying for history loaded on the first successful poll. -struct DeploymentTracker: Codable { +/// Tracks terminal transitions for one run: the first poll is a baseline, so history and work that finished while the app was closed stay silent. +struct DeploymentTracker { private var statuses: [String: Deployment.Status] = [:] private var initialized = false diff --git a/Sources/AgentNotch/Infrastructure/VercelMonitor.swift b/Sources/AgentNotch/Infrastructure/VercelMonitor.swift index 8f993c9..ccf5458 100644 --- a/Sources/AgentNotch/Infrastructure/VercelMonitor.swift +++ b/Sources/AgentNotch/Infrastructure/VercelMonitor.swift @@ -22,11 +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 + /// Whether this finished deployment may post a notification. Injected by the app layer: the monitor reads no preferences and knows nothing about the notch. + private let shouldNotify: (Deployment) -> Bool - init(notificationsEnabled: @escaping () -> Bool = { false }) { - self.notificationsEnabled = notificationsEnabled + init(shouldNotify: @escaping (Deployment) -> Bool = { _ in false }) { + self.shouldNotify = shouldNotify 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 } @@ -79,13 +79,12 @@ final class VercelMonitor: ObservableObject { _ = 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 } + // The tracker starts empty on every run: a deployment that finished while the app was closed must not be announced. while !Task.isCancelled { do { let status = try JSONDecoder().decode(ConnectionStatus.self, from: try await request("status", credentials: credentials)) @@ -104,7 +103,6 @@ final class VercelMonitor: ObservableObject { 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 { @@ -117,7 +115,7 @@ final class VercelMonitor: ObservableObject { private func notify(_ deployment: Deployment) async { guard Bundle.main.bundleURL.pathExtension == "app" else { return } - guard notificationsEnabled() else { return } + guard shouldNotify(deployment) else { return } let center = UNUserNotificationCenter.current() guard (try? await center.requestAuthorization(options: [.alert])) == true else { return } let content = UNMutableNotificationContent() diff --git a/Tests/AgentNotchTests/DeploymentTrackerTests.swift b/Tests/AgentNotchTests/DeploymentTrackerTests.swift index 2a58dde..38840da 100644 --- a/Tests/AgentNotchTests/DeploymentTrackerTests.swift +++ b/Tests/AgentNotchTests/DeploymentTrackerTests.swift @@ -14,13 +14,13 @@ final class DeploymentTrackerTests: XCTestCase { XCTAssertEqual(tracker.update([deployment("new", .failed)]).map(\.id), ["new"]) } - func testRestartAndStalePollDoNotRepeatNotifications() throws { + /// A new run starts with an empty tracker, so work that finished while the app was closed is never announced. + func testFinishedWhileClosedIsNotAnnounced() { 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) + XCTAssertTrue(tracker.update([deployment("closed", .ready)]).isEmpty) + XCTAssertTrue(tracker.update([deployment("closed", .ready), deployment("live", .building)]).isEmpty) + XCTAssertEqual(tracker.update([deployment("closed", .ready), deployment("live", .ready)]).map(\.id), ["live"]) + XCTAssertTrue(tracker.update([deployment("closed", .ready), deployment("live", .ready)]).isEmpty) } func testDuplicateItemsNotifyOnlyOnce() { From 019178f0a9d370cfc0cf17cc45f9a5a892f354e2 Mon Sep 17 00:00:00 2001 From: Anthoni Rivera Date: Tue, 3 Nov 2026 23:20:48 -0500 Subject: [PATCH 3/3] docs: record the quiet notch rules as implemented Update the collapsed-notch rules to match the code (no layout delta, one badge, details on hover) and the notification rule now that the tracker is per run. --- docs/vercel-deployment-monitoring.md | 2 +- docs/vercel-notch-ux.md | 30 ++++++++++++++++------------ 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/docs/vercel-deployment-monitoring.md b/docs/vercel-deployment-monitoring.md index 1cf7d3d..3d0a7af 100644 --- a/docs/vercel-deployment-monitoring.md +++ b/docs/vercel-deployment-monitoring.md @@ -8,7 +8,7 @@ Show production deployments from every project available to a Vercel integration 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. +5. Request macOS notification permission and notify once for new terminal transitions, including failures and cancellations. The tracker starts empty on every run, so the initial snapshot and any deployment that finished while the app was closed stay silent; only transitions observed while it runs are announced, with multiple concurrent deployments handled. ## Verification diff --git a/docs/vercel-notch-ux.md b/docs/vercel-notch-ux.md index 18cd905..4c76401 100644 --- a/docs/vercel-notch-ux.md +++ b/docs/vercel-notch-ux.md @@ -18,20 +18,22 @@ anything the user is looking at, or take focus away from a browser tab. ## 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. +1. **No layout delta.** The notch width comes from agent activity only, so starting or finishing + a deployment never resizes or shifts it. The deployment badge uses the idle mascot's slot, + which costs no extra width. +2. **Agents keep the primary signal.** A deployment never replaces a working agent's indicator. + While everything is idle it may take the idle mascot's slot, which carries no agent status, + and it stays otherwise absent from the collapsed notch: a small badge in space the notch + already has. +3. **One quiet signal, no counters.** The collapsed badge says that something is running + (`building` over `queued`). Counts, failures and every other state belong to the expanded + list; 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. +5. **Details on hover only.** Project, repository, commit and author live in the expanded list, + which is also where the indeterminate indicator sits. The collapsed badge is the mascot plus + a state dot. ## Rules for notifications @@ -42,8 +44,10 @@ anything the user is looking at, or take focus away from a browser tab. 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. +9. **Do not notify what the user is already watching.** The monitor asks an injected policy, and + the app only allows a banner while the notch is collapsed and the preference is on. The + tracker starts empty on every run, so work that finished while the app was closed is never + announced. ## Open questions