mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
99a2434f7b
* fix(macos): stabilize status menu width across hosted and native rows * fix(macos): format node worker termination status without optionals * refactor(macos): give status menu width one owner fittedTitle's hasImage/hasSubmenu/keyEquivalent parameters never changed the computation - the budget always included image, submenu, and shortcut chrome - because AppKit aligns those columns across the whole menu, so a per-item budget is the wrong model. Collapse to one menu-wide budget and drop the misleading knobs from all 13 call sites. * refactor(macos): keep the status menu header quiet when healthy A working system now says nothing: the header renders only operator- actionable trouble lines (colored text, no status bubbles), the healthy state is title + toggle + capability strip, and the churn sources that made the open menu jump are gone - relative check ages, 'health check running', and the per-tool activity label (the critter icon already animates work). Session-row kind icons go monochrome; green conveyed nothing actionable. Healthy title is 'OpenClaw' (or the primary gateway name with multiple gateways) since the toggle already communicates active. DashboardGatewayMenuModel.connectionLabel lost its last caller and is deleted with its test. * fix(macos): preserve structured node worker diagnostics in status menu * feat(macos): highlight hosted menu rows from AppKit's selection signal AppKit paints no selection behind NSMenuItem.view, so hosted session and device rows had no hover or keyboard highlight. HostedMenuRowView now draws the native selection material (rounded, emphasized) and flips the SwiftUI content through the existing menuItemHighlighted environment, driven by NSMenuDelegate.menu(_:willHighlight:) so pointer and arrow-key navigation both light up. Submenus get a shared highlight-only delegate; the controller's root-menu guards keep open/refresh paths unaffected. * refactor(macos): drop the manual Open Canvas action Canvas panels are agent-opened; a manual open action was noise. The Allow Canvas capability tile stays - that is the permission. Removes the menu action, the dock-menu item, and the now-orphaned AppNavigationActions.toggleCanvas. * fix(macos): clear hosted submenu selection when the submenu closes AppKit sends no willHighlight(nil) on submenu close, so a hosted device row selected there reopened still lit. The shared highlight delegate now resets its menu's hosted rows in menuDidClose; regression covers the close cycle. * style(macos): apply swiftformat to highlight delegate
266 lines
9.9 KiB
Swift
266 lines
9.9 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import OpenClaw
|
|
|
|
@MainActor
|
|
struct StatusMenuDescriptorTests {
|
|
private static let referenceDate = Date(timeIntervalSince1970: 1_800_000_000)
|
|
|
|
@Test func `connected menu preserves section and entry order`() {
|
|
let snapshot = StatusMenuDescriptor.Snapshot(
|
|
quickChatEnabled: true,
|
|
debugEnabled: true,
|
|
updateReady: true,
|
|
hasUsage: true,
|
|
sessions: [Self.session("main")],
|
|
now: Self.referenceDate)
|
|
|
|
let descriptor = StatusMenuDescriptor.build(from: snapshot)
|
|
|
|
#expect(descriptor.sections.map(\.id) == ["header", "sessions", "actions", "summaries", "footer"])
|
|
#expect(descriptor.sections.flatMap(\.entries).map(\.id) == [
|
|
"header",
|
|
"session.main",
|
|
"action.dashboard",
|
|
"action.quickChat",
|
|
"action.talkMode",
|
|
"summary.automations",
|
|
"summary.usage",
|
|
"summary.devices",
|
|
"action.settings",
|
|
"action.debug",
|
|
"action.about",
|
|
"update.ready",
|
|
"action.quit",
|
|
])
|
|
}
|
|
|
|
@Test func `connection states replace sessions with visible explanations`() {
|
|
let states: [(StatusMenuDescriptor.Connection, String)] = [
|
|
(.unconfigured, "Gateway not configured"),
|
|
(.disconnected, "Gateway disconnected"),
|
|
(.connecting, "Connecting…"),
|
|
(.degraded, "Gateway needs attention"),
|
|
]
|
|
|
|
for (connection, expectedMessage) in states {
|
|
let descriptor = StatusMenuDescriptor.build(from: StatusMenuDescriptor.Snapshot(
|
|
connection: connection,
|
|
sessions: [Self.session("main")],
|
|
now: Self.referenceDate))
|
|
let entries = Self.entries(in: "sessions", descriptor: descriptor)
|
|
|
|
#expect(entries.map(\.id) == ["placeholder"])
|
|
guard case let .placeholder(message) = entries.first?.kind else {
|
|
Issue.record("Missing placeholder for connection state \(connection)")
|
|
continue
|
|
}
|
|
#expect(message == expectedMessage)
|
|
}
|
|
}
|
|
|
|
@Test func `paused menu preserves cached sessions and available actions`() {
|
|
let descriptor = StatusMenuDescriptor.build(from: StatusMenuDescriptor.Snapshot(
|
|
isPaused: true,
|
|
voiceWakeSupported: false,
|
|
sessions: [Self.session("main")],
|
|
now: Self.referenceDate))
|
|
|
|
#expect(Self.entries(in: "sessions", descriptor: descriptor).map(\.id) == ["session.main"])
|
|
#expect(Self.entries(in: "actions", descriptor: descriptor).map(\.id) == [
|
|
"action.dashboard",
|
|
"action.talkMode",
|
|
])
|
|
}
|
|
|
|
@Test func `multiple gateways retain eligible option alternate identities`() {
|
|
let primary = DashboardGatewayMenuItem(
|
|
target: .primary,
|
|
name: "Mac Studio",
|
|
health: .ok,
|
|
isPrimary: true,
|
|
canPromote: false,
|
|
shortcutNumber: 1)
|
|
let secondary = DashboardGatewayMenuItem(
|
|
target: .profile("travel"),
|
|
name: "Travel",
|
|
health: .unknown,
|
|
isPrimary: false,
|
|
canPromote: true,
|
|
shortcutNumber: 2)
|
|
|
|
let single = StatusMenuDescriptor.build(from: StatusMenuDescriptor.Snapshot(gateways: [primary]))
|
|
let multiple = StatusMenuDescriptor.build(from: StatusMenuDescriptor.Snapshot(
|
|
gateways: [primary, secondary]))
|
|
|
|
#expect(!single.sections.contains { $0.id == "gateways" })
|
|
#expect(multiple.sections.map(\.id) == [
|
|
"header",
|
|
"sessions",
|
|
"actions",
|
|
"summaries",
|
|
"gateways",
|
|
"footer",
|
|
])
|
|
#expect(Self.entries(in: "gateways", descriptor: multiple).map(\.id) == [
|
|
"gateway.header",
|
|
"gateway.primary",
|
|
"gateway.profile:travel",
|
|
"gateway.profile:travel.alternate",
|
|
])
|
|
}
|
|
|
|
@Test func `pending approvals attach to matching sessions and preserve unmatched requests`() {
|
|
let approvals = [
|
|
Self.approval("second", sessionKey: "other"),
|
|
Self.approval("orphan", sessionKey: "missing"),
|
|
Self.approval("first", sessionKey: "main"),
|
|
Self.approval("global", sessionKey: nil),
|
|
]
|
|
let descriptor = StatusMenuDescriptor.build(from: StatusMenuDescriptor.Snapshot(
|
|
sessions: [Self.session("other", age: 60), Self.session("main", age: 120)],
|
|
approvals: approvals,
|
|
now: Self.referenceDate))
|
|
|
|
#expect(Self.entries(in: "sessions", descriptor: descriptor).map(\.id) == [
|
|
"approval.orphan",
|
|
"approval.global",
|
|
"approval.first",
|
|
"session.main",
|
|
"approval.second",
|
|
"session.other",
|
|
])
|
|
}
|
|
|
|
@Test func `disconnected approvals remain actionable above the gateway explanation`() {
|
|
let descriptor = StatusMenuDescriptor.build(from: StatusMenuDescriptor.Snapshot(
|
|
connection: .disconnected,
|
|
approvals: [Self.approval("pending", sessionKey: "main")]))
|
|
|
|
#expect(Self.entries(in: "sessions", descriptor: descriptor).map(\.id) == [
|
|
"approval.pending",
|
|
"placeholder",
|
|
])
|
|
}
|
|
|
|
@Test func `stalled usage remains visible without a completed usage cache`() {
|
|
let states: [(Bool, Bool, Bool)] = [
|
|
(false, false, false),
|
|
(true, false, true),
|
|
(false, true, true),
|
|
(true, true, true),
|
|
]
|
|
|
|
for (hasUsage, isUsageStalled, expectedVisible) in states {
|
|
let descriptor = StatusMenuDescriptor.build(from: StatusMenuDescriptor.Snapshot(
|
|
hasUsage: hasUsage,
|
|
isUsageStalled: isUsageStalled))
|
|
let usageVisible = Self.entries(in: "summaries", descriptor: descriptor)
|
|
.contains { $0.id == "summary.usage" }
|
|
|
|
#expect(usageVisible == expectedVisible)
|
|
}
|
|
}
|
|
|
|
@Test func `sessions cap at six and expose the complete dashboard list`() {
|
|
let rows = (1...8).map { index in Self.session("session-\(index)", age: TimeInterval(index)) }
|
|
let descriptor = StatusMenuDescriptor.build(from: StatusMenuDescriptor.Snapshot(
|
|
sessions: rows,
|
|
approvals: [Self.approval("hidden", sessionKey: "session-8")],
|
|
now: Self.referenceDate))
|
|
|
|
#expect(Self.entries(in: "sessions", descriptor: descriptor).map(\.id) == [
|
|
"approval.hidden",
|
|
"session.session-1",
|
|
"session.session-2",
|
|
"session.session-3",
|
|
"session.session-4",
|
|
"session.session-5",
|
|
"session.session-6",
|
|
"action.allSessions",
|
|
])
|
|
|
|
let exactLimit = StatusMenuDescriptor.build(from: StatusMenuDescriptor.Snapshot(
|
|
sessions: Array(rows.prefix(6)),
|
|
now: Self.referenceDate))
|
|
#expect(!Self.entries(in: "sessions", descriptor: exactLimit).contains { $0.id == "action.allSessions" })
|
|
}
|
|
|
|
@Test func `active rows keep main first and filter the exact twenty-four-hour window`() {
|
|
let rows = [
|
|
Self.session("stale", age: 86401),
|
|
Self.session("older", age: 120),
|
|
Self.session("main", age: 172_800),
|
|
Self.session("newer", age: 30),
|
|
Self.session("boundary", age: 86400),
|
|
Self.session("undated", updatedAt: nil),
|
|
]
|
|
|
|
let visible = StatusMenuDescriptor.activeRows(from: rows, now: Self.referenceDate)
|
|
|
|
#expect(visible.map(\.key) == ["main", "newer", "older", "boundary"])
|
|
}
|
|
|
|
@Test func `configured main session replaces the canonical main alias`() {
|
|
let rows = [
|
|
Self.session("main", age: 1),
|
|
Self.session("recent", age: 2),
|
|
Self.session("agent:primary", updatedAt: nil),
|
|
]
|
|
|
|
let visible = StatusMenuDescriptor.activeRows(
|
|
from: rows,
|
|
mainSessionKey: "agent:primary",
|
|
now: Self.referenceDate)
|
|
|
|
#expect(visible.map(\.key) == ["agent:primary", "recent"])
|
|
}
|
|
|
|
@Test func `session errors remain visible instead of appearing as an empty success`() {
|
|
let descriptor = StatusMenuDescriptor.build(from: StatusMenuDescriptor.Snapshot(
|
|
sessionError: "The gateway session list could not be loaded."))
|
|
let entries = Self.entries(in: "sessions", descriptor: descriptor)
|
|
|
|
guard case let .placeholder(message) = entries.first?.kind else {
|
|
Issue.record("Expected the session failure to be visible")
|
|
return
|
|
}
|
|
#expect(message == "The gateway session list could not be loaded.")
|
|
}
|
|
|
|
private static func entries(
|
|
in sectionID: String,
|
|
descriptor: StatusMenuDescriptor) -> [StatusMenuDescriptor.Entry]
|
|
{
|
|
descriptor.sections.first { $0.id == sectionID }?.entries ?? []
|
|
}
|
|
|
|
private static func session(_ key: String, age: TimeInterval = 0) -> SessionRow {
|
|
self.session(key, updatedAt: self.referenceDate.addingTimeInterval(-age))
|
|
}
|
|
|
|
private static func session(_ key: String, updatedAt: Date?) -> SessionRow {
|
|
SessionRow(
|
|
id: key,
|
|
key: key,
|
|
kind: .direct,
|
|
displayName: nil,
|
|
updatedAt: updatedAt,
|
|
sessionId: nil,
|
|
thinkingLevel: nil,
|
|
verboseLevel: nil,
|
|
systemSent: false,
|
|
abortedLastRun: false,
|
|
tokens: SessionTokenStats(input: 0, output: 0, total: 0, contextTokens: 200_000),
|
|
model: nil)
|
|
}
|
|
|
|
private static func approval(_ id: String, sessionKey: String?) -> ExecApprovalQueueItem {
|
|
ExecApprovalQueueItem(
|
|
id: id,
|
|
request: ExecApprovalPromptRequest(command: "echo ready", sessionKey: sessionKey),
|
|
createdAtMs: 1,
|
|
expiresAtMs: 2)
|
|
}
|
|
}
|