mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 20:35:39 -06:00
2b0da0e193
* 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.
441 lines
16 KiB
Swift
441 lines
16 KiB
Swift
import AppKit
|
|
import Foundation
|
|
import Observation
|
|
import SwiftUI
|
|
|
|
@MainActor
|
|
@Observable
|
|
final class StatusMenuSessions: NSObject {
|
|
static let shared = StatusMenuSessions()
|
|
|
|
private(set) var rows: [SessionRow] = []
|
|
private(set) var errorText: String?
|
|
private(set) var cachedSnapshot: SessionStoreSnapshot?
|
|
|
|
@ObservationIgnored private var updatedAt: Date?
|
|
@ObservationIgnored private var previewTasks: [Task<Void, Never>] = []
|
|
@ObservationIgnored private let refreshInterval: TimeInterval = 12
|
|
|
|
func refresh(force: Bool = false) async {
|
|
if !force,
|
|
let updatedAt,
|
|
Date().timeIntervalSince(updatedAt) < self.refreshInterval
|
|
{
|
|
return
|
|
}
|
|
|
|
guard case .connected = ControlChannel.shared.state else {
|
|
self.errorText = self.cachedSnapshot == nil
|
|
? nil
|
|
: String(localized: "Gateway disconnected (showing cached)")
|
|
self.updatedAt = Date()
|
|
return
|
|
}
|
|
|
|
do {
|
|
let snapshot = try await SessionLoader.loadSnapshot(limit: 32)
|
|
self.cachedSnapshot = snapshot
|
|
self.rows = snapshot.rows
|
|
self.errorText = nil
|
|
self.updatedAt = Date()
|
|
self.prewarmPreviews(for: snapshot.rows)
|
|
} catch {
|
|
self.cachedSnapshot = nil
|
|
self.rows = []
|
|
self.errorText = self.compactError(error)
|
|
self.updatedAt = Date()
|
|
}
|
|
}
|
|
|
|
func configureSessionItem(_ item: NSMenuItem, row: SessionRow) {
|
|
item.title = row.label
|
|
item.isEnabled = true
|
|
self.configureHostedView(
|
|
item,
|
|
rootView: AnyView(StatusSessionCard(row: row)),
|
|
width: StatusMenuRenderer.cardWidth)
|
|
|
|
if let submenu = item.submenu {
|
|
self.updateSessionSubmenu(submenu, row: row)
|
|
} else {
|
|
item.submenu = self.buildSessionSubmenu(for: row)
|
|
}
|
|
}
|
|
|
|
func configureApprovalItem(_ item: NSMenuItem, request: ExecApprovalQueueItem) {
|
|
item.title = String(localized: "Approval requested")
|
|
item.isEnabled = true
|
|
item.submenu = nil
|
|
self.configureHostedView(
|
|
item,
|
|
rootView: AnyView(StatusApprovalCard(request: request)),
|
|
width: StatusMenuRenderer.cardWidth)
|
|
}
|
|
|
|
func cancelPreviewTasks() {
|
|
self.previewTasks.forEach { $0.cancel() }
|
|
self.previewTasks.removeAll()
|
|
}
|
|
|
|
private func configureHostedView(_ item: NSMenuItem, rootView: AnyView, width: CGFloat) {
|
|
if let hosting = item.view as? NSHostingView<AnyView> {
|
|
hosting.rootView = rootView
|
|
hosting.invalidateIntrinsicContentSize()
|
|
hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: hosting.fittingSize.height))
|
|
return
|
|
}
|
|
|
|
let hosting = NSHostingView(rootView: rootView)
|
|
hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: hosting.fittingSize.height))
|
|
item.view = hosting
|
|
}
|
|
|
|
private func prewarmPreviews(for rows: [SessionRow]) {
|
|
let keys = StatusMenuDescriptor.activeRows(
|
|
from: rows,
|
|
mainSessionKey: WorkActivityStore.shared.mainSessionKey)
|
|
.prefix(6)
|
|
.map(\.key)
|
|
guard !keys.isEmpty else { return }
|
|
self.previewTasks.append(Task {
|
|
await SessionMenuPreviewLoader.prewarm(sessionKeys: keys, maxItems: 10)
|
|
})
|
|
}
|
|
|
|
private func compactError(_ error: Error) -> String {
|
|
if let loadError = error as? SessionLoadError {
|
|
switch loadError {
|
|
case .gatewayUnavailable:
|
|
return String(localized: "No connection to gateway")
|
|
case .decodeFailed:
|
|
return String(localized: "Sessions unavailable")
|
|
}
|
|
}
|
|
return String(localized: "Sessions unavailable")
|
|
}
|
|
}
|
|
|
|
extension StatusMenuSessions {
|
|
private func buildSessionSubmenu(for row: SessionRow) -> NSMenu {
|
|
let menu = NSMenu()
|
|
menu.autoenablesItems = false
|
|
StatusMenuAppearance.pin(menu)
|
|
|
|
menu.addItem(self.makePreviewItem(
|
|
sessionKey: row.key,
|
|
title: String(localized: "Recent messages (last 10)"),
|
|
maxLines: 3))
|
|
|
|
let morePreview = NSMenuItem(title: String(localized: "More preview…"), action: nil, keyEquivalent: "")
|
|
morePreview.submenu = self.buildPreviewSubmenu(sessionKey: row.key)
|
|
menu.addItem(morePreview)
|
|
|
|
if self.shouldRecommendCompaction(for: row) {
|
|
menu.addItem(self.makeCompactItem(key: row.key, recommended: true))
|
|
}
|
|
|
|
menu.addItem(NSMenuItem.separator())
|
|
|
|
let thinking = NSMenuItem(title: String(localized: "Thinking"), action: nil, keyEquivalent: "")
|
|
thinking.identifier = NSUserInterfaceItemIdentifier("session.thinking")
|
|
thinking.submenu = self.buildPreferenceMenu(
|
|
key: row.key,
|
|
levels: ["off", "minimal", "low", "medium", "high"],
|
|
current: row.thinkingLevel,
|
|
action: #selector(self.patchThinking(_:)))
|
|
menu.addItem(thinking)
|
|
|
|
let verbose = NSMenuItem(title: String(localized: "Verbose"), action: nil, keyEquivalent: "")
|
|
verbose.identifier = NSUserInterfaceItemIdentifier("session.verbose")
|
|
verbose.submenu = self.buildPreferenceMenu(
|
|
key: row.key,
|
|
levels: ["on", "off"],
|
|
current: row.verboseLevel,
|
|
action: #selector(self.patchVerbose(_:)))
|
|
menu.addItem(verbose)
|
|
|
|
self.updateDebugLogItem(in: menu, row: row)
|
|
|
|
menu.addItem(NSMenuItem.separator())
|
|
menu.addItem(self.makeActionItem(
|
|
title: String(localized: "Reset Session"),
|
|
action: #selector(self.resetSession(_:)),
|
|
payload: row.key))
|
|
menu.addItem(self.makeCompactItem(key: row.key, recommended: false))
|
|
|
|
if row.key != WorkActivityStore.shared.mainSessionKey, row.key != "global" {
|
|
menu.addItem(self.makeActionItem(
|
|
title: String(localized: "Delete Session"),
|
|
action: #selector(self.deleteSession(_:)),
|
|
payload: row.key))
|
|
}
|
|
|
|
return menu
|
|
}
|
|
|
|
private func updateSessionSubmenu(_ menu: NSMenu, row: SessionRow) {
|
|
let recommendedID = NSUserInterfaceItemIdentifier("session.compact-recommended")
|
|
let recommendedIndex = menu.items.firstIndex { $0.identifier == recommendedID }
|
|
if self.shouldRecommendCompaction(for: row), recommendedIndex == nil {
|
|
menu.insertItem(self.makeCompactItem(key: row.key, recommended: true), at: min(2, menu.items.count))
|
|
} else if !self.shouldRecommendCompaction(for: row), let recommendedIndex {
|
|
menu.removeItem(at: recommendedIndex)
|
|
}
|
|
|
|
self.updatePreferenceMenu(
|
|
menu.items.first { $0.identifier?.rawValue == "session.thinking" }?.submenu,
|
|
current: row.thinkingLevel)
|
|
self.updatePreferenceMenu(
|
|
menu.items.first { $0.identifier?.rawValue == "session.verbose" }?.submenu,
|
|
current: row.verboseLevel)
|
|
self.updateDebugLogItem(in: menu, row: row)
|
|
}
|
|
|
|
private func updateDebugLogItem(in menu: NSMenu, row: SessionRow) {
|
|
let logID = NSUserInterfaceItemIdentifier("session.open-log")
|
|
let separatorID = NSUserInterfaceItemIdentifier("session.open-log-separator")
|
|
let existing = menu.items.first { $0.identifier == logID }
|
|
|
|
guard AppStateStore.shared.debugPaneEnabled,
|
|
AppStateStore.shared.connectionMode == .local,
|
|
let sessionId = row.sessionId,
|
|
!sessionId.isEmpty
|
|
else {
|
|
if let existing {
|
|
menu.removeItem(existing)
|
|
}
|
|
if let separator = menu.items.first(where: { $0.identifier == separatorID }) {
|
|
menu.removeItem(separator)
|
|
}
|
|
return
|
|
}
|
|
|
|
let payload = ["sessionId": sessionId, "storePath": self.cachedSnapshot?.storePath ?? ""]
|
|
if let existing {
|
|
existing.representedObject = payload
|
|
return
|
|
}
|
|
|
|
guard let verboseIndex = menu.items.firstIndex(where: { $0.identifier?.rawValue == "session.verbose" })
|
|
else { return }
|
|
let separator = NSMenuItem.separator()
|
|
separator.identifier = separatorID
|
|
menu.insertItem(separator, at: verboseIndex + 1)
|
|
|
|
let openLog = self.makeActionItem(
|
|
title: String(localized: "Open Session Log"),
|
|
action: #selector(self.openSessionLog(_:)),
|
|
payload: payload)
|
|
openLog.identifier = logID
|
|
menu.insertItem(openLog, at: verboseIndex + 2)
|
|
}
|
|
|
|
private func shouldRecommendCompaction(for row: SessionRow) -> Bool {
|
|
guard let percentUsed = row.tokens.percentUsed else { return false }
|
|
return Double(percentUsed) / 100 >= ContextRingView.compactThreshold
|
|
}
|
|
|
|
private func makeCompactItem(key: String, recommended: Bool) -> NSMenuItem {
|
|
let title = recommended
|
|
? String(localized: "Compact Session Log (recommended)")
|
|
: String(localized: "Compact Session Log")
|
|
let item = self.makeActionItem(title: title, action: #selector(self.compactSession(_:)), payload: key)
|
|
if recommended {
|
|
item.identifier = NSUserInterfaceItemIdentifier("session.compact-recommended")
|
|
item.image = NSImage(systemSymbolName: "exclamationmark.circle", accessibilityDescription: nil)
|
|
}
|
|
return item
|
|
}
|
|
|
|
private func buildPreferenceMenu(key: String, levels: [String], current: String?, action: Selector) -> NSMenu {
|
|
let menu = NSMenu()
|
|
menu.autoenablesItems = false
|
|
menu.showsStateColumn = true
|
|
StatusMenuAppearance.pin(menu)
|
|
let selected = levels.contains(current ?? "") ? current ?? "off" : "off"
|
|
|
|
for level in levels {
|
|
let item = self.makeActionItem(
|
|
title: self.preferenceTitle(for: level),
|
|
action: action,
|
|
payload: ["key": key, "value": level])
|
|
item.state = selected == level ? .on : .off
|
|
menu.addItem(item)
|
|
}
|
|
|
|
return menu
|
|
}
|
|
|
|
private func preferenceTitle(for level: String) -> String {
|
|
switch level {
|
|
case "off": String(localized: "Off")
|
|
case "on": String(localized: "On")
|
|
case "minimal": String(localized: "Minimal")
|
|
case "low": String(localized: "Low")
|
|
case "medium": String(localized: "Medium")
|
|
case "high": String(localized: "High")
|
|
default: level.capitalized
|
|
}
|
|
}
|
|
|
|
private func updatePreferenceMenu(_ menu: NSMenu?, current: String?) {
|
|
guard let menu else { return }
|
|
let selected = menu.items.contains {
|
|
($0.representedObject as? [String: String])?["value"] == current
|
|
} ? current : "off"
|
|
|
|
for item in menu.items {
|
|
let value = (item.representedObject as? [String: String])?["value"]
|
|
item.state = value == selected ? .on : .off
|
|
}
|
|
}
|
|
|
|
private func buildPreviewSubmenu(sessionKey: String) -> NSMenu {
|
|
let menu = NSMenu()
|
|
StatusMenuAppearance.pin(menu)
|
|
menu.addItem(self.makePreviewItem(
|
|
sessionKey: sessionKey,
|
|
title: String(localized: "Recent messages (expanded)"),
|
|
maxLines: 8))
|
|
return menu
|
|
}
|
|
|
|
private func makePreviewItem(sessionKey: String, title: String, maxLines: Int) -> NSMenuItem {
|
|
let item = NSMenuItem()
|
|
item.isEnabled = false
|
|
let width = StatusMenuRenderer.cardWidth
|
|
let initialView = AnyView(SessionMenuPreviewView(
|
|
width: width,
|
|
maxLines: maxLines,
|
|
title: title,
|
|
items: [],
|
|
status: .loading)
|
|
.environment(\.isEnabled, true))
|
|
self.configureHostedView(item, rootView: initialView, width: width)
|
|
|
|
self.previewTasks.append(Task { [weak self, weak item] in
|
|
let snapshot = await SessionMenuPreviewLoader.load(sessionKey: sessionKey, maxItems: 10)
|
|
guard !Task.isCancelled, let self, let item else { return }
|
|
self.configureHostedView(
|
|
item,
|
|
rootView: AnyView(SessionMenuPreviewView(
|
|
width: width,
|
|
maxLines: maxLines,
|
|
title: title,
|
|
items: snapshot.items,
|
|
status: snapshot.status)
|
|
.environment(\.isEnabled, true)),
|
|
width: width)
|
|
})
|
|
|
|
return item
|
|
}
|
|
|
|
private func makeActionItem(title: String, action: Selector, payload: Any) -> NSMenuItem {
|
|
let item = NSMenuItem(title: title, action: action, keyEquivalent: "")
|
|
item.target = self
|
|
item.representedObject = payload
|
|
item.isEnabled = true
|
|
return item
|
|
}
|
|
}
|
|
|
|
extension StatusMenuSessions {
|
|
@objc private func patchThinking(_ sender: NSMenuItem) {
|
|
guard let payload = sender.representedObject as? [String: String],
|
|
let key = payload["key"],
|
|
let value = payload["value"]
|
|
else { return }
|
|
|
|
Task {
|
|
do {
|
|
try await SessionActions.patchSession(key: key, thinking: .some(value))
|
|
await self.refresh(force: true)
|
|
} catch {
|
|
SessionActions.presentError(title: String(localized: "Update thinking failed"), error: error)
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc private func patchVerbose(_ sender: NSMenuItem) {
|
|
guard let payload = sender.representedObject as? [String: String],
|
|
let key = payload["key"],
|
|
let value = payload["value"]
|
|
else { return }
|
|
|
|
Task {
|
|
do {
|
|
try await SessionActions.patchSession(key: key, verbose: .some(value))
|
|
await self.refresh(force: true)
|
|
} catch {
|
|
SessionActions.presentError(title: String(localized: "Update verbose failed"), error: error)
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc private func openSessionLog(_ sender: NSMenuItem) {
|
|
guard let payload = sender.representedObject as? [String: String],
|
|
let sessionId = payload["sessionId"]
|
|
else { return }
|
|
SessionActions.openSessionLogInCode(sessionId: sessionId, storePath: payload["storePath"])
|
|
}
|
|
|
|
@objc private func resetSession(_ sender: NSMenuItem) {
|
|
guard let key = sender.representedObject as? String else { return }
|
|
Task {
|
|
guard SessionActions.confirmDestructiveAction(
|
|
title: String(localized: "Reset session?"),
|
|
message: String(localized: "Starts a new session ID for “\(key)”."),
|
|
action: String(localized: "Reset"))
|
|
else { return }
|
|
|
|
do {
|
|
try await SessionActions.resetSession(key: key)
|
|
await self.refresh(force: true)
|
|
} catch {
|
|
SessionActions.presentError(title: String(localized: "Reset failed"), error: error)
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc private func compactSession(_ sender: NSMenuItem) {
|
|
guard let key = sender.representedObject as? String else { return }
|
|
Task {
|
|
guard SessionActions.confirmDestructiveAction(
|
|
title: String(localized: "Compact session log?"),
|
|
message: String(localized: "Keeps the last 400 lines and archives the old file."),
|
|
action: String(localized: "Compact"))
|
|
else { return }
|
|
|
|
do {
|
|
try await SessionActions.compactSession(key: key, maxLines: 400)
|
|
await self.refresh(force: true)
|
|
} catch {
|
|
SessionActions.presentError(title: String(localized: "Compact failed"), error: error)
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc private func deleteSession(_ sender: NSMenuItem) {
|
|
guard let key = sender.representedObject as? String,
|
|
key != WorkActivityStore.shared.mainSessionKey,
|
|
key != "global"
|
|
else { return }
|
|
|
|
Task {
|
|
guard SessionActions.confirmDestructiveAction(
|
|
title: String(localized: "Delete session?"),
|
|
message: String(localized: "Deletes the “\(key)” entry and archives its transcript."),
|
|
action: String(localized: "Delete"))
|
|
else { return }
|
|
|
|
do {
|
|
try await SessionActions.deleteSession(key: key)
|
|
await self.refresh(force: true)
|
|
} catch {
|
|
SessionActions.presentError(title: String(localized: "Delete failed"), error: error)
|
|
}
|
|
}
|
|
}
|
|
}
|