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

595 lines
24 KiB
Swift

import AppKit
import Foundation
import Observation
import OpenClawKit
import QuartzCore
@MainActor
@Observable
final class StatusMenuSummaries: NSObject {
static let shared = StatusMenuSummaries()
@ObservationIgnored private let nodes = NodesStore.shared
@ObservationIgnored private let cron = CronJobsStore.shared
private var cachedUsage: GatewayUsageSummary?
private var cachedCost: GatewayCostUsageSummary?
private var costError: String?
@ObservationIgnored private var usageUpdatedAt: Date?
@ObservationIgnored private var costUpdatedAt: Date?
@ObservationIgnored private var usageRetry: Task<Void, Never>?
@ObservationIgnored private var refreshTask: Task<Void, Never>?
@ObservationIgnored private var updateHandler: (@MainActor () -> Void)?
@ObservationIgnored private var copiedValues: [String: String] = [:]
@ObservationIgnored private var usageGeneration = 0
private var usageRetryAttempts = 0
private var usageRefreshPending = false
@ObservationIgnored private let usageRetryLimit = 3
var hasUsage: Bool {
!self.usageRows.isEmpty || self.cachedCost != nil || self.cachedUsage?.refreshing == true
|| self.usageRefreshPending
}
var isUsageStalled: Bool {
self.isConnected && self.cachedUsage?.refreshing == true && self.usageRetryAttempts >= self.usageRetryLimit
}
var usageSummary: String? {
let rows = self.usageRows
guard let row = rows.first(where: { $0.providerId.lowercased() == self.selectedProviderID }) ?? rows.first
else { return nil }
return "\(row.titleText) · \(row.detailText())"
}
var connectedDeviceCount: Int {
self.nodes.nodes.filter(\.isConnected).count
}
func refresh(onUpdate: @escaping @MainActor () -> Void) {
self.updateHandler = onUpdate
self.nodes.start()
self.cron.start()
guard self.refreshTask == nil else { return }
self.refreshTask = Task { [weak self] in
guard let self else { return }
async let jobs: Void = self.refreshAutomations()
async let devices: Void = self.refreshDevices()
async let usage: Void = self.refreshUsage()
async let cost: Void = self.refreshCost()
_ = await (jobs, devices, usage, cost)
self.refreshTask = nil
}
}
func menuDidClose() {
self.updateHandler = nil
self.usageRetry?.cancel()
self.usageRetry = nil
self.usageRetryAttempts = 0
self.usageGeneration += 1
}
func configureAutomations(_ item: NSMenuItem) {
let jobs = self.enabledJobs
let detail = if let next = jobs.compactMap(\.nextRunDate).min() {
String(localized: "\(jobs.count) · \(Self.relativeRun(next))")
} else {
String(localized: "\(jobs.count)")
}
item.title = String(localized: "Automations")
item.image = nil
StatusMenuRenderer.configureHostedView(
item,
rootView: StatusSummaryCard(
symbolName: "clock.badge.checkmark",
title: String(localized: "Automations"),
detail: detail),
highlights: true)
var entries = jobs.prefix(8).map { job in
MenuEntry(id: "cron.job.\(job.id)") { [weak self] item in
item.title = job.displayName
item.target = self
item.action = #selector(Self.openAutomations(_:))
item.isEnabled = true
StatusMenuRenderer.configureHostedView(
item,
rootView: StatusJobRow(
name: job.displayName,
nextRun: job.nextRunDate.map(Self.relativeRun)),
highlights: true)
}
}
if !entries.isEmpty {
entries.append(.separator(id: "cron.separator"))
}
entries.append(MenuEntry(id: "cron.open") { [weak self] item in
item.title = String(localized: "Open Automations…")
item.target = self
item.action = #selector(Self.openAutomations(_:))
item.isEnabled = true
})
self.reconcileSubmenu(for: item, entries: entries)
}
func configureUsage(_ item: NSMenuItem) {
item.title = String(localized: "Usage")
item.image = nil
StatusMenuRenderer.configureHostedView(
item,
rootView: StatusSummaryCard(
symbolName: "chart.bar.xaxis",
title: String(localized: "Usage"),
detail: self.usageSummary),
highlights: true)
var entries = self.orderedUsageRows.map { row in
MenuEntry(id: "usage.provider.\(row.id)") { item in
item.title = StatusMenuMetrics.fittedTitle(row.titleText)
item.isEnabled = false
StatusMenuRenderer.configureHostedView(item, rootView: UsageMenuLabelView(row: row))
}
}
if self.isUsageStalled, entries.isEmpty {
entries.append(.info(
id: "usage.stalled",
title: String(localized: "Usage did not finish loading. Close and reopen this menu to retry.")))
} else if entries.isEmpty, self.usageRefreshPending || self.cachedUsage?.refreshing == true {
entries.append(.info(id: "usage.loading", title: String(localized: "Loading usage…")))
}
if let summary = self.cachedCost, !summary.daily.isEmpty {
if !entries.isEmpty {
entries.append(.separator(id: "usage.cost.separator"))
}
entries.append(MenuEntry(id: "usage.cost.chart") { item in
item.title = String(localized: "Usage cost (30 days)")
item.isEnabled = false
StatusMenuRenderer.configureHostedView(item, rootView: CostUsageHistoryMenuView(summary: summary))
})
} else if let error = self.costError {
if !entries.isEmpty {
entries.append(.separator(id: "usage.cost.separator"))
}
entries.append(.info(id: "usage.cost.error", title: error))
}
self.reconcileSubmenu(for: item, entries: entries)
}
func configureDevices(_ item: NSMenuItem) {
let count = self.connectedDeviceCount
item.title = String(localized: "Devices")
item.image = nil
StatusMenuRenderer.configureHostedView(
item,
rootView: StatusSummaryCard(
symbolName: "laptopcomputer.and.iphone",
title: String(localized: "Devices"),
detail: String(localized: "\(count) connected")),
highlights: true)
var entries: [MenuEntry] = []
if let gateway = self.gatewayEntry() {
entries.append(self.nodeEntry(gateway))
}
if let notice = self.nodes.persistentServiceNotice {
entries.append(.info(id: "devices.service.notice", title: notice))
}
if case .connecting = ControlChannel.shared.state {
entries.append(.info(id: "devices.connecting", title: String(localized: "Connecting…")))
} else if self.isConnected {
if let error = self.nodes.lastError?.nonEmpty {
entries.append(.info(id: "devices.error", title: String(localized: "Error: \(error)")))
} else if let message = self.nodes.statusMessage?.nonEmpty {
entries.append(.info(id: "devices.status", title: message))
}
let devices = self.sortedNodes
if devices.isEmpty {
entries.append(.info(
id: "devices.empty",
title: self.nodes.isLoading
? String(localized: "Loading devices…")
: String(localized: "No devices yet")))
} else {
entries.append(contentsOf: devices.prefix(8).map(self.nodeEntry))
if devices.count > 8 {
let overflow = Array(devices.dropFirst(8))
entries.append(MenuEntry(id: "devices.overflow") { [weak self] item in
item.title = String(localized: "More Devices…")
item.image = NSImage(systemSymbolName: "ellipsis.circle", accessibilityDescription: nil)
self?.reconcileSubmenu(for: item, entries: overflow.compactMap { self?.nodeEntry($0) })
})
}
}
}
self.reconcileSubmenu(for: item, entries: entries)
}
func configureGateway(_ item: NSMenuItem, gateway: DashboardGatewayMenuItem, isAlternate: Bool) {
item.identifier = NSUserInterfaceItemIdentifier(gateway.id)
item.target = self
item.isAlternate = isAlternate
if isAlternate {
item.title = String(localized: "Set \(gateway.name) as Primary…")
item.action = #selector(Self.setPrimary(_:))
item.keyEquivalentModifierMask = [.option]
item.image = nil
item.state = .off
} else {
item.title = gateway.name
item.action = #selector(Self.openGateway(_:))
item.keyEquivalentModifierMask = []
item.image = Self.gatewayImage(health: gateway.health, name: gateway.name)
item.state = gateway.isPrimary ? .on : .off
}
item.title = StatusMenuMetrics.fittedTitle(item.title)
}
private var enabledJobs: [CronJob] {
self.cron.jobs.filter(\.enabled).sorted { lhs, rhs in
(lhs.nextRunDate ?? .distantFuture) < (rhs.nextRunDate ?? .distantFuture)
}
}
private var usageRows: [UsageRow] {
self.cachedUsage?.primaryRows() ?? []
}
private var orderedUsageRows: [UsageRow] {
let rows = self.usageRows
guard let selected = self.selectedProviderID,
let primary = rows.first(where: { $0.providerId.lowercased() == selected })
else { return rows }
return [primary] + rows.filter { $0.id != primary.id }
}
private var selectedProviderID: String? {
guard let model = StatusMenuSessions.shared.cachedSnapshot?.defaults.model.nonEmpty,
let slash = model.firstIndex(of: "/")
else { return nil }
return model[..<slash].trimmingCharacters(in: .whitespacesAndNewlines).lowercased().nonEmpty
}
private var isConnected: Bool {
if case .connected = ControlChannel.shared.state { return true }
return false
}
private func refreshAutomations() async {
guard self.isConnected else { return }
await self.cron.refreshJobs()
self.updateHandler?()
}
private func refreshDevices() async {
guard self.isConnected else { return }
await self.nodes.refresh()
self.updateHandler?()
}
private func refreshUsage() async {
guard self.isConnected,
self.usageUpdatedAt.map({ Date().timeIntervalSince($0) >= 30 }) ?? true
else { return }
self.usageGeneration += 1
let generation = self.usageGeneration
self.usageRetry?.cancel()
self.usageRetry = nil
self.usageRetryAttempts = 0
self.usageRefreshPending = true
await self.loadUsage(generation: generation)
}
private func loadUsage(generation: Int) async {
do {
let summary = try await UsageLoader.loadSummary()
guard generation == self.usageGeneration else { return }
self.cachedUsage = summary
if summary.refreshing == true {
self.usageUpdatedAt = nil
self.scheduleUsageRetry(generation: generation)
} else {
self.usageRefreshPending = false
self.usageUpdatedAt = Date()
}
} catch {
guard generation == self.usageGeneration else { return }
if self.cachedUsage?.refreshing == true {
self.usageUpdatedAt = nil
self.scheduleUsageRetry(generation: generation)
} else {
self.cachedUsage = nil
self.usageRefreshPending = false
self.usageUpdatedAt = Date()
}
}
self.updateHandler?()
}
private func scheduleUsageRetry(generation: Int) {
guard self.usageRetryAttempts < self.usageRetryLimit else {
self.usageRefreshPending = false
return
}
self.usageRetryAttempts += 1
self.usageRetry = Task { [weak self] in
try? await Task.sleep(for: .seconds(5))
guard let self, !Task.isCancelled, self.isConnected, generation == self.usageGeneration else { return }
await self.loadUsage(generation: generation)
}
}
private func refreshCost() async {
guard self.isConnected,
self.costUpdatedAt.map({ Date().timeIntervalSince($0) >= 45 }) ?? true
else { return }
do {
self.cachedCost = try await CostUsageLoader.loadSummary()
self.costError = nil
} catch {
self.cachedCost = nil
let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
self.costError = message.isEmpty
? String(localized: "Usage unavailable")
: (message.count > 90 ? "\(message.prefix(87))…" : message)
}
self.costUpdatedAt = Date()
self.updateHandler?()
}
private static func relativeRun(_ date: Date) -> String {
let delta = date.timeIntervalSinceNow
if delta <= 0 { return String(localized: "due") }
if delta < 60 { return String(localized: "in <1m") }
let minutes = Int(round(delta / 60))
if minutes < 60 { return String(localized: "in \(minutes)m") }
let hours = Int(round(Double(minutes) / 60))
if hours < 48 { return String(localized: "in \(hours)h") }
return String(localized: "in \(Int(round(Double(hours) / 24)))d")
}
@objc
private func openAutomations(_: NSMenuItem) {
Task { await DashboardManager.shared.show(atPath: DashboardRouteMap.cronJobsPagePath) }
}
@objc
private func openGateway(_ sender: NSMenuItem) {
guard let id = sender.identifier?.rawValue, let target = DashboardGatewayTarget(bridgeID: id) else { return }
DashboardManager.shared.openOrFocusDashboard(for: target)
}
@objc
private func setPrimary(_ sender: NSMenuItem) {
guard let id = sender.identifier?.rawValue, let target = DashboardGatewayTarget(bridgeID: id) else { return }
DashboardManager.shared.confirmSetPrimary(target)
}
private static func gatewayImage(health: DashboardGatewayHealth, name: String) -> NSImage? {
let (symbol, color, accessibility): (String, NSColor, String) = switch health {
case .ok:
("circle.fill", .systemGreen, String(localized: "\(name), healthy"))
case .error:
("exclamationmark.circle.fill", .systemRed, String(localized: "\(name), health error"))
case .unknown:
("circle", .tertiaryLabelColor, String(localized: "\(name), health unknown"))
}
return NSImage(systemSymbolName: symbol, accessibilityDescription: accessibility)?
.withSymbolConfiguration(.init(paletteColors: [color]))
}
}
extension StatusMenuSummaries {
private var sortedNodes: [NodeInfo] {
self.nodes.nodes.filter { $0.isConnected || $0.isPaired }.sorted { lhs, rhs in
if lhs.isConnected != rhs.isConnected { return lhs.isConnected }
if lhs.isPaired != rhs.isPaired { return lhs.isPaired }
let lhsName = NodeMenuEntryFormatter.primaryName(lhs).lowercased()
let rhsName = NodeMenuEntryFormatter.primaryName(rhs).lowercased()
return lhsName == rhsName ? lhs.nodeId < rhs.nodeId : lhsName < rhsName
}
}
private func gatewayEntry() -> NodeInfo? {
let mode = AppStateStore.shared.connectionMode
var host: String?
let platform: String?
switch mode {
case .remote:
platform = "remote"
if AppStateStore.shared.remoteTransport == .direct {
let raw = AppStateStore.shared.remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines)
if let url = URL(string: raw), let urlHost = url.host, !urlHost.isEmpty {
host = url.port.map { "\(urlHost):\($0)" } ?? urlHost
} else {
host = raw.nonEmpty
}
} else {
let target = AppStateStore.shared.remoteTarget
if let parsed = CommandResolver.parseSSHTarget(target) {
host = parsed.port == 22 ? parsed.host : "\(parsed.host):\(parsed.port)"
} else {
host = target.nonEmpty
}
}
case .local:
platform = "local"
host = GatewayConnectivityCoordinator.shared.localEndpointHostLabel
?? "127.0.0.1:\(GatewayEnvironment.gatewayPort())"
case .unconfigured:
platform = nil
host = nil
}
return NodeInfo(
nodeId: "gateway",
displayName: String(localized: "Gateway"),
platform: platform,
version: nil,
coreVersion: nil,
uiVersion: nil,
deviceFamily: nil,
modelIdentifier: nil,
remoteIp: host,
caps: nil,
commands: nil,
permissions: nil,
paired: nil,
connected: self.isConnected)
}
private func nodeEntry(_ node: NodeInfo) -> MenuEntry {
MenuEntry(id: "devices.node.\(node.nodeId)") { [weak self] item in
item.title = StatusMenuMetrics.fittedTitle(NodeMenuEntryFormatter.primaryName(node))
item.target = self
item.action = #selector(Self.copyNodeValue(_:))
item.isEnabled = true
if let id = item.representedObject as? String {
self?.copiedValues[id] = NodeMenuEntryFormatter.summaryText(node)
}
StatusMenuRenderer.configureHostedView(item, rootView: NodeMenuRowView(entry: node), highlights: true)
self?.configureNodeSubmenu(for: item, node: node)
}
}
private func configureNodeSubmenu(for item: NSMenuItem, node: NodeInfo) {
var entries = [self.copyEntry(node: node, id: "id", label: String(localized: "Node ID"), value: node.nodeId)]
if let name = node.displayName?.nonEmpty {
entries.append(self.copyEntry(node: node, id: "name", label: String(localized: "Name"), value: name))
}
if let ip = node.remoteIp?.nonEmpty {
entries.append(self.copyEntry(node: node, id: "ip", label: String(localized: "IP"), value: ip))
}
entries.append(self.copyEntry(
node: node,
id: "status",
label: String(localized: "Status"),
value: NodeMenuEntryFormatter.roleText(node)))
if let platform = NodeMenuEntryFormatter.platformText(node) {
entries.append(self.copyEntry(
node: node, id: "platform", label: String(localized: "Platform"), value: platform))
}
if let version = NodeMenuEntryFormatter.detailRightVersion(node)?.nonEmpty {
entries.append(self.copyEntry(
node: node, id: "version", label: String(localized: "Version"), value: version))
}
entries.append(.info(
id: "devices.node.\(node.nodeId).connected",
title: String(localized: "Connected: \(node.isConnected ? "Yes" : "No")")))
entries.append(.info(
id: "devices.node.\(node.nodeId).paired",
title: String(localized: "Paired: \(node.isPaired ? "Yes" : "No")")))
if let capabilities = node.caps?.filter({ !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }),
!capabilities.isEmpty
{
entries.append(self.copyEntry(
node: node,
id: "capabilities",
label: String(localized: "Caps"),
value: capabilities.joined(separator: ", ")))
}
if let commands = node.commands?.filter({ !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }),
!commands.isEmpty
{
entries.append(self.copyEntry(
node: node,
id: "commands",
label: String(localized: "Commands"),
value: commands.joined(separator: ", ")))
}
self.reconcileSubmenu(for: item, entries: entries)
}
private func copyEntry(node: NodeInfo, id: String, label: String, value: String) -> MenuEntry {
let entryID = "devices.node.\(node.nodeId).\(id)"
self.copiedValues[entryID] = value
return MenuEntry(id: entryID) { [weak self] item in
item.title = StatusMenuMetrics.fittedTitle("\(label): \(value)")
item.target = self
item.action = #selector(Self.copyNodeValue(_:))
item.isEnabled = true
}
}
@objc
private func copyNodeValue(_ sender: NSMenuItem) {
guard let id = sender.representedObject as? String, let value = self.copiedValues[id] else { return }
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(value, forType: .string)
}
}
extension StatusMenuSummaries {
private struct MenuEntry {
let id: String
var isSeparator = false
var update: @MainActor (NSMenuItem) -> Void
static func separator(id: String) -> Self {
Self(id: id, isSeparator: true, update: { _ in })
}
static func info(id: String, title: String) -> Self {
Self(id: id) { item in
item.title = StatusMenuMetrics.fittedTitle(title)
item.isEnabled = false
}
}
}
private func reconcileSubmenu(for parent: NSMenuItem, entries: [MenuEntry]) {
let menu: NSMenu
if let existing = parent.submenu {
menu = existing
} else {
menu = NSMenu()
menu.autoenablesItems = false
menu.delegate = StatusMenuHighlightDelegate.shared
StatusMenuAppearance.pin(menu)
parent.submenu = menu
}
let oldIDs = menu.items.map { $0.representedObject as? String ?? "" }
let newIDs = entries.map(\.id)
let sharedCount = min(oldIDs.count, newIDs.count)
var prefix = 0
while prefix < sharedCount, oldIDs[prefix] == newIDs[prefix] {
prefix += 1
}
var suffix = 0
while suffix < sharedCount - prefix,
oldIDs[oldIDs.count - 1 - suffix] == newIDs[newIDs.count - 1 - suffix]
{
suffix += 1
}
CATransaction.begin()
CATransaction.setDisableActions(true)
for index in stride(from: oldIDs.count - suffix - 1, through: prefix, by: -1) where index >= prefix {
menu.removeItem(at: index)
}
for index in prefix..<(entries.count - suffix) {
let entry = entries[index]
let item = entry.isSeparator ? NSMenuItem.separator() : NSMenuItem()
item.representedObject = entry.id
entry.update(item)
menu.insertItem(item, at: index)
}
for index in 0..<prefix {
entries[index].update(menu.item(at: index)!)
}
for index in (entries.count - suffix)..<entries.count {
entries[index].update(menu.item(at: index)!)
}
CATransaction.commit()
}
}