Files
openclaw/apps/macos/Sources/OpenClaw/StatusMenuController.swift
Peter Steinberger 2b0da0e193 refactor(macos): single-owner hybrid status menu with live session cards and exec approvals (#130041)
* test(macos): add OPENCLAW_DEBUG_OPEN_MENU screenshot hook

* feat(macos): add live execution approval queue

* refactor(macos): replace menu injectors with owned status menu

* fix(macos): keep unconfigured menu header calm

* fix(macos): route status-item right-clicks through a local event monitor

NSControl's send-action mask ignores right mouse buttons, so the previous
sendAction(on: [.rightMouseUp]) wiring never fired and the menu was
unreachable by pointer. A local monitor now owns pointer routing (left =
dashboard, right = menu) — the same mechanism the shipped
StatusItemMouseRouter used — and menuWillOpen gained the re-entrancy
guard the old injector carried, since reconciling tracked rows can
re-enter the callback without a close.

* chore(i18n): refresh native inventory for status menu strings

* chore(macos): remove menu-refactor dead code

Periphery flagged the orphans the status-menu refactor left behind: the
ExecApprovalQuickMode enum and AppState's entire quick-mode read/retry
surface (its only consumer was the deleted menu picker; the Settings
pane owns exec-approval policy UI), SessionMenuLabelView,
TrackingAreaSupport, NodeMenuMultilineView, UpdateStatus.disabled, and
two fixture-only initializers. StatusMenuController.stop() is now wired
into applicationWillTerminate. The menu-highlight environment key moved
from the deleted view file into MenuItemHighlightColors.

* chore(macos): fix status-menu lint style and refresh i18n inventory

* fix(macos): converge approval cards after losing a resolution race

The status-menu queue and the modal prompter intentionally share the
gateway approval event stream: the gateway resolves each approval
exactly once, the resolved broadcast removes the card, and the modal
stays the active presentation owner while the menu is the passive,
ambient one. What was missing: when the menu's resolve loses the race
(modal or another client answered first), the gateway rejection left a
zombie card if the resolved event was dropped. Resolve failures now
re-list from the authoritative queue. Regression test simulates the
race at the socket boundary and fails pre-fix.
2026-08-26 06:16:16 -07:00

385 lines
15 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 }
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)
}
/// 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,
canvasEnabled: self.state.canvasEnabled,
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.isRefreshing
_ = HealthStore.shared.lastSuccess
_ = 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
let environment = ProcessInfo.processInfo.environment
if environment["OPENCLAW_DEBUG_OPEN_MENU"] == "1" {
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 environment["OPENCLAW_DEBUG_PROBE_RIGHTCLICK"] == "1" {
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())
}
}