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 ### 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

@ -1,4 +1,5 @@
import { html } from "lit"; import { html } from "lit";
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";
@ -52,22 +53,18 @@ 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,
}); });
void loadChatHistory(state);
}} }}
> >
${sessionOptions.map( ${repeat(
sessionOptions,
(entry) => entry.key,
(entry) => (entry) =>
html`<option value=${entry.key}> html`<option value=${entry.key}>
${entry.displayName ?? entry.key} ${entry.displayName ?? entry.key}
@ -119,9 +116,11 @@ function resolveSessionOptions(sessionKey: string, sessions: SessionsListResult
const seen = new Set<string>(); const seen = new Set<string>();
const options: Array<{ key: string; displayName?: string }> = []; const options: Array<{ key: string; displayName?: string }> = [];
const resolvedCurrent = sessions?.sessions?.find((s) => s.key === sessionKey);
// Add current session key first // Add current session key first
seen.add(sessionKey); seen.add(sessionKey);
options.push({ key: sessionKey }); options.push({ key: sessionKey, displayName: resolvedCurrent?.displayName });
// Add sessions from the result // Add sessions from the result
if (sessions?.sessions) { if (sessions?.sessions) {

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,22 +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, {
source: "route",
syncUrl: false,
resetChat: false,
loadHistory: false,
});
} else if (sessionRaw != null) {
params.delete("session");
} }
} else if (sessionRaw != null) {
params.delete("session"); params.delete("session");
shouldCleanUrl = true;
} }
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) {
@ -214,18 +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;
setTabFromRoute(host, resolved);
} }
export function setTabFromRoute(host: SettingsHost, next: Tab) { 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) { 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;
if (currentPath === targetPath) return;
const url = new URL(window.location.href);
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);
} }
} }

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",