Scope Control UI sessions per gateway (#47453)

* Scope Control UI sessions per gateway

Signed-off-by: sallyom <somalley@redhat.com>

* Add changelog for Control UI session scoping

Signed-off-by: sallyom <somalley@redhat.com>

---------

Signed-off-by: sallyom <somalley@redhat.com>
This commit is contained in:
Sally O'Malley
2026-03-15 13:08:37 -04:00
committed by GitHub
parent 13e256ac9d
commit d37e3d582f
5 changed files with 291 additions and 17 deletions

View File

@@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai
- Group mention gating: reject invalid and unsafe nested-repetition `mentionPatterns`, reuse the shared safe config-regex compiler across mention stripping and detection, and cache strip-time regex compilation so noisy groups avoid repeated recompiles.
- Control UI/chat sessions: show human-readable labels in the grouped session dropdown again, keep unique scoped fallbacks when metadata is missing, and disambiguate duplicate labels only when needed. (#45130) thanks @luzhidong.
- Control UI: scope persisted session selection per gateway, prevent stale session bleed across tokenized gateway opens, and cap stored gateway session history. (#47453) Thanks @sallyom.
- Slack/interactive replies: preserve `channelData.slack.blocks` through live DM delivery and preview-finalized edits so Block Kit button and select directives render instead of falling back to raw text. (#45890) Thanks @vincentkoc.
- Feishu/topic threads: fetch full thread context, including prior bot replies, when starting a topic-thread session so follow-up turns in Feishu topics keep the right conversation state. (#45254) Thanks @Coobiw.
- Configure/startup: move outbound send-deps resolution into a lightweight helper so `openclaw configure` no longer stalls after the banner while eagerly loading channel plugins. (#46301) thanks @scoootscooob.

View File

@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
applyResolvedTheme,
applySettings,
applySettingsFromUrl,
attachThemeListener,
setTabFromRoute,
syncThemeWithSettings,
@@ -60,6 +61,8 @@ type SettingsHost = {
themeMediaHandler: ((event: MediaQueryListEvent) => void) | null;
logsPollInterval: number | null;
debugPollInterval: number | null;
pendingGatewayUrl?: string | null;
pendingGatewayToken?: string | null;
};
function createStorageMock(): Storage {
@@ -118,6 +121,8 @@ const createHost = (tab: Tab): SettingsHost => ({
themeMediaHandler: null,
logsPollInterval: null,
debugPollInterval: null,
pendingGatewayUrl: null,
pendingGatewayToken: null,
});
describe("setTabFromRoute", () => {
@@ -224,3 +229,81 @@ describe("setTabFromRoute", () => {
expect(root.style.colorScheme).toBe("light");
});
});
describe("applySettingsFromUrl", () => {
beforeEach(() => {
vi.stubGlobal("localStorage", createStorageMock());
vi.stubGlobal("navigator", { language: "en-US" } as Navigator);
});
afterEach(() => {
vi.unstubAllGlobals();
window.history.replaceState({}, "", "/chat");
});
it("resets stale persisted session selection to main when a token is supplied without a session", () => {
const host = createHost("chat");
host.settings = {
...host.settings,
gatewayUrl: "ws://localhost:18789",
token: "",
sessionKey: "agent:test_old:main",
lastActiveSessionKey: "agent:test_old:main",
};
host.sessionKey = "agent:test_old:main";
window.history.replaceState({}, "", "/chat#token=test-token");
applySettingsFromUrl(host);
expect(host.sessionKey).toBe("main");
expect(host.settings.sessionKey).toBe("main");
expect(host.settings.lastActiveSessionKey).toBe("main");
});
it("preserves an explicit session from the URL when token and session are both supplied", () => {
const host = createHost("chat");
host.settings = {
...host.settings,
gatewayUrl: "ws://localhost:18789",
token: "",
sessionKey: "agent:test_old:main",
lastActiveSessionKey: "agent:test_old:main",
};
host.sessionKey = "agent:test_old:main";
window.history.replaceState({}, "", "/chat?session=agent%3Atest_new%3Amain#token=test-token");
applySettingsFromUrl(host);
expect(host.sessionKey).toBe("agent:test_new:main");
expect(host.settings.sessionKey).toBe("agent:test_new:main");
expect(host.settings.lastActiveSessionKey).toBe("agent:test_new:main");
});
it("does not reset the current gateway session when a different gateway is pending confirmation", () => {
const host = createHost("chat");
host.settings = {
...host.settings,
gatewayUrl: "ws://gateway-a.example:18789",
token: "",
sessionKey: "agent:test_old:main",
lastActiveSessionKey: "agent:test_old:main",
};
host.sessionKey = "agent:test_old:main";
window.history.replaceState(
{},
"",
"/chat?gatewayUrl=ws%3A%2F%2Fgateway-b.example%3A18789#token=test-token",
);
applySettingsFromUrl(host);
expect(host.sessionKey).toBe("agent:test_old:main");
expect(host.settings.sessionKey).toBe("agent:test_old:main");
expect(host.settings.lastActiveSessionKey).toBe("agent:test_old:main");
expect(host.pendingGatewayUrl).toBe("ws://gateway-b.example:18789");
expect(host.pendingGatewayToken).toBe("test-token");
});
});

View File

@@ -100,6 +100,9 @@ export function applySettingsFromUrl(host: SettingsHost) {
const tokenRaw = hashParams.get("token");
const passwordRaw = params.get("password") ?? hashParams.get("password");
const sessionRaw = params.get("session") ?? hashParams.get("session");
const shouldResetSessionForToken = Boolean(
tokenRaw?.trim() && !sessionRaw?.trim() && !gatewayUrlChanged,
);
let shouldCleanUrl = false;
if (params.has("token")) {
@@ -118,6 +121,15 @@ export function applySettingsFromUrl(host: SettingsHost) {
shouldCleanUrl = true;
}
if (shouldResetSessionForToken) {
host.sessionKey = "main";
applySettings(host, {
...host.settings,
sessionKey: "main",
lastActiveSessionKey: "main",
});
}
if (passwordRaw != null) {
// Never hydrate password from URL params; strip only.
params.delete("password");

View File

@@ -126,8 +126,6 @@ describe("loadSettings default gateway URL derivation", () => {
});
expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}")).toEqual({
gatewayUrl: "wss://gateway.example:8443/openclaw",
sessionKey: "agent",
lastActiveSessionKey: "agent",
theme: "claw",
themeMode: "system",
chatFocusMode: false,
@@ -137,6 +135,12 @@ describe("loadSettings default gateway URL derivation", () => {
navCollapsed: false,
navWidth: 220,
navGroupsCollapsed: {},
sessionsByGateway: {
"wss://gateway.example:8443/openclaw": {
sessionKey: "agent",
lastActiveSessionKey: "agent",
},
},
});
expect(sessionStorage.length).toBe(0);
});
@@ -249,8 +253,6 @@ describe("loadSettings default gateway URL derivation", () => {
expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}")).toEqual({
gatewayUrl: "wss://gateway.example:8443/openclaw",
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "claw",
themeMode: "system",
chatFocusMode: false,
@@ -260,6 +262,12 @@ describe("loadSettings default gateway URL derivation", () => {
navCollapsed: false,
navWidth: 220,
navGroupsCollapsed: {},
sessionsByGateway: {
"wss://gateway.example:8443/openclaw": {
sessionKey: "main",
lastActiveSessionKey: "main",
},
},
});
expect(sessionStorage.length).toBe(1);
});
@@ -337,4 +345,110 @@ describe("loadSettings default gateway URL derivation", () => {
navWidth: 320,
});
});
it("scopes persisted session selection per gateway", async () => {
setTestLocation({
protocol: "https:",
host: "gateway.example:8443",
pathname: "/",
});
const { loadSettings, saveSettings } = await import("./storage.ts");
saveSettings({
gatewayUrl: "wss://gateway-a.example:8443/openclaw",
token: "",
sessionKey: "agent:test_old:main",
lastActiveSessionKey: "agent:test_old:main",
theme: "claw",
themeMode: "system",
chatFocusMode: false,
chatShowThinking: true,
chatShowToolCalls: true,
splitRatio: 0.6,
navCollapsed: false,
navWidth: 220,
navGroupsCollapsed: {},
});
saveSettings({
gatewayUrl: "wss://gateway-b.example:8443/openclaw",
token: "",
sessionKey: "agent:test_new:main",
lastActiveSessionKey: "agent:test_new:main",
theme: "claw",
themeMode: "system",
chatFocusMode: false,
chatShowThinking: true,
chatShowToolCalls: true,
splitRatio: 0.6,
navCollapsed: false,
navWidth: 220,
navGroupsCollapsed: {},
});
localStorage.setItem(
"openclaw.control.settings.v1",
JSON.stringify({
...JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}"),
gatewayUrl: "wss://gateway-a.example:8443/openclaw",
}),
);
expect(loadSettings()).toMatchObject({
gatewayUrl: "wss://gateway-a.example:8443/openclaw",
sessionKey: "agent:test_old:main",
lastActiveSessionKey: "agent:test_old:main",
});
localStorage.setItem(
"openclaw.control.settings.v1",
JSON.stringify({
...JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}"),
gatewayUrl: "wss://gateway-b.example:8443/openclaw",
}),
);
expect(loadSettings()).toMatchObject({
gatewayUrl: "wss://gateway-b.example:8443/openclaw",
sessionKey: "agent:test_new:main",
lastActiveSessionKey: "agent:test_new:main",
});
});
it("caps persisted session scopes to the most recent gateways", async () => {
setTestLocation({
protocol: "https:",
host: "gateway.example:8443",
pathname: "/",
});
const { saveSettings } = await import("./storage.ts");
for (let i = 0; i < 12; i += 1) {
saveSettings({
gatewayUrl: `wss://gateway-${i}.example:8443/openclaw`,
token: "",
sessionKey: `agent:test_${i}:main`,
lastActiveSessionKey: `agent:test_${i}:main`,
theme: "claw",
themeMode: "system",
chatFocusMode: false,
chatShowThinking: true,
chatShowToolCalls: true,
splitRatio: 0.6,
navCollapsed: false,
navWidth: 220,
navGroupsCollapsed: {},
});
}
const persisted = JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}");
const scopes = Object.keys(persisted.sessionsByGateway ?? {});
expect(scopes).toHaveLength(10);
expect(scopes).not.toContain("wss://gateway-0.example:8443/openclaw");
expect(scopes).not.toContain("wss://gateway-1.example:8443/openclaw");
expect(scopes).toContain("wss://gateway-11.example:8443/openclaw");
});
});

