Files
openclaw/apps/macos/Sources/OpenClaw/StatusMenuController.swift
Peter Steinberger 64f30c70d7 refactor(macos): automation summaries as hosted cards with clickable job rows (#130502)
* test(macos): add debug menu fixtures and submenu-open capture hooks

* feat(macos): render automation summaries as hosted cards

The Automations/Usage/Devices rows were native items cramming count and
next-run into a fitted title that middle-ellipsized into garbage
('5 …s'). They are now hosted summary cards - title left, compact detail
right, own chevron - which escape the native title budget and pick up
the selection highlight. Automation submenu jobs become hosted rows
(name left, next run right, full name on the accessibility label) that
open the Automations page on click instead of dead disabled text.

* style(macos): swiftformat pass on menu fixture hooks

* test(macos): capture hooks accept CLI arguments

launchctl setenv races open(1), which made screenshot launches flaky;
--debug-open-menu / --debug-menu-fixtures / --debug-probe-rightclick now
work as arguments. The arrow-key submenu navigation never fired inside
the tracking loop and is removed.
2026-08-26 17:11:35 -07:00

397 lines
16 KiB
Swift

import AppKit
import Foundation
import Observation
import SwiftUI
@MainActor
final class StatusMenuController: NSObject, NSMenuDelegate {
private let state: AppState
private let updater: UpdaterProviding
private let menu = NSMenu()
private let gatewayManager = GatewayProcessManager.shared
private let controlChannel = ControlChannel.shared
private let activityStore = WorkActivityStore.shared
private let sessions = StatusMenuSessions.shared
private let approvals = ExecApprovalQueueStore.shared
private let summaries = StatusMenuSummaries.shared
private let nodes = NodesStore.shared
private let cron = CronJobsStore.shared
private let dashboard = DashboardManager.shared
private var statusItem: NSStatusItem?
private var clickMonitor: Any?
private var hostingView: NSHostingView<StatusMenuIconView>?
private var renderer: StatusMenuRenderer?
private var refreshTask: Task<Void, Never>?
private var observationGeneration: UInt64 = 0
private var isMenuOpen = false
private var isChatWindowVisible = false
private var observedPaused: Bool
private var observedConnectionMode: AppState.ConnectionMode
private var observedPushToTalk: Bool
init(state: AppState, updater: UpdaterProviding) {
self.state = state
self.updater = updater
self.observedPaused = state.isPaused
self.observedConnectionMode = state.connectionMode
self.observedPushToTalk = state.voicePushToTalkEnabled
super.init()
}
func start() {
guard self.statusItem == nil else { return }
self.menu.autoenablesItems = false
self.menu.delegate = self
StatusMenuAppearance.pin(self.menu)
let renderer = StatusMenuRenderer(menu: self.menu, state: self.state)
renderer.onInstallUpdate = { [weak self] in
self?.updater.checkForUpdates(nil)
}
self.renderer = renderer
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
self.statusItem = item
self.installButton(in: item)
self.installWindowCallbacks()
self.approvals.start()
VoicePushToTalkHotkey.shared.setEnabled(voiceWakeSupported && self.state.voicePushToTalkEnabled)
self.renderCachedMenu()
self.updateStatusAppearance()
self.observeChanges()
self.scheduleDebugMenuOpen()
}
func stop() {
self.observationGeneration &+= 1
self.refreshTask?.cancel()
self.refreshTask = nil
self.sessions.cancelPreviewTasks()
self.summaries.menuDidClose()
self.approvals.stop()
if let clickMonitor = self.clickMonitor {
NSEvent.removeMonitor(clickMonitor)
self.clickMonitor = nil
}
guard let statusItem else { return }
self.statusItem = nil
NSStatusBar.system.removeStatusItem(statusItem)
}
func menuWillOpen(_ menu: NSMenu) {
StatusMenuAppearance.pin(menu)
guard menu === self.menu, menu.supermenu == nil else { return }
// Reconciling tracked rows can re-enter this callback without a close;
// a second pass here would cancel refreshes and re-reconcile forever.
guard !self.isMenuOpen else { return }
// Cache projection must precede network work: AppKit begins tracking immediately.
self.renderCachedMenu()
self.isMenuOpen = true
self.refreshTask?.cancel()
self.refreshTask = Task { [weak self] in
guard let self else { return }
async let sessionRefresh: Void = self.sessions.refresh(force: true)
async let approvalRefresh: Void = self.approvals.refresh()
async let healthRefresh: Void = HealthStore.shared.refresh(onDemand: true)
_ = await (sessionRefresh, approvalRefresh, healthRefresh)
}
self.summaries.refresh { [weak self] in
guard let self, self.isMenuOpen else { return }
self.renderCachedMenu()
}
}
func menuDidClose(_ menu: NSMenu) {
guard menu === self.menu else { return }
StatusMenuHighlightDelegate.shared.menu(menu, willHighlight: nil)
self.isMenuOpen = false
self.sessions.cancelPreviewTasks()
self.summaries.menuDidClose()
// Leaving the menu attached makes subsequent left clicks open AppKit's menu.
self.statusItem?.menu = nil
self.statusItem?.button?.highlight(self.isChatWindowVisible)
}
func menu(_ menu: NSMenu, willHighlight item: NSMenuItem?) {
StatusMenuHighlightDelegate.shared.menu(menu, willHighlight: item)
}
/// Accessibility/keyboard activation path; pointer clicks are consumed by the
/// local event monitor below, so an action firing here has no mouse event.
@objc
private func handleStatusClick(_: Any?) {
AppNavigationActions.openDashboard()
}
private func installButton(in item: NSStatusItem) {
guard let button = item.button else { return }
let host = NSHostingView(rootView: self.makeIconView())
host.translatesAutoresizingMaskIntoConstraints = false
button.addSubview(host)
NSLayoutConstraint.activate([
host.centerXAnchor.constraint(equalTo: button.centerXAnchor),
host.centerYAnchor.constraint(equalTo: button.centerYAnchor),
host.widthAnchor.constraint(equalToConstant: 18),
host.heightAnchor.constraint(equalToConstant: 18),
])
self.hostingView = host
button.target = self
button.action = #selector(self.handleStatusClick(_:))
self.installClickMonitor()
}
/// NSControl's send-action mask ignores right mouse buttons, so a local
/// monitor owns pointer routing: left opens the dashboard, right the menu.
private func installClickMonitor() {
guard self.clickMonitor == nil else { return }
self.clickMonitor = NSEvent.addLocalMonitorForEvents(
matching: [.leftMouseDown, .rightMouseDown])
{ [weak self] event in
guard let self, let button = self.statusItem?.button,
let window = button.window, event.windowNumber == window.windowNumber
else { return event }
let point = button.convert(event.locationInWindow, from: nil)
guard button.bounds.contains(point) else { return event }
switch event.type {
case .leftMouseDown:
AppNavigationActions.openDashboard()
return nil
case .rightMouseDown:
self.presentMenu()
return nil
default:
return event
}
}
}
private func presentMenu() {
guard let item = self.statusItem, let button = item.button else { return }
item.menu = self.menu
button.performClick(nil)
}
private func installWindowCallbacks() {
WebChatManager.shared.onChatWindowVisibilityChanged = { [weak self] visible in
guard let self else { return }
self.isChatWindowVisible = visible
self.statusItem?.button?.highlight(visible || self.isMenuOpen)
}
CanvasManager.shared.onPanelVisibilityChanged = { [weak self] visible in
self?.state.canvasPanelVisible = visible
}
CanvasManager.shared.defaultAnchorProvider = { [weak self] in
guard let button = self?.statusItem?.button, let window = button.window else { return nil }
let frame = button.convert(button.bounds, to: nil)
return window.convertToScreen(frame)
}
}
private func makeIconView() -> StatusMenuIconView {
let sleeping = self.isGatewaySleeping
return StatusMenuIconView(label: CritterStatusLabel(
isPaused: self.state.isPaused,
isSleeping: sleeping,
isWorking: self.state.isWorking,
earBoostActive: self.state.earBoostActive,
blinkTick: self.state.blinkTick,
sendCelebrationTick: self.state.sendCelebrationTick,
gatewayStatus: self.gatewayManager.status,
connectionMode: self.state.connectionMode,
controlChannelState: self.controlChannel.state,
animationsEnabled: self.state.iconAnimationsEnabled && !sleeping,
iconState: self.effectiveIconState,
voiceWakeMeterActive: self.state.voiceWakeMeterActive))
}
private func renderCachedMenu() {
let connection: StatusMenuDescriptor.Connection = if self.state.connectionMode == .unconfigured {
.unconfigured
} else {
switch self.controlChannel.state {
case .connected: .connected
case .connecting: .connecting
case .disconnected: .disconnected
case .degraded: .degraded
}
}
let snapshot = StatusMenuDescriptor.Snapshot(
isPaused: self.state.isPaused,
connection: connection,
quickChatEnabled: self.state.quickChatEnabled,
voiceWakeSupported: voiceWakeSupported,
debugEnabled: self.state.debugPaneEnabled,
updateReady: self.updater.isAvailable && self.updater.updateStatus.isUpdateReady,
hasUsage: self.summaries.hasUsage,
isUsageStalled: self.summaries.isUsageStalled,
sessions: self.sessions.rows,
sessionError: self.sessions.errorText,
mainSessionKey: self.activityStore.mainSessionKey,
approvals: self.approvals.requests,
gateways: DashboardGatewayMenuModel.items(from: self.dashboard.gatewayEntries))
self.renderer?.isSleeping = self.isGatewaySleeping
self.renderer?.reconcile(StatusMenuDescriptor.build(from: snapshot))
}
private func observeChanges() {
let generation = self.observationGeneration
withObservationTracking {
_ = self.makeIconView()
_ = self.state.voicePushToTalkEnabled
_ = self.state.quickChatEnabled
_ = self.state.canvasEnabled
_ = self.state.canvasPanelVisible
_ = self.state.talkEnabled
_ = self.state.debugPaneEnabled
_ = self.updater.updateStatus.isUpdateReady
_ = self.activityStore.current
_ = self.activityStore.mainSessionKey
_ = HealthStore.shared.state
_ = HealthStore.shared.degradedSummary
_ = MacNodeChannelStatusStore.shared.state
_ = self.nodes.nodes
_ = self.nodes.isLoading
_ = self.nodes.lastError
_ = self.nodes.localNodeIdentityState
_ = self.cron.jobs
_ = self.cron.schedulerNextWakeAtMs
_ = self.dashboard.gatewayEntries
_ = self.sessions.rows
_ = self.sessions.errorText
_ = self.approvals.requests
_ = self.summaries.hasUsage
_ = self.summaries.isUsageStalled
} onChange: { [weak self] in
Task { @MainActor [weak self] in
guard let self, self.observationGeneration == generation else { return }
self.applyStateSideEffects()
self.hostingView?.rootView = self.makeIconView()
self.updateStatusAppearance()
if self.isMenuOpen {
self.renderCachedMenu()
}
self.observeChanges()
}
}
}
private func applyStateSideEffects() {
let paused = self.state.isPaused
if paused != self.observedPaused {
self.observedPaused = paused
if self.state.connectionMode == .local {
self.gatewayManager.setActive(!paused)
} else {
self.gatewayManager.stop()
}
}
let mode = self.state.connectionMode
if mode != self.observedConnectionMode {
self.observedConnectionMode = mode
Task { await ConnectionModeCoordinator.shared.apply(mode: mode, paused: self.state.isPaused) }
if AppLaunchRuntimePlan.current.allowsAutomaticPresentation {
CLIInstallPrompter.shared.checkAndPromptIfNeeded(reason: "connection-mode")
}
BrowserProfileImportModel.shared.handleConnectionModeChange()
}
let pushToTalk = self.state.voicePushToTalkEnabled
if pushToTalk != self.observedPushToTalk {
self.observedPushToTalk = pushToTalk
VoicePushToTalkHotkey.shared.setEnabled(voiceWakeSupported && pushToTalk)
}
}
private func updateStatusAppearance() {
guard let button = self.statusItem?.button else { return }
button.appearsDisabled = false
button.toolTip = self.state.voiceWakeMeterActive
? String(localized: "OpenClaw - Voice Wake live meter active")
: String(localized: "OpenClaw")
}
private var isGatewaySleeping: Bool {
guard !self.state.isPaused else { return false }
return switch self.state.connectionMode {
case .unconfigured:
true
case .remote:
self.controlChannel.state != .connected
case .local:
switch self.gatewayManager.status {
case .running, .starting, .attachedExisting:
self.controlChannel.state != .connected
case .failed, .stopped:
true
}
}
}
private var effectiveIconState: IconState {
let selection = self.state.iconOverride
guard selection != .system else { return self.activityStore.iconState }
return switch selection.toIconState() {
case let .workingMain(kind), let .workingOther(kind), let .overridden(kind):
.overridden(kind)
case .idle:
.idle
}
}
private func scheduleDebugMenuOpen() {
#if DEBUG
// launchctl setenv races `open`; arguments are reliable for captures.
let arguments = CommandLine.arguments
let environment = ProcessInfo.processInfo.environment
func flag(_ env: String, _ arg: String) -> Bool {
environment[env] == "1" || arguments.contains(arg)
}
// Screenshot/demo helper: seed synthetic menu content so UI proof
// captures show populated rows without a configured gateway.
if flag("OPENCLAW_DEBUG_MENU_FIXTURES", "--debug-menu-fixtures") {
CronJobsStore.shared.seedDebugFixtureJobs()
}
if flag("OPENCLAW_DEBUG_OPEN_MENU", "--debug-open-menu") {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
guard let self, let button = self.statusItem?.button else { return }
self.statusItem?.menu = self.menu
button.performClick(nil)
}
}
if flag("OPENCLAW_DEBUG_PROBE_RIGHTCLICK", "--debug-probe-rightclick") {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
guard let self, let button = self.statusItem?.button,
let window = button.window else { return }
let center = NSPoint(x: button.bounds.midX, y: button.bounds.midY)
let inWindow = button.convert(center, to: nil)
guard let event = NSEvent.mouseEvent(
with: .rightMouseDown,
location: inWindow,
modifierFlags: [],
timestamp: ProcessInfo.processInfo.systemUptime,
windowNumber: window.windowNumber,
context: nil,
eventNumber: 0,
clickCount: 1,
pressure: 1) else { return }
// Route through the local monitor path a real click takes.
NSApp.postEvent(event, atStart: false)
}
}
#endif
}
}
private struct StatusMenuIconView: View {
let label: CritterStatusLabel
var body: some View {
self.label.background(SettingsWindowOpenRegistrar())
}
}