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"); + }); +});