fix: refactor control UI session routing (#1288) (thanks @bradleypriest)
This commit is contained in:
parent
cbfae08bf8
commit
a6d02d5808
@ -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
|
||||
|
||||
|
||||
@ -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") {
|
||||
|
||||
@ -4,7 +4,6 @@ import { repeat } from "lit/directives/repeat.js";
|
||||
import type { AppViewState } from "./app-view-state";
|
||||
import { iconForTab, pathForTab, titleForTab, type Tab } from "./navigation";
|
||||
import { loadChatHistory } from "./controllers/chat";
|
||||
import { syncUrlWithSessionKey } from "./app-settings";
|
||||
import type { SessionsListResult } from "./types";
|
||||
import type { ThemeMode } from "./theme";
|
||||
import type { ThemeTransitionContext } from "./theme-transition";
|
||||
@ -54,20 +53,13 @@ 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,
|
||||
});
|
||||
syncUrlWithSessionKey(state, next, true);
|
||||
void loadChatHistory(state);
|
||||
}}
|
||||
>
|
||||
${repeat(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,25 +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;
|
||||
applySettings(host, {
|
||||
...host.settings,
|
||||
sessionKey: session,
|
||||
lastActiveSessionKey: 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");
|
||||
}
|
||||
|
||||
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) {
|
||||
@ -217,30 +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;
|
||||
|
||||
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 onPopState(host: SessionKeyHost) {
|
||||
applyStateFromLocation(host, { replace: true });
|
||||
}
|
||||
|
||||
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) {
|
||||
if (typeof window === "undefined") return;
|
||||
const targetPath = normalizePath(pathForTab(tab, host.basePath));
|
||||
const currentPath = normalizePath(window.location.pathname);
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
await Promise.all([
|
||||
loadChannels(host as unknown as ClawdbotApp, false),
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user