diff --git a/ui/src/styles/config.css b/ui/src/styles/config.css
index f33c05f94fa..bab0d41d6a3 100644
--- a/ui/src/styles/config.css
+++ b/ui/src/styles/config.css
@@ -5,7 +5,7 @@
/* Layout Container */
.config-layout {
display: grid;
- grid-template-columns: 260px minmax(0, 1fr);
+ grid-template-columns: minmax(0, 1fr);
gap: 0;
height: calc(100vh - 160px);
margin: 0 -16px -32px; /* preserve margin-top: 0 for onboarding mode */
@@ -436,6 +436,78 @@
color: var(--muted);
}
+.config-top-tabs {
+ display: grid;
+ grid-template-columns: minmax(220px, 320px) minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 12px;
+ padding: 12px 22px;
+ background: var(--bg-accent);
+ border-bottom: 1px solid var(--border);
+ flex-shrink: 0;
+}
+
+:root[data-theme="light"] .config-top-tabs {
+ background: var(--bg-hover);
+}
+
+.config-search--top {
+ padding: 0;
+ border-bottom: none;
+ min-width: 0;
+}
+
+.config-top-tabs__scroller {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+ overflow-x: auto;
+ padding-bottom: 2px;
+ scrollbar-width: thin;
+}
+
+.config-top-tabs__tab {
+ flex: 0 0 auto;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ padding: 8px 12px;
+ background: var(--bg-elevated);
+ color: var(--muted);
+ font-size: 12px;
+ font-weight: 600;
+ white-space: nowrap;
+ cursor: pointer;
+ transition:
+ border-color var(--duration-fast) ease,
+ background var(--duration-fast) ease,
+ color var(--duration-fast) ease,
+ box-shadow var(--duration-fast) ease;
+}
+
+:root[data-theme="light"] .config-top-tabs__tab {
+ background: white;
+}
+
+.config-top-tabs__tab:hover {
+ color: var(--text);
+ border-color: var(--border-strong);
+ background: var(--bg-hover);
+}
+
+.config-top-tabs__tab.active {
+ color: var(--accent);
+ border-color: color-mix(in srgb, var(--accent) 35%, var(--border));
+ background: var(--accent-subtle);
+ box-shadow: inset 0 1px 0 color-mix(in srgb, white 18%, transparent);
+}
+
+.config-top-tabs__right {
+ display: flex;
+ justify-content: flex-end;
+ min-width: 0;
+}
+
/* Diff Panel */
.config-diff {
margin: 18px 22px 0;
@@ -624,6 +696,7 @@
flex: 1;
overflow-y: auto;
padding: 22px;
+ min-width: 0;
}
.config-raw-field textarea {
@@ -687,9 +760,12 @@
.config-form--modern {
display: grid;
gap: 20px;
+ width: 100%;
+ min-width: 0;
}
.config-section-card {
+ width: 100%;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--bg-elevated);
@@ -754,6 +830,7 @@
.config-section-card__content {
padding: 18px;
+ min-width: 0;
}
/* ===========================================
@@ -1542,10 +1619,6 @@
=========================================== */
@media (max-width: 768px) {
- .config-layout {
- grid-template-columns: 1fr;
- }
-
.config-sidebar {
border-right: none;
border-bottom: 1px solid var(--border);
@@ -1589,6 +1662,24 @@
justify-content: center;
}
+ .config-top-tabs {
+ grid-template-columns: 1fr;
+ align-items: stretch;
+ padding: 12px 16px;
+ }
+
+ .config-top-tabs__right {
+ justify-content: stretch;
+ }
+
+ .config-top-tabs__right .config-mode-toggle {
+ width: 100%;
+ }
+
+ .config-top-tabs__right .config-mode-toggle__btn {
+ flex: 1 1 50%;
+ }
+
.config-section-hero {
padding: 14px 16px;
}
diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css
index b7559ad1af6..749fad3ab02 100644
--- a/ui/src/styles/layout.css
+++ b/ui/src/styles/layout.css
@@ -185,6 +185,64 @@
font-size: 13px;
}
+.topbar-theme-mode {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 4px;
+ border: 1px solid var(--border);
+ border-radius: calc(var(--radius-lg) + 2px);
+ background: color-mix(in srgb, var(--bg-elevated) 82%, transparent);
+ box-shadow: inset 0 1px 0 color-mix(in srgb, white 10%, transparent);
+}
+
+.topbar-theme-mode__btn {
+ width: 32px;
+ height: 32px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+ border: 1px solid transparent;
+ border-radius: var(--radius-md);
+ background: transparent;
+ color: var(--muted);
+ cursor: pointer;
+ transition:
+ color var(--duration-fast) ease,
+ background var(--duration-fast) ease,
+ border-color var(--duration-fast) ease,
+ box-shadow var(--duration-fast) ease;
+}
+
+.topbar-theme-mode__btn:hover {
+ color: var(--text);
+ background: var(--bg-hover);
+ border-color: color-mix(in srgb, var(--border-strong) 70%, transparent);
+}
+
+.topbar-theme-mode__btn:focus-visible {
+ outline: none;
+ box-shadow: var(--focus-ring);
+}
+
+.topbar-theme-mode__btn--active {
+ color: var(--accent);
+ background: var(--accent-subtle);
+ border-color: color-mix(in srgb, var(--accent) 35%, var(--border));
+ box-shadow: inset 0 1px 0 color-mix(in srgb, white 14%, transparent);
+}
+
+.topbar-theme-mode__btn svg {
+ width: 14px;
+ height: 14px;
+ stroke: currentColor;
+ fill: none;
+ stroke-width: 1.75px;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+
/* ===========================================
Navigation Sidebar
=========================================== */
diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts
index dbfd45b4ef8..600f211c2f8 100644
--- a/ui/src/ui/app-render.ts
+++ b/ui/src/ui/app-render.ts
@@ -425,7 +425,8 @@ export function renderApp(state: AppViewState) {
`
}
diff --git a/ui/src/ui/config-layout.browser.test.ts b/ui/src/ui/config-layout.browser.test.ts
new file mode 100644
index 00000000000..48f9123d075
--- /dev/null
+++ b/ui/src/ui/config-layout.browser.test.ts
@@ -0,0 +1,98 @@
+import "../styles.css";
+import { render } from "lit";
+import { describe, expect, it, vi } from "vitest";
+import { renderConfig, resetConfigViewStateForTests } from "./views/config.ts";
+
+function baseProps() {
+ resetConfigViewStateForTests();
+ return {
+ raw: "{\n}\n",
+ originalRaw: "{\n}\n",
+ valid: true,
+ issues: [],
+ loading: false,
+ saving: false,
+ applying: false,
+ updating: false,
+ connected: true,
+ schema: {
+ type: "object",
+ properties: {
+ gateway: { type: "object", properties: {} },
+ communication: {
+ type: "object",
+ properties: {
+ webhookBaseUrl: {
+ type: "string",
+ title: "Webhook Base URL",
+ },
+ },
+ },
+ },
+ },
+ schemaLoading: false,
+ uiHints: {},
+ formMode: "form" as const,
+ showModeToggle: true,
+ formValue: {},
+ originalValue: {},
+ searchQuery: "",
+ activeSection: "communication",
+ activeSubsection: null,
+ streamMode: false,
+ onRawChange: vi.fn(),
+ onFormModeChange: vi.fn(),
+ onFormPatch: vi.fn(),
+ onSearchChange: vi.fn(),
+ onSectionChange: vi.fn(),
+ onReload: vi.fn(),
+ onSave: vi.fn(),
+ onApply: vi.fn(),
+ onUpdate: vi.fn(),
+ onSubsectionChange: vi.fn(),
+ version: "",
+ theme: "claw" as const,
+ themeMode: "system" as const,
+ setTheme: vi.fn(),
+ setThemeMode: vi.fn(),
+ gatewayUrl: "",
+ assistantName: "",
+ };
+}
+
+describe("config layout width", () => {
+ it("lets the main config pane span the available width instead of collapsing to a dead sidebar track", () => {
+ const host = document.createElement("div");
+ host.style.width = "1200px";
+ document.body.append(host);
+
+ render(renderConfig(baseProps()), host);
+
+ const layout = host.querySelector(".config-layout");
+ const main = host.querySelector(".config-main");
+ const card = host.querySelector(".config-section-card");
+
+ expect(layout).not.toBeNull();
+ expect(main).not.toBeNull();
+ expect(card).not.toBeNull();
+ expect(getComputedStyle(layout!).display).toBe("grid");
+ expect(main!.getBoundingClientRect().width).toBeGreaterThan(800);
+ expect(card!.getBoundingClientRect().width).toBeGreaterThan(800);
+ });
+
+ it("lays out the search, tabs, and mode toggle as a real full-width top rail", () => {
+ const host = document.createElement("div");
+ host.style.width = "1200px";
+ document.body.append(host);
+
+ render(renderConfig(baseProps()), host);
+
+ const topTabs = host.querySelector(".config-top-tabs");
+ const scroller = host.querySelector(".config-top-tabs__scroller");
+
+ expect(topTabs).not.toBeNull();
+ expect(scroller).not.toBeNull();
+ expect(getComputedStyle(topTabs!).display).toBe("grid");
+ expect(getComputedStyle(scroller!).display).toBe("flex");
+ });
+});
diff --git a/ui/src/ui/icon-layout.browser.test.ts b/ui/src/ui/icon-layout.browser.test.ts
index 2bc0cb193b8..0ea944879ba 100644
--- a/ui/src/ui/icon-layout.browser.test.ts
+++ b/ui/src/ui/icon-layout.browser.test.ts
@@ -45,4 +45,23 @@ describe("icon layout styling", () => {
expect(getComputedStyle(svg!).width).toBe("14px");
expect(getComputedStyle(svg!).height).toBe("14px");
});
+
+ it("renders the shared nav collapse trigger with the compact hamburger icon", () => {
+ const container = document.createElement("div");
+ document.body.append(container);
+ render(
+ html``,
+ container,
+ );
+
+ const button = container.querySelector(".nav-collapse-toggle");
+ const svg = container.querySelector(".nav-collapse-toggle__icon svg");
+ expect(button).not.toBeNull();
+ expect(svg).not.toBeNull();
+ expect(getComputedStyle(button!).display).toBe("flex");
+ expect(getComputedStyle(svg!).width).toBe("18px");
+ expect(getComputedStyle(svg!).height).toBe("18px");
+ });
});
diff --git a/ui/src/ui/storage.node.test.ts b/ui/src/ui/storage.node.test.ts
index ab67368a56b..f6c601fd2b6 100644
--- a/ui/src/ui/storage.node.test.ts
+++ b/ui/src/ui/storage.node.test.ts
@@ -58,6 +58,33 @@ function setControlUiBasePath(value: string | undefined) {
});
}
+function setViteDevScript(enabled: boolean) {
+ if (
+ typeof document === "undefined" ||
+ typeof (document as Partial).querySelectorAll !== "function" ||
+ typeof (document as Partial).createElement !== "function"
+ ) {
+ vi.stubGlobal("document", {
+ querySelector: (selector: string) =>
+ enabled && selector.includes("/@vite/client") ? ({} as Element) : null,
+ querySelectorAll: () => [],
+ createElement: () => ({ setAttribute() {}, remove() {} }) as unknown as HTMLScriptElement,
+ head: { append() {} },
+ } as Document);
+ return;
+ }
+ document
+ .querySelectorAll('script[data-test-vite-client="true"]')
+ .forEach((node) => node.remove());
+ if (!enabled) {
+ return;
+ }
+ const script = document.createElement("script");
+ script.setAttribute("data-test-vite-client", "true");
+ script.setAttribute("src", "/@vite/client");
+ document.head.append(script);
+}
+
function expectedGatewayUrl(basePath: string): string {
const proto = location.protocol === "https:" ? "wss" : "ws";
return `${proto}://${location.host}${basePath}`;
@@ -90,11 +117,13 @@ describe("loadSettings default gateway URL derivation", () => {
localStorage.clear();
sessionStorage.clear();
setControlUiBasePath(undefined);
+ setViteDevScript(false);
});
afterEach(() => {
vi.restoreAllMocks();
setControlUiBasePath(undefined);
+ setViteDevScript(false);
vi.unstubAllGlobals();
});
@@ -121,6 +150,44 @@ describe("loadSettings default gateway URL derivation", () => {
expect(loadSettings().gatewayUrl).toBe(expectedGatewayUrl("/apps/openclaw"));
});
+ it("defaults vite dev pages to the local gateway port", async () => {
+ setTestLocation({
+ protocol: "http:",
+ host: "127.0.0.1:5174",
+ pathname: "/chat",
+ });
+ setViteDevScript(true);
+
+ const { loadSettings } = await import("./storage.ts");
+ expect(loadSettings().gatewayUrl).toBe("ws://127.0.0.1:18789");
+ });
+
+ it("migrates persisted vite dev gateway URLs to the local gateway port", async () => {
+ setTestLocation({
+ protocol: "http:",
+ host: "127.0.0.1:5174",
+ pathname: "/chat",
+ });
+ setViteDevScript(true);
+ localStorage.setItem(
+ "openclaw.control.settings.v1",
+ JSON.stringify({
+ gatewayUrl: "ws://127.0.0.1:5174",
+ sessionKey: "main",
+ lastActiveSessionKey: "main",
+ theme: "system",
+ chatFocusMode: false,
+ chatShowThinking: true,
+ splitRatio: 0.6,
+ navCollapsed: false,
+ navGroupsCollapsed: {},
+ }),
+ );
+
+ const { loadSettings } = await import("./storage.ts");
+ expect(loadSettings().gatewayUrl).toBe("ws://127.0.0.1:18789");
+ });
+
it("ignores and scrubs legacy persisted tokens", async () => {
setTestLocation({
protocol: "https:",
diff --git a/ui/src/ui/storage.ts b/ui/src/ui/storage.ts
index db295dd8d97..4a46b8d0703 100644
--- a/ui/src/ui/storage.ts
+++ b/ui/src/ui/storage.ts
@@ -24,6 +24,35 @@ export type UiSettings = {
locale?: string;
};
+function isViteDevPage(): boolean {
+ if (typeof document === "undefined") {
+ return false;
+ }
+ return Boolean(document.querySelector('script[src*="/@vite/client"]'));
+}
+
+function formatHostWithPort(hostname: string, port: string): string {
+ const normalizedHost = hostname.includes(":") ? `[${hostname}]` : hostname;
+ return `${normalizedHost}:${port}`;
+}
+
+function deriveDefaultGatewayUrl(): { pageUrl: string; effectiveUrl: string } {
+ const proto = location.protocol === "https:" ? "wss" : "ws";
+ const configured =
+ typeof window !== "undefined" &&
+ typeof window.__OPENCLAW_CONTROL_UI_BASE_PATH__ === "string" &&
+ window.__OPENCLAW_CONTROL_UI_BASE_PATH__.trim();
+ const basePath = configured
+ ? normalizeBasePath(configured)
+ : inferBasePathFromPathname(location.pathname);
+ const pageUrl = `${proto}://${location.host}${basePath}`;
+ if (!isViteDevPage()) {
+ return { pageUrl, effectiveUrl: pageUrl };
+ }
+ const effectiveUrl = `${proto}://${formatHostWithPort(location.hostname, "18789")}`;
+ return { pageUrl, effectiveUrl };
+}
+
function getSessionStorage(): Storage | null {
if (typeof window !== "undefined" && window.sessionStorage) {
return window.sessionStorage;
@@ -91,17 +120,7 @@ function persistSessionToken(gatewayUrl: string, token: string) {
}
export function loadSettings(): UiSettings {
- const defaultUrl = (() => {
- const proto = location.protocol === "https:" ? "wss" : "ws";
- const configured =
- typeof window !== "undefined" &&
- typeof window.__OPENCLAW_CONTROL_UI_BASE_PATH__ === "string" &&
- window.__OPENCLAW_CONTROL_UI_BASE_PATH__.trim();
- const basePath = configured
- ? normalizeBasePath(configured)
- : inferBasePathFromPathname(location.pathname);
- return `${proto}://${location.host}${basePath}`;
- })();
+ const { pageUrl: pageDerivedUrl, effectiveUrl: defaultUrl } = deriveDefaultGatewayUrl();
const defaults: UiSettings = {
gatewayUrl: defaultUrl,
@@ -124,21 +143,19 @@ export function loadSettings(): UiSettings {
return defaults;
}
const parsed = JSON.parse(raw) as Partial;
+ const parsedGatewayUrl =
+ typeof parsed.gatewayUrl === "string" && parsed.gatewayUrl.trim()
+ ? parsed.gatewayUrl.trim()
+ : defaults.gatewayUrl;
+ const gatewayUrl = parsedGatewayUrl === pageDerivedUrl ? defaultUrl : parsedGatewayUrl;
const { theme, mode } = parseThemeSelection(
(parsed as { theme?: unknown }).theme,
(parsed as { themeMode?: unknown }).themeMode,
);
const settings = {
- gatewayUrl:
- typeof parsed.gatewayUrl === "string" && parsed.gatewayUrl.trim()
- ? parsed.gatewayUrl.trim()
- : defaults.gatewayUrl,
+ gatewayUrl,
// Gateway auth is intentionally in-memory only; scrub any legacy persisted token on load.
- token: loadSessionToken(
- typeof parsed.gatewayUrl === "string" && parsed.gatewayUrl.trim()
- ? parsed.gatewayUrl.trim()
- : defaults.gatewayUrl,
- ),
+ token: loadSessionToken(gatewayUrl),
sessionKey:
typeof parsed.sessionKey === "string" && parsed.sessionKey.trim()
? parsed.sessionKey.trim()
diff --git a/ui/src/ui/topbar-theme-mode.browser.test.ts b/ui/src/ui/topbar-theme-mode.browser.test.ts
new file mode 100644
index 00000000000..361d9b359d7
--- /dev/null
+++ b/ui/src/ui/topbar-theme-mode.browser.test.ts
@@ -0,0 +1,30 @@
+import "../styles.css";
+import { render } from "lit";
+import { describe, expect, it } from "vitest";
+import { renderTopbarThemeModeToggle } from "./app-render.helpers.ts";
+
+describe("topbar theme mode styling", () => {
+ it("renders icon-sized mode buttons instead of unstyled fallback boxes", () => {
+ const container = document.createElement("div");
+ document.body.append(container);
+ render(
+ renderTopbarThemeModeToggle({
+ themeMode: "system",
+ setThemeMode: () => {},
+ } as never),
+ container,
+ );
+
+ const group = container.querySelector(".topbar-theme-mode");
+ const button = container.querySelector(".topbar-theme-mode__btn");
+ const svg = container.querySelector(".topbar-theme-mode__btn svg");
+
+ expect(group).not.toBeNull();
+ expect(button).not.toBeNull();
+ expect(svg).not.toBeNull();
+ expect(getComputedStyle(group!).display).toBe("inline-flex");
+ expect(getComputedStyle(button!).display).toBe("flex");
+ expect(getComputedStyle(svg!).width).toBe("14px");
+ expect(getComputedStyle(svg!).height).toBe("14px");
+ });
+});