Compare commits

...

2 Commits

Author SHA1 Message Date
Peter Steinberger
a6d02d5808 fix: refactor control UI session routing (#1288) (thanks @bradleypriest) 2026-01-20 07:44:12 +00:00
Bradley Priest
cbfae08bf8 ui(chat): persist session in URL and stabilize picker
- Keep the selected chat session in ?session=... for deep links and reloads.\n- Only apply the query param on the Chat tab (avoid leaking it across navigation).\n- Render session <option> entries with stable keys to prevent label glitches.
2026-01-20 07:26:55 +00:00
8 changed files with 196 additions and 75 deletions

View File

@ -12,6 +12,7 @@ Docs: https://docs.clawd.bot
### Fixes
- Gateway: strip inbound envelope headers from chat history messages to keep clients clean.
- UI: prevent double-scroll in Control UI chat by locking chat layout to the viewport. (#1283) — thanks @bradleypriest.
- UI: centralize Control UI session routing + URL sync so chat deep links stay stable. (#1288) — thanks @bradleypriest.
## 2026.1.19-2

View File

@ -1,11 +1,10 @@
import type { Tab } from "./navigation";
import { connectGateway } from "./app-gateway";
import {
applySettingsFromUrl,
applyStateFromLocation,
attachThemeListener,
detachThemeListener,
inferBasePath,
syncTabWithLocation,
syncThemeWithSettings,
} from "./app-settings";
import { observeTopbar, scheduleChatScroll, scheduleLogsScroll } from "./app-scroll";
@ -33,9 +32,9 @@ type LifecycleHost = {
export function handleConnected(host: LifecycleHost) {
host.basePath = inferBasePath();
syncTabWithLocation(
host as unknown as Parameters<typeof syncTabWithLocation>[0],
true,
applyStateFromLocation(
host as unknown as Parameters<typeof applyStateFromLocation>[0],
{ replace: true },
);
syncThemeWithSettings(
host as unknown as Parameters<typeof syncThemeWithSettings>[0],
@ -44,9 +43,6 @@ export function handleConnected(host: LifecycleHost) {
host as unknown as Parameters<typeof attachThemeListener>[0],
);
window.addEventListener("popstate", host.popStateHandler);
applySettingsFromUrl(
host as unknown as Parameters<typeof applySettingsFromUrl>[0],
);
connectGateway(host as unknown as Parameters<typeof connectGateway>[0]);
startNodesPolling(host as unknown as Parameters<typeof startNodesPolling>[0]);
if (host.tab === "logs") {

View File

@ -1,4 +1,5 @@
import { html } from "lit";
import { repeat } from "lit/directives/repeat.js";
import type { AppViewState } from "./app-view-state";
import { iconForTab, pathForTab, titleForTab, type Tab } from "./navigation";
@ -52,22 +53,18 @@ export function renderChatControls(state: AppViewState) {
?disabled=${!state.connected}
@change=${(e: Event) => {
const next = (e.target as HTMLSelectElement).value;
state.sessionKey = next;
state.chatMessage = "";
state.chatStream = null;
state.chatStreamStartedAt = null;
state.chatRunId = null;
state.resetToolStream();
state.resetChatScroll();
state.applySettings({
...state.settings,
sessionKey: next,
lastActiveSessionKey: next,
state.setSessionKey(next, {
source: "user",
resetChat: true,
loadHistory: true,
syncUrl: true,
replace: true,
});
void loadChatHistory(state);
}}
>
${sessionOptions.map(
${repeat(
sessionOptions,
(entry) => entry.key,
(entry) =>
html`<option value=${entry.key}>
${entry.displayName ?? entry.key}
@ -119,9 +116,11 @@ function resolveSessionOptions(sessionKey: string, sessions: SessionsListResult
const seen = new Set<string>();
const options: Array<{ key: string; displayName?: string }> = [];
const resolvedCurrent = sessions?.sessions?.find((s) => s.key === sessionKey);
// Add current session key first
seen.add(sessionKey);
options.push({ key: sessionKey });
options.push({ key: sessionKey, displayName: resolvedCurrent?.displayName });
// Add sessions from the result
if (sessions?.sessions) {

View File

@ -183,14 +183,14 @@ export function renderApp(state: AppViewState) {
onSettingsChange: (next) => state.applySettings(next),
onPasswordChange: (next) => (state.password = next),
onSessionKeyChange: (next) => {
state.sessionKey = next;
state.setSessionKey(next, {
source: "settings",
resetChat: false,
loadHistory: false,
syncUrl: false,
});
state.chatMessage = "";
state.resetToolStream();
state.applySettings({
...state.settings,
sessionKey: next,
lastActiveSessionKey: next,
});
},
onConnect: () => state.connect(),
onRefresh: () => state.loadOverview(),
@ -366,20 +366,14 @@ export function renderApp(state: AppViewState) {
? renderChat({
sessionKey: state.sessionKey,
onSessionKeyChange: (next) => {
state.sessionKey = next;
state.chatMessage = "";
state.chatStream = null;
state.chatStreamStartedAt = null;
state.chatRunId = null;
state.chatQueue = [];
state.resetToolStream();
state.resetChatScroll();
state.applySettings({
...state.settings,
sessionKey: next,
lastActiveSessionKey: next,
state.setSessionKey(next, {
source: "user",
resetChat: true,
loadHistory: true,
syncUrl: true,
replace: true,
clearQueue: true,
});
void loadChatHistory(state);
},
thinkingLevel: state.chatThinkingLevel,
loading: state.chatLoading,

View File

@ -1,3 +1,4 @@
import { loadChatHistory } from "./controllers/chat";
import { loadConfig, loadConfigSchema } from "./controllers/config";
import { loadCronJobs, loadCronStatus } from "./controllers/cron";
import { loadChannels } from "./controllers/channels";
@ -16,6 +17,7 @@ import { scheduleChatScroll, scheduleLogsScroll } from "./app-scroll";
import { startLogsPolling, stopLogsPolling } from "./app-polling";
import { refreshChat } from "./app-chat";
import type { ClawdbotApp } from "./app";
import type { ChatQueueItem } from "./ui-types";
type SettingsHost = {
settings: UiSettings;
@ -34,6 +36,25 @@ type SettingsHost = {
themeMediaHandler: ((event: MediaQueryListEvent) => void) | null;
};
export type SessionKeyOptions = {
source?: "user" | "route" | "settings" | "gateway";
syncUrl?: boolean;
replace?: boolean;
resetChat?: boolean;
loadHistory?: boolean;
clearQueue?: boolean;
};
type SessionKeyHost = SettingsHost & {
chatMessage: string;
chatStream: string | null;
chatStreamStartedAt: number | null;
chatRunId: string | null;
chatQueue: ChatQueueItem[];
resetToolStream: () => void;
resetChatScroll: () => void;
};
export function applySettings(host: SettingsHost, next: UiSettings) {
const normalized = {
...next,
@ -55,13 +76,85 @@ export function setLastActiveSessionKey(host: SettingsHost, next: string) {
applySettings(host, { ...host.settings, lastActiveSessionKey: trimmed });
}
export function applySettingsFromUrl(host: SettingsHost) {
if (!window.location.search) return;
const params = new URLSearchParams(window.location.search);
function updateSessionSettings(host: SettingsHost, next: string) {
const trimmed = next.trim();
if (!trimmed) return;
if (
host.settings.sessionKey === trimmed &&
host.settings.lastActiveSessionKey === trimmed
) {
return;
}
applySettings(host, {
...host.settings,
sessionKey: trimmed,
lastActiveSessionKey: trimmed,
});
}
export function setSessionKey(
host: SessionKeyHost,
next: string,
options: SessionKeyOptions = {},
) {
const trimmed = next.trim();
if (!trimmed) return;
const shouldResetChat = options.resetChat ?? false;
if (shouldResetChat) {
host.chatMessage = "";
host.chatStream = null;
host.chatStreamStartedAt = null;
host.chatRunId = null;
host.resetToolStream();
host.resetChatScroll();
if (options.clearQueue ?? true) {
host.chatQueue = [];
}
}
if (host.sessionKey !== trimmed) {
host.sessionKey = trimmed;
}
updateSessionSettings(host, trimmed);
if (options.syncUrl ?? host.tab === "chat") {
syncUrlWithTab(host, host.tab, options.replace ?? true);
}
if (options.loadHistory) {
void loadChatHistory(host as unknown as ClawdbotApp);
}
}
type LocationApplyOptions = {
replace: boolean;
};
function buildUrlForTab(host: SettingsHost, tab: Tab, baseUrl?: URL) {
const url = baseUrl ? new URL(baseUrl.toString()) : new URL(window.location.href);
const targetPath = normalizePath(pathForTab(tab, host.basePath));
url.pathname = targetPath;
if (tab === "chat" && host.sessionKey) {
url.searchParams.set("session", host.sessionKey);
} else {
url.searchParams.delete("session");
}
return url;
}
export function applyStateFromLocation(
host: SessionKeyHost,
options: LocationApplyOptions,
) {
if (typeof window === "undefined") return;
const currentHref = window.location.href;
const url = new URL(currentHref);
const params = url.searchParams;
const tokenRaw = params.get("token");
const passwordRaw = params.get("password");
const sessionRaw = params.get("session");
let shouldCleanUrl = false;
if (tokenRaw != null) {
const token = tokenRaw.trim();
@ -69,7 +162,6 @@ export function applySettingsFromUrl(host: SettingsHost) {
applySettings(host, { ...host.settings, token });
}
params.delete("token");
shouldCleanUrl = true;
}
if (passwordRaw != null) {
@ -78,22 +170,34 @@ export function applySettingsFromUrl(host: SettingsHost) {
(host as { password: string }).password = password;
}
params.delete("password");
shouldCleanUrl = true;
}
if (sessionRaw != null) {
const session = sessionRaw.trim();
const resolved = tabFromPath(url.pathname, host.basePath) ?? "chat";
const session = sessionRaw?.trim() ?? "";
if (resolved === "chat") {
if (session) {
host.sessionKey = session;
setSessionKey(host, session, {
source: "route",
syncUrl: false,
resetChat: false,
loadHistory: false,
});
} else if (sessionRaw != null) {
params.delete("session");
}
} else if (sessionRaw != null) {
params.delete("session");
shouldCleanUrl = true;
}
if (!shouldCleanUrl) return;
const url = new URL(window.location.href);
url.search = params.toString();
window.history.replaceState({}, "", url.toString());
setTabFromRoute(host, resolved);
const targetUrl = buildUrlForTab(host, resolved, url).toString();
if (targetUrl === currentHref) return;
if (options.replace) {
window.history.replaceState({}, "", targetUrl);
} else {
window.history.pushState({}, "", targetUrl);
}
}
export function setTab(host: SettingsHost, next: Tab) {
@ -214,18 +318,8 @@ export function detachThemeListener(host: SettingsHost) {
host.themeMediaHandler = null;
}
export function syncTabWithLocation(host: SettingsHost, replace: boolean) {
if (typeof window === "undefined") return;
const resolved = tabFromPath(window.location.pathname, host.basePath) ?? "chat";
setTabFromRoute(host, resolved);
syncUrlWithTab(host, resolved, replace);
}
export function onPopState(host: SettingsHost) {
if (typeof window === "undefined") return;
const resolved = tabFromPath(window.location.pathname, host.basePath);
if (!resolved) return;
setTabFromRoute(host, resolved);
export function onPopState(host: SessionKeyHost) {
applyStateFromLocation(host, { replace: true });
}
export function setTabFromRoute(host: SettingsHost, next: Tab) {
@ -239,15 +333,12 @@ export function setTabFromRoute(host: SettingsHost, next: Tab) {
export function syncUrlWithTab(host: SettingsHost, tab: Tab, replace: boolean) {
if (typeof window === "undefined") return;
const targetPath = normalizePath(pathForTab(tab, host.basePath));
const currentPath = normalizePath(window.location.pathname);
if (currentPath === targetPath) return;
const url = new URL(window.location.href);
url.pathname = targetPath;
const targetUrl = buildUrlForTab(host, tab).toString();
if (targetUrl === window.location.href) return;
if (replace) {
window.history.replaceState({}, "", url.toString());
window.history.replaceState({}, "", targetUrl);
} else {
window.history.pushState({}, "", url.toString());
window.history.pushState({}, "", targetUrl);
}
}

View File

@ -1,5 +1,6 @@
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway";
import type { Tab } from "./navigation";
import type { SessionKeyOptions } from "./app-settings";
import type { UiSettings } from "./storage";
import type { ThemeMode } from "./theme";
import type { ThemeTransitionContext } from "./theme-transition";
@ -160,7 +161,7 @@ export type AppViewState = {
handleDebugCall: () => Promise<void>;
handleRunUpdate: () => Promise<void>;
setPassword: (next: string) => void;
setSessionKey: (next: string) => void;
setSessionKey: (next: string, options?: SessionKeyOptions) => void;
setChatMessage: (next: string) => void;
handleChatSend: () => Promise<void>;
handleChatAbort: () => Promise<void>;

View File

@ -50,8 +50,10 @@ import {
applySettings as applySettingsInternal,
loadCron as loadCronInternal,
loadOverview as loadOverviewInternal,
setSessionKey as setSessionKeyInternal,
setTab as setTabInternal,
setTheme as setThemeInternal,
type SessionKeyOptions,
onPopState as onPopStateInternal,
} from "./app-settings";
import {
@ -297,6 +299,14 @@ export class ClawdbotApp extends LitElement {
setTabInternal(this as unknown as Parameters<typeof setTabInternal>[0], next);
}
setSessionKey(next: string, options?: SessionKeyOptions) {
setSessionKeyInternal(
this as unknown as Parameters<typeof setSessionKeyInternal>[0],
next,
options,
);
}
setTheme(next: ThemeMode, context?: Parameters<typeof setThemeInternal>[2]) {
setThemeInternal(
this as unknown as Parameters<typeof setThemeInternal>[0],

View File

@ -167,6 +167,35 @@ describe("control UI routing", () => {
expect(window.location.search).toBe("");
});
it("hydrates session from chat URL params", async () => {
const app = mountApp("/chat?session=agent:main");
await app.updateComplete;
expect(app.sessionKey).toBe("agent:main");
expect(window.location.pathname).toBe("/chat");
expect(new URLSearchParams(window.location.search).get("session")).toBe(
"agent:main",
);
});
it("adds session param for chat routes when missing", async () => {
const app = mountApp("/chat");
await app.updateComplete;
expect(app.sessionKey).toBe("main");
expect(window.location.pathname).toBe("/chat");
expect(new URLSearchParams(window.location.search).get("session")).toBe("main");
});
it("strips session params from non-chat routes", async () => {
const app = mountApp("/sessions?session=agent:main");
await app.updateComplete;
expect(app.tab).toBe("sessions");
expect(window.location.pathname).toBe("/sessions");
expect(new URLSearchParams(window.location.search).get("session")).toBeNull();
});
it("hydrates token from URL params even when settings already set", async () => {
localStorage.setItem(
"clawdbot.control.settings.v1",