From 4aa0f1ac5b7606f44df02c47608c47afa373fbe4 Mon Sep 17 00:00:00 2001 From: siddseethepalli Date: Tue, 24 Feb 2026 19:55:07 +0000 Subject: [PATCH] feat: add status indication to macOS menu bar icon Co-Authored-By: Claude --- .../App/AppDelegate+MenuBar.swift | 35 ++++++++++++++++++- .../vellum-assistant/App/AppDelegate.swift | 23 ++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/clients/macos/vellum-assistant/App/AppDelegate+MenuBar.swift b/clients/macos/vellum-assistant/App/AppDelegate+MenuBar.swift index c4dd5a87776..e93a75c641c 100644 --- a/clients/macos/vellum-assistant/App/AppDelegate+MenuBar.swift +++ b/clients/macos/vellum-assistant/App/AppDelegate+MenuBar.swift @@ -16,6 +16,14 @@ extension AppDelegate { button.action = #selector(statusBarButtonClicked(_:)) button.target = self } + + // Start observing daemon connection state immediately so the icon + // reflects disconnected/connected before the main window opens. + connectionStatusCancellable = daemonClient.$isConnected + .receive(on: RunLoop.main) + .sink { [weak self] _ in + self?.updateMenuBarIcon() + } } func setupFileMenu() { @@ -80,6 +88,7 @@ extension AppDelegate { let status = currentAssistantStatus let dotColor = status.statusColor + let dotAlpha = status.shouldPulse ? pulsePhase : 1.0 let composited = NSImage(size: NSSize(width: iconSize, height: iconSize)) composited.lockFocus() @@ -94,14 +103,38 @@ extension AppDelegate { let dotRect = NSRect(x: dotX, y: dotY, width: dotSize, height: dotSize) NSColor.black.withAlphaComponent(0.5).setFill() NSBezierPath(ovalIn: dotRect.insetBy(dx: -0.5, dy: -0.5)).fill() - dotColor.setFill() + dotColor.withAlphaComponent(dotAlpha).setFill() NSBezierPath(ovalIn: dotRect).fill() composited.unlockFocus() composited.isTemplate = false button.image = composited + + managePulseTimer(for: status) + } + + /// Starts or stops the pulse timer based on the current status. + private func managePulseTimer(for status: AssistantStatus) { + if status.shouldPulse { + guard pulseTimer == nil else { return } + pulsePhase = 1.0 + pulseTimer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in + Task { @MainActor in + guard let self, self.statusItem != nil, let button = self.statusItem.button else { return } + // Triangle wave between 0.3 and 1.0 over ~1.4s (28 frames at 50ms) + self.pulsePhase -= 0.05 + if self.pulsePhase <= 0.3 { self.pulsePhase = 1.0 } + self.configureMenuBarIcon(button) + } + } + } else { + pulseTimer?.invalidate() + pulseTimer = nil + pulsePhase = 1.0 + } } var currentAssistantStatus: AssistantStatus { + if !daemonClient.isConnected { return .disconnected } guard let viewModel = mainWindow?.threadManager.activeViewModel else { return .idle } if let error = viewModel.errorText { return .error(error) } if viewModel.isThinking { return .thinking } diff --git a/clients/macos/vellum-assistant/App/AppDelegate.swift b/clients/macos/vellum-assistant/App/AppDelegate.swift index a1bee502154..3d0cd4fa43f 100644 --- a/clients/macos/vellum-assistant/App/AppDelegate.swift +++ b/clients/macos/vellum-assistant/App/AppDelegate.swift @@ -12,12 +12,14 @@ enum AssistantStatus { case idle case thinking case error(String) + case disconnected var menuTitle: String { switch self { case .idle: return "Assistant is idle" case .thinking: return "Assistant is thinking..." case .error(let msg): return "Error: \(msg)" + case .disconnected: return "Disconnected from daemon" } } @@ -26,6 +28,7 @@ enum AssistantStatus { case .idle: return .systemGray case .thinking: return .systemGreen case .error: return .systemRed + case .disconnected: return .systemOrange } } @@ -38,6 +41,12 @@ enum AssistantStatus { image.unlockFocus() return image } + + /// Whether the dot should pulse (animate opacity) + var shouldPulse: Bool { + if case .thinking = self { return true } + return false + } } enum InteractionType { @@ -103,6 +112,9 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { private var quickChatShortcutObserver: AnyCancellable? private weak var recordingViewModel: ChatViewModel? private var statusIconCancellable: AnyCancellable? + private var connectionStatusCancellable: AnyCancellable? + private var pulseTimer: Timer? + private var pulsePhase: CGFloat = 1.0 var cachedSkills: [SkillInfo] = [] var refreshSkillsTask: Task? var cachedApps: [AppItem] = [] @@ -377,6 +389,10 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { } statusIconCancellable?.cancel() statusIconCancellable = nil + connectionStatusCancellable?.cancel() + connectionStatusCancellable = nil + pulseTimer?.invalidate() + pulseTimer = nil if let item = statusItem { NSStatusBar.system.removeStatusItem(item) @@ -490,6 +506,10 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { } statusIconCancellable?.cancel() statusIconCancellable = nil + connectionStatusCancellable?.cancel() + connectionStatusCancellable = nil + pulseTimer?.invalidate() + pulseTimer = nil if let item = statusItem { NSStatusBar.system.removeStatusItem(item) @@ -1564,6 +1584,9 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { NotificationCenter.default.removeObserver(observer) } statusIconCancellable?.cancel() + connectionStatusCancellable?.cancel() + pulseTimer?.invalidate() + pulseTimer = nil voiceInput?.stop() ambientAgent.teardown() surfaceManager.dismissAll()