feat(ui): make the sidebar resizable (#102491)

* feat(ui): make the sidebar resizable

* chore: defer release notes to release automation
This commit is contained in:
Peter Steinberger
2026-07-09 08:18:56 +01:00
committed by GitHub
parent 04b64ffc19
commit 67630e8973
9 changed files with 145 additions and 18 deletions
+39
View File
@@ -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<RouteId>;
@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<HTMLElement>(".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`
<openclaw-command-palette
.onNavigate=${(routeId: RouteId) => 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()}
></openclaw-app-sidebar>
</div>
${!navCollapsed && !this.onboarding
? html`
<resizable-divider
class="sidebar-resizer"
.label=${t("nav.resize")}
.splitRatio=${this.navWidth / shellWidth}
.minRatio=${NAV_WIDTH_MIN / shellWidth}
.maxRatio=${NAV_WIDTH_MAX / shellWidth}
aria-valuetext=${`${this.navWidth} pixels`}
title=${t("nav.resize")}
@resize=${(event: CustomEvent<{ splitRatio: number }>) =>
this.resizeNavigation(event.detail.splitRatio)}
></resizable-divider>
`
: nothing}
<main
class="content ${activeRoute === "chat" ? "content--chat" : ""} ${activeRoute ===
"workboard"
+3
View File
@@ -158,6 +158,7 @@ function createApplicationNavigationPreferences(
let settings = initialSettings;
let snapshot: ApplicationNavigationPreferencesSnapshot = {
navCollapsed: settings.navCollapsed,
navWidth: settings.navWidth,
sidebarPinnedRoutes: settings.sidebarPinnedRoutes,
sidebarMoreExpanded: settings.sidebarMoreExpanded,
};
@@ -171,6 +172,7 @@ function createApplicationNavigationPreferences(
const nextSnapshot = { ...snapshot, ...patch };
if (
nextSnapshot.navCollapsed === snapshot.navCollapsed &&
nextSnapshot.navWidth === snapshot.navWidth &&
nextSnapshot.sidebarPinnedRoutes === snapshot.sidebarPinnedRoutes &&
nextSnapshot.sidebarMoreExpanded === snapshot.sidebarMoreExpanded
) {
@@ -178,6 +180,7 @@ function createApplicationNavigationPreferences(
}
settings = patchSettings({
navCollapsed: nextSnapshot.navCollapsed,
navWidth: nextSnapshot.navWidth,
sidebarPinnedRoutes: [...nextSnapshot.sidebarPinnedRoutes],
sidebarMoreExpanded: nextSnapshot.sidebarMoreExpanded,
});
+1
View File
@@ -32,6 +32,7 @@ export type ApplicationTheme = {
export type ApplicationNavigationPreferencesSnapshot = {
navCollapsed: boolean;
navWidth: number;
sidebarPinnedRoutes: readonly SidebarNavRoute[];
sidebarMoreExpanded: boolean;
};
+17 -14
View File
@@ -58,7 +58,7 @@ function makeSettings(gatewayUrl: string, overrides: Partial<UiSettings> = {}):
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,
+7 -2
View File
@@ -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:
@@ -23,6 +23,10 @@ async function trimmedTextContents(locator: Locator): Promise<string[]> {
return (await locator.allTextContents()).map((text) => text.trim());
}
async function roundedWidth(locator: Locator): Promise<number> {
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),
+1 -1
View File
@@ -23,7 +23,7 @@ function createOverviewProps(overrides: Partial<OverviewProps> = {}): OverviewPr
chatShowToolCalls: true,
splitRatio: 0.6,
navCollapsed: false,
navWidth: 220,
navWidth: 258,
sidebarPinnedRoutes: ["overview"],
sidebarMoreExpanded: false,
borderRadius: 50,
+15 -1
View File
@@ -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;
+4
View File
@@ -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);