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:
Peter Steinberger
2026-07-11 03:19:33 -07:00
committed by GitHub
parent 7875dd97a1
commit df16372af3
6 changed files with 98 additions and 12 deletions

View File

@@ -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 {}
})();

View File

@@ -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)

View File

@@ -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)

View File

@@ -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;

View File

@@ -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();

View File

@@ -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;