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
This commit is contained in:
Peter Steinberger
2026-08-26 00:35:39 -07:00
committed by GitHub
parent f991a984e2
commit 0482cbf1c0
5 changed files with 50 additions and 22 deletions
@@ -31,6 +31,27 @@ async function withChatPage(run: (page: Page) => Promise<void>): Promise<void> {
}
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, {
+1 -1
View File
@@ -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,
+12 -11
View File
@@ -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<RenderedPane>("openclaw-chat-pane");
+8 -8
View File
@@ -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);
}
+8 -2
View File
@@ -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> = {},
): 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 {