fix: refactor control UI session routing (#1288) (thanks @bradleypriest)

This commit is contained in:
Peter Steinberger 2026-01-20 07:44:12 +00:00
parent cbfae08bf8
commit a6d02d5808
8 changed files with 189 additions and 111 deletions

View File

@ -12,6 +12,7 @@ Docs: https://docs.clawd.bot
### Fixes ### Fixes
- Gateway: strip inbound envelope headers from chat history messages to keep clients clean. - 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: 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 ## 2026.1.19-2

View File

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

View File

@ -4,7 +4,6 @@ import { repeat } from "lit/directives/repeat.js";
import type { AppViewState } from "./app-view-state"; import type { AppViewState } from "./app-view-state";
import { iconForTab, pathForTab, titleForTab, type Tab } from "./navigation"; import { iconForTab, pathForTab, titleForTab, type Tab } from "./navigation";
import { loadChatHistory } from "./controllers/chat"; import { loadChatHistory } from "./controllers/chat";
import { syncUrlWithSessionKey } from "./app-settings";
import type { SessionsListResult } from "./types"; import type { SessionsListResult } from "./types";
import type { ThemeMode } from "./theme"; import type { ThemeMode } from "./theme";
import type { ThemeTransitionContext } from "./theme-transition"; import type { ThemeTransitionContext } from "./theme-transition";
@ -54,20 +53,13 @@ export function renderChatControls(state: AppViewState) {
?disabled=${!state.connected} ?disabled=${!state.connected}
@change=${(e: Event) => { @change=${(e: Event) => {
const next = (e.target as HTMLSelectElement).value; const next = (e.target as HTMLSelectElement).value;
state.sessionKey = next; state.setSessionKey(next, {
state.chatMessage = ""; source: "user",
state.chatStream = null; resetChat: true,
state.chatStreamStartedAt = null; loadHistory: true,
state.chatRunId = null; syncUrl: true,
state.resetToolStream(); replace: true,
state.resetChatScroll();
state.applySettings({
...state.settings,
sessionKey: next,
lastActiveSessionKey: next,
}); });
syncUrlWithSessionKey(state, next, true);
void loadChatHistory(state);
}} }}
> >
${repeat( ${repeat(

View File

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

View File

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

View File

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

View File

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

View File

@ -167,6 +167,35 @@ describe("control UI routing", () => {
expect(window.location.search).toBe(""); 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 () => { it("hydrates token from URL params even when settings already set", async () => {
localStorage.setItem( localStorage.setItem(
"clawdbot.control.settings.v1", "clawdbot.control.settings.v1",