feat(mac): swap dashboard titlebar buttons with sidebar state (#105175)

* feat(mac): swap dashboard titlebar buttons with sidebar state

The Control UI reports sidebar collapsed/width over a new openclawNav
WKScriptMessage; the titlebar accessory shows toggle+back/forward
right-aligned to the sidebar edge while expanded, and
toggle+search+new-session while collapsed. Search opens the command
palette and + opens the new-session surface via openclaw:native-*
events. Older gateway bundles that never report keep the shipped
toggle/back/forward layout.

Refs #105129

* docs: describe state-dependent mac titlebar buttons
This commit is contained in:
Peter Steinberger
2026-07-12 10:13:36 +01:00
committed by GitHub
parent 3ac83ddfca
commit a8b104c4e3
12 changed files with 655 additions and 100 deletions
+1 -1
View File
@@ -17851,7 +17851,7 @@
},
{
"kind": "conditional-branch",
"line": 720,
"line": 781,
"path": "apps/macos/Sources/OpenClaw/DashboardWindowController.swift",
"source": "[\\(host)]",
"surface": "apple",
@@ -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)
}
}
@@ -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
@@ -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)
}
}
@@ -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 {
+2
View File
@@ -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
+1 -1
View File
@@ -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)
+78 -1
View File
@@ -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;
+46
View File
@@ -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<typeof globalThis.setTimeout>
@@ -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);
+49
View File
@@ -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();
});
});
+27
View File
@@ -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.
}
}
@@ -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 () => {