fix: scope control UI settings by page base path (#47932) (thanks @bobBot-claw)
This commit is contained in:
parent
f100c96b53
commit
6061a6acef
@ -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.
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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") {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user