Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions webviews/src/agent-session/acpmux/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5097,6 +5097,7 @@ function AcpmuxPane() {
<Composer
snapshot={composerSnapshot}
sessionId={snapshot.sessionId ?? snapshot.summary?.sessionId}
switching={Boolean(switchView.intent)}
chips={ComposerChips}
permissionAccess={permissionAccess}
draft={draft}
Expand Down
19 changes: 18 additions & 1 deletion webviews/src/agent-session/acpmux/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,9 @@ type Props = {
draft?: string;
/// The durable session whose unsent prompt belongs in this composer.
sessionId?: string;
/// A harness switch is under way (another provider's model was picked): the session changes
/// under the prompt, and the typed text stays instead of the new session's empty draft.
switching?: boolean;
/// The bar's left button, such as attach; by default + opens the agent's commands. `null` leaves the slot empty.
leading?: React.ReactNode;
/// Granted folder trust and chat-wide permission state, rendered inside Access.
Expand Down Expand Up @@ -262,6 +265,7 @@ export function Composer({
onOpenInWindow,
handle,
sessionId,
switching,
blocked,
}: Props) {
const t = useT();
Expand Down Expand Up @@ -331,6 +335,8 @@ export function Composer({
const persistedSession = useRef(sessionId);
const hydratedSession = useRef<string | undefined>(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<string | undefined>(undefined);
Expand Down Expand Up @@ -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;
Expand All @@ -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) => {
Expand Down
140 changes: 140 additions & 0 deletions webviews/test/composer-switch-keeps-draft.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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("<!doctype html><div id=root></div>", { 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(
<Composer
snapshot={snapshot}
sessionId={sessionId}
chips={Chips}
onSend={() => 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("");
});