From 84c2d64206379718b586f29038cb5f75f7d8cf5e Mon Sep 17 00:00:00 2001 From: Josh Lehman Date: Fri, 14 Aug 2026 11:01:28 -0700 Subject: [PATCH] fix(ui): preserve new session drafts across navigation (#123697) --- ui/src/app/chat-attachment-handoff.ts | 12 +++-- ui/src/app/context.ts | 2 + ...ession-page.prompt-attachments.e2e.test.ts | 45 ++++++++++++++++- ui/src/pages/new-session/attachment-draft.ts | 8 +++ .../new-session/draft-navigation-handoff.ts | 50 +++++++++++++++++++ ui/src/pages/new-session/new-session-page.ts | 6 +-- 6 files changed, 115 insertions(+), 8 deletions(-) create mode 100644 ui/src/pages/new-session/draft-navigation-handoff.ts diff --git a/ui/src/app/chat-attachment-handoff.ts b/ui/src/app/chat-attachment-handoff.ts index fed9bd8010fb..9d9158c3c23b 100644 --- a/ui/src/app/chat-attachment-handoff.ts +++ b/ui/src/app/chat-attachment-handoff.ts @@ -12,6 +12,7 @@ type PendingChatAttachmentHandoff = { scopeKey: string; attachments: ChatAttachment[]; fallbacks: Record; + message: string; }; export function createChatAttachmentHandoff(): ApplicationChatAttachmentHandoff { @@ -48,11 +49,11 @@ export function createChatAttachmentHandoff(): ApplicationChatAttachmentHandoff }; return { - prepare: ({ owner, paneId, scopeKey, attachments, fallbacks }) => { + prepare: ({ owner, paneId, scopeKey, attachments, fallbacks, message = "" }) => { const key = entryKey(paneId, scopeKey); const previous = take(key); const fallbackEntries = Object.entries(fallbacks); - if (attachments.length === 0 && fallbackEntries.length === 0) { + if (!message && attachments.length === 0 && fallbackEntries.length === 0) { releaseHandoff(previous); return; } @@ -75,6 +76,7 @@ export function createChatAttachmentHandoff(): ApplicationChatAttachmentHandoff paneId, scopeKey, attachments: [...attachments], + message, fallbacks: Object.fromEntries( fallbackEntries.map(([fallbackKey, fallback]) => [ fallbackKey, @@ -96,7 +98,11 @@ export function createChatAttachmentHandoff(): ApplicationChatAttachmentHandoff // A Gateway mismatch is terminal for this exact presentation. Other // retained session scopes under the same logical pane remain independent. if (match?.owner === owner) { - return { attachments: match.attachments, fallbacks: match.fallbacks }; + return { + attachments: match.attachments, + fallbacks: match.fallbacks, + ...(match.message ? { message: match.message } : {}), + }; } releaseHandoff(match); return null; diff --git a/ui/src/app/context.ts b/ui/src/app/context.ts index 31ad7a5ef217..1f8cd6946fa1 100644 --- a/ui/src/app/context.ts +++ b/ui/src/app/context.ts @@ -84,11 +84,13 @@ export type ApplicationChatAttachmentHandoff = { handoff: ChatAttachmentHandoffKey & { attachments: readonly ChatAttachment[]; fallbacks: Readonly>; + message?: string; }, ): void; consume(handoff: ChatAttachmentHandoffKey): { attachments: ChatAttachment[]; fallbacks: Record; + message?: string; } | null; clearPane(paneId: string): void; dispose(): void; diff --git a/ui/src/e2e/new-session-page.prompt-attachments.e2e.test.ts b/ui/src/e2e/new-session-page.prompt-attachments.e2e.test.ts index 37423702fb94..b12b7b9c8293 100644 --- a/ui/src/e2e/new-session-page.prompt-attachments.e2e.test.ts +++ b/ui/src/e2e/new-session-page.prompt-attachments.e2e.test.ts @@ -34,6 +34,44 @@ async function withNewSessionPage(run: (page: Page) => Promise): Promise { + it("restores prompt text and files after navigating away and back", async () => { + await withNewSessionPage(async (page) => { + const sessionKey = "agent:main:existing-session"; + await installMockGateway(page, { + methodResponses: { + "sessions.list": createdSessionListResult(sessionKey), + }, + }); + await page.goto(`${suite.server.baseUrl}chat`); + const existingSession = page + .locator(".sidebar-recent-session") + .filter({ hasText: "Created session" }); + await existingSession.waitFor(); + await page.locator(".sidebar-brand__new-thread").click(); + await page.waitForURL((url) => url.pathname.endsWith("/new") && url.search === "?agent=main"); + + const message = page.locator(".new-session-page__message"); + await message.fill("keep this new session draft"); + await page + .locator(".agent-chat__photo-input") + .setInputFiles(path.join(process.cwd(), "ui/public/favicon-32.png")); + await page.locator('.chat-attachment-thumb img[alt="Attachment preview"]').waitFor(); + await captureUiProof(page, "new-session-draft-before-navigation.png"); + + await existingSession.click(); + await page.waitForURL((url) => url.pathname === controlUiSessionPath(sessionKey)); + await page.locator(".sidebar-brand__new-thread").click(); + await page.waitForURL((url) => url.pathname.endsWith("/new") && url.search === "?agent=main"); + + await expect.poll(() => message.inputValue()).toBe("keep this new session draft"); + await expect.poll(() => page.locator(".chat-attachment-thumb").count()).toBe(1); + await expect + .poll(() => page.locator(".chat-attachment-thumb img").getAttribute("src")) + .toMatch(/^(blob:|data:image\/png;base64,)/u); + await captureUiProof(page, "new-session-draft-restored.png"); + }); + }); + it("grows the first prompt downward without moving the identity, then caps at ten lines", async () => { await withNewSessionPage(async (page) => { const gateway = await installMockGateway(page); @@ -531,7 +569,7 @@ suite.define(() => { }); }); - it("releases pasted image previews after remove, reset, disconnect, and success", async () => { + it("releases pasted image previews after remove, reset, restored removal, and success", async () => { await withNewSessionPage(async (page) => { await page.addInitScript(() => { const createObjectURL = URL.createObjectURL.bind(URL); @@ -623,10 +661,13 @@ suite.define(() => { await page.locator('.chat-attachment-thumb img[alt="Attachment preview"]').waitFor(); await navigate("chat"); await page.waitForURL((url) => url.pathname.endsWith("/chat")); - await expect.poll(async () => (await proof()).revoked).toBe(3); + await expect.poll(async () => (await proof()).revoked).toBe(2); await navigate("new-session"); await composer.waitFor(); + await expect.poll(() => page.locator(".chat-attachment-thumb").count()).toBe(1); + await page.getByRole("button", { name: "Remove attachment" }).click(); + await expect.poll(async () => (await proof()).revoked).toBe(3); await pastePng(composer); await page.locator('.chat-attachment-thumb img[alt="Attachment preview"]').waitFor(); await page.getByRole("button", { name: "Start session" }).click(); diff --git a/ui/src/pages/new-session/attachment-draft.ts b/ui/src/pages/new-session/attachment-draft.ts index b7918f262862..81f9b07fd505 100644 --- a/ui/src/pages/new-session/attachment-draft.ts +++ b/ui/src/pages/new-session/attachment-draft.ts @@ -31,6 +31,14 @@ export class NewSessionAttachmentDraft { this.reads.abortReads(); } + take(): ChatAttachment[] { + this.abortReads(); + const attachments = this.attachments; + this.attachments = []; + this.notify(); + return attachments; + } + reset(options: { release: boolean }) { this.abortReads(); if (options.release) { diff --git a/ui/src/pages/new-session/draft-navigation-handoff.ts b/ui/src/pages/new-session/draft-navigation-handoff.ts new file mode 100644 index 000000000000..1eb6124ceb8a --- /dev/null +++ b/ui/src/pages/new-session/draft-navigation-handoff.ts @@ -0,0 +1,50 @@ +import type { ApplicationContext } from "../../app/context.ts"; +import * as catalog from "./catalog-target.ts"; +import type { DraftSubmissionFlow } from "./draft-submission-flow.ts"; + +const NEW_SESSION_DRAFT_PANE_ID = "new-session-draft"; + +export function retainDraft( + context: ApplicationContext | undefined, + submission: DraftSubmissionFlow, + openedFor: string | null, + messageOwnerKey: string, +) { + const owner = context?.gateway.snapshot.client; + if (!context || !owner || submission.submitting || submission.pendingCloud.sessionKey) { + return; + } + const routeKey = openedFor ?? catalog.routeKeyFromSearch(window.location.search); + context.chatAttachmentHandoff.prepare({ + owner, + paneId: NEW_SESSION_DRAFT_PANE_ID, + scopeKey: routeKey, + message: messageOwnerKey === routeKey ? submission.message : "", + attachments: submission.attachmentDraft.take(), + fallbacks: {}, + }); +} + +export function restoreDraft( + context: ApplicationContext | undefined, + submission: DraftSubmissionFlow, + routeKey: string, + ownedMessage: string, +) { + const owner = context?.gateway.snapshot.client; + const draft = + context && owner + ? context.chatAttachmentHandoff.consume({ + owner, + paneId: NEW_SESSION_DRAFT_PANE_ID, + scopeKey: routeKey, + }) + : null; + if (ownedMessage || draft) { + submission.setMessage(ownedMessage || draft?.message || ""); + } + if (draft) { + submission.attachmentDraft.replace(draft.attachments); + } + return routeKey; +} diff --git a/ui/src/pages/new-session/new-session-page.ts b/ui/src/pages/new-session/new-session-page.ts index 544b1cc6e2e6..3b5ae122f8e9 100644 --- a/ui/src/pages/new-session/new-session-page.ts +++ b/ui/src/pages/new-session/new-session-page.ts @@ -27,6 +27,7 @@ import { renderConnectMachineDialog } from "./connect-machine-dialog.ts"; import { isWorktreeNameValid } from "./create-params.ts"; import { renderDetailChip, resolveDetailChip } from "./detail-chip.ts"; import { DraftGatewayState } from "./draft-gateway-state.ts"; +import { restoreDraft, retainDraft } from "./draft-navigation-handoff.ts"; import { DraftPlaceBrowser } from "./draft-place-browser.ts"; import { DraftPlaceState } from "./draft-place-state.ts"; import { DraftSubmissionFlow } from "./draft-submission-flow.ts"; @@ -218,6 +219,7 @@ class NewSessionPage extends OpenClawLightDomElement { override disconnectedCallback() { document.removeEventListener("keydown", this, true); document.removeEventListener("pointerdown", this, true); + retainDraft(this.context, this.submission, this.openedFor, this.messageOwnerKey); this.subscriptions.clear(); this.gateway.invalidateDiscovery( true, @@ -259,9 +261,7 @@ class NewSessionPage extends OpenClawLightDomElement { this.openedAgentId = resolvedAgentId; this.place.setAgentsHydrated(agentsReady); this.resetDraft(); - if (ownedMessage) { - this.setMessage(ownedMessage, openKey); - } + this.messageOwnerKey = restoreDraft(this.context, this.submission, openKey, ownedMessage); return; } if (this.openedAgentId !== resolvedAgentId) {