Files
openclaw/apps/macos/Sources/OpenClaw/StatusMenuDescriptor.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

260 lines
8.6 KiB
Swift

import Foundation
@MainActor
struct StatusMenuDescriptor {
struct Snapshot {
var isPaused: Bool
var connection: Connection
var quickChatEnabled: Bool
var canvasEnabled: Bool
var voiceWakeSupported: Bool
var debugEnabled: Bool
var updateReady: Bool
var hasUsage: Bool
var isUsageStalled: Bool
var sessions: [SessionRow]
var sessionError: String?
var mainSessionKey: String
var approvals: [ExecApprovalQueueItem]
var gateways: [DashboardGatewayMenuItem]
var now: Date
init(
isPaused: Bool = false,
connection: Connection = .connected,
quickChatEnabled: Bool = false,
canvasEnabled: Bool = false,
voiceWakeSupported: Bool = true,
debugEnabled: Bool = false,
updateReady: Bool = false,
hasUsage: Bool = false,
isUsageStalled: Bool = false,
sessions: [SessionRow] = [],
sessionError: String? = nil,
mainSessionKey: String = "main",
approvals: [ExecApprovalQueueItem] = [],
gateways: [DashboardGatewayMenuItem] = [],
now: Date = Date())
{
self.isPaused = isPaused
self.connection = connection
self.quickChatEnabled = quickChatEnabled
self.canvasEnabled = canvasEnabled
self.voiceWakeSupported = voiceWakeSupported
self.debugEnabled = debugEnabled
self.updateReady = updateReady
self.hasUsage = hasUsage
self.isUsageStalled = isUsageStalled
self.sessions = sessions
self.sessionError = sessionError
self.mainSessionKey = mainSessionKey
self.approvals = approvals
self.gateways = gateways
self.now = now
}
}
enum Connection {
case unconfigured
case disconnected
case connecting
case connected
case degraded
}
struct Section {
let id: String
var entries: [Entry]
}
struct Entry {
let kind: Kind
init(_ kind: Kind) {
self.kind = kind
}
var id: String {
switch self.kind {
case .header:
"header"
case .gatewayHeader:
"gateway.header"
case let .session(session):
"session.\(session.key)"
case let .approval(approval):
"approval.\(approval.id)"
case .placeholder:
"placeholder"
case let .action(action):
"action.\(action.rawValue)"
case let .summary(summary):
"summary.\(summary.rawValue)"
case let .gateway(gateway, isAlternate):
"gateway.\(gateway.id)\(isAlternate ? ".alternate" : "")"
case .updateReady:
"update.ready"
}
}
}
enum Kind {
case header
case gatewayHeader
case session(SessionRow)
case approval(ExecApprovalQueueItem)
case placeholder(String)
case action(Action)
case summary(Summary)
case gateway(DashboardGatewayMenuItem, isAlternate: Bool)
case updateReady
}
enum Action: String {
case dashboard
case quickChat
case talkMode
case canvas
case allSessions
case settings
case debug
case about
case quit
}
enum Summary: String {
case automations
case usage
case devices
}
static let maximumSessionCount = 6
private static let activeWindowSeconds: TimeInterval = 24 * 60 * 60
var sections: [Section]
static func build(from snapshot: Snapshot) -> StatusMenuDescriptor {
var sections = [Section(id: "header", entries: [Entry(.header)])]
sections.append(self.sessionSection(from: snapshot))
sections.append(self.actionSection(from: snapshot))
sections.append(self.summarySection(from: snapshot))
if snapshot.gateways.count >= 2 {
let entries = [Entry(.gatewayHeader)] + snapshot.gateways.flatMap { gateway in
var entries = [Entry(.gateway(gateway, isAlternate: false))]
if gateway.canPromote {
entries.append(Entry(.gateway(gateway, isAlternate: true)))
}
return entries
}
sections.append(Section(id: "gateways", entries: entries))
}
sections.append(self.footerSection(from: snapshot))
return StatusMenuDescriptor(sections: sections)
}
static func activeRows(
from rows: [SessionRow],
mainSessionKey: String = "main",
now: Date = Date()) -> [SessionRow]
{
rows.filter { row in
if row.key == "main", mainSessionKey != "main" { return false }
if row.key == mainSessionKey { return true }
guard let updatedAt = row.updatedAt else { return false }
return now.timeIntervalSince(updatedAt) <= self.activeWindowSeconds
}.sorted { lhs, rhs in
if lhs.key == mainSessionKey { return true }
if rhs.key == mainSessionKey { return false }
return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast)
}
}
private static func sessionSection(from snapshot: Snapshot) -> Section {
guard snapshot.connection == .connected else {
let approvals = snapshot.approvals.map { Entry(.approval($0)) }
return Section(id: "sessions", entries: approvals + [Entry(.placeholder(
self.connectionMessage(snapshot.connection)))])
}
let activeRows = self.activeRows(
from: snapshot.sessions,
mainSessionKey: snapshot.mainSessionKey,
now: snapshot.now)
let visibleRows = Array(activeRows.prefix(self.maximumSessionCount))
let visibleKeys = Set(visibleRows.map(\.key))
var entries = snapshot.approvals
.filter { approval in
guard let sessionKey = approval.request.sessionKey else { return true }
return !visibleKeys.contains(sessionKey)
}
.map { Entry(.approval($0)) }
for row in visibleRows {
entries.append(contentsOf: snapshot.approvals
.filter { $0.request.sessionKey == row.key }
.map { Entry(.approval($0)) })
entries.append(Entry(.session(row)))
}
if visibleRows.isEmpty {
let message = snapshot.sessionError ?? String(localized: "No recent sessions")
entries.append(Entry(.placeholder(message)))
} else if activeRows.count > self.maximumSessionCount {
entries.append(Entry(.action(.allSessions)))
}
return Section(id: "sessions", entries: entries)
}
private static func actionSection(from snapshot: Snapshot) -> Section {
var entries = [Entry(.action(.dashboard))]
if snapshot.quickChatEnabled {
entries.append(Entry(.action(.quickChat)))
}
entries.append(Entry(.action(.talkMode)))
if snapshot.canvasEnabled {
entries.append(Entry(.action(.canvas)))
}
return Section(id: "actions", entries: entries)
}
private static func summarySection(from snapshot: Snapshot) -> Section {
var entries = [Entry(.summary(.automations))]
if snapshot.hasUsage || snapshot.isUsageStalled {
entries.append(Entry(.summary(.usage)))
}
entries.append(Entry(.summary(.devices)))
return Section(id: "summaries", entries: entries)
}
private static func footerSection(from snapshot: Snapshot) -> Section {
var entries = [Entry(.action(.settings))]
if snapshot.debugEnabled {
entries.append(Entry(.action(.debug)))
}
entries.append(Entry(.action(.about)))
if snapshot.updateReady {
entries.append(Entry(.updateReady))
}
entries.append(Entry(.action(.quit)))
return Section(id: "footer", entries: entries)
}
private static func connectionMessage(_ connection: Connection) -> String {
switch connection {
case .unconfigured:
String(localized: "Gateway not configured")
case .disconnected:
String(localized: "Gateway disconnected")
case .connecting:
String(localized: "Connecting…")
case .connected:
String(localized: "No recent sessions")
case .degraded:
String(localized: "Gateway needs attention")
}
}
}