diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d7287e37..c1d0b4660 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/ui/src/ui/app-lifecycle.ts b/ui/src/ui/app-lifecycle.ts index 817151d5f..3cdf4b7f0 100644 --- a/ui/src/ui/app-lifecycle.ts +++ b/ui/src/ui/app-lifecycle.ts @@ -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[0], - true, + applyStateFromLocation( + host as unknown as Parameters[0], + { replace: true }, ); syncThemeWithSettings( host as unknown as Parameters[0], @@ -44,9 +43,6 @@ export function handleConnected(host: LifecycleHost) { host as unknown as Parameters[0], ); window.addEventListener("popstate", host.popStateHandler); - applySettingsFromUrl( - host as unknown as Parameters[0], - ); connectGateway(host as unknown as Parameters[0]); startNodesPolling(host as unknown as Parameters[0]); if (host.tab === "logs") { diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index a9dee71d6..08dd9ca3c 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -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( diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 09dbd1832..91da4aa1a 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -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, diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts index 423c8327e..7fea4a366 100644 --- a/ui/src/ui/app-settings.ts +++ b/ui/src/ui/app-settings.ts @@ -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), diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index dc97d335f..0bb7b8a86 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -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; handleRunUpdate: () => Promise; setPassword: (next: string) => void; - setSessionKey: (next: string) => void; + setSessionKey: (next: string, options?: SessionKeyOptions) => void; setChatMessage: (next: string) => void; handleChatSend: () => Promise; handleChatAbort: () => Promise; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 1c5f1fff3..4d448007d 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -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[0], next); } + setSessionKey(next: string, options?: SessionKeyOptions) { + setSessionKeyInternal( + this as unknown as Parameters[0], + next, + options, + ); + } + setTheme(next: ThemeMode, context?: Parameters[2]) { setThemeInternal( this as unknown as Parameters[0], diff --git a/ui/src/ui/navigation.browser.test.ts b/ui/src/ui/navigation.browser.test.ts index 0139ec552..9b31f8173 100644 --- a/ui/src/ui/navigation.browser.test.ts +++ b/ui/src/ui/navigation.browser.test.ts @@ -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",