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
8 changes: 5 additions & 3 deletions Sources/AgentNotch/App/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -18,17 +19,18 @@ 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 {
FileHandle.standardError.write(Data("Agent Notch: cannot start server: \(error)\n".utf8))
}
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() }
}
Expand Down
27 changes: 25 additions & 2 deletions Sources/AgentNotch/App/StatusMenu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: "")
Expand All @@ -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)]
Expand All @@ -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")
Expand All @@ -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
Expand Down Expand Up @@ -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 }
Expand All @@ -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
Expand Down
45 changes: 45 additions & 0 deletions Sources/AgentNotch/Domain/Deployment.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
21 changes: 19 additions & 2 deletions Sources/AgentNotch/Infrastructure/HookServer.swift
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
22 changes: 15 additions & 7 deletions Sources/AgentNotch/Infrastructure/NotchPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Void, Never>?
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() {
Expand All @@ -60,15 +62,15 @@ 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)
if let panel {
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]
Expand All @@ -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 }

Expand Down
Loading
Loading