Skip to content

Commit df16372

Browse files
authored
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
1 parent 7875dd9 commit df16372

6 files changed

Lines changed: 98 additions & 12 deletions

File tree

apps/macos/Sources/OpenClaw/DashboardWindowController.swift

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -467,11 +467,20 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
467467
Self.installNativeAuthScript(into: controller, url: url, auth: auth)
468468
}
469469

470-
/// Back/forward buttons next to the traffic lights. The window has no
471-
/// native toolbar (full-size content view with the web UI's own chrome), so
472-
/// a leading titlebar accessory is the only native slot for them.
470+
/// Sidebar toggle plus back/forward buttons next to the traffic lights
471+
/// (Safari's ordering). The window has no native toolbar (full-size content
472+
/// view with the web UI's own chrome), so a leading titlebar accessory is
473+
/// the only native slot for them.
473474
private func installNavigationControls() {
474475
guard let window = self.window else { return }
476+
let sidebar = Self.makeNavigationButton(
477+
symbolName: "sidebar.leading",
478+
label: "Toggle Sidebar",
479+
action: #selector(self.toggleNavigationSidebar(_:)),
480+
target: self)
481+
// Unlike back/forward there is no readiness state to observe; the web
482+
// UI ignores the toggle event on surfaces without a collapsible nav.
483+
sidebar.isEnabled = true
475484
let back = Self.makeNavigationButton(
476485
symbolName: "chevron.left",
477486
label: "Back",
@@ -485,11 +494,11 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
485494
self.backButton = back
486495
self.forwardButton = forward
487496

488-
let stack = NSStackView(views: [back, forward])
497+
let stack = NSStackView(views: [sidebar, back, forward])
489498
stack.orientation = .horizontal
490-
stack.spacing = 4
491-
stack.edgeInsets = NSEdgeInsets(top: 0, left: 8, bottom: 0, right: 0)
492-
stack.setFrameSize(NSSize(width: 68, height: 28))
499+
stack.spacing = 6
500+
stack.edgeInsets = NSEdgeInsets(top: 0, left: 12, bottom: 0, right: 0)
501+
stack.setFrameSize(NSSize(width: 104, height: 28))
493502

494503
let accessory = NSTitlebarAccessoryViewController()
495504
accessory.view = stack
@@ -553,6 +562,14 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
553562
self.webView.goForward()
554563
}
555564

565+
/// Named to avoid AppKit's standard `toggleSidebar(_:)` responder action,
566+
/// which would otherwise reach the split view controller and collapse the
567+
/// native link-browser pane instead of the web UI's navigation sidebar.
568+
@objc private func toggleNavigationSidebar(_: Any?) {
569+
self.webView.evaluateJavaScript(
570+
"window.dispatchEvent(new CustomEvent('openclaw:native-toggle-sidebar'))")
571+
}
572+
556573
private var activeNavigationWebView: WKWebView {
557574
guard let linkWebView = self.linkBrowser.activeWebView,
558575
let firstResponder = self.window?.firstResponder as? NSView,
@@ -648,7 +665,10 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
648665
const style = document.createElement("style");
649666
style.id = "openclaw-native-macos-chrome";
650667
style.textContent = \(Self.jsStringLiteral(css));
651-
document.documentElement.classList.add("openclaw-native-macos");
668+
// openclaw-native-nav advertises the titlebar sidebar toggle so a
669+
// matching Control UI hides its floating expand button; older web
670+
// bundles ignore the class and keep their own fallback control.
671+
document.documentElement.classList.add("openclaw-native-macos", "openclaw-native-nav");
652672
document.head.appendChild(style);
653673
} catch {}
654674
})();

apps/macos/Tests/OpenClawIPCTests/DashboardWindowSmokeTests.swift

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -462,9 +462,12 @@ struct DashboardWindowSmokeTests {
462462
#expect(chromeScript.source.contains("min-width: 700px"))
463463
#expect(chromeScript.source.contains("--openclaw-native-titlebar-height"))
464464
#expect(!chromeScript.source.contains("max-width: 1100px"))
465+
// Advertises the native titlebar sidebar toggle so the Control UI can
466+
// drop its floating expand button (layout.css keys off this class).
467+
#expect(chromeScript.source.contains("openclaw-native-nav"))
465468
}
466469

