mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
3ac83ddfca
commit
a8b104c4e3
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user