diff --git a/ui/src/app/app-host.ts b/ui/src/app/app-host.ts index f266914f160d..4b15df53897f 100644 --- a/ui/src/app/app-host.ts +++ b/ui/src/app/app-host.ts @@ -11,6 +11,7 @@ import "../components/exec-approval.ts"; import "../components/gateway-url-confirmation.ts"; import "../components/github-link-hovercard.ts"; import "../components/login-gate.ts"; +import "../components/resizable-divider.ts"; import "../components/terminal/terminal-panel.ts"; import "../components/tooltip.ts"; import "../components/update-banner.ts"; @@ -41,6 +42,7 @@ import { hasOperatorAdminAccess } from "./operator-access.ts"; import type { ApplicationOverlaySnapshot } from "./overlays.ts"; import { controlUiPublicAssetPath } from "./public-assets.ts"; import { selectRenderedRouteMatch } from "./router-outlet.ts"; +import { NAV_WIDTH_DEFAULT, NAV_WIDTH_MAX, NAV_WIDTH_MIN } from "./settings.ts"; type ShellRouteState = { routeId?: RouteId; @@ -372,6 +374,7 @@ class OpenClawShell extends LitElement { private context?: ApplicationContext; @state() private navCollapsed = false; + @state() private navWidth = NAV_WIDTH_DEFAULT; @state() private sidebarPinnedRoutes: readonly SidebarNavRoute[] = []; @state() private sidebarMoreExpanded = false; @state() private navDrawerOpen = false; @@ -418,6 +421,7 @@ class OpenClawShell extends LitElement { this.startSubscriptions(); this.addEventListener(COMMAND_PALETTE_TARGET_EVENT, this.handleCommandPaletteTarget); document.addEventListener("keydown", this.handleDocumentKeydown); + window.addEventListener("resize", this.handleWindowResize); } override updated() { @@ -486,6 +490,7 @@ class OpenClawShell extends LitElement { override disconnectedCallback() { this.removeEventListener(COMMAND_PALETTE_TARGET_EVENT, this.handleCommandPaletteTarget); document.removeEventListener("keydown", this.handleDocumentKeydown); + window.removeEventListener("resize", this.handleWindowResize); this.stopAgentsSubscription?.(); this.stopAgentsSubscription = undefined; this.stopConfigSubscription?.(); @@ -576,6 +581,22 @@ class OpenClawShell extends LitElement { }); } + private resizeNavigation(splitRatio: number) { + const shell = this.querySelector(".shell"); + const context = this.context; + if (!shell || !context) { + return; + } + const navWidth = Math.round( + Math.min(NAV_WIDTH_MAX, Math.max(NAV_WIDTH_MIN, splitRatio * shell.clientWidth)), + ); + context.navigation.update({ navWidth }); + } + + private readonly handleWindowResize = () => { + this.requestUpdate(); + }; + private readonly handleShellKeydown = (event: KeyboardEvent) => { if (event.defaultPrevented || event.key !== "Escape" || !this.navDrawerOpen) { return; @@ -731,6 +752,7 @@ class OpenClawShell extends LitElement { snapshot: ApplicationRuntime["context"]["navigation"]["snapshot"], ) => { this.navCollapsed = snapshot.navCollapsed; + this.navWidth = snapshot.navWidth; this.sidebarPinnedRoutes = snapshot.sidebarPinnedRoutes; this.sidebarMoreExpanded = snapshot.sidebarMoreExpanded; }; @@ -751,6 +773,7 @@ class OpenClawShell extends LitElement { // Drawer navigation always opens expanded; the desktop collapse preference // stays persisted for when the viewport returns to the desktop layout. const navCollapsed = this.navCollapsed && !navDrawerOpen; + const shellWidth = Math.max(globalThis.innerWidth || 0, NAV_WIDTH_MAX); return html` this.navigate(routeId)} @@ -766,6 +789,7 @@ class OpenClawShell extends LitElement { : ""} ${navDrawerOpen ? "shell--nav-drawer-open" : ""} ${this.onboarding ? "shell--onboarding" : ""}" + style=${`--shell-nav-expanded-width: ${this.navWidth}px`} @keydown=${this.handleShellKeydown} @theme-change=${this.handleThemeChange} > @@ -817,6 +841,21 @@ class OpenClawShell extends LitElement { isRouteId(routeId) ? context.preload(routeId) : Promise.resolve()} > + ${!navCollapsed && !this.onboarding + ? html` + ) => + this.resizeNavigation(event.detail.splitRatio)} + > + ` + : nothing}
= {}): chatShowToolCalls: true, splitRatio: 0.6, navCollapsed: false, - navWidth: 220, + navWidth: 258, sidebarPinnedRoutes: ["overview"], sidebarMoreExpanded: false, borderRadius: 50, @@ -167,7 +167,7 @@ describe("loadSettings default gateway URL derivation", () => { chatAutoScroll: "near-bottom", splitRatio: 0.6, navCollapsed: false, - navWidth: 220, + navWidth: 258, sidebarPinnedRoutes: ["overview"], sidebarMoreExpanded: false, borderRadius: 50, @@ -202,7 +202,7 @@ describe("loadSettings default gateway URL derivation", () => { chatAutoScroll: "near-bottom", splitRatio: 0.6, navCollapsed: false, - navWidth: 220, + navWidth: 258, sidebarPinnedRoutes: ["overview"], sidebarMoreExpanded: false, borderRadius: 50, @@ -235,7 +235,7 @@ describe("loadSettings default gateway URL derivation", () => { chatAutoScroll: "near-bottom", splitRatio: 0.6, navCollapsed: false, - navWidth: 220, + navWidth: 258, sidebarPinnedRoutes: ["overview"], sidebarMoreExpanded: false, borderRadius: 50, @@ -253,7 +253,7 @@ describe("loadSettings default gateway URL derivation", () => { chatAutoScroll: "near-bottom", splitRatio: 0.6, navCollapsed: false, - navWidth: 220, + navWidth: 258, sidebarPinnedRoutes: ["overview"], sidebarMoreExpanded: false, borderRadius: 50, @@ -283,7 +283,7 @@ describe("loadSettings default gateway URL derivation", () => { chatShowToolCalls: true, splitRatio: 0.6, navCollapsed: false, - navWidth: 220, + navWidth: 258, sidebarPinnedRoutes: ["overview"], sidebarMoreExpanded: false, borderRadius: 50, @@ -303,7 +303,7 @@ describe("loadSettings default gateway URL derivation", () => { chatAutoScroll: "near-bottom", splitRatio: 0.6, navCollapsed: false, - navWidth: 220, + navWidth: 258, sidebarPinnedRoutes: ["overview"], sidebarMoreExpanded: false, borderRadius: 50, @@ -338,7 +338,7 @@ describe("loadSettings default gateway URL derivation", () => { chatAutoScroll: "near-bottom", splitRatio: 0.6, navCollapsed: false, - navWidth: 220, + navWidth: 258, sidebarPinnedRoutes: ["sessions", "cron"], sidebarMoreExpanded: true, borderRadius: 50, @@ -347,6 +347,7 @@ describe("loadSettings default gateway URL derivation", () => { expect(loadSettings().sidebarPinnedRoutes).toEqual(["sessions", "cron"]); expect(loadSettings().sidebarMoreExpanded).toBe(true); + expect(loadSettings().navWidth).toBe(258); // Corrupt the persisted list; load falls back to the default pinned set. const scopedKey = `openclaw.control.settings.v1:${gwUrl}`; @@ -356,10 +357,12 @@ describe("loadSettings default gateway URL derivation", () => { >; persisted.sidebarPinnedRoutes = "sessions"; persisted.sidebarMoreExpanded = "yes"; + persisted.navWidth = 220; localStorage.setItem(scopedKey, JSON.stringify(persisted)); expect(loadSettings().sidebarPinnedRoutes).toEqual(["overview"]); expect(loadSettings().sidebarMoreExpanded).toBe(false); + expect(loadSettings().navWidth).toBe(258); }); it("normalizes persisted text scale to the nearest supported stop", () => { @@ -475,7 +478,7 @@ describe("loadSettings default gateway URL derivation", () => { chatShowToolCalls: true, splitRatio: 0.6, navCollapsed: false, - navWidth: 220, + navWidth: 258, sidebarPinnedRoutes: ["overview"], sidebarMoreExpanded: false, borderRadius: 50, @@ -491,7 +494,7 @@ describe("loadSettings default gateway URL derivation", () => { chatShowToolCalls: true, splitRatio: 0.6, navCollapsed: false, - navWidth: 220, + navWidth: 258, sidebarPinnedRoutes: ["overview"], sidebarMoreExpanded: false, borderRadius: 50, @@ -592,7 +595,7 @@ describe("loadSettings default gateway URL derivation", () => { chatShowToolCalls: true, splitRatio: 0.6, navCollapsed: false, - navWidth: 220, + navWidth: 258, sidebarPinnedRoutes: ["overview"], sidebarMoreExpanded: false, borderRadius: 50, @@ -623,7 +626,7 @@ describe("loadSettings default gateway URL derivation", () => { chatShowToolCalls: true, splitRatio: 0.6, navCollapsed: false, - navWidth: 220, + navWidth: 258, sidebarPinnedRoutes: ["overview"], sidebarMoreExpanded: false, borderRadius: 50, @@ -668,7 +671,7 @@ describe("loadSettings default gateway URL derivation", () => { chatShowToolCalls: true, splitRatio: 0.6, navCollapsed: false, - navWidth: 220, + navWidth: 258, sidebarPinnedRoutes: ["overview"], sidebarMoreExpanded: false, borderRadius: 50, @@ -712,7 +715,7 @@ describe("loadSettings default gateway URL derivation", () => { chatShowToolCalls: true, splitRatio: 0.6, navCollapsed: false, - navWidth: 220, + navWidth: 258, sidebarPinnedRoutes: ["overview"], sidebarMoreExpanded: false, borderRadius: 50, diff --git a/ui/src/app/settings.ts b/ui/src/app/settings.ts index b348c3cb93ab..ab80672d623f 100644 --- a/ui/src/app/settings.ts +++ b/ui/src/app/settings.ts @@ -1,6 +1,9 @@ // Control UI module implements storage behavior. const SETTINGS_KEY_PREFIX = "openclaw.control.settings.v1:"; const LEGACY_SETTINGS_KEY = "openclaw.control.settings.v1"; +export const NAV_WIDTH_MIN = 240; +export const NAV_WIDTH_MAX = 400; +export const NAV_WIDTH_DEFAULT = 258; const CURRENT_GATEWAY_SELECTION_KEY_PREFIX = "openclaw.control.currentGateway.v1:"; const LOCAL_USER_IDENTITY_KEY = "openclaw.control.user.v1"; const LEGACY_TOKEN_SESSION_KEY = "openclaw.control.token.v1"; @@ -532,7 +535,7 @@ export function loadSettings(): UiSettings { chatSendShortcut: "enter", splitRatio: 0.6, navCollapsed: false, - navWidth: 220, + navWidth: NAV_WIDTH_DEFAULT, sidebarPinnedRoutes: [...DEFAULT_SIDEBAR_PINNED_ROUTES], sidebarMoreExpanded: false, borderRadius: 50, @@ -599,7 +602,9 @@ export function loadSettings(): UiSettings { navCollapsed: typeof parsed.navCollapsed === "boolean" ? parsed.navCollapsed : defaults.navCollapsed, navWidth: - typeof parsed.navWidth === "number" && parsed.navWidth >= 200 && parsed.navWidth <= 400 + typeof parsed.navWidth === "number" && + parsed.navWidth >= NAV_WIDTH_MIN && + parsed.navWidth <= NAV_WIDTH_MAX ? parsed.navWidth : defaults.navWidth, sidebarPinnedRoutes: diff --git a/ui/src/e2e/sidebar-customization.e2e.test.ts b/ui/src/e2e/sidebar-customization.e2e.test.ts index 316eb23523cb..45bd6523c53c 100644 --- a/ui/src/e2e/sidebar-customization.e2e.test.ts +++ b/ui/src/e2e/sidebar-customization.e2e.test.ts @@ -23,6 +23,10 @@ async function trimmedTextContents(locator: Locator): Promise { return (await locator.allTextContents()).map((text) => text.trim()); } +async function roundedWidth(locator: Locator): Promise { + return Math.round((await locator.boundingBox())?.width ?? 0); +} + async function captureUiProof(page: Page, fileName: string) { if (process.env.OPENCLAW_CAPTURE_UI_PROOF !== "1") { return; @@ -74,6 +78,44 @@ describeControlUiE2e("Control UI sidebar customization mocked Gateway E2E", () = await expect.poll(() => page.locator(".topbar").isVisible()).toBe(true); await expect.poll(() => page.locator(".dashboard-header").isVisible()).toBe(true); await expect.poll(() => page.locator(".topbar-brand").isVisible()).toBe(false); + const shellNav = page.locator(".shell-nav"); + const sidebarResizer = page.getByRole("separator", { name: "Resize sidebar" }); + await expect.poll(() => roundedWidth(shellNav)).toBe(258); + await expect.poll(() => sidebarResizer.getAttribute("aria-valuetext")).toBe("258 pixels"); + await captureUiProof(page, "00-sidebar-default-width.png"); + + const resizerBounds = await sidebarResizer.boundingBox(); + if (!resizerBounds) { + throw new Error("expected visible desktop sidebar resizer"); + } + const resizerX = resizerBounds.x + resizerBounds.width / 2; + const resizerY = resizerBounds.y + resizerBounds.height / 2; + await page.mouse.move(resizerX, resizerY); + await expect + .poll(() => + page.evaluate(({ x, y }) => document.elementFromPoint(x, y)?.tagName.toLowerCase(), { + x: resizerX, + y: resizerY, + }), + ) + .toBe("resizable-divider"); + await page.mouse.down(); + await expect.poll(() => sidebarResizer.getAttribute("class")).toContain("dragging"); + await page.mouse.move(resizerX + 100, resizerY); + await page.mouse.up(); + await expect.poll(() => roundedWidth(shellNav)).toBe(358); + await expect.poll(() => sidebarResizer.getAttribute("aria-valuetext")).toBe("358 pixels"); + await captureUiProof(page, "00-sidebar-resized.png"); + + await page.reload(); + await expect.poll(() => roundedWidth(shellNav)).toBe(358); + await page.setViewportSize({ height: 900, width: 1300 }); + await expect.poll(() => roundedWidth(shellNav)).toBe(358); + await sidebarResizer.focus(); + await page.keyboard.press("Home"); + await expect.poll(() => roundedWidth(shellNav)).toBe(240); + await page.keyboard.press("End"); + await expect.poll(() => roundedWidth(shellNav)).toBe(400); const settingsLink = sidebar.getByRole("link", { name: "Settings" }); await expect.poll(() => settingsLink.isVisible()).toBe(true); await settingsLink.click(); @@ -158,6 +200,14 @@ describeControlUiE2e("Control UI sidebar customization mocked Gateway E2E", () = await expect .poll(() => page.locator(".shell").getAttribute("class")) .toContain("shell--nav-collapsed"); + await expect + .poll(() => + page + .locator(".shell") + .evaluate((element) => getComputedStyle(element).getPropertyValue("--shell-nav-width")), + ) + .toBe("78px"); + await expect.poll(() => sidebarResizer.count()).toBe(0); // Rail mode keeps the palette entry reachable as an icon-only control. await expect.poll(() => searchButton.isVisible()).toBe(true); await page.reload(); @@ -181,6 +231,14 @@ describeControlUiE2e("Control UI sidebar customization mocked Gateway E2E", () = ) .toBe(false); await expect.poll(() => moreButton.isVisible()).toBe(true); + await expect.poll(() => sidebarResizer.isVisible()).toBe(false); + await expect + .poll(() => + page + .locator(".shell") + .evaluate((element) => getComputedStyle(element).getPropertyValue("--shell-nav-width")), + ) + .toBe("0px"); await expect .poll(() => page.locator(".shell-nav").evaluate((element) => element.getBoundingClientRect().left), diff --git a/ui/src/pages/overview/view.render.test.ts b/ui/src/pages/overview/view.render.test.ts index 4e26cd2b95b4..3dc0fa623edd 100644 --- a/ui/src/pages/overview/view.render.test.ts +++ b/ui/src/pages/overview/view.render.test.ts @@ -23,7 +23,7 @@ function createOverviewProps(overrides: Partial = {}): OverviewPr chatShowToolCalls: true, splitRatio: 0.6, navCollapsed: false, - navWidth: 220, + navWidth: 258, sidebarPinnedRoutes: ["overview"], sidebarMoreExpanded: false, borderRadius: 50, diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index 73caaea3c952..bf7f7efa0996 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -5,7 +5,8 @@ .shell { --shell-pad: 16px; --shell-gap: 16px; - --shell-nav-width: 258px; + --shell-nav-expanded-width: 258px; + --shell-nav-width: var(--shell-nav-expanded-width); --shell-nav-rail-width: 78px; --shell-topbar-height: 44px; --shell-focus-duration: 200ms; @@ -350,6 +351,19 @@ display: none; } +.sidebar-resizer { + grid-area: nav; + align-self: stretch; + justify-self: end; + z-index: 20; + width: 1px; + background: transparent; +} + +.shell:has(.sidebar-resizer.dragging) { + transition: grid-template-rows var(--shell-focus-duration) var(--shell-focus-ease); +} + .sidebar { position: relative; display: flex; diff --git a/ui/src/styles/layout.mobile.css b/ui/src/styles/layout.mobile.css index 133681d118ff..c47cf473ddab 100644 --- a/ui/src/styles/layout.mobile.css +++ b/ui/src/styles/layout.mobile.css @@ -124,6 +124,10 @@ opacity var(--shell-focus-duration) var(--shell-focus-ease); } + .sidebar-resizer { + display: none; + } + .shell--nav-drawer-open .shell-nav, .shell--nav-collapsed.shell--nav-drawer-open .shell-nav { transform: translateX(0);