fix(ui): preserve new session drafts across navigation (#123697)

This commit is contained in:
Josh Lehman
2026-08-14 11:01:28 -07:00
committed by GitHub
parent bf40269cb7
commit 84c2d64206
6 changed files with 115 additions and 8 deletions
+9 -3
View File
@@ -12,6 +12,7 @@ type PendingChatAttachmentHandoff = {
scopeKey: string;
attachments: ChatAttachment[];
fallbacks: Record<string, ChatComposerMemoryFallback>;
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;
+2
View File
@@ -84,11 +84,13 @@ export type ApplicationChatAttachmentHandoff = {
handoff: ChatAttachmentHandoffKey & {
attachments: readonly ChatAttachment[];
fallbacks: Readonly<Record<string, ChatComposerMemoryFallback>>;
message?: string;
},
): void;
consume(handoff: ChatAttachmentHandoffKey): {
attachments: ChatAttachment[];
fallbacks: Record<string, ChatComposerMemoryFallback>;
message?: string;
} | null;
clearPane(paneId: string): void;
dispose(): void;
@@ -34,6 +34,44 @@ async function withNewSessionPage(run: (page: Page) => Promise<void>): Promise<v
}
suite.define(() => {
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();
@@ -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) {
@@ -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;
}
+3 -3
View File
@@ -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) {