diff --git a/apps/macos/Sources/OpenClaw/DashboardWindowController.swift b/apps/macos/Sources/OpenClaw/DashboardWindowController.swift index c86f22030ae8..ce92603353b8 100644 --- a/apps/macos/Sources/OpenClaw/DashboardWindowController.swift +++ b/apps/macos/Sources/OpenClaw/DashboardWindowController.swift @@ -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 {} })(); diff --git a/apps/macos/Tests/OpenClawIPCTests/DashboardWindowSmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/DashboardWindowSmokeTests.swift index dbdd52f8660d..cd77b5270bca 100644 --- a/apps/macos/Tests/OpenClawIPCTests/DashboardWindowSmokeTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/DashboardWindowSmokeTests.swift @@ -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) diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 15b2c565d2ac..a55716325db3 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -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) diff --git a/ui/src/app/app-host.test.ts b/ui/src/app/app-host.test.ts index 9bd2bac47f2f..33693c8874f8 100644 --- a/ui/src/app/app-host.test.ts +++ b/ui/src/app/app-host.test.ts @@ -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; diff --git a/ui/src/app/app-host.ts b/ui/src/app/app-host.ts index f7aaae2915a8..7cedc988f5b8 100644 --- a/ui/src/app/app-host.ts +++ b/ui/src/app/app-host.ts @@ -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(); diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index 46c1bf779393..c7479fdd7538 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -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;