From 0482cbf1c06cc82e2dffcc44c6dadfd9a701d5c7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 26 Aug 2026 00:35:39 -0700 Subject: [PATCH] fix(ui): preserve chat URL state during session draft cleanup (#129949) * fix(ui): preserve chat URL state during session draft cleanup * refactor(ui): consolidate chat session route identity checks --- ui/src/e2e/chat-flow.messaging.e2e.test.ts | 21 ++++++++++++++++++ ui/src/pages/chat/chat-canonical-location.ts | 2 +- ui/src/pages/chat/chat-page.test.ts | 23 ++++++++++---------- ui/src/pages/chat/chat-page.ts | 16 +++++++------- ui/src/pages/chat/route-draft.ts | 10 +++++++-- 5 files changed, 50 insertions(+), 22 deletions(-) diff --git a/ui/src/e2e/chat-flow.messaging.e2e.test.ts b/ui/src/e2e/chat-flow.messaging.e2e.test.ts index 63ddf2374ddf..d64e3eab5480 100644 --- a/ui/src/e2e/chat-flow.messaging.e2e.test.ts +++ b/ui/src/e2e/chat-flow.messaging.e2e.test.ts @@ -31,6 +31,27 @@ async function withChatPage(run: (page: Page) => Promise): Promise { } suite.define(() => { + it.each([ + { draft: "Draft from a shared link", name: "a one-shot chat draft" }, + { draft: undefined, name: "an existing session URL" }, + ])("preserves remaining URL state while resolving $name", async ({ draft }) => { + await withChatPage(async (page) => { + await installMockGateway(page, { historyMessages: [] }); + const sessionUrl = new URL(controlUiSessionUrl(suite.server.baseUrl, "main")); + if (draft !== undefined) { + sessionUrl.searchParams.set("draft", draft); + } + sessionUrl.searchParams.set("panel", "details"); + sessionUrl.hash = "#pane"; + + await page.goto(sessionUrl.href); + const composer = page.locator(".agent-chat__composer-combobox textarea"); + await expect.poll(() => composer.inputValue()).toBe(draft ?? ""); + await expect.poll(() => new URL(page.url()).search).toBe("?panel=details"); + await expect.poll(() => new URL(page.url()).hash).toBe("#pane"); + }); + }); + it("sends a chat turn through the GUI and renders the final Gateway event", async () => { await withChatPage(async (page) => { const gateway = await installMockGateway(page, { diff --git a/ui/src/pages/chat/chat-canonical-location.ts b/ui/src/pages/chat/chat-canonical-location.ts index ec690c27b4c7..218acf3756a0 100644 --- a/ui/src/pages/chat/chat-canonical-location.ts +++ b/ui/src/pages/chat/chat-canonical-location.ts @@ -1,7 +1,7 @@ import type { RouteLocation } from "@openclaw/uirouter"; import { locationWithoutDraft } from "./route-draft.ts"; -function currentRouteLocation(): RouteLocation { +export function currentRouteLocation(): RouteLocation { return { pathname: window.location.pathname, search: window.location.search, diff --git a/ui/src/pages/chat/chat-page.test.ts b/ui/src/pages/chat/chat-page.test.ts index 2438a9c10793..5be3c99cdb96 100644 --- a/ui/src/pages/chat/chat-page.test.ts +++ b/ui/src/pages/chat/chat-page.test.ts @@ -413,6 +413,7 @@ describe("chat page split layout host", () => { }); it("hands each route-provided draft to the active pane only once", async () => { + window.history.replaceState({}, "", "/chat/main?draft=one-shot%20draft&panel=details#pane"); const page = new ChatPage(); const navigation = setNavigationContext(page); const firstRouteData = { sessionKey: "main", draft: "one-shot draft" }; @@ -420,14 +421,13 @@ describe("chat page split layout host", () => { expect(getRouteDraftForActivePane(page)).toBe("one-shot draft"); document.body.append(page); - await page.updateComplete; - await Promise.resolve(); - await page.updateComplete; + await vi.waitFor(() => expect(navigation.replace).toHaveBeenCalledOnce()); expect(getRouteDraftForActivePane(page)).toBeUndefined(); - expect(navigation.replace).toHaveBeenCalledOnce(); expect(navigation.replace).toHaveBeenCalledWith("chat", { pathname: sessionPath("main"), + search: "?panel=details", + hash: "#pane", }); page.data = { ...firstRouteData }; expect(getRouteDraftForActivePane(page)).toBe("one-shot draft"); @@ -554,6 +554,8 @@ describe("chat page split layout host", () => { }); it("keeps catalog identity when consuming a route draft", async () => { + const expectedSearch = catalogSessionSearch(CATALOG_KEY); + window.history.replaceState({}, "", `/chat/research${expectedSearch}&draft=ship`); const page = new ChatPage(); const navigation = setNavigationContext(page); page.data = { @@ -562,14 +564,12 @@ describe("chat page split layout host", () => { draft: "one-shot catalog draft", }; document.body.append(page); - await page.updateComplete; - await Promise.resolve(); - await page.updateComplete; + await vi.waitFor(() => expect(navigation.replace).toHaveBeenCalledOnce()); - const expectedSearch = catalogSessionSearch(CATALOG_KEY); expect(navigation.replace).toHaveBeenCalledWith("chat", { pathname: "/chat/research", search: expectedSearch, + hash: "", }); await expect( loadChatRoute( @@ -606,6 +606,7 @@ describe("chat page split layout host", () => { }); it("preserves a resolved long prefix through drafts and face changes", async () => { + window.history.replaceState({}, "", "/chat/main/1234567890?draft=ship"); const page = new ChatPage(); const navigation = setNavigationContext(page); page.data = { @@ -615,12 +616,12 @@ describe("chat page split layout host", () => { face: "chat", }; document.body.append(page); - await page.updateComplete; - await Promise.resolve(); - await page.updateComplete; + await vi.waitFor(() => expect(navigation.replace).toHaveBeenCalledOnce()); expect(navigation.replace).toHaveBeenCalledWith("chat", { pathname: "/chat/main/1234567890", + search: "", + hash: "", }); navigation.navigate.mockClear(); const pane = page.querySelector("openclaw-chat-pane"); diff --git a/ui/src/pages/chat/chat-page.ts b/ui/src/pages/chat/chat-page.ts index 7ae6b0195612..303090672956 100644 --- a/ui/src/pages/chat/chat-page.ts +++ b/ui/src/pages/chat/chat-page.ts @@ -17,7 +17,7 @@ import { areUiSessionKeysEquivalent } from "../../lib/sessions/session-key.ts"; import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts"; import { SubscriptionsController } from "../../lit/subscriptions-controller.ts"; import { persistSessionBoardFace } from "./chat-board-face-persistence.ts"; -import { stillOwnsCanonicalLocation } from "./chat-canonical-location.ts"; +import { currentRouteLocation, stillOwnsCanonicalLocation } from "./chat-canonical-location.ts"; import { renderChatPagePaneCell } from "./chat-page-pane-render.ts"; import { ChatPageRetainedSessions } from "./chat-page-retained-sessions.ts"; import { closeStagedPane, resumeStagedPanes } from "./chat-pane-attachment-handoff.ts"; @@ -403,12 +403,8 @@ export class ChatPage extends OpenClawLightDomElement { private updateRoute(sessionKey: string, replace = false, face = this.data.face ?? "chat") { const data = this.data; - if ( - data?.sessionKey === sessionKey && - (data.face ?? "chat") === face && - !data.draft && - !data.focusComposer - ) { + const sameSession = data && areUiSessionKeysEquivalent(data.sessionKey, sessionKey); + if (sameSession && (data.face ?? "chat") === face && !data.draft && !data.focusComposer) { return; } const options = sessionNavigationTarget({ @@ -419,7 +415,11 @@ export class ChatPage extends OpenClawLightDomElement { shortIdLength: data?.sessionKey === sessionKey ? data.shortId?.length : undefined, }).options; if (replace) { - this.context.replace(face, options); + const location = + sameSession && (data.draft || data.focusComposer) + ? locationWithoutDraft(currentRouteLocation(), options) + : options; + this.context.replace(face, location); } else { this.context.navigate(face, options); } diff --git a/ui/src/pages/chat/route-draft.ts b/ui/src/pages/chat/route-draft.ts index 36ffb2cea2d5..adb4f8e4adad 100644 --- a/ui/src/pages/chat/route-draft.ts +++ b/ui/src/pages/chat/route-draft.ts @@ -13,12 +13,18 @@ function focusComposerFromLocation(location: RouteLocation): boolean { return new URLSearchParams(location.search).get(SESSION_COMPOSER_FOCUS_PARAM) === "1"; } -export function locationWithoutDraft(location: RouteLocation): RouteLocation { +export function locationWithoutDraft( + location: RouteLocation, + destination: Partial = {}, +): RouteLocation { const params = new URLSearchParams(location.search); + for (const [name, value] of new URLSearchParams(destination.search)) { + params.set(name, value); + } params.delete("draft"); params.delete(SESSION_COMPOSER_FOCUS_PARAM); const search = params.toString(); - return { ...location, search: search ? `?${search}` : "" }; + return { ...location, ...destination, search: search ? `?${search}` : "" }; } export function draftRouteDataFromLocation(location: RouteLocation): RouteDraftHint {