From 963a2f81326ef98c23c967fe5619c527a17f71d2 Mon Sep 17 00:00:00 2001 From: Chaitanya Rahalkar Date: Mon, 27 Jul 2026 12:09:00 -0500 Subject: [PATCH] fix(tui): restore queued messages when a session is interrupted Double-ESC aborts the session, which leaves any queued follow-up persisted but never run, so the typed text silently disappeared. Put the queued text back into the prompt before aborting, the way session.undo already does. Co-authored-by: Cursor --- packages/tui/src/component/prompt/index.tsx | 21 ++++++++++++++- packages/tui/src/prompt/queued.ts | 14 ++++++++++ packages/tui/test/prompt/queued.test.ts | 29 +++++++++++++++++++++ 3 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 packages/tui/src/prompt/queued.ts create mode 100644 packages/tui/test/prompt/queued.test.ts diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 00efcbed2887..cc1847a0a34d 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -32,7 +32,8 @@ import { promptOffsetWidth } from "../../prompt/display" import { createStore, produce, unwrap } from "solid-js/store" import { usePromptHistory, type PromptInfo } from "../../prompt/history" import { computePromptTraits } from "../../prompt/traits" -import { expandPastedTextPlaceholders, expandTrackedPastedText } from "../../prompt/part" +import { expandPastedTextPlaceholders, expandTrackedPastedText, stripPromptPartIDs } from "../../prompt/part" +import { queuedMessages } from "../../prompt/queued" import { usePromptStash } from "../../prompt/stash" import { DialogStash } from "../dialog-stash" import { type AutocompleteRef, Autocomplete } from "./autocomplete" @@ -411,6 +412,24 @@ export function Prompt(props: PromptProps) { }, 5000) if (store.interrupt >= 2) { + // Aborting drops queued follow-ups, so hand their text back unless a new draft started. + const queued = + store.prompt.input || store.prompt.parts.length > 0 + ? [] + : queuedMessages(sync.data.message[props.sessionID] ?? []) + if (queued.length > 0) + ref.set( + queued + .flatMap((message) => sync.data.part[message.id] ?? []) + .reduce( + (agg, part) => { + if (part.type === "text" && !part.synthetic) agg.input += part.text + if (part.type === "file") agg.parts.push(stripPromptPartIDs(part)) + return agg + }, + { input: "", parts: [] as PromptInfo["parts"] }, + ), + ) void sdk.client.session.abort({ sessionID: props.sessionID, }) diff --git a/packages/tui/src/prompt/queued.ts b/packages/tui/src/prompt/queued.ts new file mode 100644 index 000000000000..2562004667ee --- /dev/null +++ b/packages/tui/src/prompt/queued.ts @@ -0,0 +1,14 @@ +/** + * User messages submitted while a turn is still running. The server holds them until the + * turn finishes, so they are only queued while an assistant message is still incomplete. + */ +export function queuedMessages< + Message extends { id: string; role: string; time: { created: number; completed?: number } }, +>(messages: Message[]) { + const completed = messages.findLast((message) => message.role === "assistant" && message.time.completed)?.id + const pending = messages.findLast( + (message) => message.role === "assistant" && !message.time.completed && (!completed || message.id > completed), + )?.id + if (!pending) return [] + return messages.filter((message) => message.role === "user" && message.id > pending) +} diff --git a/packages/tui/test/prompt/queued.test.ts b/packages/tui/test/prompt/queued.test.ts new file mode 100644 index 000000000000..a59a03ad283b --- /dev/null +++ b/packages/tui/test/prompt/queued.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "bun:test" +import { queuedMessages } from "../../src/prompt/queued" + +const assistant = (id: string, completed?: number) => ({ id, role: "assistant", time: { created: 1, completed } }) +const user = (id: string) => ({ id, role: "user", time: { created: 1 } }) + +describe("prompt queued", () => { + test("returns user messages sent while a turn is still running", () => { + expect( + queuedMessages([user("msg_1"), assistant("msg_2", 2), user("msg_3"), assistant("msg_4"), user("msg_5")]).map( + (message) => message.id, + ), + ).toEqual(["msg_5"]) + }) + + test("returns every queued message when several stack up", () => { + expect( + queuedMessages([user("msg_1"), assistant("msg_2"), user("msg_3"), user("msg_4")]).map((message) => message.id), + ).toEqual(["msg_3", "msg_4"]) + }) + + test("returns nothing when the session is idle", () => { + expect(queuedMessages([user("msg_1"), assistant("msg_2", 2)])).toEqual([]) + }) + + test("ignores an abandoned turn that a later turn already completed", () => { + expect(queuedMessages([user("msg_1"), assistant("msg_2"), assistant("msg_3", 3)])).toEqual([]) + }) +})