Compare commits

...

1 Commits

Author SHA1 Message Date
Kit Langton c3111fe15f fix(tui): undo latest pending prompt 2026-08-08 21:18:27 -04:00
3 changed files with 75 additions and 7 deletions
+13 -7
View File
@@ -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: [],
+14
View File
@@ -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()
})
}
+48
View File
@@ -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" })
})