mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-13 17:07:40 +00:00
fix(macos): host the dashboard sidebar toggle beside back/forward in the titlebar (#104380)
* fix(macos): host the dashboard sidebar toggle beside back/forward in the titlebar The Control UI's floating sidebar-expand button rendered as a bordered web control crowding the traffic lights in the dashboard window. The toggle now lives as a native borderless button in the leading titlebar accessory ahead of back/forward (Safari ordering) and bridges to the web UI via the openclaw:native-toggle-sidebar event; the injected chrome script advertises the capability with an openclaw-native-nav class so the web control retires itself visually while staying keyboard/screen-reader reachable. * docs: note the macOS app's native titlebar sidebar toggle in the Control UI guide
This commit is contained in:
committed by
GitHub
parent
7875dd97a1
commit
df16372af3
@@ -467,11 +467,20 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
|
||||
Self.installNativeAuthScript(into: controller, url: url, auth: auth)
|
||||
}
|
||||
|
||||
/// Back/forward buttons next to the traffic lights. The window has no
|
||||
/// native toolbar (full-size content view with the web UI's own chrome), so
|
||||
/// a leading titlebar accessory is the only native slot for them.
|
||||
/// Sidebar toggle plus back/forward buttons next to the traffic lights
|
||||
/// (Safari's ordering). The window has no native toolbar (full-size content
|
||||
/// view with the web UI's own chrome), so a leading titlebar accessory is
|
||||
/// the only native slot for them.
|
||||
private func installNavigationControls() {
|
||||
guard let window = self.window else { return }
|
||||
let sidebar = Self.makeNavigationButton(
|
||||
symbolName: "sidebar.leading",
|
||||
label: "Toggle Sidebar",
|
||||
action: #selector(self.toggleNavigationSidebar(_:)),
|
||||
target: self)
|
||||
// Unlike back/forward there is no readiness state to observe; the web
|
||||
// UI ignores the toggle event on surfaces without a collapsible nav.
|
||||
sidebar.isEnabled = true
|
||||
let back = Self.makeNavigationButton(
|
||||
symbolName: "chevron.left",
|
||||
label: "Back",
|
||||
@@ -485,11 +494,11 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
|
||||
self.backButton = back
|
||||
self.forwardButton = forward
|
||||
|
||||
let stack = NSStackView(views: [back, forward])
|
||||
let stack = NSStackView(views: [sidebar, back, forward])
|
||||
stack.orientation = .horizontal
|
||||
stack.spacing = 4
|
||||
stack.edgeInsets = NSEdgeInsets(top: 0, left: 8, bottom: 0, right: 0)
|
||||
stack.setFrameSize(NSSize(width: 68, height: 28))
|
||||
stack.spacing = 6
|
||||
stack.edgeInsets = NSEdgeInsets(top: 0, left: 12, bottom: 0, right: 0)
|
||||
stack.setFrameSize(NSSize(width: 104, height: 28))
|
||||
|
||||
let accessory = NSTitlebarAccessoryViewController()
|
||||
accessory.view = stack
|
||||
@@ -553,6 +562,14 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
|
||||
self.webView.goForward()
|
||||
}
|
||||
|
||||
/// Named to avoid AppKit's standard `toggleSidebar(_:)` responder action,
|
||||
/// which would otherwise reach the split view controller and collapse the
|
||||
/// native link-browser pane instead of the web UI's navigation sidebar.
|
||||
@objc private func toggleNavigationSidebar(_: Any?) {
|
||||
self.webView.evaluateJavaScript(
|
||||
"window.dispatchEvent(new CustomEvent('openclaw:native-toggle-sidebar'))")
|
||||
}
|
||||
|
||||
private var activeNavigationWebView: WKWebView {
|
||||
guard let linkWebView = self.linkBrowser.activeWebView,
|
||||
let firstResponder = self.window?.firstResponder as? NSView,
|
||||
@@ -648,7 +665,10 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
|
||||
const style = document.createElement("style");
|
||||
style.id = "openclaw-native-macos-chrome";
|
||||
style.textContent = \(Self.jsStringLiteral(css));
|
||||
document.documentElement.classList.add("openclaw-native-macos");
|
||||
// openclaw-native-nav advertises the titlebar sidebar toggle so a
|
||||
// matching Control UI hides its floating expand button; older web
|
||||
// bundles ignore the class and keep their own fallback control.
|
||||
document.documentElement.classList.add("openclaw-native-macos", "openclaw-native-nav");
|
||||
document.head.appendChild(style);
|
||||
} catch {}
|
||||
})();
|
||||
|
||||
@@ -462,9 +462,12 @@ struct DashboardWindowSmokeTests {
|
||||
#expect(chromeScript.source.contains("min-width: 700px"))
|
||||
#expect(chromeScript.source.contains("--openclaw-native-titlebar-height"))
|
||||
#expect(!chromeScript.source.contains("max-width: 1100px"))
|
||||
// Advertises the native titlebar sidebar toggle so the Control UI can
|
||||
// drop its floating expand button (layout.css keys off this class).
|
||||
#expect(chromeScript.source.contains("openclaw-native-nav"))
|
||||
}
|
||||
|
||||
@Test func `dashboard titlebar hosts back and forward controls`() throws {
|
||||
@Test func `dashboard titlebar hosts sidebar and history controls`() throws {
|
||||
let url = try #require(URL(string: "http://127.0.0.1:18789/control/"))
|
||||
let controller = DashboardWindowController(
|
||||
url: url,
|
||||
@@ -473,10 +476,22 @@ struct DashboardWindowSmokeTests {
|
||||
let buttons = accessories.flatMap { accessory in
|
||||
accessory.view.subviews.compactMap { $0 as? NSButton }
|
||||
}
|
||||
let sidebar = try #require(buttons.first { $0.accessibilityLabel() == "Toggle Sidebar" })
|
||||
let back = try #require(buttons.first { $0.accessibilityLabel() == "Back" })
|
||||
let forward = try #require(buttons.first { $0.accessibilityLabel() == "Forward" })
|
||||
// Nothing to traverse on a fresh webview: both stay disabled until the
|
||||
// back-forward list gains entries (the SPA pushes history entries).
|
||||
// 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.
|
||||
#expect(sidebar.image != nil)
|
||||
#expect(stack.fittingSize.width <= stack.frame.width)
|
||||
// The toggle has no readiness state; back/forward stay disabled until
|
||||
// the back-forward list gains entries (the SPA pushes history entries).
|
||||
#expect(sidebar.isEnabled)
|
||||
#expect(!sidebar.isBordered)
|
||||
#expect(!back.isEnabled)
|
||||
#expect(!forward.isEnabled)
|
||||
#expect(controller._testAllowsBackForwardGestures)
|
||||
|
||||
@@ -164,7 +164,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). 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 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 native 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). 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.
|
||||
|
||||
## What it can do (today)
|
||||
|
||||
|
||||
@@ -38,6 +38,13 @@ type ShellKeyboardState = {
|
||||
handleDocumentKeydown: (event: KeyboardEvent) => void;
|
||||
};
|
||||
|
||||
type ShellNavigationState = {
|
||||
runtime: {
|
||||
context: ApplicationContext;
|
||||
};
|
||||
handleNativeToggleSidebar: () => void;
|
||||
};
|
||||
|
||||
type ShellEpochState = {
|
||||
navDrawerOpen: boolean;
|
||||
navDrawerTrigger: HTMLElement | null;
|
||||
@@ -195,6 +202,25 @@ describe("OpenClaw shell keyboard shortcuts", () => {
|
||||
expect(navigate).toHaveBeenCalledWith("config", undefined);
|
||||
});
|
||||
|
||||
it("toggles the navigation sidebar when the native macOS titlebar button fires", () => {
|
||||
const snapshot = { navCollapsed: false };
|
||||
const update = vi.fn((next: { navCollapsed: boolean }) => {
|
||||
snapshot.navCollapsed = next.navCollapsed;
|
||||
});
|
||||
const shell = document.createElement("openclaw-app-shell") as unknown as ShellNavigationState;
|
||||
shell.runtime = {
|
||||
context: {
|
||||
navigation: { snapshot, update },
|
||||
} as unknown as ApplicationContext,
|
||||
};
|
||||
|
||||
shell.handleNativeToggleSidebar();
|
||||
expect(update).toHaveBeenLastCalledWith({ navCollapsed: true });
|
||||
|
||||
shell.handleNativeToggleSidebar();
|
||||
expect(update).toHaveBeenLastCalledWith({ navCollapsed: false });
|
||||
});
|
||||
|
||||
it("leaves plain Command-Comma to the browser", () => {
|
||||
const navigate = vi.fn();
|
||||
const shell = document.createElement("openclaw-app-shell") as unknown as ShellKeyboardState;
|
||||
|
||||
@@ -493,12 +493,14 @@ class OpenClawShell extends OpenClawLightDomElement {
|
||||
this.addEventListener(COMMAND_PALETTE_TARGET_EVENT, this.handleCommandPaletteTarget);
|
||||
document.addEventListener("keydown", this.handleDocumentKeydown);
|
||||
window.addEventListener("resize", this.handleWindowResize);
|
||||
window.addEventListener("openclaw:native-toggle-sidebar", this.handleNativeToggleSidebar);
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
this.removeEventListener(COMMAND_PALETTE_TARGET_EVENT, this.handleCommandPaletteTarget);
|
||||
document.removeEventListener("keydown", this.handleDocumentKeydown);
|
||||
window.removeEventListener("resize", this.handleWindowResize);
|
||||
window.removeEventListener("openclaw:native-toggle-sidebar", this.handleNativeToggleSidebar);
|
||||
this.resetShellEpochState();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
@@ -626,6 +628,13 @@ class OpenClawShell extends OpenClawLightDomElement {
|
||||
context.navigation.update({ navWidth });
|
||||
}
|
||||
|
||||
/** The macOS app's titlebar sidebar button dispatches this window event
|
||||
* (DashboardWindowController) because AppKit drag regions cover the row
|
||||
* where an in-page control would live. */
|
||||
private readonly handleNativeToggleSidebar = () => {
|
||||
this.toggleNavigationSurface();
|
||||
};
|
||||
|
||||
private readonly handleWindowResize = () => {
|
||||
const dismissedHiddenMenus =
|
||||
isMobileNavLayout() && !this.navDrawerOpen && this.dismissSidebarTransientMenus();
|
||||
|
||||
@@ -105,6 +105,22 @@ html.openclaw-native-macos .shell-nav-expand {
|
||||
top: 52px;
|
||||
}
|
||||
|
||||
/* Newer Mac apps host a native sidebar toggle in the titlebar next to
|
||||
back/forward and add openclaw-native-nav; visually retire the floating
|
||||
duplicate but keep it focusable: collapse focus restoration targets it
|
||||
(app-host.ts) and keyboard/screen-reader users still need an in-page
|
||||
expand control, so it reveals itself on keyboard focus like a skip link.
|
||||
Older apps only add openclaw-native-macos and keep it visible. */
|
||||
html.openclaw-native-nav .shell-nav-expand {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
html.openclaw-native-nav .shell-nav-expand:focus-visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.shell--onboarding {
|
||||
grid-template-columns: 0 minmax(0, 1fr);
|
||||
grid-template-rows: 0 1fr;
|
||||
|
||||
Reference in New Issue
Block a user