diff --git a/webviews/src/agent-session/acpmux/App.tsx b/webviews/src/agent-session/acpmux/App.tsx index 944a6ae6fd7e..147b6d7c8501 100644 --- a/webviews/src/agent-session/acpmux/App.tsx +++ b/webviews/src/agent-session/acpmux/App.tsx @@ -5097,6 +5097,7 @@ function AcpmuxPane() { (undefined); const restoringSession = useRef(false); + /// Set while a switch runs and kept until the session change it brings (Lucas 2026-10-19). + const carryPrompt = useRef(false); // Once a new chat has shown its location strip, keep its layout slot through the first send. // Resetting on a session key prevents a reopened chat from inheriting that reserved space. const locationSlotSession = useRef(undefined); @@ -460,8 +466,13 @@ export function Composer({ setCaret(draft.length); pendingCaret.current = draft.length; }, [draft]); + useEffect(() => { + if (switching) carryPrompt.current = true; + }, [switching]); useEffect(() => { if (persistedSession.current === sessionId) return; + // A switch's new session has no draft yet: the prompt typed before the pick moves to it. + const carried = carryPrompt.current ? (field.current?.value() ?? text) : ""; const previous = persistedSession.current; if (previous && hydratedSession.current === previous) writePersistedDraft(previous, field.current?.value() ?? text); persistedSession.current = sessionId; @@ -471,11 +482,17 @@ export function Composer({ // A relaunch draft arrives with the handshake at the same time as the session reset. Keep it // when the local remount cache is empty; otherwise this effect can clear the host-provided // draft before the editor has initialized. - const initial = restored || draft || ""; + // A chat's own saved draft wins: carried text only fills a session that has none, which a + // switch's new session always is, so moving to another chat mid-switch keeps that chat's draft. + const initial = restored || carried || draft || ""; setText(initial); setCaret(initial.length); pendingCaret.current = initial.length; }, [sessionId, text, draft]); + // After the session effect, so a switch that ends with its session change still carries. + useEffect(() => { + if (!switching) carryPrompt.current = false; + }, [switching]); useEffect(() => { let current = true; void readDurableDraft(sessionId).then((restored) => { diff --git a/webviews/test/composer-switch-keeps-draft.test.tsx b/webviews/test/composer-switch-keeps-draft.test.tsx new file mode 100644 index 000000000000..debd3e6cf3f6 --- /dev/null +++ b/webviews/test/composer-switch-keeps-draft.test.tsx @@ -0,0 +1,140 @@ +// Lucas 2026-10-19: picking another provider's model in the composer is a harness switch, which +// starts a new session; the composer swapped to that session's (empty) saved draft and the typed +// prompt vanished. During a switch the prompt stays and becomes the new session's draft. Moving to +// another chat still shows that chat's own draft. +import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"; +import { JSDOM } from "jsdom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import { noChat } from "../src/gallery/fixtures/acpmux"; + +const scope = globalThis as Record; +const keys = [ + "window", + "document", + "navigator", + "HTMLElement", + "IS_REACT_ACT_ENVIRONMENT", + "Node", + "Element", + "MutationObserver", + "ResizeObserver", + "getComputedStyle", + "requestAnimationFrame", + "cancelAnimationFrame", + "localStorage", +] as const; +const saved = keys.map((key) => [key, key in scope, scope[key]] as const); +let dom: JSDOM; +let root: Root | undefined; +let host: HTMLElement; + +beforeAll(() => { + dom = new JSDOM("
", { pretendToBeVisual: true, url: "https://cmux.test/" }); + const { window } = dom; + Object.assign(scope, { + ResizeObserver: class { + observe() {} + unobserve() {} + disconnect() {} + }, + window, + document: window.document, + navigator: window.navigator, + HTMLElement: window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + Node: window.Node, + Element: window.Element, + MutationObserver: window.MutationObserver, + getComputedStyle: window.getComputedStyle.bind(window), + requestAnimationFrame: (run: (time: number) => void) => setTimeout(() => run(0), 0), + cancelAnimationFrame: (id: number) => clearTimeout(id), + localStorage: window.localStorage, + }); + host = window.document.getElementById("root")!; +}); + +afterEach(() => { + act(() => root?.unmount()); + root = undefined; + host.replaceChildren(); +}); + +afterAll(() => { + dom.window.close(); + for (const [key, had, value] of saved) { + if (had) scope[key] = value; + else delete scope[key]; + } +}); + +function Chips() { + return null; +} + +async function render(sessionId: string, props: { draft?: string; switching?: boolean } = {}) { + const { createRoot } = await import("react-dom/client"); + const { Composer } = await import("../src/agent-session/acpmux/Composer"); + const snapshot = noChat( + [], + sessionId ? { sessionId, summary: { sessionId, harness: "codex", cwd: "/Users/you/src/app", turnCount: 0 } } : {}, + ); + root ??= createRoot(host); + await act(async () => + root!.render( + true} + onStop={() => {}} + onProject={() => {}} + onNewChat={() => {}} + localName="studio" + projectChoices={[{ cwd: "/Users/you/src/app", label: "app" }]} + draft={props.draft} + switching={props.switching} + />, + ), + ); +} + +const prompt = () => host.querySelector('[role="combobox"]')?.textContent ?? ""; + +test("a harness switch keeps the typed prompt through the new session", async () => { + await render("chat-a", { draft: "compare these cars" }); + expect(prompt()).toBe("compare these cars"); + // The pick: the old chat is hidden while the new session starts, then it arrives. + await render("", { switching: true }); + expect(prompt()).toBe("compare these cars"); + await render("chat-b", { switching: true }); + expect(prompt()).toBe("compare these cars"); + // The switch settles on the new session; a later move to another chat shows that chat's draft. + await render("chat-b"); + expect(prompt()).toBe("compare these cars"); + await render("chat-c"); + expect(prompt()).toBe(""); +}); + +test("a switch that ends in the same render as its session change still keeps the prompt", async () => { + await render("chat-a", { draft: "compare these cars" }); + await render("", { switching: true }); + // The intent clears in the batch that brings the new session. + await render("chat-b"); + expect(prompt()).toBe("compare these cars"); +}); + +test("moving to a chat with its own draft mid-switch keeps that chat's draft", async () => { + const { writePersistedDraft } = await import("../src/agent-session/acpmux/composerDraft"); + writePersistedDraft("chat-d", "my other draft"); + await render("chat-a", { draft: "compare these cars" }); + await render("chat-d", { switching: true }); + expect(prompt()).toBe("my other draft"); +}); + +test("moving to another chat shows that chat's draft, not the typed prompt", async () => { + await render("chat-a", { draft: "compare these cars" }); + expect(prompt()).toBe("compare these cars"); + await render("chat-c"); + expect(prompt()).toBe(""); +});