diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 0252d11f59d3..027b06c346ae 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -26163,7 +26163,7 @@ }, { "kind": "conditional-branch", - "line": 781, + "line": 643, "path": "apps/macos/Sources/OpenClaw/DashboardWindowController.swift", "source": "[\\(host)]", "surface": "apple", diff --git a/apps/macos/Sources/OpenClaw/DashboardNavAccessory.swift b/apps/macos/Sources/OpenClaw/DashboardNavAccessory.swift deleted file mode 100644 index a4cead199051..000000000000 --- a/apps/macos/Sources/OpenClaw/DashboardNavAccessory.swift +++ /dev/null @@ -1,130 +0,0 @@ -import AppKit - -struct DashboardNavStateMessage: Equatable { - let collapsed: Bool - let width: CGFloat - - static func parse(_ body: Any) -> Self? { - guard let payload = body as? [String: Any], - payload["type"] as? String == "nav-state", - let collapsed = payload["collapsed"] as? Bool, - let widthNumber = payload["width"] as? NSNumber - else { - return nil - } - let width = CGFloat(widthNumber.doubleValue) - guard width.isFinite else { return nil } - return Self(collapsed: collapsed, width: min(2000, max(0, width))) - } -} - -enum DashboardNavAccessoryState: Equatable { - case legacy - case collapsed - case expanded(sidebarWidth: CGFloat) -} - -@MainActor -final class DashboardNavAccessoryView: NSView { - nonisolated static let compactWidth: CGFloat = 116 - // The shipped stack had no trailing inset, so its arrows ended at the 116pt - // frame edge; the shared trailing -12 constraint needs the extra 12pt or - // legacy (old gateway bundles) would shift back/forward left of the - // shipped positions. - nonisolated static let legacyWidth: CGFloat = 128 - nonisolated static let trafficLightClearance: CGFloat = 78 - - private let searchButton: NSButton - private let newSessionButton: NSButton - private let backButton: NSButton - private let forwardButton: NSButton - private(set) var state = DashboardNavAccessoryState.legacy - - init( - toggleButton: NSButton, - searchButton: NSButton, - newSessionButton: NSButton, - backButton: NSButton, - forwardButton: NSButton) - { - self.searchButton = searchButton - self.newSessionButton = newSessionButton - self.backButton = backButton - self.forwardButton = forwardButton - super.init(frame: NSRect(x: 0, y: 0, width: Self.compactWidth, height: 28)) - self.translatesAutoresizingMaskIntoConstraints = true - - let buttons = [toggleButton, searchButton, newSessionButton, backButton, forwardButton] - for button in buttons { - button.translatesAutoresizingMaskIntoConstraints = false - self.addSubview(button) - } - NSLayoutConstraint.activate([ - toggleButton.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 12), - toggleButton.centerYAnchor.constraint(equalTo: self.centerYAnchor), - searchButton.leadingAnchor.constraint(equalTo: toggleButton.trailingAnchor, constant: 12), - searchButton.centerYAnchor.constraint(equalTo: self.centerYAnchor), - newSessionButton.leadingAnchor.constraint(equalTo: searchButton.trailingAnchor, constant: 12), - newSessionButton.centerYAnchor.constraint(equalTo: self.centerYAnchor), - forwardButton.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -12), - forwardButton.centerYAnchor.constraint(equalTo: self.centerYAnchor), - backButton.trailingAnchor.constraint(equalTo: forwardButton.leadingAnchor, constant: -12), - backButton.centerYAnchor.constraint(equalTo: self.centerYAnchor), - ]) - self.apply(.legacy, windowWidth: nil) - } - - @available(*, unavailable) - required init?(coder _: NSCoder) { - fatalError("init(coder:) is not supported") - } - - func apply(_ state: DashboardNavAccessoryState, windowWidth: CGFloat?) { - self.state = state - let width: CGFloat - switch state { - case .legacy: - width = Self.legacyWidth - self.searchButton.isHidden = true - self.newSessionButton.isHidden = true - self.backButton.isHidden = false - self.forwardButton.isHidden = false - case .collapsed: - width = Self.compactWidth - self.searchButton.isHidden = false - self.newSessionButton.isHidden = false - self.backButton.isHidden = true - self.forwardButton.isHidden = true - case let .expanded(sidebarWidth): - let originX = self.accessoryOriginX() - width = Self.accessoryWidth( - sidebarWidth: sidebarWidth, - originX: originX, - windowWidth: windowWidth ?? Self.compactWidth + originX + 12) - self.searchButton.isHidden = true - self.newSessionButton.isHidden = true - self.backButton.isHidden = false - self.forwardButton.isHidden = false - } - // Frame changes make AppKit place the titlebar accessory again; button - // constraints remain local to this frame. - self.setFrameSize(NSSize(width: width, height: 28)) - } - - private func accessoryOriginX() -> CGFloat { - let measured = self.window == nil ? 0 : self.convert(.zero, to: nil).x - // Before AppKit installs the accessory there is no window-space origin; - // use the same traffic-light clearance as the dashboard drag regions. - return measured > 0 ? measured : Self.trafficLightClearance - } - - nonisolated static func accessoryWidth( - sidebarWidth: CGFloat, - originX: CGFloat, - windowWidth: CGFloat) -> CGFloat - { - let desired = sidebarWidth - originX - 12 - let available = max(Self.compactWidth, windowWidth - originX - 12) - return min(max(Self.compactWidth, desired), available) - } -} diff --git a/apps/macos/Sources/OpenClaw/DashboardWindowController.swift b/apps/macos/Sources/OpenClaw/DashboardWindowController.swift index 83bb49e4c7c4..e5d132f24d33 100644 --- a/apps/macos/Sources/OpenClaw/DashboardWindowController.swift +++ b/apps/macos/Sources/OpenClaw/DashboardWindowController.swift @@ -36,15 +36,6 @@ private final class DashboardWindowDragMessageHandler: NSObject, WKScriptMessage } } -@MainActor -private final class DashboardNavMessageHandler: NSObject, WKScriptMessageHandler { - weak var owner: DashboardWindowController? - - func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) { - self.owner?.receiveNavMessage(message) - } -} - @MainActor private final class DashboardUpdateMessageHandler: NSObject, WKScriptMessageHandler { weak var owner: DashboardWindowController? @@ -58,7 +49,6 @@ private final class DashboardUpdateMessageHandler: NSObject, WKScriptMessageHand final class DashboardWindowController: NSWindowController, WKNavigationDelegate, WKUIDelegate, NSWindowDelegate { private static let linkMessageHandlerName = "openclawLink" private static let windowDragMessageHandlerName = "openclawWindowDrag" - private static let navMessageHandlerName = "openclawNav" private static let updateMessageHandlerName = "openclawUpdate" private let webView: WKWebView @@ -70,9 +60,6 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate, private var auth: DashboardWindowAuth private let updater: UpdaterProviding? private var updateBridgeEnabled: Bool - private var backButton: NSButton? - private var forwardButton: NSButton? - private var navAccessory: DashboardNavAccessoryView? private var canGoBackObservation: NSKeyValueObservation? private var canGoForwardObservation: NSKeyValueObservation? @@ -99,8 +86,6 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate, config.userContentController.add(linkMessageHandler, name: Self.linkMessageHandlerName) let windowDragMessageHandler = DashboardWindowDragMessageHandler() config.userContentController.add(windowDragMessageHandler, name: Self.windowDragMessageHandlerName) - let navMessageHandler = DashboardNavMessageHandler() - config.userContentController.add(navMessageHandler, name: Self.navMessageHandlerName) let updateMessageHandler = DashboardUpdateMessageHandler() self.updateMessageHandler = updateMessageHandler if shouldEnableUpdateBridge { @@ -116,8 +101,7 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate, configuration: config) self.webView.setValue(true, forKey: "drawsBackground") // The Control UI routes via pushState, so WKWebView's back-forward list - // carries in-app navigation; without this (and the titlebar buttons - // below) the dashboard window has no way back. + // carries in-app navigation; the web titlebar buttons use this list. self.webView.allowsBackForwardNavigationGestures = true let linkBrowser = DashboardLinkBrowserView(websiteDataStore: dataStore) @@ -158,7 +142,6 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate, self.linkBrowserItem.isCollapsed = true linkMessageHandler.owner = self windowDragMessageHandler.owner = self - navMessageHandler.owner = self updateMessageHandler.owner = self self.webView.navigationDelegate = self self.webView.uiDelegate = self @@ -167,7 +150,7 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate, self.linkBrowser.onClose = { [weak self] in self?.closeLinkBrowser() } self.linkBrowser.onOpenExternal = { [weak self] url in self?.openExternal(url) } self.window?.delegate = self - self.installNavigationControls() + self.installHistoryStateBridge() } func setUpdateBridgeEnabled(_ enabled: Bool) { @@ -294,9 +277,6 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate, } func showFailure(title: String, message: String, detail: String? = nil) { - // Failure pages and older gateway bundles do not report nav state; keep - // the shipped toggle/back/forward layout until a trusted report arrives. - self.applyNavAccessoryState(.legacy) self.currentURL = URL(string: "about:blank")! self.auth = DashboardWindowAuth(gatewayUrl: nil, token: nil, password: nil) self.setUpdateBridgeEnabled(false) @@ -309,9 +289,6 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate, } private func load(_ url: URL) { - // Endpoint swaps may load an older web bundle, so each main-frame load - // starts from the shipped layout rather than retaining stale web state. - self.applyNavAccessoryState(.legacy) dashboardWindowLogger.debug("dashboard load \(dashboardLogString(for: url), privacy: .public)") self.webView.load(URLRequest(url: url)) } @@ -385,19 +362,6 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate, return payload["type"] as? String == "window-drag" } - fileprivate func receiveNavMessage(_ message: WKScriptMessage) { - guard message.name == Self.navMessageHandlerName, - message.webView === self.webView, - message.frameInfo.isMainFrame, - Self.isTrustedLinkSource(message.frameInfo.request.url, dashboardURL: self.currentURL), - let request = DashboardNavStateMessage.parse(message.body) - else { - return - } - self.applyNavAccessoryState( - request.collapsed ? .collapsed : .expanded(sidebarWidth: request.width)) - } - fileprivate func receiveUpdateMessage(_ message: WKScriptMessage) { guard message.name == Self.updateMessageHandlerName, message.webView === self.webView, @@ -500,99 +464,34 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate, Self.installNativeAuthScript(into: controller, url: url, auth: auth) } - /// Sidebar toggle plus back/forward buttons next to the traffic lights - /// (Safari's ordering). The window has no native toolbar (full-size content - /// view with the web UI's own chrome), so a leading titlebar accessory is - /// the only native slot for them. - private func installNavigationControls() { - guard let window = self.window else { return } - let sidebar = Self.makeNavigationButton( - symbolName: "sidebar.leading", - label: "Toggle Sidebar", - action: #selector(self.toggleNavigationSidebar(_:)), - target: self) - // Unlike back/forward there is no readiness state to observe; the web - // UI ignores the toggle event on surfaces without a collapsible nav. - sidebar.isEnabled = true - let search = Self.makeNavigationButton( - symbolName: "magnifyingglass", - label: "Search", - action: #selector(self.openNativeSearch(_:)), - target: self) - search.isEnabled = true - let newSession = Self.makeNavigationButton( - symbolName: "plus", - label: "New Session", - action: #selector(self.openNativeNewSession(_:)), - target: self) - newSession.isEnabled = true - let back = Self.makeNavigationButton( - symbolName: "chevron.left", - label: "Back", - action: #selector(self.navigateBack(_:)), - target: self) - let forward = Self.makeNavigationButton( - symbolName: "chevron.right", - label: "Forward", - action: #selector(self.navigateForward(_:)), - target: self) - self.backButton = back - self.forwardButton = forward - - // Compact frame: 12 leading inset + three ~27pt buttons + two 12pt - // gaps. Expanded state stretches this frame to the web sidebar edge. - let container = DashboardNavAccessoryView( - toggleButton: sidebar, - searchButton: search, - newSessionButton: newSession, - backButton: back, - forwardButton: forward) - self.navAccessory = container - - let accessory = NSTitlebarAccessoryViewController() - accessory.view = container - accessory.layoutAttribute = .leading - window.addTitlebarAccessoryViewController(accessory) - + private func installHistoryStateBridge() { self.canGoBackObservation = self.webView.observe(\.canGoBack, options: [ .initial, .new, - ]) { [weak self] _, change in - guard let canGoBack = change.newValue else { return } + ]) { [weak self] _, _ in Task { @MainActor in - self?.backButton?.isEnabled = canGoBack + self?.publishNativeHistoryState() } } self.canGoForwardObservation = self.webView.observe(\.canGoForward, options: [ .initial, .new, - ]) { [weak self] _, change in - guard let canGoForward = change.newValue else { return } + ]) { [weak self] _, _ in Task { @MainActor in - self?.forwardButton?.isEnabled = canGoForward + self?.publishNativeHistoryState() } } } - private static func makeNavigationButton( - symbolName: String, - label: String, - action: Selector, - target: AnyObject) -> NSButton - { - let button = NSButton() - button.bezelStyle = .accessoryBarAction - button.isBordered = false - button.image = NSImage(systemSymbolName: symbolName, accessibilityDescription: label)? - .withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: 13, weight: .semibold)) - button.imagePosition = .imageOnly - button.contentTintColor = .secondaryLabelColor - button.target = target - button.action = action - button.toolTip = label - button.setAccessibilityLabel(label) - button.isEnabled = false - return button + private func publishNativeHistoryState() { + let canGoBack = self.webView.canGoBack ? "true" : "false" + let canGoForward = self.webView.canGoForward ? "true" : "false" + self.webView.evaluateJavaScript( + """ + window.__OPENCLAW_NATIVE_HISTORY__ = {canGoBack:\(canGoBack),canGoForward:\(canGoForward)}; + window.dispatchEvent(new CustomEvent('openclaw:native-history-state', \ + {detail:window.__OPENCLAW_NATIVE_HISTORY__})); + """) } func navigateBack() { @@ -603,36 +502,6 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate, self.activeNavigationWebView.goForward() } - @objc private func navigateBack(_: Any?) { - self.webView.goBack() - } - - @objc private func navigateForward(_: Any?) { - self.webView.goForward() - } - - /// Named to avoid AppKit's standard `toggleSidebar(_:)` responder action, - /// which would otherwise reach the split view controller and collapse the - /// native link-browser pane instead of the web UI's navigation sidebar. - @objc private func toggleNavigationSidebar(_: Any?) { - self.webView.evaluateJavaScript( - "window.dispatchEvent(new CustomEvent('openclaw:native-toggle-sidebar'))") - } - - @objc private func openNativeSearch(_: Any?) { - self.webView.evaluateJavaScript( - "window.dispatchEvent(new CustomEvent('openclaw:native-open-search'))") - } - - @objc private func openNativeNewSession(_: Any?) { - self.webView.evaluateJavaScript( - "window.dispatchEvent(new CustomEvent('openclaw:native-new-session'))") - } - - private func applyNavAccessoryState(_ state: DashboardNavAccessoryState) { - self.navAccessory?.apply(state, windowWidth: self.window?.frame.width) - } - private var activeNavigationWebView: WKWebView { guard let linkWebView = self.linkBrowser.activeWebView, let firstResponder = self.window?.firstResponder as? NSView, @@ -658,9 +527,6 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate, let topRightDragRegion = DashboardWindowDragRegionView() topRightDragRegion.translatesAutoresizingMaskIntoConstraints = false container.addSubview(topRightDragRegion) - let sidebarDragRegion = DashboardWindowDragRegionView() - sidebarDragRegion.translatesAutoresizingMaskIntoConstraints = false - container.addSubview(sidebarDragRegion) NSLayoutConstraint.activate([ contentView.leadingAnchor.constraint(equalTo: container.leadingAnchor), contentView.trailingAnchor.constraint(equalTo: container.trailingAnchor), @@ -671,20 +537,13 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate, topDragRegion.topAnchor.constraint(equalTo: container.topAnchor), // Thin edge strip only: the web UI has no desktop topbar row, so a // taller region would swallow clicks meant for the top of the - // content column (chat thread, page headers). The sidebar region - // below stays the primary drag surface — it floats over the 50px - // strip the native chrome CSS reserves in the web sidebar. At - // narrow widths the compact drawer topbar keeps x 78-254 passive - // (its brand strip), so the region stays click-safe there too. + // content column (chat thread, page headers). The web titlebar + // toolbar owns the larger drag surface beside the traffic lights. topDragRegion.heightAnchor.constraint(equalToConstant: 12), topRightDragRegion.leadingAnchor.constraint(equalTo: topDragRegion.trailingAnchor), topRightDragRegion.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -8), topRightDragRegion.topAnchor.constraint(equalTo: container.topAnchor), topRightDragRegion.heightAnchor.constraint(equalToConstant: 6), - sidebarDragRegion.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 78), - sidebarDragRegion.topAnchor.constraint(equalTo: container.topAnchor), - sidebarDragRegion.widthAnchor.constraint(equalToConstant: 176), - sidebarDragRegion.heightAnchor.constraint(equalToConstant: 46), ]) window.title = "OpenClaw" window.titleVisibility = .hidden @@ -704,6 +563,12 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate, } private static func installNativeChromeScript(into userContentController: WKUserContentController) { + // Deliberately no native fallback for pages that ignore this flag + // (older gateway bundles, failure pages): they keep their own in-page + // toggles plus back/forward gestures and the Cmd-[/] menu items. + let capabilityScript = "window.__OPENCLAW_NATIVE_WEB_CHROME__ = true;" + userContentController.addUserScript( + WKUserScript(source: capabilityScript, injectionTime: .atDocumentStart, forMainFrameOnly: true)) // Narrow widths need no rules here: the Control UI's own // `html.openclaw-native-macos` styles fold the titlebar clearance into // the drawer topbar row (layout.mobile.css); their body-qualified @@ -728,10 +593,7 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate, const style = document.createElement("style"); style.id = "openclaw-native-macos-chrome"; style.textContent = \(Self.jsStringLiteral(css)); - // openclaw-native-nav advertises the titlebar sidebar toggle so a - // matching Control UI hides its in-page expand/collapse buttons; - // older web bundles ignore the class and keep their own controls. - document.documentElement.classList.add("openclaw-native-macos", "openclaw-native-nav"); + document.documentElement.classList.add("openclaw-native-macos", "openclaw-native-web-chrome"); document.head.appendChild(style); } catch {} })(); @@ -866,11 +728,6 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate, self.closeLinkBrowser(focusDashboard: false) } - func windowDidResize(_: Notification) { - guard let navAccessory else { return } - navAccessory.apply(navAccessory.state, windowWidth: self.window?.frame.width) - } - private func showLoadFailure(_ error: Error) { let nsError = error as NSError if nsError.domain == NSURLErrorDomain, nsError.code == NSURLErrorCancelled { @@ -881,7 +738,6 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate, dashboard load failed url=\(dashboardLogString(for: self.currentURL), privacy: .public) \ error=\(error.localizedDescription, privacy: .public) """) - self.applyNavAccessoryState(.legacy) let html = DashboardFailurePage.html( title: "Dashboard unavailable", message: error.localizedDescription, @@ -989,6 +845,8 @@ extension DashboardWindowController { func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { if self.linkBrowser.owns(webView) { self.linkBrowser.navigationDidFinish(navigation, for: webView) + } else if webView === self.webView { + self.publishNativeHistoryState() } } diff --git a/apps/macos/Tests/OpenClawIPCTests/DashboardNavAccessoryTests.swift b/apps/macos/Tests/OpenClawIPCTests/DashboardNavAccessoryTests.swift deleted file mode 100644 index f752fe602d8e..000000000000 --- a/apps/macos/Tests/OpenClawIPCTests/DashboardNavAccessoryTests.swift +++ /dev/null @@ -1,70 +0,0 @@ -import AppKit -import Testing -@testable import OpenClaw - -struct DashboardNavAccessoryTests { - @Test func `parses and bounds nav state messages`() { - #expect(DashboardNavStateMessage.parse([ - "type": "nav-state", - "collapsed": true, - "width": 280.5, - ]) == DashboardNavStateMessage(collapsed: true, width: 280.5)) - #expect(DashboardNavStateMessage.parse([ - "type": "nav-state", - "collapsed": false, - "width": 5000, - ]) == DashboardNavStateMessage(collapsed: false, width: 2000)) - #expect(DashboardNavStateMessage.parse([ - "type": "nav-state", - "collapsed": false, - "width": -10, - ]) == DashboardNavStateMessage(collapsed: false, width: 0)) - } - - @Test func `rejects malformed nav state messages`() { - #expect(DashboardNavStateMessage.parse([ - "type": "other", - "collapsed": true, - "width": 280, - ]) == nil) - #expect(DashboardNavStateMessage.parse([ - "type": "nav-state", - "collapsed": true, - ]) == nil) - #expect(DashboardNavStateMessage.parse([ - "type": "nav-state", - "collapsed": true, - "width": Double.nan, - ]) == nil) - #expect(DashboardNavStateMessage.parse([ - "type": "nav-state", - "collapsed": "yes", - "width": 280, - ]) == nil) - #expect(DashboardNavStateMessage.parse([ - "type": "nav-state", - "collapsed": 1, - "width": 280, - ]) == nil) - #expect(DashboardNavStateMessage.parse([ - "type": "nav-state", - "collapsed": true, - "width": "280", - ]) == nil) - } - - @Test func `computes bounded accessory widths`() { - #expect(DashboardNavAccessoryView.accessoryWidth( - sidebarWidth: 280, - originX: 78, - windowWidth: 1000) == 190) - #expect(DashboardNavAccessoryView.accessoryWidth( - sidebarWidth: 100, - originX: 78, - windowWidth: 1000) == 116) - #expect(DashboardNavAccessoryView.accessoryWidth( - sidebarWidth: 500, - originX: 78, - windowWidth: 300) == 210) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/DashboardWindowSmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/DashboardWindowSmokeTests.swift index 494e4668347e..ecb3dd783cf5 100644 --- a/apps/macos/Tests/OpenClawIPCTests/DashboardWindowSmokeTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/DashboardWindowSmokeTests.swift @@ -487,59 +487,25 @@ struct DashboardWindowSmokeTests { #expect(chromeScript.source.contains("min-width: 700px")) #expect(chromeScript.source.contains("--openclaw-native-titlebar-height")) #expect(!chromeScript.source.contains("max-width: 1100px")) - // Advertises the native titlebar sidebar toggle so the Control UI can - // drop its floating expand button (layout.css keys off this class). - #expect(chromeScript.source.contains("openclaw-native-nav")) + #expect(chromeScript.source.contains("openclaw-native-web-chrome")) + #expect(!chromeScript.source.contains("openclaw-native-nav")) + #expect(chromeScript.injectionTime == .atDocumentEnd) + #expect(chromeScript.isForMainFrameOnly) } - @Test func `dashboard titlebar hosts sidebar and history controls`() throws { + @Test func `dashboard advertises web titlebar chrome before document load`() 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)) - let accessories = try #require(controller.window?.titlebarAccessoryViewControllers) - let buttons = accessories.flatMap { accessory in - accessory.view.subviews.compactMap { $0 as? NSButton } - } - let sidebar = try #require(buttons.first { $0.accessibilityLabel() == "Toggle Sidebar" }) - let back = try #require(buttons.first { $0.accessibilityLabel() == "Back" }) - let forward = try #require(buttons.first { $0.accessibilityLabel() == "Forward" }) - let search = try #require(buttons.first { $0.accessibilityLabel() == "Search" }) - let newSession = try #require(buttons.first { $0.accessibilityLabel() == "New Session" }) - let accessory = try #require(sidebar.superview as? DashboardNavAccessoryView) - // Until a trusted nav-state report arrives (older gateway bundles never - // send one), the shipped toggle/back/forward trio stays visible and the - // collapsed-only pair stays hidden. - #expect(accessory.state == .legacy) - #expect(!back.isHidden) - #expect(!forward.isHidden) - #expect(search.isHidden) - #expect(newSession.isHidden) - // A typo'd SF Symbol name yields a nil image (invisible button). - #expect(sidebar.image != nil) - #expect(search.image != nil) - #expect(newSession.image != nil) - // The toggle has no readiness state; back/forward stay disabled until - // the back-forward list gains entries (the SPA pushes history entries). - #expect(sidebar.isEnabled) - #expect(!sidebar.isBordered) - #expect(!back.isEnabled) - #expect(!forward.isEnabled) - #expect(controller._testAllowsBackForwardGestures) + let capabilityScript = try #require(controller._testUserScripts.first { + $0.source.contains("__OPENCLAW_NATIVE_WEB_CHROME__") + }) - // Collapsed swaps history for search/new-session; expanded stretches - // the accessory toward the reported web sidebar edge. - accessory.apply(.collapsed, windowWidth: 1280) - #expect(back.isHidden) - #expect(forward.isHidden) - #expect(!search.isHidden) - #expect(!newSession.isHidden) - accessory.apply(.expanded(sidebarWidth: 258), windowWidth: 1280) - #expect(!back.isHidden) - #expect(!forward.isHidden) - #expect(search.isHidden) - #expect(newSession.isHidden) - #expect(accessory.frame.width > DashboardNavAccessoryView.legacyWidth) + #expect(capabilityScript.injectionTime == .atDocumentStart) + #expect(capabilityScript.isForMainFrameOnly) + #expect(controller.window?.titlebarAccessoryViewControllers.isEmpty == true) + #expect(controller._testAllowsBackForwardGestures) } @Test func `dashboard failure state opens in dashboard window`() throws { diff --git a/ui/src/app/app-host.test.ts b/ui/src/app/app-host.test.ts index a2d664fbebe8..6f2d3f3b22a9 100644 --- a/ui/src/app/app-host.test.ts +++ b/ui/src/app/app-host.test.ts @@ -45,6 +45,8 @@ type ShellNavigationState = { handleNativeToggleSidebar: () => void; handleNativeOpenSearch: () => void; handleNativeNewSession: () => void; + handleNativeHistoryState: (event: Event) => void; + nativeHistoryState: { canGoBack: boolean; canGoForward: boolean }; onboarding: boolean; updated: () => void; }; @@ -366,6 +368,17 @@ describe("OpenClaw shell keyboard shortcuts", () => { expect(navigate).not.toHaveBeenCalled(); }); + it("updates native history state from the host event", () => { + const shell = document.createElement("openclaw-app-shell") as unknown as ShellNavigationState; + shell.handleNativeHistoryState( + new CustomEvent("openclaw:native-history-state", { + detail: { canGoBack: true, canGoForward: false }, + }), + ); + + expect(shell.nativeHistoryState).toEqual({ canGoBack: true, canGoForward: false }); + }); + it("deduplicates native nav state reports", () => { const postMessage = vi.fn(); (window as TestWebKitWindow).webkit = { diff --git a/ui/src/app/app-host.ts b/ui/src/app/app-host.ts index c48429b25fb4..e887d133d09e 100644 --- a/ui/src/app/app-host.ts +++ b/ui/src/app/app-host.ts @@ -11,6 +11,7 @@ import "../components/gateway-url-confirmation.ts"; import "../components/github-link-hovercard.ts"; import "../components/login-gate.ts"; import "../components/browser/browser-panel.ts"; +import "../components/macos-titlebar-controls.ts"; import "../components/resizable-divider.ts"; import "../components/sidebar-update-card.ts"; import "../components/terminal/terminal-panel.ts"; @@ -45,6 +46,12 @@ import { } from "./context.ts"; import { resolveControlUiAuthToken } from "./control-ui-auth.ts"; import { postNativeNavState, type NativeNavState } from "./native-nav-state.ts"; +import { + isNativeWebChromeHost, + NATIVE_HISTORY_STATE_EVENT, + readNativeHistoryState, + type NativeHistoryState, +} from "./native-web-chrome.ts"; import { hasOperatorAdminAccess } from "./operator-access.ts"; import { controlUiPublicAssetPath } from "./public-assets.ts"; import { selectRenderedRouteMatch } from "./router-outlet.ts"; @@ -435,6 +442,7 @@ class OpenClawShell extends OpenClawLightDomElement { @state() private activeSessionKey = ""; @state() private settingsSearchQuery = ""; @state() private routeState: ShellRouteState = {}; + @state() private nativeHistoryState: NativeHistoryState = readNativeHistoryState(); @query("openclaw-command-palette") private commandPalette?: CommandPalette; private commandPaletteTarget?: CommandPaletteTargetDetail; private navDrawerTrigger: HTMLElement | null = null; @@ -520,9 +528,12 @@ class OpenClawShell extends OpenClawLightDomElement { override connectedCallback() { super.connectedCallback(); + this.nativeHistoryState = readNativeHistoryState(); this.addEventListener(COMMAND_PALETTE_TARGET_EVENT, this.handleCommandPaletteTarget); document.addEventListener("keydown", this.handleDocumentKeydown); window.addEventListener("resize", this.handleWindowResize); + window.addEventListener(NATIVE_HISTORY_STATE_EVENT, this.handleNativeHistoryState); + // Shipped Mac app builds without web chrome still drive these events. window.addEventListener("openclaw:native-toggle-sidebar", this.handleNativeToggleSidebar); window.addEventListener("openclaw:native-open-search", this.handleNativeOpenSearch); window.addEventListener("openclaw:native-new-session", this.handleNativeNewSession); @@ -532,6 +543,7 @@ class OpenClawShell extends OpenClawLightDomElement { this.removeEventListener(COMMAND_PALETTE_TARGET_EVENT, this.handleCommandPaletteTarget); document.removeEventListener("keydown", this.handleDocumentKeydown); window.removeEventListener("resize", this.handleWindowResize); + window.removeEventListener(NATIVE_HISTORY_STATE_EVENT, this.handleNativeHistoryState); window.removeEventListener("openclaw:native-toggle-sidebar", this.handleNativeToggleSidebar); window.removeEventListener("openclaw:native-open-search", this.handleNativeOpenSearch); window.removeEventListener("openclaw:native-new-session", this.handleNativeNewSession); @@ -649,10 +661,9 @@ class OpenClawShell extends OpenClawLightDomElement { } } - /** Focus a restoration target, falling back to the content anchor. The - * in-page sidebar toggles are display:none when the Mac app hosts the - * toggle in its titlebar (openclaw-native-nav, DashboardWindowController), - * so focus must not strand on the body or inside an offscreen drawer. */ + /** Focus a restoration target, falling back to the content anchor. Native + * Mac chrome hides the in-page toggles, so focus must not strand on the body + * or inside an offscreen drawer. */ private restoreFocusTo(target: HTMLElement | null | undefined) { const resolved = target?.isConnected && target.checkVisibility() @@ -688,9 +699,7 @@ class OpenClawShell extends OpenClawLightDomElement { context.navigation.update({ navWidth }); } - /** The macOS app's titlebar sidebar button dispatches this window event - * (DashboardWindowController) because AppKit drag regions cover the row - * where an in-page control would live. */ + // Shipped Mac app builds without web chrome still drive these handlers. private readonly handleNativeToggleSidebar = () => { this.toggleNavigationSurface(); }; @@ -710,6 +719,14 @@ class OpenClawShell extends OpenClawLightDomElement { }); }; + private readonly handleNativeHistoryState = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (typeof detail?.canGoBack !== "boolean" || typeof detail.canGoForward !== "boolean") { + return; + } + this.nativeHistoryState = detail; + }; + private readonly handleWindowResize = () => { const dismissedHiddenMenus = isMobileNavLayout() && !this.navDrawerOpen && this.dismissSidebarTransientMenus(); @@ -821,20 +838,27 @@ class OpenClawShell extends OpenClawLightDomElement { this.requestUpdate(); }; + /** Collapsed as seen by macOS titlebar chrome (native accessory on shipped + * apps, the web toolbar on current ones): drawer widths, settings takeover, + * and onboarding all hide the expanded rail. */ + private nativeNavCollapsed(): boolean { + const mobileNavLayout = isMobileNavLayout(); + return ( + this.onboarding || + mobileNavLayout || + (this.isSettingsTakeover() && !mobileNavLayout) || + (!this.navDrawerOpen && (this.context?.navigation.snapshot.navCollapsed ?? false)) + ); + } + override updated() { const context = this.context; if (!context) { return; } - const mobileNavLayout = isMobileNavLayout(); - const snapshot = context.navigation.snapshot; const navState = { - collapsed: - this.onboarding || - mobileNavLayout || - (this.isSettingsTakeover() && !mobileNavLayout) || - (!this.navDrawerOpen && snapshot.navCollapsed), - width: snapshot.navWidth, + collapsed: this.nativeNavCollapsed(), + width: context.navigation.snapshot.navWidth, } satisfies NativeNavState; if ( navState.collapsed === this.lastNativeNavState?.collapsed && @@ -843,6 +867,7 @@ class OpenClawShell extends OpenClawLightDomElement { return; } this.lastNativeNavState = navState; + // Shipped Mac app builds without web chrome still consume this bridge. postNativeNavState(navState); } @@ -976,6 +1001,7 @@ class OpenClawShell extends OpenClawLightDomElement { uiHints: runtimeConfig.configUiHints, }); const navDrawerOpen = this.navDrawerOpen && !this.onboarding; + const mobileNavLayout = isMobileNavLayout(); // Drawer navigation always opens expanded; the desktop collapse preference // stays persisted for when the viewport returns to the desktop layout. // The settings sidebar has a fixed width, so the collapse state pauses too. @@ -983,7 +1009,7 @@ class OpenClawShell extends OpenClawLightDomElement { const navigationSurfaceHidden = navigationSurfaceIsHidden({ navCollapsed, navDrawerOpen, - mobileNavLayout: isMobileNavLayout(), + mobileNavLayout, }); const shellWidth = Math.max(globalThis.innerWidth || 0, NAV_WIDTH_MAX); // One storage read per render; theme.refresh() re-renders on pref changes. @@ -1016,6 +1042,18 @@ class OpenClawShell extends OpenClawLightDomElement { aria-label=${t("nav.close")} @click=${() => this.closeNavDrawer({ restoreFocus: true })} > + ${isNativeWebChromeHost() && !this.onboarding + ? html` + this.toggleNavigationSurface()} + .onOpenPalette=${this.openPalette} + .onOpenNewSession=${this.handleNativeNewSession} + > + ` + : nothing} { + Reflect.deleteProperty(window, "__OPENCLAW_NATIVE_WEB_CHROME__"); + Reflect.deleteProperty(window, "__OPENCLAW_NATIVE_HISTORY__"); +}); + +describe("native web chrome capability", () => { + it("requires the document-start capability flag", () => { + expect(isNativeWebChromeHost()).toBe(false); + (window as TestNativeWindow)["__OPENCLAW_NATIVE_WEB_CHROME__"] = true; + expect(isNativeWebChromeHost()).toBe(true); + }); + + it("reads native history state and defaults safely", () => { + expect(readNativeHistoryState()).toEqual({ canGoBack: false, canGoForward: false }); + (window as TestNativeWindow)["__OPENCLAW_NATIVE_HISTORY__"] = { + canGoBack: true, + canGoForward: false, + }; + expect(readNativeHistoryState()).toEqual({ canGoBack: true, canGoForward: false }); + }); +}); diff --git a/ui/src/app/native-web-chrome.ts b/ui/src/app/native-web-chrome.ts new file mode 100644 index 000000000000..b9489a2e6b04 --- /dev/null +++ b/ui/src/app/native-web-chrome.ts @@ -0,0 +1,22 @@ +export const NATIVE_HISTORY_STATE_EVENT = "openclaw:native-history-state"; + +export type NativeHistoryState = { + canGoBack: boolean; + canGoForward: boolean; +}; + +type NativeWebChromeWindow = Window & { + __OPENCLAW_NATIVE_WEB_CHROME__?: boolean; + __OPENCLAW_NATIVE_HISTORY__?: NativeHistoryState; +}; + +export function isNativeWebChromeHost(): boolean { + return (window as NativeWebChromeWindow)["__OPENCLAW_NATIVE_WEB_CHROME__"] === true; +} + +export function readNativeHistoryState(): NativeHistoryState { + const state = (window as NativeWebChromeWindow)["__OPENCLAW_NATIVE_HISTORY__"]; + return state && typeof state.canGoBack === "boolean" && typeof state.canGoForward === "boolean" + ? state + : { canGoBack: false, canGoForward: false }; +} diff --git a/ui/src/components/app-sidebar.ts b/ui/src/components/app-sidebar.ts index 2acf1c2b2551..928a879906d9 100644 --- a/ui/src/components/app-sidebar.ts +++ b/ui/src/components/app-sidebar.ts @@ -29,6 +29,7 @@ import { type ApplicationContext, type ApplicationNavigationOptions, } from "../app/context.ts"; +import { beginNativeWindowDragFromTopInset } from "../app/native-window-drag.ts"; import { controlUiPublicAssetPath } from "../app/public-assets.ts"; import type { ThemeMode } from "../app/theme.ts"; import "./menu-surface.ts"; @@ -2935,7 +2936,10 @@ class AppSidebar extends OpenClawLightDomContentsElement { (chipName || chipAgentId).slice(0, 1).toUpperCase(); return html`