diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 247b20fa58b..66d02d6879c 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -130,7 +130,7 @@ function formatEditorContext(selection: EditorSelection) { return `${ranges.join("\n")} This may or may not be relevant to the current task.\n` } -let stashed: { prompt: PromptInfo; cursor: number } | undefined +const drafts = new Map() function argumentSlash(input: string, commands: readonly KeymapCommand[]) { const head = parseSlashHead(input, /\s/) @@ -600,22 +600,40 @@ export function Prompt(props: PromptProps) { }, } - onMount(() => { - const saved = stashed - stashed = undefined - if (store.prompt.text) return - if (saved && saved.prompt.text) { - input.setText(saved.prompt.text) - setStore("prompt", saved.prompt) - restoreExtmarksFromPrompt(saved.prompt) - input.cursorOffset = saved.cursor + function saveDraft(sessionID: string | undefined) { + if (!store.prompt.text) { + drafts.delete(sessionID) + return } - }) + drafts.set(sessionID, { prompt: unwrap(store.prompt), cursor: input.cursorOffset }) + } + + function restoreDraft(sessionID: string | undefined) { + const saved = drafts.get(sessionID) + drafts.delete(sessionID) + ref.reset() + if (!saved?.prompt.text) return + ref.set(saved.prompt) + input.cursorOffset = saved.cursor + } + + let draftSessionID = props.sessionID + onMount(() => restoreDraft(draftSessionID)) + + createEffect( + on( + () => props.sessionID, + (sessionID) => { + saveDraft(draftSessionID) + draftSessionID = sessionID + restoreDraft(sessionID) + }, + { defer: true }, + ), + ) onCleanup(() => { - if (store.prompt.text) { - stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset } - } + saveDraft(draftSessionID) setInputTarget(undefined) props.ref?.(undefined) }) diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index ef31b879544..d3e20dbd1d2 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -294,3 +294,72 @@ test("session startup prompt is submitted exactly once", async () => { await server.stop() } }) + +test("new session does not inherit the current session prompt draft", async () => { + const setup = await createTestRenderer({ width: 80, height: 24, useThread: false }) + const events = createEventStream() + const cwd = process.cwd() + const location = { directory: cwd, project: { id: "project", directory: cwd } } + const session = { + id: "dummy", + title: "Demo session", + projectID: "project", + location: { directory: cwd }, + agent: "build", + model: { providerID: "provider", id: "model" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 0, updated: 0 }, + } + const calls = createFetch((url) => { + if (url.pathname === "/api/location") return json(location) + if (url.pathname === "/api/session") return json({ data: [session], cursor: {} }) + if (url.pathname === "/api/session/dummy") return json({ data: session }) + if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} }) + if (url.pathname === "/api/session/dummy/pending") return json({ data: [] }) + if (url.pathname === "/api/session/dummy/permission") return json({ data: [] }) + if (url.pathname === "/api/agent") + return json({ location, data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }] }) + if (url.pathname === "/api/model") + return json({ location, data: [{ id: "model", providerID: "provider", name: "Model", variants: [] }] }) + }, events) + const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) }) + + try { + const { run } = await import("../src/app") + const task = Effect.runPromise( + run({ + app: { name: "test", version: "test", channel: "test" }, + server: { endpoint: { url: server.url.toString() } }, + config: { get: async () => ({}), update: async () => ({}) }, + packages: { resolve: async () => undefined }, + terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }), + args: { sessionID: "dummy" }, + log: () => {}, + }).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))), + ) + + await Promise.race([ + (async () => { + while (!setup.renderer.currentFocusedEditor) await Bun.sleep(10) + })(), + Bun.sleep(2_000).then(() => { + throw new Error("session prompt did not focus") + }), + ]) + await setup.mockInput.typeText("keep this draft") + expect(setup.renderer.currentFocusedEditor?.plainText).toBe("keep this draft") + + setup.mockInput.pressKey("x", { ctrl: true }) + await Bun.sleep(10) + setup.mockInput.pressKey("n") + await Bun.sleep(20) + + expect(setup.renderer.currentFocusedEditor?.plainText).toBe("") + setup.renderer.destroy() + await task + } finally { + if (!setup.renderer.isDestroyed) setup.renderer.destroy() + await server.stop() + } +})