fix: scope control UI settings by page base path (#47932) (thanks @bobBot-claw)

This commit is contained in:
Peter Steinberger 2026-03-16 06:49:36 +00:00
parent f100c96b53
commit 6061a6acef
4 changed files with 239 additions and 51 deletions

View File

@ -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.

View File

@ -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<void>((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);
});
});

View File

@ -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({
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",
},
},
});
});
});

View File

@ -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<string, ScopedSessionSelection> = {};
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") {