diff --git a/apps/macos/Sources/OpenClaw/DashboardWindowController.swift b/apps/macos/Sources/OpenClaw/DashboardWindowController.swift
index d20f8f86635d..024a6c5e6d40 100644
--- a/apps/macos/Sources/OpenClaw/DashboardWindowController.swift
+++ b/apps/macos/Sources/OpenClaw/DashboardWindowController.swift
@@ -456,17 +456,18 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
topDragRegion.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 78),
topDragRegion.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -380),
topDragRegion.topAnchor.constraint(equalTo: container.topAnchor),
- // Thin edge strip only: the web topbar's controls sit vertically
- // centered in its 48px row, so an 8px strip stays clear of them
- // while still offering a grab edge across the window.
- topDragRegion.heightAnchor.constraint(equalToConstant: 8),
+ // Thin edge strip only: the web UI has no desktop topbar row, so a
+ // taller region would swallow clicks meant for the top of the
+ // content column (chat thread, page headers). The sidebar region
+ // below stays the primary drag surface — it floats over the 50px
+ // strip the native chrome CSS reserves in the web sidebar. At
+ // narrow widths the compact drawer topbar keeps x 78-254 passive
+ // (its brand strip), so the region stays click-safe there too.
+ topDragRegion.heightAnchor.constraint(equalToConstant: 12),
topRightDragRegion.leadingAnchor.constraint(equalTo: topDragRegion.trailingAnchor),
topRightDragRegion.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -8),
topRightDragRegion.topAnchor.constraint(equalTo: container.topAnchor),
topRightDragRegion.heightAnchor.constraint(equalToConstant: 6),
- // Primary drag surface: floats over the web topbar's brand strip,
- // which the Control UI keeps non-interactive and >=176px wide on
- // native macOS (layout.css html.openclaw-native-macos rules).
sidebarDragRegion.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 78),
sidebarDragRegion.topAnchor.constraint(equalTo: container.topAnchor),
sidebarDragRegion.widthAnchor.constraint(equalToConstant: 176),
@@ -490,25 +491,20 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
}
private static func installNativeChromeScript(into userContentController: WKUserContentController) {
- // Desktop widths need no rules here: the Control UI's own
- // `html.openclaw-native-macos` styles inset the topbar past the
- // traffic lights and keep the brand strip passive under the drag
- // regions installed in makeWindow (layout.css).
+ // Narrow widths need no rules here: the Control UI's own
+ // `html.openclaw-native-macos` styles fold the titlebar clearance into
+ // the drawer topbar row (layout.mobile.css); their body-qualified
+ // !important selectors also outrank the rules older app builds inject.
let css = """
html.openclaw-native-macos {
--openclaw-native-titlebar-height: 50px;
}
- @media (max-width: 1100px) {
- /* The drawer topbar replaces the desktop bar below this breakpoint.
- Move its controls below AppKit's traffic lights and drag overlay. */
- html.openclaw-native-macos .shell {
- --shell-topbar-height: calc(58px + var(--openclaw-native-titlebar-height));
- }
- html.openclaw-native-macos .topbar {
- padding: var(--openclaw-native-titlebar-height) 12px 0 !important;
- }
- html.openclaw-native-macos .topnav-shell {
- min-height: 58px;
+ @media (min-width: 700px) {
+ /* Both desktop navigation surfaces must clear AppKit's window controls
+ and drag regions or their first interactive row becomes unreachable. */
+ html.openclaw-native-macos .sidebar-shell,
+ html.openclaw-native-macos .settings-sidebar__header {
+ padding-top: max(14px, var(--openclaw-native-titlebar-height)) !important;
}
}
"""
diff --git a/apps/macos/Tests/OpenClawIPCTests/DashboardWindowSmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/DashboardWindowSmokeTests.swift
index edd17259cf9d..434278b26bdd 100644
--- a/apps/macos/Tests/OpenClawIPCTests/DashboardWindowSmokeTests.swift
+++ b/apps/macos/Tests/OpenClawIPCTests/DashboardWindowSmokeTests.swift
@@ -313,7 +313,7 @@ struct DashboardWindowSmokeTests {
#expect(dashboardLogString(for: url) == "http://127.0.0.1:18789/control/")
}
- @Test func `dashboard native chrome offsets the drawer topbar`() throws {
+ @Test func `dashboard native chrome clears both desktop sidebars`() throws {
let url = try #require(URL(string: "http://127.0.0.1:18789/control/"))
let controller = DashboardWindowController(
url: url,
@@ -322,13 +322,14 @@ struct DashboardWindowSmokeTests {
$0.source.contains("openclaw-native-macos-chrome")
})
- // Desktop widths are styled by the Control UI's own
- // html.openclaw-native-macos rules; only the drawer topbar needs
+ // Narrow widths are styled by the Control UI's own compact drawer-row
+ // rules (layout.mobile.css); only the desktop sidebar surfaces need
// native padding injected here.
- #expect(chromeScript.source.contains(".topbar"))
- #expect(chromeScript.source.contains("max-width: 1100px"))
+ #expect(chromeScript.source.contains(".sidebar-shell"))
+ #expect(chromeScript.source.contains(".settings-sidebar__header"))
+ #expect(chromeScript.source.contains("min-width: 700px"))
#expect(chromeScript.source.contains("--openclaw-native-titlebar-height"))
- #expect(!chromeScript.source.contains(".sidebar-shell"))
+ #expect(!chromeScript.source.contains("max-width: 1100px"))
}
@Test func `dashboard titlebar hosts back and forward controls`() throws {
diff --git a/docs/docs_map.md b/docs/docs_map.md
index 25628e888273..f261638dae27 100644
--- a/docs/docs_map.md
+++ b/docs/docs_map.md
@@ -9923,7 +9923,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H2: Gateway host status
- H2: Language support
- H2: Appearance themes
- - H2: Navigation
+ - H2: Sidebar navigation
- H2: What it can do (today)
- H2: MCP page
- H2: Activity tab
diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md
index 03c7eb095721..b3696a5fef37 100644
--- a/docs/web/control-ui.md
+++ b/docs/web/control-ui.md
@@ -115,15 +115,13 @@ Imported themes are stored only in the current browser profile; they are not wri
Appearance also has a browser-local Text size setting, stored with the rest of Control UI preferences. It applies to chat text, composer text, tool cards, and chat sidebars, and keeps text inputs at least 16px so mobile Safari does not auto-zoom on focus.
-## Navigation
+## Sidebar navigation
-On desktop, a slim top bar is the app chrome: the OpenClaw mark (with a connection-status dot), navigation pills for **Chat** plus every pinned destination, a **More** menu with the remaining destinations, and global actions on the right — the command-palette **Search** pill (⌘K), mobile pairing, the light/dark/system color-mode toggle, and **Settings**. **Docs** and **Edit pinned items** live in the More menu; right-clicking the navigation pills also opens the pin editor. **Overview** is the only destination pinned by default, and the pinned set is stored in the current browser profile.
-
-Below the top bar, the side panel is a scrollable session list split into **Pinned**, one section per custom group (the session `category`), and **Ungrouped** for the rest. Every active session loaded for the selected agent stays visible inline; opening a session moves the selection highlight without reordering the rows. Sessions with new activity since they were last read show an unread dot, and opening one marks it read. Each session row has a context menu (kebab button or right-click) with Pin/Unpin, Mark as unread/read, Rename, Fork, Move to group (including New group and Remove from group), Archive, and Delete; touch layouts keep the direct pin and menu controls visible. Drag a session onto a custom group or **Ungrouped** to move it. Group headers can be collapsed, expanded, or dragged to reorder them; the collapsed state and custom order are stored in the current browser profile. Group headers also have a menu (kebab button or right-click) with Rename group, New group, and Delete group; renaming or deleting a group updates every member session, including archived ones, and deleting a group keeps its sessions and moves them back to Ungrouped. Groups created from the header start empty and stay visible as move targets. The sort control in the session list header also has a Group by toggle: Custom groups (default) or None for one flat list (Pinned stays separate); the choice is stored in the current browser profile. Multi-agent setups show a compact scope control in the session-list header.
+The sidebar pins navigation above a scrollable session list split into **Pinned**, one section per custom group (the session `category`), and **Ungrouped** for the rest. Every active session loaded for the selected agent stays visible inline; opening a session moves the selection highlight without reordering the rows. Sessions with new activity since they were last read show an unread dot, and opening one marks it read. Each session row has a context menu (kebab button or right-click) with Pin/Unpin, Mark as unread/read, Rename, Fork, Move to group (including New group and Remove from group), Archive, and Delete; touch layouts keep the direct pin and menu controls visible. Drag a session onto a custom group or **Ungrouped** to move it. Group headers can be collapsed, expanded, or dragged to reorder them; the collapsed state and custom order are stored in the current browser profile. Group headers also have a menu (kebab button or right-click) with Rename group, New group, and Delete group; renaming or deleting a group updates every member session, including archived ones, and deleting a group keeps its sessions and moves them back to Ungrouped. Groups created from the header start empty and stay visible as move targets. The sort control in the session list header also has a Group by toggle: Custom groups (default) or None for one flat list (Pinned stays separate); the choice is stored in the current browser profile. Multi-agent setups show a compact scope control in the session-list header. **Overview** is the only destination pinned by default; expand **More** to reach every other destination. Select **Edit pinned items** under More, or right-click the navigation area, to pin or unpin destinations and restore the defaults. The pinned set and More expansion state are stored in the current browser profile and survive reloads.
Inside **Settings**, the dedicated sidebar starts with a **Search settings** field for quickly finding settings sections.
-The top bar's panel toggle (⌘B) hides the session panel entirely for a full-width workspace; navigation stays in the top bar. Narrow viewports swap the session panel for a slide-over drawer (holding brand, pinned and More navigation, sessions, and the utility footer) behind a compact header row with the drawer toggle, brand, and command-palette search. 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. 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.
## What it can do (today)
@@ -247,14 +245,14 @@ The terminal is also available as a full-screen, terminal-only document at `/?vi
- The sidebar lists every loaded active session by pinned/custom/ungrouped section with a New Session action. Opening a visible row moves only the highlight. Custom groups are collapsible and drag-reorderable, and sessions can be dropped onto a group or Ungrouped; the browser preserves the group order and collapsed state across reloads. A new dashboard session asynchronously gets a concise generated title from its first non-command message; explicit names are never replaced. Set `agents.defaults.utilityModel` (or `agents.list[].utilityModel`) to route this separate model call to a lower-cost model. Switching the compact agent scope shows only sessions tied to that agent and falls back to that agent's main session when it has no saved dashboard sessions yet.
- Session search lives in the command palette (⌘K, or the Search field at the top of the sidebar): typing a query follows a bounded number of matching pages across agents, filters internal child/cron rows, and lists visible matches next to navigation commands. The Sessions page keeps the exhaustive searchable list with filters.
- Each sidebar row keeps direct pin access plus a full context menu for unread state, rename, fork, grouping, archive, and delete. An active run and an agent's main session cannot be archived. Archiving or deleting the currently selected session switches Chat back to that agent's main session.
- - In the macOS app, the top bar's brand strip sits next to the native window controls and stays non-interactive so it doubles as the window drag area.
+ - In the macOS app, the OpenClaw mark uses the otherwise-empty native titlebar strip next to the window controls instead of consuming a sidebar row.
- On desktop widths, chat controls stay on one compact row and collapse while scrolling down the transcript; scrolling up, returning to the top, or reaching the bottom restores the controls.
- Consecutive duplicate text-only messages render as one bubble with a count badge. Messages that carry images, attachments, tool output, or canvas previews are left uncollapsed.
- - The session workspace rail in each Chat pane lists session files, project files, and artifacts. It docks to the pane's right edge by default; drag its header (or use the dock button) to move it to the bottom, and the choice is stored in the current browser profile. Press ⇧⌘B to expand or collapse the active pane's rail; the collapsed strip keeps working expand, files (with a changed-file count), and terminal buttons. The separate file, tool, and Canvas detail panel is unaffected.
+ - The session workspace rail in each Chat pane lists session files, project files, and artifacts. It docks to the pane's right edge by default; drag its header (or use the dock button) to move it to the bottom, and the choice is stored in the current browser profile. A collapsed rail takes no space at all: reopen it with ⇧⌘B, the files toggle in the split-pane header, or the floating files button in single-pane chat (both carry a changed-file count badge). The separate file, tool, and Canvas detail panel is unaffected.
- The chat header model and thinking pickers patch the active session immediately through `sessions.patch`; they are persistent session overrides, not one-turn-only send options.
- **Split view:** open it from the bottom-right page action, then split the active pane right or down for as many panes as fit. Each pane has its own session, transcript, composer, and tool stream.
- Drag a session from the sidebar into chat to open it in a pane. An animated drop preview glides between zones and labels the outcome — "Split" over the exact half a new pane will occupy, "Open here" over a whole pane — and drops also work from single-pane mode.
- - The active split pane drives the sidebar selection and URL. Each pane carries its own header row with the session title and split/close controls; dividers resize columns and stacked panes, and the browser stores the layout locally across reloads.
+ - The active split pane drives the sidebar selection and URL. Each pane carries its own header row with the session title plus workspace-rail, split, and close controls; dividers resize columns and stacked panes, and the browser stores the layout locally across reloads.
- On narrow screens, split view keeps the layout but renders only the active pane, including its header with the close control.
- If you send a message while a model picker change for the same session is still saving, the composer waits for that session patch before calling `chat.send` so the send uses the selected model.
- Typing `/new` creates and switches to the same fresh dashboard session as New Chat, except when `session.dmScope: "main"` is configured and the current parent is the agent's main session; then it resets the main session in place. Typing `/reset` keeps the Gateway's explicit in-place reset for the current session.
diff --git a/ui/src/app-navigation.ts b/ui/src/app-navigation.ts
index 6e15f11d9e38..1f966d145bf5 100644
--- a/ui/src/app-navigation.ts
+++ b/ui/src/app-navigation.ts
@@ -1,5 +1,4 @@
// Control UI app navigation defines sidebar and settings presentation metadata.
-import type { GatewayControlUiPluginTab } from "./api/gateway.ts";
import type { RouteId } from "./app-route-paths.ts";
import type { IconName } from "./components/icons.ts";
import { t } from "./i18n/index.ts";
@@ -63,16 +62,6 @@ export function sidebarMoreRoutes(pinned: readonly SidebarNavRoute[]): SidebarNa
return SIDEBAR_NAV_ROUTES.filter((routeId) => !pinned.includes(routeId));
}
-/** Dynamic plugin tabs stay in "More"; only stable static route ids can be
- * persisted as pins. Grouped ordering keeps related tabs adjacent. */
-export function orderedControlUiPluginTabs(
- tabs: readonly GatewayControlUiPluginTab[],
-): GatewayControlUiPluginTab[] {
- return ["chat", "control", "agent", "settings"].flatMap((group) =>
- tabs.filter((tab) => (tab.group ?? "control") === group),
- );
-}
-
type SettingsNavigationGroup = {
/** i18n key for the group heading; null renders the group without a label. */
labelKey: string | null;
diff --git a/ui/src/app/app-host.ts b/ui/src/app/app-host.ts
index 0eaa4c363494..0d294cec9c98 100644
--- a/ui/src/app/app-host.ts
+++ b/ui/src/app/app-host.ts
@@ -14,17 +14,14 @@ import "../components/resizable-divider.ts";
import "../components/terminal/terminal-panel.ts";
import "../components/tooltip.ts";
import "../components/update-banner.ts";
-import {
- isSettingsNavigationRoute,
- orderedControlUiPluginTabs,
- type SidebarNavRoute,
-} from "../app-navigation.ts";
+import { isSettingsNavigationRoute, type SidebarNavRoute } from "../app-navigation.ts";
import { APP_ROUTE_IDS, isRouteId, type RouteId } from "../app-routes.ts";
import {
COMMAND_PALETTE_TARGET_EVENT,
type CommandPalette,
type CommandPaletteTargetDetail,
} from "../components/command-palette.ts";
+import { icons } from "../components/icons.ts";
import { renderSettingsSidebar } from "../components/settings-sidebar.ts";
import type { ThemeModeChangeDetail } from "../components/theme-mode-toggle.ts";
import { t } from "../i18n/index.ts";
@@ -822,28 +819,24 @@ class OpenClawShell extends OpenClawLightDomElement {
.searchDisabled=${false}
.navDrawerOpen=${navDrawerOpen}
.onboarding=${this.onboarding}
- .activeRouteId=${activeRoute}
- .activePluginTabId=${activePluginTabId}
- .enabledRouteIds=${this.enabledRouteIds()}
- .pinnedRoutes=${navigationSnapshot.sidebarPinnedRoutes}
- .pluginTabs=${orderedControlUiPluginTabs(gatewaySnapshot.hello?.controlUiTabs ?? [])}
- .sessionKey=${this.activeSessionKey}
- .connected=${gatewaySnapshot.connected}
- .canPairDevice=${gatewaySnapshot.connected &&
- hasOperatorAdminAccess(gatewaySnapshot.hello?.auth ?? null)}
- .themeMode=${context.theme.mode}
- .navCollapsed=${navCollapsed}
.onOpenPalette=${this.openPalette}
.onToggleDrawer=${(trigger: HTMLElement) => this.toggleNavigationSurface(trigger)}
- .onToggleSidebar=${() => this.toggleNavigationSurface()}
- .onPairMobile=${() => void context.overlays.openDevicePairSetup()}
- .onNavigate=${(routeId: string, options?: ApplicationNavigationOptions) =>
- this.navigate(routeId, options)}
- .onPreloadRoute=${(routeId: string) =>
- isRouteId(routeId) ? context.preload(routeId) : Promise.resolve()}
- .onUpdatePinnedRoutes=${(routes: SidebarNavRoute[]) =>
- context.navigation.update({ sidebarPinnedRoutes: routes })}
>
+ ${navCollapsed && !this.onboarding
+ ? html`
+