Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion Sources/AgentNotch/App/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
4 changes: 2 additions & 2 deletions Sources/AgentNotch/Domain/Deployment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
14 changes: 6 additions & 8 deletions Sources/AgentNotch/Infrastructure/VercelMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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))
Expand All @@ -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 {
Expand All @@ -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()
Expand Down
82 changes: 40 additions & 42 deletions Sources/AgentNotch/UI/NotchRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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] {
Expand All @@ -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))
Expand Down Expand Up @@ -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)
Expand All @@ -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)")
Expand All @@ -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)))
Expand Down
12 changes: 10 additions & 2 deletions Sources/AgentNotch/UI/SessionListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
}
}
Expand Down
12 changes: 6 additions & 6 deletions Tests/AgentNotchTests/DeploymentTrackerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
2 changes: 1 addition & 1 deletion docs/vercel-deployment-monitoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
30 changes: 17 additions & 13 deletions docs/vercel-notch-ux.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
Loading