diff --git a/ui/src/components/form-controls.browser.test.ts b/ui/src/components/form-controls.browser.test.ts
index bba0fb5055d5..379b40c47449 100644
--- a/ui/src/components/form-controls.browser.test.ts
+++ b/ui/src/components/form-controls.browser.test.ts
@@ -220,3 +220,77 @@ describeBrowserLayout("mount fallback cursor", () => {
}
});
});
+
+describeBrowserLayout("app chrome interaction styles", () => {
+ it("keeps sidebars compact while preserving normal content scroll and text entry", async () => {
+ const browser = await chromium.launch({
+ executablePath: chromiumExecutablePath,
+ headless: true,
+ });
+ try {
+ const page = await browser.newPage({ viewport: { width: 1200, height: 800 } });
+ await page.setContent(`
+
+
+
+
+
+
+
+ App chrome tile
+
+
+
+
+
+ `);
+
+ const metrics = await page.evaluate(() => {
+ const style = (selector: string) => {
+ const node = document.querySelector(selector);
+ if (!(node instanceof HTMLElement)) {
+ throw new Error(`Missing interaction fixture ${selector}`);
+ }
+ return getComputedStyle(node);
+ };
+ const scrollbarWidth = (selector: string) => {
+ const node = document.querySelector(selector);
+ if (!(node instanceof HTMLElement)) {
+ throw new Error(`Missing scrollbar fixture ${selector}`);
+ }
+ return getComputedStyle(node, "::-webkit-scrollbar").width;
+ };
+ return {
+ chatSelection: style(".chat-thread").userSelect,
+ chromeSelection: style(".settings-card").userSelect,
+ contentScrollbar: scrollbarWidth(".content"),
+ inputSelection: style(".settings-sidebar__search-input").userSelect,
+ regularSidebarScrollbar: scrollbarWidth(".sidebar-recent-sessions"),
+ regularSidebarSelection: style(".sidebar-recent-sessions").userSelect,
+ settingsSidebarScrollbar: scrollbarWidth(".settings-sidebar__nav"),
+ settingsSidebarSelection: style(".settings-sidebar__nav").userSelect,
+ };
+ });
+
+ expect(metrics).toEqual({
+ chatSelection: "text",
+ chromeSelection: "none",
+ contentScrollbar: "12px",
+ inputSelection: "text",
+ regularSidebarScrollbar: "6px",
+ regularSidebarSelection: "none",
+ settingsSidebarScrollbar: "6px",
+ settingsSidebarSelection: "none",
+ });
+ } finally {
+ await browser.close().catch(() => {});
+ }
+ });
+});
diff --git a/ui/src/e2e/app-chrome-interaction.e2e.test.ts b/ui/src/e2e/app-chrome-interaction.e2e.test.ts
new file mode 100644
index 000000000000..ead871796696
--- /dev/null
+++ b/ui/src/e2e/app-chrome-interaction.e2e.test.ts
@@ -0,0 +1,172 @@
+// Control UI tests cover contextual scrollbars and native-style text selection.
+import { mkdir } from "node:fs/promises";
+import path from "node:path";
+import { chromium, type Browser, type Locator, type Page } from "playwright";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import {
+ canRunPlaywrightChromium,
+ installMockGateway,
+ resolvePlaywrightChromiumExecutablePath,
+ startControlUiE2eServer,
+ type ControlUiE2eServer,
+} from "../test-helpers/control-ui-e2e.ts";
+
+const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
+const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath);
+const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1";
+const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
+const captureUiProofEnabled = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
+const uiProofArtifactDir = path.join(
+ process.cwd(),
+ ".artifacts",
+ "control-ui-e2e",
+ "app-chrome-interaction",
+);
+
+let browser: Browser;
+let server: ControlUiE2eServer;
+
+async function dragAcross(page: Page, locator: Locator): Promise {
+ await locator.scrollIntoViewIfNeeded();
+ const box = await locator.boundingBox();
+ if (!box) {
+ throw new Error("Expected a visible text-selection target");
+ }
+ const y = box.y + box.height / 2;
+ await page.mouse.move(box.x + 2, y);
+ await page.mouse.down();
+ await page.mouse.move(box.x + Math.max(3, box.width - 2), y, { steps: 8 });
+ await page.mouse.up();
+ return page.evaluate(() => globalThis.getSelection()?.toString() ?? "");
+}
+
+async function captureUiProof(page: Page, fileName: string) {
+ if (!captureUiProofEnabled) {
+ return;
+ }
+ await mkdir(uiProofArtifactDir, { recursive: true });
+ await page.screenshot({
+ animations: "disabled",
+ path: path.join(uiProofArtifactDir, fileName),
+ });
+}
+
+describeControlUiE2e("Control UI app chrome interaction mocked Gateway E2E", () => {
+ beforeAll(async () => {
+ if (!chromiumAvailable) {
+ throw new Error(
+ `Playwright Chromium is not installed or cannot start at ${chromiumExecutablePath}. Run \`pnpm --dir ui exec playwright install --with-deps chromium\`, or set OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM=1 only when intentionally skipping this lane.`,
+ );
+ }
+ server = await startControlUiE2eServer();
+ browser = await chromium.launch({ executablePath: chromiumExecutablePath });
+ });
+
+ afterAll(async () => {
+ await browser?.close();
+ await server?.close();
+ });
+
+ it("uses compact sidebar scrollbars and keeps selection in chat and inputs", async () => {
+ if (captureUiProofEnabled) {
+ await mkdir(uiProofArtifactDir, { recursive: true });
+ }
+ const context = await browser.newContext({
+ locale: "en-US",
+ recordVideo: captureUiProofEnabled
+ ? { dir: path.join(uiProofArtifactDir, "video"), size: { height: 900, width: 1440 } }
+ : undefined,
+ serviceWorkers: "block",
+ viewport: { height: 900, width: 1440 },
+ });
+ const page = await context.newPage();
+ await installMockGateway(page, {
+ historyMessages: [
+ {
+ role: "assistant",
+ content: [{ type: "text", text: "Selectable transcript content" }],
+ },
+ ],
+ });
+
+ try {
+ await page.goto(`${server.baseUrl}chat`);
+ const transcript = page.getByText("Selectable transcript content", { exact: true });
+ await transcript.waitFor();
+ const chatStyles = await page.evaluate(() => {
+ const sidebar = document.querySelector(".sidebar");
+ const sessions = document.querySelector(".sidebar-recent-sessions");
+ const thread = document.querySelector(".chat-thread");
+ if (!sidebar || !sessions || !thread) {
+ throw new Error("Missing chat interaction surface");
+ }
+ return {
+ chatSelection: getComputedStyle(thread).userSelect,
+ chatScrollbar: getComputedStyle(thread, "::-webkit-scrollbar").width,
+ sidebarSelection: getComputedStyle(sidebar).userSelect,
+ sidebarScrollbar: getComputedStyle(sessions, "::-webkit-scrollbar").width,
+ };
+ });
+ expect(chatStyles).toEqual({
+ chatSelection: "text",
+ chatScrollbar: "12px",
+ sidebarSelection: "none",
+ sidebarScrollbar: "6px",
+ });
+ expect(await dragAcross(page, transcript)).toContain("Selectable transcript");
+ await captureUiProof(page, "01-chat-selectable-transcript.png");
+
+ await page.goto(`${server.baseUrl}settings/general`);
+ const settingsSidebar = page.locator(".settings-sidebar");
+ const settingsTitle = settingsSidebar.locator(".settings-sidebar__title");
+ const settingsSearch = settingsSidebar.locator(".settings-sidebar__search-input");
+ const content = page.locator(".content");
+ await settingsSidebar.waitFor();
+ await expect
+ .poll(() => content.evaluate((element) => element.scrollHeight))
+ .toBeGreaterThan(await content.evaluate((element) => element.clientHeight));
+
+ const settingsStyles = await page.evaluate(() => {
+ const contentNode = document.querySelector(".content");
+ const nav = document.querySelector(".settings-sidebar__nav");
+ const search = document.querySelector(".settings-sidebar__search-input");
+ const sidebar = document.querySelector(".settings-sidebar");
+ if (!contentNode || !nav || !search || !sidebar) {
+ throw new Error("Missing settings interaction surface");
+ }
+ return {
+ contentScrollbar: getComputedStyle(contentNode, "::-webkit-scrollbar").width,
+ contentSelection: getComputedStyle(contentNode).userSelect,
+ inputSelection: getComputedStyle(search).userSelect,
+ sidebarScrollbar: getComputedStyle(nav, "::-webkit-scrollbar").width,
+ sidebarSelection: getComputedStyle(sidebar).userSelect,
+ };
+ });
+ expect(settingsStyles).toEqual({
+ contentScrollbar: "12px",
+ contentSelection: "none",
+ inputSelection: "text",
+ sidebarScrollbar: "6px",
+ sidebarSelection: "none",
+ });
+
+ await page.evaluate(() => globalThis.getSelection()?.removeAllRanges());
+ expect(await dragAcross(page, settingsTitle)).toBe("");
+ await settingsSearch.selectText();
+ expect(
+ await settingsSearch.evaluate(
+ (element) =>
+ element instanceof HTMLInputElement &&
+ element.selectionStart === 0 &&
+ element.selectionEnd === element.value.length,
+ ),
+ ).toBe(true);
+ await content.evaluate((element) => {
+ element.scrollTop = Math.min(160, element.scrollHeight - element.clientHeight);
+ });
+ await captureUiProof(page, "02-settings-contextual-scrollbars.png");
+ } finally {
+ await context.close();
+ }
+ });
+});
diff --git a/ui/src/styles/base.css b/ui/src/styles/base.css
index 700886279d96..77e002020828 100644
--- a/ui/src/styles/base.css
+++ b/ui/src/styles/base.css
@@ -529,6 +529,10 @@ body {
letter-spacing: -0.01em;
background: var(--bg);
color: var(--text);
+ /* App chrome behaves like native controls: dragging across labels, tiles,
+ and navigation must not create accidental text selections. Text-entry
+ surfaces and document-like content opt back in below or in route CSS. */
+ user-select: none;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
@@ -619,15 +623,22 @@ select {
color: inherit;
}
+input,
+textarea,
+[contenteditable]:not([contenteditable="false"]) {
+ user-select: text;
+}
+
::selection {
background: var(--selection-bg);
color: var(--selection-fg);
}
-/* Scrollbar styling - Minimal, barely visible */
+/* Main/document surfaces keep a normal scrollbar hit target. Dense sidebars
+ override this inherited size locally so their row actions retain room. */
::-webkit-scrollbar {
- width: 6px;
- height: 6px;
+ width: var(--scrollbar-size, 12px);
+ height: var(--scrollbar-size, 12px);
}
::-webkit-scrollbar-track {
diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css
index 5d6600a4dea0..8c9b432a477e 100644
--- a/ui/src/styles/chat/layout.css
+++ b/ui/src/styles/chat/layout.css
@@ -109,6 +109,8 @@ openclaw-chat-page {
/* Grow, shrink, and use 0 base for proper scrolling */
overflow-y: auto;
overflow-x: hidden;
+ /* The transcript is document content, unlike the surrounding app chrome. */
+ user-select: text;
scrollbar-gutter: stable both-edges;
scrollbar-color: var(--muted-strong) transparent;
/* The tall top inset is a titlebar band: it keeps the floating rail
diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css
index d0226b3ad372..1c4085c576ce 100644
--- a/ui/src/styles/layout.css
+++ b/ui/src/styles/layout.css
@@ -238,6 +238,7 @@ html.openclaw-native-web-chrome .sidebar-brand__collapse {
}
.settings-sidebar {
+ --scrollbar-size: 6px;
display: flex;
flex-direction: column;
flex: 1;
@@ -795,6 +796,7 @@ html.openclaw-native-web-chrome .sidebar-brand__collapse {
/* Sticky headers inside the sidebar reuse --sidebar-bg so scrolled rows
never shine through with a mismatched tone. */
--sidebar-bg: color-mix(in srgb, var(--bg) 96%, var(--bg-elevated) 4%);
+ --scrollbar-size: 6px;
position: relative;
display: flex;
flex-direction: column;
@@ -803,14 +805,6 @@ html.openclaw-native-web-chrome .sidebar-brand__collapse {
min-width: 0;
overflow: hidden;
background: var(--sidebar-bg);
- /* Sidebar chrome is controls, not copy; drag-selecting row labels reads as
- broken. Editable fields inside (menu filters) re-enable selection below. */
- user-select: none;
-}
-
-.sidebar input,
-.sidebar textarea {
- user-select: text;
}
:root[data-theme-mode="light"] .sidebar {