diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 50791e6db5a2..860835014bf9 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -17851,7 +17851,7 @@ }, { "kind": "conditional-branch", - "line": 720, + "line": 781, "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 new file mode 100644 index 000000000000..a4cead199051 --- /dev/null +++ b/apps/macos/Sources/OpenClaw/DashboardNavAccessory.swift @@ -0,0 +1,130 @@ +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 dc0997cc54b1..83bb49e4c7c4 100644 --- a/apps/macos/Sources/OpenClaw/DashboardWindowController.swift +++ b/apps/macos/Sources/OpenClaw/DashboardWindowController.swift @@ -36,6 +36,15 @@ 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? @@ -49,6 +58,7 @@ 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 @@ -62,6 +72,7 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate, private var updateBridgeEnabled: Bool private var backButton: NSButton? private var forwardButton: NSButton? + private var navAccessory: DashboardNavAccessoryView? private var canGoBackObservation: NSKeyValueObservation? private var canGoForwardObservation: NSKeyValueObservation? @@ -88,6 +99,8 @@ 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 { @@ -145,6 +158,7 @@ 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 @@ -280,6 +294,9 @@ 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) @@ -292,6 +309,9 @@ 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)) } @@ -365,6 +385,19 @@ 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, @@ -481,6 +514,18 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate, // 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", @@ -494,16 +539,18 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate, self.backButton = back self.forwardButton = forward - let stack = NSStackView(views: [sidebar, back, forward]) - stack.orientation = .horizontal - stack.spacing = 12 - stack.edgeInsets = NSEdgeInsets(top: 0, left: 12, bottom: 0, right: 0) - // Fixed accessory frame: 12 leading inset + three ~27pt buttons + two - // 12pt gaps. Keep in sync with `spacing`/`edgeInsets` above. - stack.setFrameSize(NSSize(width: 116, height: 28)) + // 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 = stack + accessory.view = container accessory.layoutAttribute = .leading window.addTitlebarAccessoryViewController(accessory) @@ -572,6 +619,20 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate, "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, @@ -742,6 +803,97 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate, return String(raw.dropFirst().dropLast()) } + static func shouldAllowNavigation(to url: URL, dashboardURL: URL) -> Bool { + guard let scheme = url.scheme?.lowercased() else { return true } + if scheme == "about" || scheme == "blob" || scheme == "data" { + return true + } + guard scheme == "http" || scheme == "https" else { return false } + return url.scheme?.lowercased() == dashboardURL.scheme?.lowercased() && + url.host?.lowercased() == dashboardURL.host?.lowercased() && + url.port == dashboardURL.port + } + + static func shouldAllowBrowserNavigation(to url: URL, isMainFrame: Bool) -> Bool { + if isMainFrame { + return self.isHTTPURL(url) + } + guard let scheme = url.scheme?.lowercased() else { return false } + return scheme == "about" || scheme == "blob" || scheme == "data" || self.isHTTPURL(url) + } + + static func shouldOpenExternalDashboardNavigation( + _ url: URL, + navigationType: WKNavigationType, + buttonNumber: Int) -> Bool + { + // WebKit also labels synthetic anchor.click() as linkActivated. Its + // action reports button 0; a physical primary click reports 1 here. + navigationType == .linkActivated && buttonNumber > 0 && self.isExternalURL(url) + } + + static func targetlessNavigationAction( + for url: URL, + navigationType: WKNavigationType, + buttonNumber: Int, + allowEditorURLs: Bool) -> DashboardTargetlessNavigationAction + { + if self.isHTTPURL(url) { + return .allow + } + // The trusted Control UI's file sidebar opens these explicit editor URLs + // with window.open(); never grant the same synthetic-launch path to web content. + if allowEditorURLs, self.isEditorURL(url) { + return .openExternal + } + if self.shouldOpenExternalDashboardNavigation( + url, + navigationType: navigationType, + buttonNumber: buttonNumber) + { + return .openExternal + } + return .cancel + } + + static func newWindowAction(for url: URL?, sourceIsLinkBrowser: Bool) -> DashboardNewWindowAction { + guard let url, self.isHTTPURL(url) else { return .ignore } + return sourceIsLinkBrowser ? .openTab(url) : .openExternal(url) + } + + func windowWillClose(_: Notification) { + self.webView.stopLoading() + 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 { + return + } + dashboardWindowLogger.error( + """ + 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, + 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) + } +} + +/// WKNavigationDelegate policy lives in an extension to keep the class +/// body inside the swiftlint type_body_length budget. +extension DashboardWindowController { func webView( _ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, @@ -862,64 +1014,6 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate, self.showLoadFailure(error) } - static func shouldAllowNavigation(to url: URL, dashboardURL: URL) -> Bool { - guard let scheme = url.scheme?.lowercased() else { return true } - if scheme == "about" || scheme == "blob" || scheme == "data" { - return true - } - guard scheme == "http" || scheme == "https" else { return false } - return url.scheme?.lowercased() == dashboardURL.scheme?.lowercased() && - url.host?.lowercased() == dashboardURL.host?.lowercased() && - url.port == dashboardURL.port - } - - static func shouldAllowBrowserNavigation(to url: URL, isMainFrame: Bool) -> Bool { - if isMainFrame { - return self.isHTTPURL(url) - } - guard let scheme = url.scheme?.lowercased() else { return false } - return scheme == "about" || scheme == "blob" || scheme == "data" || self.isHTTPURL(url) - } - - static func shouldOpenExternalDashboardNavigation( - _ url: URL, - navigationType: WKNavigationType, - buttonNumber: Int) -> Bool - { - // WebKit also labels synthetic anchor.click() as linkActivated. Its - // action reports button 0; a physical primary click reports 1 here. - navigationType == .linkActivated && buttonNumber > 0 && self.isExternalURL(url) - } - - static func targetlessNavigationAction( - for url: URL, - navigationType: WKNavigationType, - buttonNumber: Int, - allowEditorURLs: Bool) -> DashboardTargetlessNavigationAction - { - if self.isHTTPURL(url) { - return .allow - } - // The trusted Control UI's file sidebar opens these explicit editor URLs - // with window.open(); never grant the same synthetic-launch path to web content. - if allowEditorURLs, self.isEditorURL(url) { - return .openExternal - } - if self.shouldOpenExternalDashboardNavigation( - url, - navigationType: navigationType, - buttonNumber: buttonNumber) - { - return .openExternal - } - return .cancel - } - - static func newWindowAction(for url: URL?, sourceIsLinkBrowser: Bool) -> DashboardNewWindowAction { - guard let url, self.isHTTPURL(url) else { return .ignore } - return sourceIsLinkBrowser ? .openTab(url) : .openExternal(url) - } - private func decideTargetlessNavigation( _ url: URL, navigationType: WKNavigationType, @@ -942,29 +1036,6 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate, decisionHandler(.cancel) } } - - func windowWillClose(_: Notification) { - self.webView.stopLoading() - self.closeLinkBrowser(focusDashboard: false) - } - - private func showLoadFailure(_ error: Error) { - let nsError = error as NSError - if nsError.domain == NSURLErrorDomain, nsError.code == NSURLErrorCancelled { - return - } - dashboardWindowLogger.error( - """ - dashboard load failed url=\(dashboardLogString(for: self.currentURL), privacy: .public) \ - error=\(error.localizedDescription, privacy: .public) - """) - let html = DashboardFailurePage.html( - 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) - } } #if DEBUG diff --git a/apps/macos/Tests/OpenClawIPCTests/DashboardNavAccessoryTests.swift b/apps/macos/Tests/OpenClawIPCTests/DashboardNavAccessoryTests.swift new file mode 100644 index 000000000000..f752fe602d8e --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/DashboardNavAccessoryTests.swift @@ -0,0 +1,70 @@ +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 d31251316f6a..494e4668347e 100644 --- a/apps/macos/Tests/OpenClawIPCTests/DashboardWindowSmokeTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/DashboardWindowSmokeTests.swift @@ -504,15 +504,21 @@ struct DashboardWindowSmokeTests { 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" }) - // Titlebar order mirrors Safari: sidebar toggle first, then history. - let stack = try #require(sidebar.superview as? NSStackView) - #expect(stack.arrangedSubviews.firstIndex(of: sidebar) == 0) - #expect(stack.arrangedSubviews.firstIndex(of: back) == 1) - #expect(stack.arrangedSubviews.firstIndex(of: forward) == 2) - // A typo'd SF Symbol name yields a nil image (invisible button), and a - // frame narrower than the fitting size clips the trailing control. + 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(stack.fittingSize.width <= stack.frame.width) + #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) @@ -520,6 +526,20 @@ struct DashboardWindowSmokeTests { #expect(!back.isEnabled) #expect(!forward.isEnabled) #expect(controller._testAllowsBackForwardGestures) + + // 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) } @Test func `dashboard failure state opens in dashboard window`() throws { diff --git a/docs/platforms/macos.md b/docs/platforms/macos.md index c280eeb44c8e..48c024fe32c5 100644 --- a/docs/platforms/macos.md +++ b/docs/platforms/macos.md @@ -60,6 +60,8 @@ stay on stable app builds. In the macOS app's embedded dashboard, clicking an external web link opens it in a resizable browser sidebar. Each link opens in its own tab; clicking the same link again reuses its existing tab. Drag tabs to reorder them, close them with the tab close button or a middle-click, and right-click a tab for **Open in Default Browser**, **Copy Link**, **Reload**, **Close Tab**, and **Close Other Tabs**. The window's titlebar back/forward controls and trackpad swipes navigate dashboard history; the sidebar's own back/forward controls navigate the active tab's history. The sidebar also has reload, open-in-default-browser, and close controls, and it remembers its width. +The titlebar controls follow the app sidebar: while it is expanded, back/forward sit at its right edge next to the sidebar toggle; while it is collapsed, they make way for a search button (opens the command palette) and a new-session button. + Right-click an external link to choose **Open in Sidebar**, **Open in Default Browser**, or **Copy Link**. Modified clicks and user-activated new-window links from the dashboard continue to open in the default browser; new-window links inside the sidebar open as new sidebar tabs. Regular browser-hosted Control UI pages keep the browser's normal link and context-menu behavior. ## Import browser logins diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 4ff2fa5e1237..f517ffa032cb 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -176,7 +176,7 @@ The **+** in the sidebar session-list header opens a full-page draft at `/new`: Inside **Settings**, the dedicated sidebar starts with a **Search settings** field for quickly finding settings sections. -A **Search** field at the top of the sidebar opens the command palette (⌘K). Clicking the OpenClaw brand in the sidebar header opens the clean New session start screen. When something needs action — failed or overdue cron jobs, expiring or expired model auth — compact attention chips appear above the sidebar footer and click through to the owning page. The compact footer keeps connection status, **Settings**, **Docs**, mobile pairing, and the light/dark/system color-mode toggle together; when the gateway runs from a source checkout on a branch other than `main`, the footer also shows that branch name in red so a non-release gateway is obvious at a glance (release installs never show it). Shift-Command-Comma opens **Settings** without overriding the browser's Command-Comma shortcut. The sidebar header also holds the collapse toggle (⌘B); collapsing hides the sidebar entirely for a full-width workspace, and a floating expand control (or ⌘B) brings it back; the macOS app hosts that toggle natively in the titlebar instead. The sidebar is the only navigation chrome on desktop, with no top bar. Narrow viewports swap the sidebar for a slide-over drawer behind a compact header row holding the drawer toggle, brand, and command-palette search; in the macOS app that header row folds the titlebar clearance into a single compact strip beside the window controls. Navigation uses regular browser history, so the browser's back/forward buttons traverse it; the macOS app adds a native sidebar toggle and back/forward buttons next to the window controls, plus trackpad swipe gestures. +A **Search** field at the top of the sidebar opens the command palette (⌘K). Clicking the OpenClaw brand in the sidebar header opens the clean New session start screen. When something needs action — failed or overdue cron jobs, expiring or expired model auth — compact attention chips appear above the sidebar footer and click through to the owning page. The compact footer keeps connection status, **Settings**, **Docs**, mobile pairing, and the light/dark/system color-mode toggle together; when the gateway runs from a source checkout on a branch other than `main`, the footer also shows that branch name in red so a non-release gateway is obvious at a glance (release installs never show it). Shift-Command-Comma opens **Settings** without overriding the browser's Command-Comma shortcut. The sidebar header also holds the collapse toggle (⌘B); collapsing hides the sidebar entirely for a full-width workspace, and a floating expand control (or ⌘B) brings it back; the macOS app hosts that toggle natively in the titlebar instead. The sidebar is the only navigation chrome on desktop, with no top bar. Narrow viewports swap the sidebar for a slide-over drawer behind a compact header row holding the drawer toggle, brand, and command-palette search; in the macOS app that header row folds the titlebar clearance into a single compact strip beside the window controls. Navigation uses regular browser history, so the browser's back/forward buttons traverse it; the macOS app adds a native sidebar toggle next to the window controls plus trackpad swipe gestures, with back/forward buttons at the sidebar's right edge while it is expanded and native search (command palette) and new-session buttons while it is collapsed. ## What it can do (today) diff --git a/ui/src/app/app-host.test.ts b/ui/src/app/app-host.test.ts index 3d5cea80b828..732a1d2e765f 100644 --- a/ui/src/app/app-host.test.ts +++ b/ui/src/app/app-host.test.ts @@ -1,7 +1,7 @@ /* @vitest-environment jsdom */ import { render } from "lit"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { GatewayBrowserClient } from "../api/gateway.ts"; import { navigationSurfaceIsHidden, renderFloatingUpdateCard } from "./app-host.ts"; import type { @@ -43,8 +43,24 @@ type ShellNavigationState = { context: ApplicationContext; }; handleNativeToggleSidebar: () => void; + handleNativeOpenSearch: () => void; + handleNativeNewSession: () => void; + onboarding: boolean; + updated: () => void; }; +type TestWebKitWindow = Window & { + webkit?: { + messageHandlers: { + openclawNav: { postMessage: (message: unknown) => void }; + }; + }; +}; + +afterEach(() => { + Reflect.deleteProperty(window, "webkit"); +}); + type ShellEpochState = { navDrawerOpen: boolean; navDrawerTrigger: HTMLElement | null; @@ -221,6 +237,67 @@ describe("OpenClaw shell keyboard shortcuts", () => { expect(update).toHaveBeenLastCalledWith({ navCollapsed: false }); }); + it("opens search and starts a session from native titlebar events", () => { + const navigate = vi.fn(); + const openPalette = vi.fn(); + const shell = document.createElement("openclaw-app-shell") as unknown as ShellNavigationState; + Object.defineProperty(shell, "commandPalette", { + configurable: true, + value: { openPalette }, + }); + shell.runtime = { + context: { + navigate, + agentSelection: { state: { selectedId: "agent/a" } }, + } as unknown as ApplicationContext, + }; + shell.handleNativeOpenSearch(); + shell.handleNativeNewSession(); + + expect(openPalette).toHaveBeenCalledOnce(); + expect(navigate).toHaveBeenCalledWith("new-session", { search: "?agent=agent%2Fa" }); + }); + + it("does not start a native session during onboarding", () => { + const navigate = vi.fn(); + const shell = document.createElement("openclaw-app-shell") as unknown as ShellNavigationState; + shell.runtime = { + context: { + navigate, + agentSelection: { state: { selectedId: "main" } }, + } as unknown as ApplicationContext, + }; + shell.onboarding = true; + + shell.handleNativeNewSession(); + + expect(navigate).not.toHaveBeenCalled(); + }); + + it("deduplicates native nav state reports", () => { + const postMessage = vi.fn(); + (window as TestWebKitWindow).webkit = { + messageHandlers: { openclawNav: { postMessage } }, + }; + const snapshot = { navCollapsed: false, navWidth: 280 }; + const shell = document.createElement("openclaw-app-shell") as unknown as ShellNavigationState; + shell.runtime = { + context: { + navigation: { snapshot }, + } as unknown as ApplicationContext, + }; + + shell.updated(); + shell.updated(); + snapshot.navCollapsed = true; + shell.updated(); + + expect(postMessage.mock.calls).toEqual([ + [{ type: "nav-state", collapsed: false, width: 280 }], + [{ type: "nav-state", collapsed: true, width: 280 }], + ]); + }); + it("leaves plain Command-Comma to the browser", () => { const navigate = vi.fn(); const shell = document.createElement("openclaw-app-shell") as unknown as ShellKeyboardState; diff --git a/ui/src/app/app-host.ts b/ui/src/app/app-host.ts index 8477db892201..711a2b53874e 100644 --- a/ui/src/app/app-host.ts +++ b/ui/src/app/app-host.ts @@ -43,6 +43,7 @@ import { type ApplicationNavigationOptions, } from "./context.ts"; import { resolveControlUiAuthToken } from "./control-ui-auth.ts"; +import { postNativeNavState, type NativeNavState } from "./native-nav-state.ts"; import { hasOperatorAdminAccess } from "./operator-access.ts"; import { controlUiPublicAssetPath } from "./public-assets.ts"; import { selectRenderedRouteMatch } from "./router-outlet.ts"; @@ -444,6 +445,7 @@ class OpenClawShell extends OpenClawLightDomElement { private sessionKeyClient: GatewayBrowserClient | null = null; private runtimeConfigClient: GatewayBrowserClient | null = null; private runtimeConfigSource: ApplicationContext["runtimeConfig"] | null = null; + private lastNativeNavState: NativeNavState | undefined; private readonly settingsPreloadTimers = new Map< EventTarget, ReturnType @@ -521,6 +523,8 @@ class OpenClawShell extends OpenClawLightDomElement { document.addEventListener("keydown", this.handleDocumentKeydown); window.addEventListener("resize", this.handleWindowResize); window.addEventListener("openclaw:native-toggle-sidebar", this.handleNativeToggleSidebar); + window.addEventListener("openclaw:native-open-search", this.handleNativeOpenSearch); + window.addEventListener("openclaw:native-new-session", this.handleNativeNewSession); } override disconnectedCallback() { @@ -528,6 +532,8 @@ class OpenClawShell extends OpenClawLightDomElement { document.removeEventListener("keydown", this.handleDocumentKeydown); window.removeEventListener("resize", this.handleWindowResize); window.removeEventListener("openclaw:native-toggle-sidebar", this.handleNativeToggleSidebar); + window.removeEventListener("openclaw:native-open-search", this.handleNativeOpenSearch); + window.removeEventListener("openclaw:native-new-session", this.handleNativeNewSession); this.resetShellEpochState(); super.disconnectedCallback(); } @@ -672,6 +678,21 @@ class OpenClawShell extends OpenClawLightDomElement { this.toggleNavigationSurface(); }; + private readonly handleNativeOpenSearch = () => { + this.openPalette(); + }; + + private readonly handleNativeNewSession = () => { + const context = this.context; + if (!context || this.onboarding) { + return; + } + const agentId = context.agentSelection.state.selectedId ?? ""; + this.navigate("new-session", { + search: agentId ? `?agent=${encodeURIComponent(agentId)}` : "", + }); + }; + private readonly handleWindowResize = () => { const dismissedHiddenMenus = isMobileNavLayout() && !this.navDrawerOpen && this.dismissSidebarTransientMenus(); @@ -783,6 +804,31 @@ class OpenClawShell extends OpenClawLightDomElement { this.requestUpdate(); }; + 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, + } satisfies NativeNavState; + if ( + navState.collapsed === this.lastNativeNavState?.collapsed && + navState.width === this.lastNativeNavState.width + ) { + return; + } + this.lastNativeNavState = navState; + postNativeNavState(navState); + } + private synchronizeGateway(snapshot: ApplicationContext["gateway"]["snapshot"]) { this.updateGatewaySessionKey(snapshot); this.ensureAgentsList(snapshot); diff --git a/ui/src/app/native-nav-state.test.ts b/ui/src/app/native-nav-state.test.ts new file mode 100644 index 000000000000..55afd7db0388 --- /dev/null +++ b/ui/src/app/native-nav-state.test.ts @@ -0,0 +1,49 @@ +/* @vitest-environment jsdom */ + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { postNativeNavState } from "./native-nav-state.ts"; + +type TestWebKitWindow = Window & { + webkit?: { + messageHandlers: { + openclawNav: { + postMessage(message: unknown): void; + }; + }; + }; +}; + +afterEach(() => { + Reflect.deleteProperty(window, "webkit"); +}); + +describe("native nav state bridge", () => { + it("posts the typed payload to WebKit", () => { + const postMessage = vi.fn(); + (window as TestWebKitWindow).webkit = { + messageHandlers: { openclawNav: { postMessage } }, + }; + + postNativeNavState({ collapsed: false, width: 280 }); + + expect(postMessage).toHaveBeenCalledWith({ + type: "nav-state", + collapsed: false, + width: 280, + }); + }); + + it("does nothing without WebKit and tolerates a disappearing handler", () => { + expect(() => postNativeNavState({ collapsed: true, width: 280 })).not.toThrow(); + (window as TestWebKitWindow).webkit = { + messageHandlers: { + openclawNav: { + postMessage: () => { + throw new Error("handler removed"); + }, + }, + }, + }; + expect(() => postNativeNavState({ collapsed: true, width: 280 })).not.toThrow(); + }); +}); diff --git a/ui/src/app/native-nav-state.ts b/ui/src/app/native-nav-state.ts new file mode 100644 index 000000000000..b30eab43876c --- /dev/null +++ b/ui/src/app/native-nav-state.ts @@ -0,0 +1,27 @@ +export type NativeNavState = { + collapsed: boolean; + width: number; +}; + +type WebKitMessageHandler = { + postMessage(message: unknown): void; +}; + +type WebKitBridgeWindow = Window & { + webkit?: { + messageHandlers?: { + openclawNav?: WebKitMessageHandler; + }; + }; +}; + +export function postNativeNavState(state: NativeNavState): void { + try { + (window as WebKitBridgeWindow).webkit?.messageHandlers?.openclawNav?.postMessage({ + type: "nav-state", + ...state, + }); + } catch { + // WebKit may remove a handler while the document is being replaced. + } +} diff --git a/ui/src/e2e/native-nav-sidebar-toggle.e2e.test.ts b/ui/src/e2e/native-nav-sidebar-toggle.e2e.test.ts index f4c8a19bb72e..c07cd87492e3 100644 --- a/ui/src/e2e/native-nav-sidebar-toggle.e2e.test.ts +++ b/ui/src/e2e/native-nav-sidebar-toggle.e2e.test.ts @@ -52,6 +52,22 @@ describeControlUiE2e("Control UI native-nav sidebar toggle E2E", () => { // which runs at document end. Playwright init scripts fire before // document.documentElement exists, so defer until the DOM is parsed. await page.addInitScript(() => { + const nativeWindow = window as Window & { + openclawNavMessages?: unknown[]; + }; + nativeWindow.openclawNavMessages = []; + Object.defineProperty(window, "webkit", { + configurable: true, + value: { + messageHandlers: { + openclawNav: { + postMessage(message: unknown) { + nativeWindow.openclawNavMessages?.push(message); + }, + }, + }, + }, + }); const stamp = () => document.documentElement.classList.add("openclaw-native-macos", "openclaw-native-nav"); if (document.documentElement) { @@ -86,6 +102,32 @@ describeControlUiE2e("Control UI native-nav sidebar toggle E2E", () => { it("hides both web toggles when the native titlebar toggle is present", async () => { const page = await openPage({ nativeNav: true }); + await expect + .poll(() => + page.evaluate(() => { + const messages = (window as Window & { openclawNavMessages?: unknown[] }) + .openclawNavMessages; + return messages?.find( + (message) => + typeof message === "object" && + message !== null && + (message as { type?: string }).type === "nav-state", + ); + }), + ) + .toMatchObject({ type: "nav-state", collapsed: false }); + const initialWidth = await page.evaluate(() => { + const messages = (window as Window & { openclawNavMessages?: unknown[] }).openclawNavMessages; + const message = messages?.find( + (candidate) => + typeof candidate === "object" && + candidate !== null && + (candidate as { type?: string }).type === "nav-state", + ); + return (message as { width?: number } | undefined)?.width ?? 0; + }); + expect(initialWidth).toBeGreaterThan(0); + await expect.poll(() => page.locator(".sidebar-brand__collapse").isVisible()).toBe(false); // Collapse through the native titlebar path; the floating expand control @@ -96,12 +138,33 @@ describeControlUiE2e("Control UI native-nav sidebar toggle E2E", () => { await expect .poll(() => page.locator(".shell").getAttribute("class")) .toContain("shell--nav-collapsed"); + await expect + .poll(() => + page.evaluate(() => + ( + window as Window & { openclawNavMessages?: Array<{ collapsed?: boolean }> } + ).openclawNavMessages?.some((message) => message.collapsed === true), + ), + ) + .toBe(true); await expect.poll(() => page.locator(".shell-nav-expand").isVisible()).toBe(false); // With the in-page expand control hidden, collapse anchors keyboard focus // on the content column instead of stranding it on the body. await expect .poll(() => page.evaluate(() => document.activeElement?.classList.contains("content"))) .toBe(true); + + await page.evaluate(() => { + window.dispatchEvent(new CustomEvent("openclaw:native-open-search")); + }); + await expect + .poll(() => page.locator(".cmd-palette-overlay").getAttribute("open")) + .not.toBeNull(); + + await page.evaluate(() => { + window.dispatchEvent(new CustomEvent("openclaw:native-new-session")); + }); + await expect.poll(() => new URL(page.url()).pathname).toBe("/new"); }); it("keeps the drawer hamburger at narrow widths in plain browsers", async () => {