From c3111fe15fd21fcf7c2235b04a457e2893ebb81f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 8 Aug 2026 21:18:27 -0400 Subject: [PATCH] fix(tui): undo latest pending prompt --- packages/tui/src/routes/session/index.tsx | 20 ++++++---- packages/tui/src/routes/session/undo.ts | 14 +++++++ packages/tui/test/cli/tui/undo.test.ts | 48 +++++++++++++++++++++++ 3 files changed, 75 insertions(+), 7 deletions(-) create mode 100644 packages/tui/src/routes/session/undo.ts create mode 100644 packages/tui/test/cli/tui/undo.test.ts diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index ce99714ee81a..ffa1fee488df 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -84,6 +84,7 @@ import { usePathFormatter } from "../../context/path-format" import { useLocation } from "../../context/location" import { PluginSlot } from "../../plugin/render" import { usePlugin } from "../../plugin/context" +import { undoMessage } from "./undo" import { cacheReuseDrop, createSessionRows, @@ -656,19 +657,24 @@ export function Session() { group: "Session", slash: { name: "undo" }, run: () => { + const admitted = pendingUsers().at(-1) const boundary = session()?.revert?.messageID - const message = messages().findLast( - (message): message is SessionMessageUser => - message.type === "user" && !!message.text.trim() && (!boundary || message.id < boundary), - ) + const message = admitted + ? { id: admitted.id, ...admitted.data } + : messages().findLast( + (message): message is SessionMessageUser => + message.type === "user" && !!message.text.trim() && (!boundary || message.id < boundary), + ) if (!message) { toast.show({ message: "Nothing to undo", variant: "error", duration: 3000 }) dialog.clear() return } - void client.api.session.revert - .stage({ sessionID: route.sessionID, messageID: message.id }) - .catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 })) + void undoMessage(client.api, { + sessionID: route.sessionID, + messageID: message.id, + pending: admitted !== undefined, + }).catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 })) prompt()?.set({ ...projectedPromptInput(message), pasted: [], diff --git a/packages/tui/src/routes/session/undo.ts b/packages/tui/src/routes/session/undo.ts new file mode 100644 index 000000000000..6948405352fc --- /dev/null +++ b/packages/tui/src/routes/session/undo.ts @@ -0,0 +1,14 @@ +import type { OpenCodeClient } from "@opencode-ai/client" + +export async function undoMessage( + client: OpenCodeClient, + input: { readonly sessionID: string; readonly messageID: string; readonly pending: boolean }, +) { + const revert = () => client.session.revert.stage(input).then(() => undefined) + if (!input.pending) return revert() + + return client.session.pending.cancel({ sessionID: input.sessionID, inputID: input.messageID }).catch((error) => { + if (typeof error !== "object" || error === null || !("_tag" in error) || error._tag !== "ConflictError") throw error + return revert() + }) +} diff --git a/packages/tui/test/cli/tui/undo.test.ts b/packages/tui/test/cli/tui/undo.test.ts new file mode 100644 index 000000000000..7a8e1403f05d --- /dev/null +++ b/packages/tui/test/cli/tui/undo.test.ts @@ -0,0 +1,48 @@ +import { expect, test } from "bun:test" +import { OpenCode } from "@opencode-ai/client" +import { undoMessage } from "../../../src/routes/session/undo" + +test.each([ + { name: "projected", pending: false, cancelStatus: 204, expected: ["revert"] }, + { name: "pending", pending: true, cancelStatus: 204, expected: ["cancel"] }, + { name: "promoted race", pending: true, cancelStatus: 409, expected: ["cancel", "revert"] }, +])("undo routes $name messages", async ({ pending, cancelStatus, expected }) => { + const calls: string[] = [] + const client = OpenCode.make({ + baseUrl: "http://localhost:3000", + fetch: Object.assign( + async (input: URL | RequestInfo, init?: BunFetchRequestInit | RequestInit) => { + const request = input instanceof Request ? input : new Request(input, init) + const operation = request.method === "DELETE" ? "cancel" : "revert" + calls.push(operation) + if (operation === "cancel") { + if (cancelStatus === 409) + return Response.json({ _tag: "ConflictError", message: "Input was promoted" }, { status: 409 }) + return new Response(null, { status: 204 }) + } + return Response.json({ data: { messageID: "msg_user" } }) + }, + { preconnect: fetch.preconnect }, + ), + }) + + await undoMessage(client, { sessionID: "ses_test", messageID: "msg_user", pending }) + + expect(calls).toEqual([...expected]) +}) + +test("undo does not reinterpret transport failures as promotion races", async () => { + const client = OpenCode.make({ + baseUrl: "http://localhost:3000", + fetch: Object.assign( + async () => { + throw new Error("offline") + }, + { preconnect: fetch.preconnect }, + ), + }) + + await expect( + undoMessage(client, { sessionID: "ses_test", messageID: "msg_user", pending: true }), + ).rejects.toMatchObject({ reason: "Transport" }) +})