diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 67e0f47e9b28..951c8d990839 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -56,6 +56,7 @@ import { DialogSessionRename } from "../../component/dialog-session-rename" import { DialogMessage } from "./dialog-message" import { DialogFork } from "./dialog-fork" import { DialogTimeline } from "./dialog-timeline" +import { findUndoBoundary } from "./undo" import { Sidebar } from "./sidebar" import { Composer } from "./composer" import { filetype } from "../../util/filetype" @@ -591,10 +592,10 @@ export function Session() { group: "Session", slash: { name: "undo" }, run: () => { - 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 = findUndoBoundary( + messages(), + data.session.input.list(route.sessionID), + session()?.revert?.messageID, ) if (!message) { toast.show({ message: "Nothing to undo", variant: "error", duration: 3000 }) diff --git a/packages/tui/src/routes/session/undo.ts b/packages/tui/src/routes/session/undo.ts new file mode 100644 index 000000000000..668cce47347d --- /dev/null +++ b/packages/tui/src/routes/session/undo.ts @@ -0,0 +1,11 @@ +import type { SessionMessageInfo, SessionMessageUser } from "@opencode-ai/client" + +export function findUndoBoundary(messages: SessionMessageInfo[], pendingInputIDs: string[], boundary?: string) { + return messages.findLast( + (message): message is SessionMessageUser => + message.type === "user" && + !!message.text.trim() && + !pendingInputIDs.includes(message.id) && + (!boundary || message.id < boundary), + ) +} 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..2299f77366b5 --- /dev/null +++ b/packages/tui/test/cli/tui/undo.test.ts @@ -0,0 +1,38 @@ +import { expect, test } from "bun:test" +import type { SessionMessageInfo, SessionMessageUser } from "@opencode-ai/client" +import { findUndoBoundary } from "../../../src/routes/session/undo" + +test("selects the latest promoted user message without pending inputs", () => { + expect(findUndoBoundary([user("msg_001", "First"), user("msg_002", "Second")], [])?.id).toBe("msg_002") +}) + +test("skips the latest user message while it is pending", () => { + expect(findUndoBoundary([user("msg_001", "First"), user("msg_002", "Second")], ["msg_002"])?.id).toBe("msg_001") +}) + +test("returns no boundary when all user messages are pending", () => { + expect( + findUndoBoundary([user("msg_001", "First"), user("msg_002", "Second")], ["msg_001", "msg_002"]), + ).toBeUndefined() +}) + +test("selects only messages before an existing revert boundary", () => { + expect( + findUndoBoundary([user("msg_001", "First"), user("msg_002", "Second"), user("msg_003", "Third")], [], "msg_003") + ?.id, + ).toBe("msg_002") +}) + +test("skips blank user messages", () => { + expect(findUndoBoundary([user("msg_001", "First"), user("msg_002", " \n ")], [])?.id).toBe("msg_001") +}) + +test("selects a newer promoted message when an earlier message is pending", () => { + expect( + findUndoBoundary([user("msg_001", "First"), user("msg_002", "Pending"), user("msg_003", "Third")], ["msg_002"])?.id, + ).toBe("msg_003") +}) + +function user(id: string, text: string): SessionMessageUser { + return { type: "user", id, text, time: { created: 0 } } satisfies SessionMessageInfo +}