From 6061a6acefb9516256e55db6e40dc0a38f2748ba Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 06:49:36 +0000 Subject: [PATCH] fix: scope control UI settings by page base path (#47932) (thanks @bobBot-claw) --- CHANGELOG.md | 1 + ui/src/ui/navigation.browser.test.ts | 32 ++-- ui/src/ui/storage.node.test.ts | 232 +++++++++++++++++++++++---- ui/src/ui/storage.ts | 25 +-- 4 files changed, 239 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a21ff47279b..3e97d4569ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,7 @@ Docs: https://docs.openclaw.ai - Gateway/config validation: stop treating the implicit default memory slot as a required explicit plugin config, so startup no longer fails with `plugins.slots.memory: plugin not found: memory-core` when `memory-core` was only inferred. (#47494) Thanks @ngutman. - CLI/startup: lazy-load channel add and root help startup paths to trim avoidable RSS and help latency on constrained hosts. (#46784) Thanks @vincentkoc. - CLI/onboarding: import static provider definitions directly for onboarding model/config helpers so those paths no longer pull provider discovery just for built-in defaults. (#47467) Thanks @vincentkoc. +- Control UI: scope persisted settings by page base path while preserving custom gateway choices and migrating legacy shared storage on first load. (#47932) Thanks @bobBot-claw. - CLI/auth choice: lazy-load plugin/provider fallback resolution so mapped auth choices stay on the static path and only unknown choices pay the heavy provider load. (#47495) Thanks @vincentkoc. - CLI/completion: reduce recursive completion-script string churn and fix nested PowerShell command-path matching so generated nested completions resolve on PowerShell too. (#45537) Thanks @yiShanXin and @vincentkoc. - Gateway/startup: load bundled channel plugins from compiled `dist/extensions` entries in built installs, so gateway boot no longer recompiles bundled extension TypeScript on every startup and WhatsApp-class cold starts drop back to seconds instead of tens of seconds or worse. diff --git a/ui/src/ui/navigation.browser.test.ts b/ui/src/ui/navigation.browser.test.ts index 5251eda790c..5e76ba88037 100644 --- a/ui/src/ui/navigation.browser.test.ts +++ b/ui/src/ui/navigation.browser.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import "../styles.css"; +import { inferBasePathFromPathname } from "./navigation.ts"; import { mountApp as mountTestApp, registerAppMountHooks } from "./test-helpers/app-mount.ts"; registerAppMountHooks(); @@ -8,6 +9,19 @@ function mountApp(pathname: string) { return mountTestApp(pathname); } +function normalizeScopedUrl(url: string): string { + const parsed = new URL(url); + const pathname = + parsed.pathname === "/" ? "" : parsed.pathname.replace(/\/+$/, "") || parsed.pathname; + return `${parsed.protocol}//${parsed.host}${pathname}`; +} + +function currentSettingsKey() { + const proto = window.location.protocol === "https:" ? "wss" : "ws"; + const basePath = inferBasePathFromPathname(window.location.pathname); + return `openclaw.control.settings.v1:${normalizeScopedUrl(`${proto}//${window.location.host}${basePath}`)}`; +} + function nextFrame() { return new Promise((resolve) => { requestAnimationFrame(() => resolve()); @@ -320,9 +334,7 @@ describe("control UI routing", () => { await app.updateComplete; expect(app.settings.token).toBe(""); - expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}").token).toBe( - undefined, - ); + expect(JSON.parse(localStorage.getItem(currentSettingsKey()) ?? "{}").token).toBe(undefined); expect(window.location.pathname).toBe("/ui/overview"); expect(window.location.search).toBe(""); }); @@ -345,12 +357,10 @@ describe("control UI routing", () => { await app.updateComplete; expect(app.settings.token).toBe("abc123"); - expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}")).toMatchObject({ + expect(JSON.parse(localStorage.getItem(currentSettingsKey()) ?? "{}")).toMatchObject({ gatewayUrl: "wss://gateway.example/openclaw", }); - expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}").token).toBe( - undefined, - ); + expect(JSON.parse(localStorage.getItem(currentSettingsKey()) ?? "{}").token).toBe(undefined); expect(window.location.pathname).toBe("/ui/overview"); expect(window.location.hash).toBe(""); }); @@ -360,9 +370,7 @@ describe("control UI routing", () => { await app.updateComplete; expect(app.settings.token).toBe("abc123"); - expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}").token).toBe( - undefined, - ); + expect(JSON.parse(localStorage.getItem(currentSettingsKey()) ?? "{}").token).toBe(undefined); expect(window.location.pathname).toBe("/ui/overview"); expect(window.location.hash).toBe(""); }); @@ -414,8 +422,6 @@ describe("control UI routing", () => { await refreshed.updateComplete; expect(refreshed.settings.token).toBe("abc123"); - expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}").token).toBe( - undefined, - ); + expect(JSON.parse(localStorage.getItem(currentSettingsKey()) ?? "{}").token).toBe(undefined); }); }); diff --git a/ui/src/ui/storage.node.test.ts b/ui/src/ui/storage.node.test.ts index 2222e193e96..2483f8585c0 100644 --- a/ui/src/ui/storage.node.test.ts +++ b/ui/src/ui/storage.node.test.ts @@ -62,6 +62,18 @@ function expectedGatewayUrl(basePath: string): string { return `${proto}://${location.host}${basePath}`; } +function normalizeScopedUrl(url: string): string { + const parsed = new URL(url); + const pathname = + parsed.pathname === "/" ? "" : parsed.pathname.replace(/\/+$/, "") || parsed.pathname; + return `${parsed.protocol}//${parsed.host}${pathname}`; +} + +function expectedSettingsStorageKey(basePath: string): string { + const normalizedBasePath = basePath === "/" ? "" : basePath; + return `openclaw.control.settings.v1:${normalizeScopedUrl(expectedGatewayUrl(normalizedBasePath))}`; +} + describe("loadSettings default gateway URL derivation", () => { beforeEach(() => { vi.resetModules(); @@ -124,7 +136,7 @@ describe("loadSettings default gateway URL derivation", () => { token: "", sessionKey: "agent", }); - expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}")).toEqual({ + expect(JSON.parse(localStorage.getItem(expectedSettingsStorageKey("/")) ?? "{}")).toEqual({ gatewayUrl: "wss://gateway.example:8443/openclaw", theme: "claw", themeMode: "system", @@ -182,23 +194,12 @@ describe("loadSettings default gateway URL derivation", () => { pathname: "/", }); - const { loadSettings, saveSettings } = await import("./storage.ts"); - saveSettings({ - gatewayUrl: "wss://gateway.example:8443/openclaw", - token: "gateway-a-token", - sessionKey: "main", - lastActiveSessionKey: "main", - theme: "claw", - themeMode: "system", - chatFocusMode: false, - chatShowThinking: true, - chatShowToolCalls: true, - splitRatio: 0.6, - navCollapsed: false, - navWidth: 220, - navGroupsCollapsed: {}, - }); + sessionStorage.setItem( + "openclaw.control.token.v1:wss://gateway.example:8443/openclaw", + "gateway-a-token", + ); + const { loadSettings } = await import("./storage.ts"); localStorage.setItem( "openclaw.control.settings.v1", JSON.stringify({ @@ -221,6 +222,17 @@ describe("loadSettings default gateway URL derivation", () => { gatewayUrl: "wss://other-gateway.example:8443/openclaw", token: "", }); + expect(JSON.parse(localStorage.getItem(expectedSettingsStorageKey("/")) ?? "{}")).toMatchObject( + { + gatewayUrl: "wss://other-gateway.example:8443/openclaw", + sessionsByGateway: { + "wss://other-gateway.example:8443/openclaw": { + sessionKey: "main", + lastActiveSessionKey: "main", + }, + }, + }, + ); }); it("does not persist gateway tokens when saving settings", async () => { @@ -251,7 +263,7 @@ describe("loadSettings default gateway URL derivation", () => { token: "memory-only-token", }); - expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}")).toEqual({ + expect(JSON.parse(localStorage.getItem(expectedSettingsStorageKey("/")) ?? "{}")).toEqual({ gatewayUrl: "wss://gateway.example:8443/openclaw", theme: "claw", themeMode: "system", @@ -272,6 +284,51 @@ describe("loadSettings default gateway URL derivation", () => { expect(sessionStorage.length).toBe(1); }); + it("migrates tokenless legacy settings on first load", async () => { + setTestLocation({ + protocol: "https:", + host: "gateway.example:8443", + pathname: "/gateway-a/chat", + }); + + localStorage.setItem( + "openclaw.control.settings.v1", + JSON.stringify({ + gatewayUrl: "wss://custom-gateway.example:8443/openclaw", + sessionKey: "agent", + lastActiveSessionKey: "agent", + theme: "claw", + themeMode: "system", + chatFocusMode: false, + chatShowThinking: true, + chatShowToolCalls: true, + splitRatio: 0.6, + navCollapsed: false, + navWidth: 220, + navGroupsCollapsed: {}, + }), + ); + + const { loadSettings } = await import("./storage.ts"); + + expect(loadSettings()).toMatchObject({ + gatewayUrl: "wss://custom-gateway.example:8443/openclaw", + sessionKey: "agent", + lastActiveSessionKey: "agent", + }); + expect( + JSON.parse(localStorage.getItem(expectedSettingsStorageKey("/gateway-a")) ?? "{}"), + ).toMatchObject({ + gatewayUrl: "wss://custom-gateway.example:8443/openclaw", + sessionsByGateway: { + "wss://custom-gateway.example:8443/openclaw": { + sessionKey: "agent", + lastActiveSessionKey: "agent", + }, + }, + }); + }); + it("clears the current-tab token when saving an empty token", async () => { setTestLocation({ protocol: "https:", @@ -339,11 +396,13 @@ describe("loadSettings default gateway URL derivation", () => { navGroupsCollapsed: {}, }); - expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}")).toMatchObject({ - theme: "dash", - themeMode: "light", - navWidth: 320, - }); + expect(JSON.parse(localStorage.getItem(expectedSettingsStorageKey("/")) ?? "{}")).toMatchObject( + { + theme: "dash", + themeMode: "light", + navWidth: 320, + }, + ); }); it("scopes persisted session selection per gateway", async () => { @@ -388,9 +447,9 @@ describe("loadSettings default gateway URL derivation", () => { }); localStorage.setItem( - "openclaw.control.settings.v1", + expectedSettingsStorageKey("/"), JSON.stringify({ - ...JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}"), + ...JSON.parse(localStorage.getItem(expectedSettingsStorageKey("/")) ?? "{}"), gatewayUrl: "wss://gateway-a.example:8443/openclaw", }), ); @@ -402,9 +461,9 @@ describe("loadSettings default gateway URL derivation", () => { }); localStorage.setItem( - "openclaw.control.settings.v1", + expectedSettingsStorageKey("/"), JSON.stringify({ - ...JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}"), + ...JSON.parse(localStorage.getItem(expectedSettingsStorageKey("/")) ?? "{}"), gatewayUrl: "wss://gateway-b.example:8443/openclaw", }), ); @@ -443,7 +502,7 @@ describe("loadSettings default gateway URL derivation", () => { }); } - const persisted = JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}"); + const persisted = JSON.parse(localStorage.getItem(expectedSettingsStorageKey("/")) ?? "{}"); const scopes = Object.keys(persisted.sessionsByGateway ?? {}); expect(scopes).toHaveLength(10); @@ -451,4 +510,121 @@ describe("loadSettings default gateway URL derivation", () => { expect(scopes).not.toContain("wss://gateway-1.example:8443/openclaw"); expect(scopes).toContain("wss://gateway-11.example:8443/openclaw"); }); + + it("scopes page settings separately even when gatewayUrl matches", async () => { + setTestLocation({ + protocol: "https:", + host: "gateway.example:8443", + pathname: "/gateway-a/chat", + }); + + const { loadSettings, saveSettings } = await import("./storage.ts"); + + saveSettings({ + gatewayUrl: "wss://shared-gateway.example:8443/openclaw", + token: "", + sessionKey: "main", + lastActiveSessionKey: "main", + theme: "dash", + themeMode: "light", + chatFocusMode: false, + chatShowThinking: true, + chatShowToolCalls: true, + splitRatio: 0.55, + navCollapsed: false, + navWidth: 240, + navGroupsCollapsed: {}, + }); + + setTestLocation({ + protocol: "https:", + host: "gateway.example:8443", + pathname: "/gateway-b/chat", + }); + + saveSettings({ + gatewayUrl: "wss://shared-gateway.example:8443/openclaw", + token: "", + sessionKey: "main", + lastActiveSessionKey: "main", + theme: "claw", + themeMode: "system", + chatFocusMode: false, + chatShowThinking: true, + chatShowToolCalls: true, + splitRatio: 0.6, + navCollapsed: true, + navWidth: 220, + navGroupsCollapsed: {}, + }); + + expect( + JSON.parse(localStorage.getItem(expectedSettingsStorageKey("/gateway-a")) ?? "{}"), + ).toMatchObject({ + theme: "dash", + themeMode: "light", + splitRatio: 0.55, + }); + expect( + JSON.parse(localStorage.getItem(expectedSettingsStorageKey("/gateway-b")) ?? "{}"), + ).toMatchObject({ + theme: "claw", + themeMode: "system", + navCollapsed: true, + }); + + setTestLocation({ + protocol: "https:", + host: "gateway.example:8443", + pathname: "/gateway-a/chat", + }); + expect(loadSettings()).toMatchObject({ + gatewayUrl: "wss://shared-gateway.example:8443/openclaw", + theme: "dash", + themeMode: "light", + splitRatio: 0.55, + }); + }); + + it("keeps a custom gatewayUrl across reloads on the same page scope", async () => { + setTestLocation({ + protocol: "https:", + host: "gateway.example:8443", + pathname: "/gateway-a/chat", + }); + + const { loadSettings, saveSettings } = await import("./storage.ts"); + saveSettings({ + gatewayUrl: "wss://custom-gateway.example:8443/openclaw", + token: "", + sessionKey: "agent:custom:main", + lastActiveSessionKey: "agent:custom:main", + theme: "claw", + themeMode: "system", + chatFocusMode: false, + chatShowThinking: true, + chatShowToolCalls: true, + splitRatio: 0.6, + navCollapsed: false, + navWidth: 220, + navGroupsCollapsed: {}, + }); + + expect(loadSettings()).toMatchObject({ + gatewayUrl: "wss://custom-gateway.example:8443/openclaw", + sessionKey: "agent:custom:main", + lastActiveSessionKey: "agent:custom:main", + }); + expect( + JSON.parse(localStorage.getItem(expectedSettingsStorageKey("/gateway-a")) ?? "{}"), + ).toMatchObject({ + gatewayUrl: "wss://custom-gateway.example:8443/openclaw", + sessionsByGateway: { + "wss://custom-gateway.example:8443/openclaw": { + sessionKey: "agent:custom:main", + lastActiveSessionKey: "agent:custom:main", + }, + }, + }); + }); }); diff --git a/ui/src/ui/storage.ts b/ui/src/ui/storage.ts index fcd9c4469c8..a6ec7655587 100644 --- a/ui/src/ui/storage.ts +++ b/ui/src/ui/storage.ts @@ -1,10 +1,11 @@ -const SETTINGS_KEY_PREFIX = "openclaw.control.settings.v1:"; +const LEGACY_SETTINGS_KEY = "openclaw.control.settings.v1"; +const SETTINGS_KEY_PREFIX = `${LEGACY_SETTINGS_KEY}:`; const LEGACY_TOKEN_SESSION_KEY = "openclaw.control.token.v1"; const TOKEN_SESSION_KEY_PREFIX = "openclaw.control.token.v1:"; const MAX_SCOPED_SESSION_ENTRIES = 10; -function settingsKeyForGateway(gatewayUrl: string): string { - return `${SETTINGS_KEY_PREFIX}${normalizeGatewayTokenScope(gatewayUrl)}`; +function settingsKeyForPage(pageUrl: string): string { + return `${SETTINGS_KEY_PREFIX}${normalizeGatewayTokenScope(pageUrl)}`; } type ScopedSessionSelection = { @@ -174,6 +175,7 @@ function persistSessionToken(gatewayUrl: string, token: string) { export function loadSettings(): UiSettings { const { pageUrl: pageDerivedUrl, effectiveUrl: defaultUrl } = deriveDefaultGatewayUrl(); const storage = getSafeLocalStorage(); + const scopedKey = settingsKeyForPage(pageDerivedUrl); const defaults: UiSettings = { gatewayUrl: defaultUrl, @@ -192,12 +194,11 @@ export function loadSettings(): UiSettings { }; try { - // First check for legacy key (no scope), then check for scoped key - const scopedKey = settingsKeyForGateway(defaults.gatewayUrl); - const raw = storage?.getItem(scopedKey) ?? storage?.getItem(SETTINGS_KEY_PREFIX + "default") ?? storage?.getItem("openclaw.control.settings.v1"); + const raw = storage?.getItem(scopedKey) ?? storage?.getItem(LEGACY_SETTINGS_KEY); if (!raw) { return defaults; } + const usedLegacyKey = storage?.getItem(scopedKey) == null; const parsed = JSON.parse(raw) as PersistedUiSettings; const parsedGatewayUrl = typeof parsed.gatewayUrl === "string" && parsed.gatewayUrl.trim() @@ -245,7 +246,12 @@ export function loadSettings(): UiSettings { : defaults.navGroupsCollapsed, locale: isSupportedLocale(parsed.locale) ? parsed.locale : undefined, }; - if ("token" in parsed) { + if ( + usedLegacyKey || + "token" in parsed || + typeof parsed.sessionKey === "string" || + typeof parsed.lastActiveSessionKey === "string" + ) { persistSettings(settings); } return settings; @@ -262,11 +268,10 @@ function persistSettings(next: UiSettings) { persistSessionToken(next.gatewayUrl, next.token); const storage = getSafeLocalStorage(); const scope = normalizeGatewayTokenScope(next.gatewayUrl); - const scopedKey = settingsKeyForGateway(next.gatewayUrl); + const scopedKey = settingsKeyForPage(deriveDefaultGatewayUrl().pageUrl); let existingSessionsByGateway: Record = {}; try { - // Try to migrate from legacy key or other scopes - const raw = storage?.getItem(scopedKey) ?? storage?.getItem(SETTINGS_KEY_PREFIX + "default") ?? storage?.getItem("openclaw.control.settings.v1"); + const raw = storage?.getItem(scopedKey) ?? storage?.getItem(LEGACY_SETTINGS_KEY); if (raw) { const parsed = JSON.parse(raw) as PersistedUiSettings; if (parsed.sessionsByGateway && typeof parsed.sessionsByGateway === "object") {