View File

@@ -1,8 +1,19 @@
const KEY = "openclaw.control.settings.v1";
const LEGACY_TOKEN_SESSION_KEY = "openclaw.control.token.v1";
const TOKEN_SESSION_KEY_PREFIX = "openclaw.control.token.v1:";
const MAX_SCOPED_SESSION_ENTRIES = 10;
type PersistedUiSettings = Omit<UiSettings, "token"> & { token?: never };
type ScopedSessionSelection = {
sessionKey: string;
lastActiveSessionKey: string;
};
type PersistedUiSettings = Omit<UiSettings, "token" | "sessionKey" | "lastActiveSessionKey"> & {
token?: never;
sessionKey?: string;
lastActiveSessionKey?: string;
sessionsByGateway?: Record<string, ScopedSessionSelection>;
};
import { isSupportedLocale } from "../i18n/index.ts";
import { inferBasePathFromPathname, normalizeBasePath } from "./navigation.ts";
@@ -87,6 +98,41 @@ function tokenSessionKeyForGateway(gatewayUrl: string): string {
return `${TOKEN_SESSION_KEY_PREFIX}${normalizeGatewayTokenScope(gatewayUrl)}`;
}
function resolveScopedSessionSelection(
gatewayUrl: string,
parsed: PersistedUiSettings,
defaults: UiSettings,
): ScopedSessionSelection {
const scope = normalizeGatewayTokenScope(gatewayUrl);
const scoped = parsed.sessionsByGateway?.[scope];
if (
scoped &&
typeof scoped.sessionKey === "string" &&
scoped.sessionKey.trim() &&
typeof scoped.lastActiveSessionKey === "string" &&
scoped.lastActiveSessionKey.trim()
) {
return {
sessionKey: scoped.sessionKey.trim(),
lastActiveSessionKey: scoped.lastActiveSessionKey.trim(),
};
}
const legacySessionKey =
typeof parsed.sessionKey === "string" && parsed.sessionKey.trim()
? parsed.sessionKey.trim()
: defaults.sessionKey;
const legacyLastActiveSessionKey =
typeof parsed.lastActiveSessionKey === "string" && parsed.lastActiveSessionKey.trim()
? parsed.lastActiveSessionKey.trim()
: legacySessionKey || defaults.lastActiveSessionKey;
return {
sessionKey: legacySessionKey,
lastActiveSessionKey: legacyLastActiveSessionKey,
};
}
function loadSessionToken(gatewayUrl: string): string {
try {
const storage = getSessionStorage();
@@ -144,12 +190,13 @@ export function loadSettings(): UiSettings {
if (!raw) {
return defaults;
}
const parsed = JSON.parse(raw) as Partial<UiSettings>;
const parsed = JSON.parse(raw) as PersistedUiSettings;
const parsedGatewayUrl =
typeof parsed.gatewayUrl === "string" && parsed.gatewayUrl.trim()
? parsed.gatewayUrl.trim()
: defaults.gatewayUrl;
const gatewayUrl = parsedGatewayUrl === pageDerivedUrl ? defaultUrl : parsedGatewayUrl;
const scopedSessionSelection = resolveScopedSessionSelection(gatewayUrl, parsed, defaults);
const { theme, mode } = parseThemeSelection(
(parsed as { theme?: unknown }).theme,
(parsed as { themeMode?: unknown }).themeMode,
@@ -158,15 +205,8 @@ export function loadSettings(): UiSettings {
gatewayUrl,
// Gateway auth is intentionally in-memory only; scrub any legacy persisted token on load.
token: loadSessionToken(gatewayUrl),
sessionKey:
typeof parsed.sessionKey === "string" && parsed.sessionKey.trim()
? parsed.sessionKey.trim()
: defaults.sessionKey,
lastActiveSessionKey:
typeof parsed.lastActiveSessionKey === "string" && parsed.lastActiveSessionKey.trim()
? parsed.lastActiveSessionKey.trim()
: (typeof parsed.sessionKey === "string" && parsed.sessionKey.trim()) ||
defaults.lastActiveSessionKey,
sessionKey: scopedSessionSelection.sessionKey,
lastActiveSessionKey: scopedSessionSelection.lastActiveSessionKey,
theme,
themeMode: mode,
chatFocusMode:
@@ -212,10 +252,33 @@ export function saveSettings(next: UiSettings) {
function persistSettings(next: UiSettings) {
persistSessionToken(next.gatewayUrl, next.token);
const scope = normalizeGatewayTokenScope(next.gatewayUrl);
let existingSessionsByGateway: Record<string, ScopedSessionSelection> = {};
try {
const raw = localStorage.getItem(KEY);
if (raw) {
const parsed = JSON.parse(raw) as PersistedUiSettings;
if (parsed.sessionsByGateway && typeof parsed.sessionsByGateway === "object") {
existingSessionsByGateway = parsed.sessionsByGateway;
}
}
} catch {
// best-effort
}
const sessionsByGateway = Object.fromEntries(
[
...Object.entries(existingSessionsByGateway).filter(([key]) => key !== scope),
[
scope,
{
sessionKey: next.sessionKey,
lastActiveSessionKey: next.lastActiveSessionKey,
},
],
].slice(-MAX_SCOPED_SESSION_ENTRIES),
);
const persisted: PersistedUiSettings = {
gatewayUrl: next.gatewayUrl,
sessionKey: next.sessionKey,
lastActiveSessionKey: next.lastActiveSessionKey,
theme: next.theme,
themeMode: next.themeMode,
chatFocusMode: next.chatFocusMode,
@@ -225,6 +288,7 @@ function persistSettings(next: UiSettings) {
navCollapsed: next.navCollapsed,
navWidth: next.navWidth,
navGroupsCollapsed: next.navGroupsCollapsed,
sessionsByGateway,
...(next.locale ? { locale: next.locale } : {}),
};
localStorage.setItem(KEY, JSON.stringify(persisted));