467-
@Test func `dashboard titlebar hosts back and forward controls`() throws {
470+
@Test func `dashboard titlebar hosts sidebar and history controls`() throws {
468471
let url = try #require(URL(string: "http://127.0.0.1:18789/control/"))
469472
let controller = DashboardWindowController(
470473
url: url,
@@ -473,10 +476,22 @@ struct DashboardWindowSmokeTests {
473476
let buttons = accessories.flatMap { accessory in
474477
accessory.view.subviews.compactMap { $0 as? NSButton }
475478
}
479+
let sidebar = try #require(buttons.first { $0.accessibilityLabel() == "Toggle Sidebar" })
476480
let back = try #require(buttons.first { $0.accessibilityLabel() == "Back" })
477481
let forward = try #require(buttons.first { $0.accessibilityLabel() == "Forward" })
478-
// Nothing to traverse on a fresh webview: both stay disabled until the
479-
// back-forward list gains entries (the SPA pushes history entries).
482+
// Titlebar order mirrors Safari: sidebar toggle first, then history.
483+
let stack = try #require(sidebar.superview as? NSStackView)
484+
#expect(stack.arrangedSubviews.firstIndex(of: sidebar) == 0)
485+
#expect(stack.arrangedSubviews.firstIndex(of: back) == 1)
486+
#expect(stack.arrangedSubviews.firstIndex(of: forward) == 2)
487+
// A typo'd SF Symbol name yields a nil image (invisible button), and a
488+
// frame narrower than the fitting size clips the trailing control.
489+
#expect(sidebar.image != nil)
490+
#expect(stack.fittingSize.width <= stack.frame.width)
491+
// The toggle has no readiness state; back/forward stay disabled until
492+
// the back-forward list gains entries (the SPA pushes history entries).
493+
#expect(sidebar.isEnabled)
494+
#expect(!sidebar.isBordered)
480495
#expect(!back.isEnabled)
481496
#expect(!forward.isEnabled)
482497
#expect(controller._testAllowsBackForwardGestures)

docs/web/control-ui.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ The **+** in the sidebar session-list header opens a full-page draft at `/new`:
164164

165165
Inside **Settings**, the dedicated sidebar starts with a **Search settings** field for quickly finding settings sections.
166166

167-
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.
167+
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.
168168

169169
## What it can do (today)
170170

ui/src/app/app-host.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,13 @@ type ShellKeyboardState = {
3838
handleDocumentKeydown: (event: KeyboardEvent) => void;
3939
};
4040

41+
type ShellNavigationState = {
42+
runtime: {
43+
context: ApplicationContext;
44+
};
45+
handleNativeToggleSidebar: () => void;
46+
};
47+
4148
type ShellEpochState = {
4249
navDrawerOpen: boolean;
4350
navDrawerTrigger: HTMLElement | null;
@@ -195,6 +202,25 @@ describe("OpenClaw shell keyboard shortcuts", () => {
195202
expect(navigate).toHaveBeenCalledWith("config", undefined);
196203
});
197204

205+
it("toggles the navigation sidebar when the native macOS titlebar button fires", () => {
206+
const snapshot = { navCollapsed: false };
207+
const update = vi.fn((next: { navCollapsed: boolean }) => {
208+
snapshot.navCollapsed = next.navCollapsed;
209+
});
210+
const shell = document.createElement("openclaw-app-shell") as unknown as ShellNavigationState;
211+
shell.runtime = {
212+
context: {
213+
navigation: { snapshot, update },
214+
} as unknown as ApplicationContext,
215+
};
216+
217+
shell.handleNativeToggleSidebar();
218+
expect(update).toHaveBeenLastCalledWith({ navCollapsed: true });
219+
220+
shell.handleNativeToggleSidebar();
221+
expect(update).toHaveBeenLastCalledWith({ navCollapsed: false });
222+
});
223+
198224
it("leaves plain Command-Comma to the browser", () => {
199225
const navigate = vi.fn();
200226
const shell = document.createElement("openclaw-app-shell") as unknown as ShellKeyboardState;

ui/src/app/app-host.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,12 +493,14 @@ class OpenClawShell extends OpenClawLightDomElement {
493493
this.addEventListener(COMMAND_PALETTE_TARGET_EVENT, this.handleCommandPaletteTarget);
494494
document.addEventListener("keydown", this.handleDocumentKeydown);
495495
window.addEventListener("resize", this.handleWindowResize);
496+
window.addEventListener("openclaw:native-toggle-sidebar", this.handleNativeToggleSidebar);
496497
}
497498

498499
override disconnectedCallback() {
499500
this.removeEventListener(COMMAND_PALETTE_TARGET_EVENT, this.handleCommandPaletteTarget);
500501
document.removeEventListener("keydown", this.handleDocumentKeydown);
501502
window.removeEventListener("resize", this.handleWindowResize);
503+
window.removeEventListener("openclaw:native-toggle-sidebar", this.handleNativeToggleSidebar);
502504
this.resetShellEpochState();
503505
super.disconnectedCallback();
504506
}
@@ -626,6 +628,13 @@ class OpenClawShell extends OpenClawLightDomElement {
626628
context.navigation.update({ navWidth });
627629
}
628630

631+
/** The macOS app's titlebar sidebar button dispatches this window event
632+
* (DashboardWindowController) because AppKit drag regions cover the row
633+
* where an in-page control would live. */
634+
private readonly handleNativeToggleSidebar = () => {
635+
this.toggleNavigationSurface();
636+
};
637+
629638
private readonly handleWindowResize = () => {
630639
const dismissedHiddenMenus =
631640
isMobileNavLayout() && !this.navDrawerOpen && this.dismissSidebarTransientMenus();

ui/src/styles/layout.css

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,22 @@ html.openclaw-native-macos .shell-nav-expand {
105105
top: 52px;
106106
}
107107

108+
/* Newer Mac apps host a native sidebar toggle in the titlebar next to
109+
back/forward and add openclaw-native-nav; visually retire the floating
110+
duplicate but keep it focusable: collapse focus restoration targets it
111+
(app-host.ts) and keyboard/screen-reader users still need an in-page
112+
expand control, so it reveals itself on keyboard focus like a skip link.
113+
Older apps only add openclaw-native-macos and keep it visible. */
114+
html.openclaw-native-nav .shell-nav-expand {
115+
opacity: 0;
116+
pointer-events: none;
117+
}
118+
119+
html.openclaw-native-nav .shell-nav-expand:focus-visible {
120+
opacity: 1;
121+
pointer-events: auto;
122+
}
123+
108124
.shell--onboarding {
109125
grid-template-columns: 0 minmax(0, 1fr);
110126
grid-template-rows: 0 1fr;

0 commit comments

Comments
 (0)