From 9ecb2f3a845dcfe1b6f67339d71c668407dddb71 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 10 Jul 2026 09:35:14 +0100 Subject: [PATCH] feat(ui): restore sidebar chrome, delete the pane workspace strip, compact native narrow header (#103561) * feat(ui): restore sidebar chrome and remove the pane workspace strip Reverse course from #103426 on the app chrome: the left sidebar owns brand, pinned navigation + More, New session, sessions, and the footer (status dot, Settings, Docs, pairing, theme) again, and the desktop topbar is gone. What stays from that PR: the dockable workspace rail (right/bottom, drag or button), the in-flow split-pane headers, and Cmd+B now hides the sidebar entirely (no 78px icon rail) with a floating expand control. The real target of the original request: the vertical icon strip at each pane's right edge is deleted. A collapsed workspace rail renders nothing; the toggle (with a changed-file badge) lives in the split-pane header next to split/close, or floats at the top-right in single-pane chat. Shift+Cmd+B still toggles. Narrow native macOS windows (e.g. the in-app link browser splitting the window) previously stacked 50px of injected titlebar padding on top of the 58px drawer row; the web CSS now folds that into one compact 58px row beside the traffic lights, with selectors that outrank the rules shipped Mac apps inject. * chore(ui): regenerate locale bundles and docs map for sidebar restore * fix(ui): bind showPaneHeader explicitly on the classic single pane * chore(ui): reconcile locale metadata after rebase onto lobster wild cards --- .../OpenClaw/DashboardWindowController.swift | 40 +- .../DashboardWindowSmokeTests.swift | 13 +- docs/docs_map.md | 2 +- docs/web/control-ui.md | 14 +- ui/src/app-navigation.ts | 11 - ui/src/app/app-host.ts | 44 +- ui/src/components/app-sidebar.ts | 75 ++- ui/src/components/app-topbar.ts | 549 +----------------- ui/src/components/settings-sidebar.ts | 2 +- ui/src/e2e/chat-flow.e2e.test.ts | 70 +-- ui/src/e2e/mobile-pairing.e2e.test.ts | 8 +- ui/src/e2e/sidebar-customization.e2e.test.ts | 118 ++-- ui/src/i18n/.i18n/ar.meta.json | 8 +- ui/src/i18n/.i18n/de.meta.json | 8 +- ui/src/i18n/.i18n/es.meta.json | 8 +- ui/src/i18n/.i18n/fa.meta.json | 8 +- ui/src/i18n/.i18n/fr.meta.json | 8 +- ui/src/i18n/.i18n/hi.meta.json | 8 +- ui/src/i18n/.i18n/id.meta.json | 8 +- ui/src/i18n/.i18n/it.meta.json | 8 +- ui/src/i18n/.i18n/ja-JP.meta.json | 8 +- ui/src/i18n/.i18n/ko.meta.json | 8 +- ui/src/i18n/.i18n/nl.meta.json | 8 +- ui/src/i18n/.i18n/pl.meta.json | 8 +- ui/src/i18n/.i18n/pt-BR.meta.json | 8 +- ui/src/i18n/.i18n/ru.meta.json | 8 +- ui/src/i18n/.i18n/th.meta.json | 8 +- ui/src/i18n/.i18n/tr.meta.json | 8 +- ui/src/i18n/.i18n/uk.meta.json | 8 +- ui/src/i18n/.i18n/vi.meta.json | 8 +- ui/src/i18n/.i18n/zh-CN.meta.json | 8 +- ui/src/i18n/.i18n/zh-TW.meta.json | 8 +- ui/src/i18n/locales/ar.ts | 1 - ui/src/i18n/locales/de.ts | 1 - ui/src/i18n/locales/en.ts | 1 - ui/src/i18n/locales/es.ts | 1 - ui/src/i18n/locales/fa.ts | 1 - ui/src/i18n/locales/fr.ts | 1 - ui/src/i18n/locales/hi.ts | 1 - ui/src/i18n/locales/id.ts | 1 - ui/src/i18n/locales/it.ts | 1 - ui/src/i18n/locales/ja-JP.ts | 1 - ui/src/i18n/locales/ko.ts | 1 - ui/src/i18n/locales/nl.ts | 1 - ui/src/i18n/locales/pl.ts | 1 - ui/src/i18n/locales/pt-BR.ts | 1 - ui/src/i18n/locales/ru.ts | 1 - ui/src/i18n/locales/th.ts | 1 - ui/src/i18n/locales/tr.ts | 1 - ui/src/i18n/locales/uk.ts | 1 - ui/src/i18n/locales/vi.ts | 1 - ui/src/i18n/locales/zh-CN.ts | 1 - ui/src/i18n/locales/zh-TW.ts | 1 - ui/src/pages/chat/chat-page.test.ts | 26 +- ui/src/pages/chat/chat-page.ts | 62 +- ui/src/pages/chat/chat-pane.ts | 71 ++- ui/src/pages/chat/chat-view.test.ts | 63 +- ui/src/pages/chat/chat-view.ts | 7 + .../chat/components/chat-session-workspace.ts | 89 +-- ui/src/styles/chat/sidebar.css | 127 ++-- ui/src/styles/chat/split-view.css | 14 - ui/src/styles/layout.css | 434 +++++--------- ui/src/styles/layout.mobile.css | 84 ++- ui/src/styles/lobster-pet.css | 3 +- 64 files changed, 738 insertions(+), 1369 deletions(-) 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` + + + + ` + : nothing}
${settingsTakeover ? renderSettingsSidebar({ @@ -868,7 +861,6 @@ class OpenClawShell extends OpenClawLightDomElement { .activeRouteId=${activeRoute} .activePluginTabId=${activePluginTabId} .enabledRouteIds=${this.enabledRouteIds()} - .variant=${isMobileNavLayout() ? "drawer" : "panel"} .sessionKey=${this.activeSessionKey} .connected=${gatewaySnapshot.connected} .canPairDevice=${gatewaySnapshot.connected && @@ -881,6 +873,8 @@ class OpenClawShell extends OpenClawLightDomElement { .gatewayVersion=${context.config.current.serverVersion ?? gatewaySnapshot.hello?.server?.version ?? null} + .onOpenPalette=${this.openPalette} + .onToggleSidebar=${() => this.toggleNavigationSurface()} .onToggleMore=${() => context.navigation.update({ sidebarMoreExpanded: !context.navigation.snapshot.sidebarMoreExpanded, diff --git a/ui/src/components/app-sidebar.ts b/ui/src/components/app-sidebar.ts index a3a023a748a9..311efcb909ec 100644 --- a/ui/src/components/app-sidebar.ts +++ b/ui/src/components/app-sidebar.ts @@ -9,7 +9,6 @@ import { DEFAULT_SIDEBAR_PINNED_ROUTES, isSettingsNavigationRoute, navigationIconForRoute, - orderedControlUiPluginTabs, scheduleRoutePreload, type NavigationRouteId, SIDEBAR_NAV_ROUTES, @@ -115,6 +114,10 @@ const SIDEBAR_SESSION_GROUPING_STORAGE_KEY = "openclaw:sidebar:sessions:grouping const SIDEBAR_SESSION_COLLAPSED_SECTIONS_STORAGE_KEY = "openclaw:sidebar:sessions:collapsed-sections"; +const PALETTE_SHORTCUT = /Mac|iP(hone|ad|od)/i.test(globalThis.navigator?.platform ?? "") + ? "⌘K" + : "Ctrl K"; + function loadStoredSidebarSessionsGrouping(): SidebarSessionsGrouping { return normalizeSidebarSessionsGrouping( getSafeLocalStorage()?.getItem(SIDEBAR_SESSION_GROUPING_STORAGE_KEY), @@ -167,9 +170,6 @@ class AppSidebar extends OpenClawLightDomContentsElement { @property({ attribute: false }) activeRouteId?: NavigationRouteId; @property({ attribute: false }) activePluginTabId = ""; @property({ attribute: false }) enabledRouteIds?: readonly NavigationRouteId[]; - /** "panel" (desktop): sessions-only column below the topbar, which owns nav. - * "drawer" (narrow viewports): full nav + sessions + footer slide-over. */ - @property({ attribute: false }) variant: "panel" | "drawer" = "panel"; @property({ attribute: false }) connected = false; @property({ attribute: false }) canPairDevice = false; @property({ attribute: false }) sessionKey = ""; @@ -180,6 +180,8 @@ class AppSidebar extends OpenClawLightDomContentsElement { @property({ attribute: false }) lobsterPetVisits = true; @property({ attribute: false }) lobsterPetSounds = false; @property({ attribute: false }) gatewayVersion: string | null = null; + @property({ attribute: false }) onOpenPalette?: () => void; + @property({ attribute: false }) onToggleSidebar?: () => void; @property({ attribute: false }) onToggleMore?: () => void; @property({ attribute: false }) onUpdatePinnedRoutes?: (routes: SidebarNavRoute[]) => void; @property({ attribute: false }) onPairMobile?: () => void; @@ -323,6 +325,7 @@ class AppSidebar extends OpenClawLightDomContentsElement { } private renderBrand() { + const collapseLabel = t("nav.collapse"); return html` `; } + /** Command palette entry point; the palette itself is owned by the shell. */ + private renderSearch() { + const tooltip = `${t("chat.openCommandPalette")} (${PALETTE_SHORTCUT})`; + return html` + + + + `; + } + private getRouteSessionKey(): string { return this.sessionKey.trim() || this.context?.gateway.snapshot.sessionKey.trim() || ""; } @@ -1195,8 +1230,12 @@ class AppSidebar extends OpenClawLightDomContentsElement { `; } + /** Dynamic plugin tabs stay in More; only stable static route ids can be persisted as pins. */ private pluginTabs(): GatewayControlUiPluginTab[] { - return orderedControlUiPluginTabs(this.context?.gateway.snapshot.hello?.controlUiTabs ?? []); + const tabs = this.context?.gateway.snapshot.hello?.controlUiTabs ?? []; + return ["chat", "control", "agent", "settings"].flatMap((group) => + tabs.filter((tab) => (tab.group ?? "control") === group), + ); } private renderPluginTab(tab: GatewayControlUiPluginTab) { @@ -1633,32 +1672,6 @@ class AppSidebar extends OpenClawLightDomContentsElement { } override render() { - // Desktop sessions panel: the topbar owns brand, navigation, and global - // actions, so the panel is just the session list plus the resident pet. - // Its container deliberately avoids the .sidebar-shell class: the Mac app - // injects titlebar padding onto .sidebar-shell for the drawer, which must - // not leak into the panel that already sits below the topbar. - if (this.variant === "panel") { - return html` - - `; - } const gatewayStatus = t("chat.gatewayStatus", { status: this.connected ? t("common.online") : t("common.offline"), }); diff --git a/ui/src/components/app-topbar.ts b/ui/src/components/app-topbar.ts index 0a26d2f1d541..415d66a3dedc 100644 --- a/ui/src/components/app-topbar.ts +++ b/ui/src/components/app-topbar.ts @@ -1,453 +1,23 @@ import { html, nothing } from "lit"; -import { property, state } from "lit/decorators.js"; -import type { GatewayControlUiPluginTab } from "../api/gateway.ts"; -import { - cancelRoutePreload, - DEFAULT_SIDEBAR_PINNED_ROUTES, - isSettingsNavigationRoute, - navigationIconForRoute, - scheduleRoutePreload, - SIDEBAR_NAV_ROUTES, - sidebarMoreRoutes, - titleForRoute, - type NavigationRouteId, - type SidebarNavRoute, -} from "../app-navigation.ts"; -import { pathForRoute } from "../app-route-paths.ts"; -import type { ApplicationNavigationOptions } from "../app/context.ts"; +import { property } from "lit/decorators.js"; import { controlUiPublicAssetPath } from "../app/public-assets.ts"; -import type { ThemeMode } from "../app/theme.ts"; import { t } from "../i18n/index.ts"; -import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "../lib/external-link.ts"; -import { searchForSession } from "../lib/sessions/index.ts"; import { OpenClawLightDomContentsElement } from "../lit/openclaw-element.ts"; -import { pluginTabKey, pluginTabSearch } from "../pages/plugin/route.ts"; -import { icons, type IconName } from "./icons.ts"; -import "./theme-mode-toggle.ts"; +import { icons } from "./icons.ts"; import "./tooltip.ts"; -const PALETTE_SHORTCUT = /Mac|iP(hone|ad|od)/i.test(globalThis.navigator?.platform ?? "") - ? "⌘K" - : "Ctrl K"; - -function shouldHandleNavigationClick(event: MouseEvent): boolean { - return ( - !event.defaultPrevented && - event.button === 0 && - !event.metaKey && - !event.ctrlKey && - !event.shiftKey && - !event.altKey - ); -} - -/** App-wide header bar: brand + primary navigation + global actions. - * Desktop shows the full bar (nav pills, search, status); narrow viewports - * collapse to drawer toggle + brand + search (layout.mobile.css). */ +/** Narrow-viewport header: drawer toggle, brand, and command-palette search. + * Desktop hides it entirely (layout.css) — the sidebar owns navigation there. */ class AppTopbar extends OpenClawLightDomContentsElement { @property({ attribute: false }) navDrawerOpen = false; @property({ attribute: false }) onboarding = false; @property({ attribute: false }) basePath = ""; - @property({ attribute: false }) activeRouteId?: NavigationRouteId; - @property({ attribute: false }) activePluginTabId = ""; - @property({ attribute: false }) enabledRouteIds?: readonly NavigationRouteId[]; - @property({ attribute: false }) pinnedRoutes: readonly SidebarNavRoute[] = - DEFAULT_SIDEBAR_PINNED_ROUTES; - @property({ attribute: false }) pluginTabs: readonly GatewayControlUiPluginTab[] = []; - @property({ attribute: false }) sessionKey = ""; - @property({ attribute: false }) connected = false; - @property({ attribute: false }) canPairDevice = false; - @property({ attribute: false }) themeMode: ThemeMode = "system"; - @property({ attribute: false }) navCollapsed = false; - @property({ attribute: false }) searchDisabled = false; @property({ attribute: false }) onToggleDrawer?: (trigger: HTMLElement) => void; - @property({ attribute: false }) onToggleSidebar?: () => void; @property({ attribute: false }) onOpenPalette?: () => void; - @property({ attribute: false }) onPairMobile?: () => void; - @property({ attribute: false }) - onNavigate?: (routeId: NavigationRouteId, options?: ApplicationNavigationOptions) => void; - @property({ attribute: false }) onPreloadRoute?: (routeId: NavigationRouteId) => Promise; - @property({ attribute: false }) onUpdatePinnedRoutes?: (routes: SidebarNavRoute[]) => void; - - @state() private moreMenuPosition: { x: number; y: number } | null = null; - @state() private customizeMenuPosition: { x: number; y: number } | null = null; - - private moreMenuTrigger: HTMLElement | null = null; - private customizeMenuTrigger: HTMLElement | null = null; - private readonly routePreloadTimers = new Map< - EventTarget, - ReturnType - >(); - - override disconnectedCallback() { - this.closeMoreMenu(); - this.closeCustomizeMenu(); - for (const timer of this.routePreloadTimers.values()) { - globalThis.clearTimeout(timer); - } - this.routePreloadTimers.clear(); - super.disconnectedCallback(); - } - - private isRouteEnabled(routeId: NavigationRouteId): boolean { - return this.enabledRouteIds?.includes(routeId) ?? true; - } - - private preloadRoute(routeId: NavigationRouteId, event: Event, immediate = false) { - scheduleRoutePreload( - this.routePreloadTimers, - routeId, - event, - (nextRouteId) => this.onPreloadRoute?.(nextRouteId), - routeId === this.activeRouteId || !this.isRouteEnabled(routeId), - immediate, - ); - } - - private readonly cancelPreload = (event: Event) => { - cancelRoutePreload(this.routePreloadTimers, event); - }; - - private isRouteActive(routeId: NavigationRouteId): boolean { - if (routeId === "config") { - return this.activeRouteId !== undefined && isSettingsNavigationRoute(this.activeRouteId); - } - return this.activeRouteId === routeId; - } - - private navigate(routeId: NavigationRouteId, options?: ApplicationNavigationOptions) { - this.closeMoreMenu(); - this.closeCustomizeMenu(); - this.onNavigate?.(routeId, options); - } - - private openMoreMenu(trigger: HTMLElement) { - if (this.moreMenuPosition) { - this.closeMoreMenu(); - return; - } - const menuWidth = 240; - const rect = trigger.getBoundingClientRect(); - this.closeCustomizeMenu(); - this.moreMenuTrigger = trigger; - this.moreMenuPosition = { - x: Math.max(8, Math.min(rect.left, window.innerWidth - menuWidth - 8)), - y: rect.bottom + 6, - }; - this.listenForDismissal(); - void this.updateComplete.then(() => { - this.querySelector(".topbar-menu .topbar-menu__item")?.focus(); - }); - } - - private closeMoreMenu(options: { restoreFocus?: boolean } = {}) { - const trigger = this.moreMenuTrigger; - this.moreMenuTrigger = null; - this.moreMenuPosition = null; - this.syncDismissListeners(); - if (options.restoreFocus) { - trigger?.focus(); - } - } - - private openCustomizeMenu(x: number, y: number, trigger: HTMLElement | null = null) { - const menuWidth = 240; - const menuMaxHeight = 420; - this.closeMoreMenu(); - this.customizeMenuTrigger = trigger; - this.customizeMenuPosition = { - x: Math.max(8, Math.min(x, window.innerWidth - menuWidth - 8)), - y: Math.max(8, Math.min(y, window.innerHeight - menuMaxHeight - 8)), - }; - this.listenForDismissal(); - void this.updateComplete.then(() => { - this.querySelector(".sidebar-customize-menu__item")?.focus(); - }); - } - - private closeCustomizeMenu(options: { restoreFocus?: boolean } = {}) { - const trigger = this.customizeMenuTrigger; - this.customizeMenuTrigger = null; - this.customizeMenuPosition = null; - this.syncDismissListeners(); - if (options.restoreFocus) { - trigger?.focus(); - } - } - - private listenForDismissal() { - document.addEventListener("pointerdown", this.handleDocumentPointerDown, true); - document.addEventListener("keydown", this.handleDocumentKeydown, true); - window.addEventListener("resize", this.handleWindowResize); - } - - private syncDismissListeners() { - if (this.moreMenuPosition || this.customizeMenuPosition) { - return; - } - document.removeEventListener("pointerdown", this.handleDocumentPointerDown, true); - document.removeEventListener("keydown", this.handleDocumentKeydown, true); - window.removeEventListener("resize", this.handleWindowResize); - } - - private readonly handleWindowResize = () => { - // Fixed-position menus are anchored to desktop-only controls. Close them - // whenever that geometry changes so an overlay cannot outlive its anchor. - this.closeMoreMenu(); - this.closeCustomizeMenu(); - }; - - private readonly handleDocumentPointerDown = (event: PointerEvent) => { - const path = event.composedPath(); - if (this.moreMenuTrigger && path.includes(this.moreMenuTrigger)) { - return; - } - const menu = this.querySelector(".topbar-menu, .sidebar-customize-menu"); - if (menu && path.includes(menu)) { - return; - } - this.closeMoreMenu(); - this.closeCustomizeMenu(); - }; - - private readonly handleDocumentKeydown = (event: KeyboardEvent) => { - if (event.key === "Escape") { - event.preventDefault(); - event.stopPropagation(); - this.closeMoreMenu({ restoreFocus: true }); - this.closeCustomizeMenu({ restoreFocus: true }); - return; - } - if (event.key === "Tab") { - // Menu items stay outside the page tab order. Restore the durable trigger - // before the browser performs its normal forward/backward Tab movement. - this.closeMoreMenu({ restoreFocus: true }); - this.closeCustomizeMenu({ restoreFocus: true }); - return; - } - this.moveMenuFocus(event); - }; - - private moveMenuFocus(event: KeyboardEvent) { - const menu = this.querySelector(".topbar-menu, .sidebar-customize-menu"); - if (!menu) { - return; - } - const items = Array.from( - menu.querySelectorAll('[role="menuitem"], [role="menuitemcheckbox"]'), - ); - if (items.length === 0) { - return; - } - const activeIndex = items.indexOf(document.activeElement as HTMLElement); - let nextIndex: number; - if (event.key === "ArrowDown") { - nextIndex = (activeIndex + 1) % items.length; - } else if (event.key === "ArrowUp") { - nextIndex = activeIndex <= 0 ? items.length - 1 : activeIndex - 1; - } else if (event.key === "Home") { - nextIndex = 0; - } else if (event.key === "End") { - nextIndex = items.length - 1; - } else { - return; - } - event.preventDefault(); - event.stopPropagation(); - items[nextIndex]?.focus(); - } - - private readonly openCustomizeMenuFromContext = (event: MouseEvent) => { - event.preventDefault(); - this.openCustomizeMenu(event.clientX, event.clientY); - }; - - private togglePinnedRoute(routeId: SidebarNavRoute) { - const pinned = this.pinnedRoutes; - const next = pinned.includes(routeId) - ? pinned.filter((route) => route !== routeId) - : [...pinned, routeId]; - this.onUpdatePinnedRoutes?.(next); - } - - private renderNavItem(routeId: NavigationRouteId, menuItem = false) { - if (!this.isRouteEnabled(routeId)) { - return nothing; - } - const active = this.isRouteActive(routeId); - const routeSessionKey = routeId === "chat" ? this.sessionKey.trim() : ""; - const href = routeSessionKey - ? `${pathForRoute("chat", this.basePath)}${searchForSession(routeSessionKey)}` - : pathForRoute(routeId, this.basePath); - const label = titleForRoute(routeId); - const classes = menuItem - ? `topbar-menu__item ${active ? "topbar-menu__item--active" : ""}` - : `topnav-item ${active ? "topnav-item--active" : ""}`; - return html` - this.preloadRoute(routeId, event)} - @blur=${this.cancelPreload} - @pointerenter=${(event: Event) => this.preloadRoute(routeId, event)} - @pointerleave=${this.cancelPreload} - @touchstart=${(event: TouchEvent) => this.preloadRoute(routeId, event, true)} - @click=${(event: MouseEvent) => { - if (!shouldHandleNavigationClick(event)) { - return; - } - event.preventDefault(); - this.navigate( - routeId, - routeSessionKey ? { search: searchForSession(routeSessionKey) } : undefined, - ); - }} - > - - ${label} - - `; - } - - private renderPluginTabItem(tab: GatewayControlUiPluginTab) { - const ref = { pluginId: tab.pluginId, id: tab.id }; - const search = pluginTabSearch(ref); - const href = `${pathForRoute("plugin", this.basePath)}${search}`; - const active = this.activeRouteId === "plugin" && this.activePluginTabId === pluginTabKey(ref); - const iconName = tab.icon && Object.hasOwn(icons, tab.icon) ? (tab.icon as IconName) : "puzzle"; - return html` - { - if (!shouldHandleNavigationClick(event)) { - return; - } - event.preventDefault(); - this.navigate("plugin", { search }); - }} - > - - ${tab.label} - - `; - } - - private renderMoreMenu() { - const position = this.moreMenuPosition; - if (!position) { - return nothing; - } - const moreRoutes = sidebarMoreRoutes(this.pinnedRoutes); - return html` - - `; - } - - private renderCustomizeMenu() { - const position = this.customizeMenuPosition; - if (!position) { - return nothing; - } - return html` - - `; - } + @property({ attribute: false }) searchDisabled = false; override render() { const drawerLabel = this.navDrawerOpen ? t("nav.collapse") : t("nav.expand"); - const panelLabel = this.navCollapsed ? t("nav.expand") : t("nav.collapse"); - const gatewayStatus = t("chat.gatewayStatus", { - status: this.connected ? t("common.online") : t("common.offline"), - }); - const settingsActive = - this.activeRouteId !== undefined && isSettingsNavigationRoute(this.activeRouteId); - const searchTooltip = `${t("chat.openCommandPalette")} (${PALETTE_SHORTCUT})`; return html` `; } diff --git a/ui/src/components/settings-sidebar.ts b/ui/src/components/settings-sidebar.ts index b31d5ad56172..710267d3b843 100644 --- a/ui/src/components/settings-sidebar.ts +++ b/ui/src/components/settings-sidebar.ts @@ -101,7 +101,7 @@ export function renderSettingsSidebar(props: SettingsSidebarProps) { const navigationGroups = filterSettingsNavigationGroups(props.searchQuery); return html`
- - `; - } - - /** Header + pane travel together so each pane owns its title bar in-flow — - * no fixed toolbar layer mirroring the split geometry. */ - private renderPaneCell(pane: ChatSplitPane, active: boolean, weight: number) { return html`
this.handleFocusPane(pane.id)} @focusin=${() => this.handleFocusPane(pane.id)} > - ${this.renderPaneHeader(pane, active)} @@ -540,6 +499,7 @@ export class ChatPage extends OpenClawLightDomElement { .sessionKey=${this.data?.sessionKey ?? ""} .active=${true} .draft=${this.data?.draft} + .showPaneHeader=${false} .onFocusPane=${this.handleFocusPane} .onPaneSessionChange=${this.handlePaneSessionChange} > diff --git a/ui/src/pages/chat/chat-pane.ts b/ui/src/pages/chat/chat-pane.ts index 850ec4fefb69..7ba56945397c 100644 --- a/ui/src/pages/chat/chat-pane.ts +++ b/ui/src/pages/chat/chat-pane.ts @@ -1,5 +1,5 @@ import { consume } from "@lit/context"; -import { html } from "lit"; +import { html, nothing } from "lit"; import { property } from "lit/decorators.js"; import type { TaskSuggestion, @@ -19,6 +19,8 @@ import { COMMAND_PALETTE_TARGET_EVENT, type CommandPaletteTargetDetail, } from "../../components/command-palette.ts"; +import { icons } from "../../components/icons.ts"; +import "../../components/tooltip.ts"; import { t } from "../../i18n/index.ts"; import { isGatewayMethodAdvertised } from "../../lib/gateway-methods.ts"; import { resolveSessionDisplayName } from "../../lib/session-display.ts"; @@ -73,8 +75,10 @@ import { renderChatControls } from "./components/chat-controls.ts"; import { createSessionWorkspaceProps, openSessionWorkspaceFile, + renderSessionWorkspaceToggle, revealSessionWorkspaceFile, toggleSessionWorkspace, + type SessionWorkspaceProps, } from "./components/chat-session-workspace.ts"; import { CHAT_DETAIL_FULL_MESSAGE_MAX_CHARS, @@ -138,6 +142,14 @@ class ChatPane extends OpenClawLightDomElement { nextSessionKey: string, options?: PaneSessionChangeOptions, ) => void; + /** Split mode renders an in-pane header row (title + workspace/split/close + * controls); classic single-pane mode renders none. */ + @property({ attribute: false }) showPaneHeader = false; + @property({ attribute: false }) paneTitle = ""; + @property({ attribute: false }) narrow = false; + @property({ attribute: false }) onSplitDown?: (paneId: string) => void; + @property({ attribute: false }) onSplitRight?: (paneId: string) => void; + @property({ attribute: false }) onClosePane?: (paneId: string) => void; private readonly chatState = new ChatStateController(this); private state: ChatPageHost | undefined; @@ -973,6 +985,54 @@ class ChatPane extends OpenClawLightDomElement { state.requestUpdate?.(); } + private renderPaneHeader(sessionWorkspace: SessionWorkspaceProps) { + return html` +
+ + ${this.paneTitle} +
+ ${renderSessionWorkspaceToggle(sessionWorkspace, "pane-header")} + ${!this.narrow + ? html` + + + + + + + ` + : nothing} + + + +
+
+ `; + } + override render() { const state = this.state; if (!state) { @@ -995,6 +1055,7 @@ class ChatPane extends OpenClawLightDomElement { const canOpenRealtimeTalkSettings = hasOperatorAdminAccess( this.context.gateway.snapshot.hello?.auth ?? null, ); + const sessionWorkspace = createSessionWorkspaceProps(state); const props: ChatProps = { paneId: this.paneId, sessionKey: state.sessionKey, @@ -1099,7 +1160,8 @@ class ChatPane extends OpenClawLightDomElement { state.requestUpdate?.(); }, }), - sessionWorkspace: createSessionWorkspaceProps(state), + sessionWorkspace, + paneHeaderActive: this.showPaneHeader, taskSuggestions: this.taskSuggestions, taskSuggestionBusyIds: this.taskSuggestionBusyIds, canAcceptTaskSuggestions: @@ -1210,7 +1272,10 @@ class ChatPane extends OpenClawLightDomElement { onAssistantAttachmentLoaded: () => state.scrollToBottom(), basePath: state.basePath, }; - return renderChat(props); + if (!this.showPaneHeader) { + return renderChat(props); + } + return html`${this.renderPaneHeader(sessionWorkspace)}${renderChat(props)}`; } } diff --git a/ui/src/pages/chat/chat-view.test.ts b/ui/src/pages/chat/chat-view.test.ts index 7f24d765602e..b324f60c07ca 100644 --- a/ui/src/pages/chat/chat-view.test.ts +++ b/ui/src/pages/chat/chat-view.test.ts @@ -1208,13 +1208,27 @@ describe("chat composer workbench", () => { expect(container.querySelector('button[aria-label="Session workspace"]')).toBeNull(); }); - it("keeps the workspace files rail reachable from the collapsed strip", () => { + it("renders no rail strip while collapsed and reopens via the floating toggle", () => { const onToggleCollapsed = vi.fn(); const container = renderChatView({ sessionWorkspace: { collapsed: true, sessionKey: "agent:main", - list: null, + list: { + sessionKey: "agent:main", + root: "/workspace", + files: [ + { + name: "AGENTS.md", + path: "/workspace/AGENTS.md", + kind: "modified", + missing: false, + size: 2048, + }, + ], + browser: { path: "", entries: [] }, + artifacts: [], + }, loading: false, error: null, activeId: null, @@ -1233,25 +1247,46 @@ describe("chat composer workbench", () => { }, }); - expect(container.querySelector(".chat-workspace-rail__list")).toBeNull(); - const toggle = container.querySelector( - 'button[aria-label="Expand session workspace"]', - ); + // A collapsed rail renders nothing — no icon strip in the layout. + expect(container.querySelector(".chat-workspace-rail")).toBeNull(); + const toggle = container.querySelector(".chat-workspace-open"); + expect(toggle?.getAttribute("aria-label")).toBe("Show session files"); expect(toggle?.getAttribute("aria-expanded")).toBe("false"); expect(toggle?.getAttribute("aria-keyshortcuts")).toBe("Meta+Shift+B"); + expect(toggle?.querySelector(".chat-workspace-toggle__badge")?.textContent?.trim()).toBe("1"); toggle?.click(); expect(onToggleCollapsed).toHaveBeenCalledTimes(1); + }); - // The file glyph is a real control (regression: it used to be a dead, - // button-looking span) — clicking it expands the rail too. - const filesButton = container.querySelector( - 'button[aria-label="Show session files"]', - ); - expect(filesButton).not.toBeNull(); - filesButton?.click(); - expect(onToggleCollapsed).toHaveBeenCalledTimes(2); + it("suppresses the floating workspace toggle when a pane header hosts it", () => { + const container = renderChatView({ + paneHeaderActive: true, + sessionWorkspace: { + collapsed: true, + sessionKey: "agent:main", + list: null, + loading: false, + error: null, + activeId: null, + dock: "right", + dockDragging: false, + dockDragZone: null, + onToggleCollapsed: () => undefined, + onSetDock: () => undefined, + onDockDragStart: () => undefined, + onRefresh: () => undefined, + onBrowsePath: () => undefined, + onCopyPath: () => undefined, + onOpenFile: () => undefined, + onSearch: () => undefined, + onOpenArtifact: () => undefined, + }, + }); + + expect(container.querySelector(".chat-workspace-open")).toBeNull(); + expect(container.querySelector(".chat-workspace-rail")).toBeNull(); }); it("keeps the secondary New session and Export controls suppressed in the composer", () => { diff --git a/ui/src/pages/chat/chat-view.ts b/ui/src/pages/chat/chat-view.ts index feed25925508..3d3b79a0329c 100644 --- a/ui/src/pages/chat/chat-view.ts +++ b/ui/src/pages/chat/chat-view.ts @@ -23,6 +23,7 @@ import { } from "./components/chat-composer.ts"; import { renderSessionWorkspaceRail, + renderSessionWorkspaceToggle, type SessionWorkspaceProps, } from "./components/chat-session-workspace.ts"; import type { @@ -147,6 +148,9 @@ export type ChatProps = { onClearReply?: () => void; onSetReply?: (target: { messageId: string; text: string; senderLabel?: string | null }) => void; sessionWorkspace?: SessionWorkspaceProps; + /** True when a split pane header hosts the workspace toggle; suppresses the + * single-pane floating opener so only one affordance renders. */ + paneHeaderActive?: boolean; taskSuggestions?: TaskSuggestion[]; taskSuggestionBusyIds?: ReadonlySet; canAcceptTaskSuggestions?: boolean; @@ -348,6 +352,9 @@ export function renderChat(props: ChatProps) { : ""} ${props.sessionWorkspace?.dock === "bottom" ? "chat-workbench--dock-bottom" : ""}" > ${renderSessionWorkspaceRail(props.sessionWorkspace)} + ${props.sessionWorkspace?.collapsed && !props.paneHeaderActive + ? renderSessionWorkspaceToggle(props.sessionWorkspace, "floating") + : nothing} ${props.sessionWorkspace?.dockDragging ? html`