fix(macos): keep dashboard failures in window

This commit is contained in:
Peter Steinberger
2026-05-18 00:55:59 +01:00
parent 086d3d012e
commit bef3356375
7 changed files with 130 additions and 29 deletions

View File

@@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Mac app: keep app-level menu commands and Dashboard failure states reachable when the remote Gateway is disconnected, and keep the Settings sidebar toggle in the leading titlebar area.
- Mac app: align the Sessions settings pane with the standard Settings page gutter and row spacing.
- Codex app-server: preserve streamed native command output in mirrored transcripts and trajectory exports when final snapshots omit aggregated output. (#83200) Thanks @rozmiarD.
- Codex app-server: fail closed when chat or sender policy denies tools, disabling native code, app, environment, and user MCP surfaces for restricted turns. (#82374) Thanks @VACInc.

View File

@@ -10,6 +10,7 @@ final class DashboardManager {
static let shared = DashboardManager()
private var controller: DashboardWindowController?
private static let failureURL = URL(string: "about:blank")!
private init() {}
@@ -69,6 +70,19 @@ final class DashboardManager {
Task { _ = try? await ControlChannel.shared.health(timeout: 3) }
}
func showFailure(_ error: Error) {
let message = (error as NSError).localizedDescription
dashboardManagerLogger.error("dashboard setup failed error=\(message, privacy: .public)")
let controller = self.controller ?? DashboardWindowController(
url: Self.failureURL,
auth: DashboardWindowAuth(gatewayUrl: nil, token: nil, password: nil))
self.controller = controller
controller.showFailure(
title: "Dashboard unavailable",
message: message,
detail: "Check Settings → Connection or use Debug → Reset Remote Tunnel, then try again.")
}
func close() {
self.controller?.closeDashboard()
}

View File

@@ -80,6 +80,17 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
self.window?.performClose(nil)
}
func showFailure(title: String, message: String, detail: String? = nil) {
self.currentURL = URL(string: "about:blank")!
self.auth = DashboardWindowAuth(gatewayUrl: nil, token: nil, password: nil)
self.refreshNativeAuthScript(url: self.currentURL, auth: self.auth)
self.webView.stopLoading()
self.webView.loadHTMLString(
Self.failureHTML(title: title, message: message, detail: detail, url: nil),
baseURL: nil)
self.show()
}
private func load(_ url: URL) {
dashboardWindowLogger.debug("dashboard load \(url.absoluteString, privacy: .public)")
self.webView.load(URLRequest(url: url))
@@ -282,54 +293,107 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
if nsError.domain == NSURLErrorDomain, nsError.code == NSURLErrorCancelled { return }
dashboardWindowLogger.error(
"dashboard load failed url=\(self.currentURL.absoluteString, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
let html = Self.failureHTML(url: self.currentURL, message: error.localizedDescription)
let html = Self.failureHTML(
title: "Dashboard unavailable",
message: error.localizedDescription,
detail: "The dashboard window is open, but the web UI could not load from this endpoint.",
url: self.currentURL)
self.webView.loadHTMLString(html, baseURL: nil)
}
private static func failureHTML(url: URL, message: String) -> String {
"""
private static func failureHTML(title: String, message: String, detail: String?, url: URL?) -> String {
let detailHTML = detail.map { "<p class=\"detail\">\(self.htmlEscape($0))</p>" } ?? ""
let urlHTML = url.map { "<code>\(self.htmlEscape($0.absoluteString))</code>" } ?? ""
return """
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>
:root { color-scheme: light dark; }
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
background: Canvas;
color: CanvasText;
font: -apple-system-body;
background: #101114;
color: rgba(255,255,255,.92);
font: 15px -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
}
main {
width: min(520px, calc(100vw - 64px));
line-height: 1.4;
width: min(540px, calc(100vw - 72px));
padding: 34px;
border: 1px solid rgba(255,255,255,.12);
border-radius: 22px;
background: rgba(255,255,255,.035);
box-shadow: 0 28px 90px rgba(0,0,0,.36);
line-height: 1.45;
}
.badge {
width: 44px;
height: 44px;
display: grid;
place-items: center;
margin-bottom: 20px;
border-radius: 14px;
background: rgba(255,255,255,.07);
color: #ff746b;
font-size: 24px;
}
h1 {
margin: 0 0 10px;
font: -apple-system-title2;
font-weight: 650;
margin: 0 0 12px;
font-size: 24px;
line-height: 1.16;
font-weight: 700;
letter-spacing: 0;
}
p {
margin: 0;
color: rgba(255,255,255,.76);
font-size: 16px;
}
.detail {
margin-top: 14px;
color: rgba(255,255,255,.56);
font-size: 13px;
}
p { margin: 8px 0; color: color-mix(in srgb, CanvasText 72%, transparent); }
code {
display: block;
margin-top: 14px;
margin-top: 18px;
padding: 12px;
border-radius: 8px;
background: color-mix(in srgb, CanvasText 8%, transparent);
color: CanvasText;
border: 1px solid rgba(255,255,255,.08);
border-radius: 10px;
background: rgba(0,0,0,.26);
color: rgba(255,255,255,.76);
overflow-wrap: anywhere;
font: 12px ui-monospace, SFMono-Regular, Menlo, monospace;
}
@media (prefers-color-scheme: light) {
body { background: #f5f6f8; color: rgba(0,0,0,.86); }
main {
background: rgba(255,255,255,.84);
border-color: rgba(0,0,0,.1);
box-shadow: 0 28px 90px rgba(0,0,0,.12);
}
.badge { background: rgba(0,0,0,.06); }
p { color: rgba(0,0,0,.68); }
.detail { color: rgba(0,0,0,.54); }
code {
background: rgba(0,0,0,.05);
border-color: rgba(0,0,0,.08);
color: rgba(0,0,0,.68);
}
}
</style>
</head>
<body>
<main>
<h1>Dashboard unavailable</h1>
<div class="badge">!</div>
<h1>\(self.htmlEscape(title))</h1>
<p>\(self.htmlEscape(message))</p>
<code>\(self.htmlEscape(url.absoluteString))</code>
\(detailHTML)
\(urlHTML)
</main>
</body>
</html>

View File

@@ -184,7 +184,7 @@ final class DeepLinkHandler {
do {
try await DashboardManager.shared.show()
} catch {
self.presentAlert(title: "Dashboard unavailable", message: error.localizedDescription)
DashboardManager.shared.showFailure(error)
}
}

View File

@@ -91,8 +91,11 @@ struct OpenClawApp: App {
}
}
private func applyStatusItemAppearance(paused: Bool, sleeping: Bool) {
self.statusItem?.button?.appearsDisabled = paused || sleeping
private func applyStatusItemAppearance(paused _: Bool, sleeping _: Bool) {
// Keep the status item actionable even when the Gateway is paused or disconnected.
// The SwiftUI label already renders those states; AppKit's disabled appearance can
// leak into menu item validation and grey out app-level commands like Settings.
self.statusItem?.button?.appearsDisabled = false
}
private static func applyAttachOnlyOverrideIfNeeded() {
@@ -180,10 +183,7 @@ struct OpenClawApp: App {
do {
try await DashboardManager.shared.show()
} catch {
let alert = NSAlert()
alert.messageText = "Dashboard unavailable"
alert.informativeText = error.localizedDescription
alert.runModal()
DashboardManager.shared.showFailure(error)
}
}
}
@@ -302,10 +302,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
do {
try await DashboardManager.shared.show()
} catch {
let alert = NSAlert()
alert.messageText = "Dashboard unavailable"
alert.informativeText = error.localizedDescription
alert.runModal()
DashboardManager.shared.showFailure(error)
}
}
}

View File

@@ -48,6 +48,17 @@ struct SettingsRootView: View {
}
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight, alignment: .topLeading)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.toolbar(removing: .sidebarToggle)
.toolbar {
ToolbarItem(placement: .navigation) {
Button {
NSApp.sendAction(#selector(NSSplitViewController.toggleSidebar(_:)), to: nil, from: nil)
} label: {
Image(systemName: "sidebar.left")
}
.help("Show or hide sidebar")
}
}
.background(SettingsWindowChromeConfigurator())
.onReceive(NotificationCenter.default.publisher(for: .openclawSelectSettingsTab)) { note in
if let tab = note.object as? SettingsTab {

View File

@@ -37,4 +37,18 @@ struct DashboardWindowSmokeTests {
let url = try #require(URL(string: "http://[fd12:3456:789a::1]:18789/control/"))
#expect(DashboardWindowController.originString(for: url) == "http://[fd12:3456:789a::1]:18789")
}
@Test func `dashboard failure state opens in dashboard window`() throws {
let url = try #require(URL(string: "http://127.0.0.1:18789/control/"))
let controller = DashboardWindowController(
url: url,
auth: DashboardWindowAuth(gatewayUrl: nil, token: nil, password: nil))
controller.showFailure(
title: "Dashboard unavailable",
message: "Remote control tunnel failed",
detail: "Reset the remote tunnel and try again.")
#expect(controller.window?.isVisible == true)
#expect(controller.window?.styleMask.contains(.closable) == true)
controller.closeDashboard()
}
}