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
35 changes: 34 additions & 1 deletion clients/macos/vellum-assistant/App/AppDelegate+MenuBar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
siddseethepalli marked this conversation as resolved.
self?.updateMenuBarIcon()
}
Comment thread
siddseethepalli marked this conversation as resolved.
}

func setupFileMenu() {
Expand Down Expand Up @@ -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()
Expand All @@ -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 }
Comment thread
siddseethepalli marked this conversation as resolved.
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 }
Expand Down
23 changes: 23 additions & 0 deletions clients/macos/vellum-assistant/App/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}

Expand All @@ -26,6 +28,7 @@ enum AssistantStatus {
case .idle: return .systemGray
case .thinking: return .systemGreen
case .error: return .systemRed
case .disconnected: return .systemOrange
}
}

Expand All @@ -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 {
Expand Down Expand Up @@ -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<Void, Never>?
var cachedApps: [AppItem] = []
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down