From b6f3294e9dc5d8f9287533686839b043a7130254 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Wed, 4 Nov 2026 22:00:21 -0600 Subject: [PATCH] fix(app): drive rewind, undo and redo with one explicit state machine (#61907) --- .../app/e2e/regression/session-header.spec.ts | 40 ++ .../session/commands/use-session-commands.tsx | 8 +- packages/app/src/session/composer/region.tsx | 4 +- .../app/src/session/revert-machine.test.ts | 350 +++++++++++++++++ packages/app/src/session/revert-machine.ts | 339 ++++++++++++++++ packages/app/src/session/revert.ts | 370 ++++++++++++------ .../app/src/session/timeline/interaction.ts | 5 +- 7 files changed, 991 insertions(+), 125 deletions(-) create mode 100644 packages/app/src/session/revert-machine.test.ts create mode 100644 packages/app/src/session/revert-machine.ts diff --git a/packages/app/e2e/regression/session-header.spec.ts b/packages/app/e2e/regression/session-header.spec.ts index 09e55d4859c..dfe83f78a68 100644 --- a/packages/app/e2e/regression/session-header.spec.ts +++ b/packages/app/e2e/regression/session-header.spec.ts @@ -715,6 +715,46 @@ test.describe("revert", () => { await expect(prompt).toHaveText("Unsent draft") }) + test("a second /undo before the staged event rewinds one prompt further, and /redo retires the toast", async ({ + page, + }) => { + const staged: { sessionID: string; messageID: string }[] = [] + const { session, editor } = await openSession(page, { ...workspace, onRevertStage: (input) => staged.push(input) }) + const prompt = page.getByRole("textbox", { name: "Prompt" }) + const undoToast = page.getByRole("button", { name: "Undo", exact: true }) + + const slash = async (command: string) => { + await editor.press("ControlOrMeta+a") + await editor.press("Backspace") + await editor.pressSequentially(`/${command}`) + await expect( + page.locator('[data-component="composer-suggestions"] [data-suggestion-id][data-active]'), + ).toContainText(`/${command}`) + await editor.press("Enter") + } + + // The mock server never sends session.revert.staged, so each step must build on the last request's result. + await slash("undo") + await expect(prompt).toHaveText("Second prompt") + await expect(undoToast).toBeVisible() + await slash("undo") + await expect(prompt).toHaveText("First prompt") + expect(staged).toEqual([ + { sessionID: session.id, messageID: "msg_second" }, + { sessionID: session.id, messageID: "msg_first" }, + ]) + + const cleared = page.waitForResponse( + (response) => + response.request().method() === "DELETE" && + new URL(response.url()).pathname === `/api/session/${session.id}/revert`, + ) + + await slash("redo") + expect((await cleared).ok()).toBe(true) + await expect(undoToast).toHaveCount(0) + }) + test("Undo after switching tabs restages the earlier revert in the Session it came from", async ({ page }) => { const staged: { sessionID: string; messageID: string }[] = [] const cleared: string[] = [] diff --git a/packages/app/src/session/commands/use-session-commands.tsx b/packages/app/src/session/commands/use-session-commands.tsx index 2d953398c3f..308fac11683 100644 --- a/packages/app/src/session/commands/use-session-commands.tsx +++ b/packages/app/src/session/commands/use-session-commands.tsx @@ -22,7 +22,7 @@ import { sideTabShortcuts } from "@/runtime/plugin/closed-tabs" type SessionCommandSource = { identity: SessionModel["identity"] - data: Pick + data: Pick history: Pick layout: SessionModel["layout"] ownership: SessionModel["ownership"] @@ -36,7 +36,7 @@ export type SessionCommandContext = { move: () => Promise } navigateMessageByOffset: (offset: number) => void - revert: Pick + revert: Pick focusInput: () => void // The composer's model, which a compaction runs with. model: () => { id: string; providerID: string; variant?: string } | undefined @@ -377,7 +377,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { title: language.t("command.session.undo"), description: language.t("command.session.undo.description"), slash: "undo", - disabled: !actions.session.identity.params.id || actions.session.history.visibleUserMessages().length === 0, + disabled: !actions.revert.canUndo(), onSelect: undo, }), sessionCommand({ @@ -385,7 +385,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { title: language.t("command.session.redo"), description: language.t("command.session.redo.description"), slash: "redo", - disabled: !actions.session.identity.params.id || !actions.session.data.revertMessageID(), + disabled: !actions.revert.canRedo(), onSelect: redo, }), sessionCommand({ diff --git a/packages/app/src/session/composer/region.tsx b/packages/app/src/session/composer/region.tsx index 33a0a9d29dc..7e9151d09bf 100644 --- a/packages/app/src/session/composer/region.tsx +++ b/packages/app/src/session/composer/region.tsx @@ -243,7 +243,9 @@ export function createActiveSessionRegion(input: { setActiveMessage: input.timeline.actions.setActiveMessage, }) - const revertMessage: NonNullable = ({ messageID }) => revert.to(messageID) + const revertMessage: NonNullable = (target) => + revert.to(target.messageID, target.sessionID) + const fork = useSessionFork() const forkMessage: NonNullable = (target) => fork(target).then(() => undefined) diff --git a/packages/app/src/session/revert-machine.test.ts b/packages/app/src/session/revert-machine.test.ts new file mode 100644 index 00000000000..40d765218d2 --- /dev/null +++ b/packages/app/src/session/revert-machine.test.ts @@ -0,0 +1,350 @@ +import { describe, expect, test } from "bun:test" +import type { SessionMessageUser } from "@opencode/client/promise" +import { + createSessionRevertState, + selectCursorAfterRevert, + transitionSessionRevert, + viewSessionRevert, + type RevertDraftSnapshot, + type SessionRevertEvent, + type SessionRevertState, + type SessionRevertTransition, +} from "./revert-machine" + +const user = (id: string): SessionMessageUser => ({ id, type: "user", text: id, time: { created: 1 } }) + +const m2 = user("msg_2") + +const m3 = user("msg_3") + +const messages = [user("msg_1"), m2, m3] + +const delivered = { messages, inboxIDs: new Set() } + +const draft = (text: string): RevertDraftSnapshot => ({ + prompt: [{ type: "text", content: text, start: 0, end: text.length }], + cursor: text.length, + context: [], +}) + +const undo = (revertMessageID?: string, inboxIDs = new Set()): SessionRevertEvent => ({ + type: "undo.requested", + revertMessageID, + messages, + inboxIDs, +}) + +const rewindTo = (messageID: string, revertMessageID?: string, inboxIDs = new Set()): SessionRevertEvent => ({ + type: "rewind.to", + messageID, + revertMessageID, + messages, + inboxIDs, +}) + +const rewound = (attempt: number, text: string): SessionRevertEvent => ({ + type: "rewind.succeeded", + attempt, + draft: draft(text), + ...delivered, +}) + +function replay(events: SessionRevertEvent[], from = createSessionRevertState()) { + return events.reduce((steps, event) => { + steps.push(transitionSessionRevert(steps.at(-1)?.state ?? from, event)) + + return steps + }, []) +} + +const last = (events: SessionRevertEvent[], from?: SessionRevertState) => { + const steps = replay(events, from) + const step = steps.at(-1) + + if (!step) throw new Error("no events") + + return step +} + +const view = (state: SessionRevertState, revertMessageID?: string) => + viewSessionRevert(state, { messages, revertMessageID }) + +describe("transitionSessionRevert", () => { + test("refuses every request while an operation runs, so two pipelines never share a Session", () => { + const running = last([undo()]).state + + expect(running).toMatchObject({ status: "running", operation: { type: "rewind", message: m3 } }) + + for (const event of [ + undo(), + rewindTo("msg_2"), + { type: "redo.requested", revertMessageID: "msg_3" } as const, + { type: "toast.undo", attempt: running.attempt } as const, + ]) { + const next = transitionSessionRevert(running, event) + + expect(next.state).toBe(running) + expect(next.effects).toEqual([]) + } + + expect(view(running)).toMatchObject({ busy: true, canUndo: false, canRedo: false }) + }) + + test("a second undo before the staged event arrives rewinds the earlier prompt", () => { + // The server's boundary is still unset: the mock server, or a slow event stream, has not reported the first stage. + const steps = replay([undo(), rewound(1, "unsent draft"), undo(undefined)]) + + expect(steps[1].state).toEqual({ + status: "restored", + attempt: 1, + echoes: ["msg_3"], + boundary: "msg_3", + prior: undefined, + draft: draft("unsent draft"), + }) + expect(steps[2].state).toEqual({ + status: "running", + attempt: 2, + echoes: ["msg_3", "msg_2"], + operation: { type: "rewind", message: m2, prior: "msg_3" }, + known: { boundary: "msg_3" }, + }) + expect(steps[2].effects).toEqual([ + { type: "toast.dismiss" }, + { type: "operation.run", attempt: 2, operation: { type: "rewind", message: m2, prior: "msg_3" } }, + ]) + }) + + test("redo right after a rewind clears it before the staged event arrives", () => { + const restored = last([undo(), rewound(1, "")]).state + + expect(view(restored, undefined).canRedo).toBe(true) + + const redo = transitionSessionRevert(restored, { type: "redo.requested", revertMessageID: undefined }) + + expect(redo.effects).toEqual([ + { type: "toast.dismiss" }, + { type: "operation.run", attempt: 2, operation: { type: "redo" } }, + ]) + + const done = transitionSessionRevert(redo.state, { type: "redo.succeeded", attempt: 2, ...delivered }) + + expect(done.state).toEqual({ + status: "idle", + attempt: 2, + echoes: ["msg_3", undefined], + known: { boundary: undefined }, + }) + expect(done.effects).toEqual([{ type: "cursor.select", messageID: "msg_3" }]) + // The store still reports the boundary until the cleared event arrives. + expect(view(done.state, "msg_3")).toMatchObject({ canRedo: false, canUndo: true }) + }) + + test("a late echo of an earlier rewind neither retires the toast nor repeats a rewind", () => { + const steps = replay([ + undo(), + rewound(1, "unsent draft"), + undo(undefined), + rewound(2, "msg_3 text"), + // The echo of the first stage lands after the second rewind; the echo of the second is still due. + { type: "revert.staged", messageID: "msg_3" }, + ]) + + expect(steps[4].state).toEqual({ ...steps[3].state, echoes: ["msg_2"] }) + expect(steps[4].effects).toEqual([]) + + // The store now says msg_3, but msg_2 is the boundary, so a third undo moves on to msg_1. + const third = transitionSessionRevert(steps[4].state, undo("msg_3")) + + expect(third.state).toMatchObject({ operation: { type: "rewind", message: { id: "msg_1" }, prior: "msg_2" } }) + + // Its own echo, the last one due, leaves the rewind undoable. + const echoed = transitionSessionRevert(steps[4].state, { type: "revert.staged", messageID: "msg_2" }) + + expect(echoed.state).toEqual({ ...steps[3].state, echoes: [] }) + expect(echoed.effects).toEqual([]) + }) + + test("a late stage echo after a redo keeps the cleared boundary until the clear echo arrives", () => { + const steps = replay([ + undo(), + rewound(1, ""), + { type: "redo.requested", revertMessageID: undefined }, + { type: "redo.succeeded", attempt: 2, ...delivered }, + { type: "revert.staged", messageID: "msg_3" }, + { type: "revert.cleared" }, + ]) + + expect(steps[4].state).toEqual({ status: "idle", attempt: 2, echoes: [undefined], known: { boundary: undefined } }) + // The store reports msg_3 from the late echo, but the Session has nothing to redo. + expect(view(steps[4].state, "msg_3")).toMatchObject({ canRedo: false }) + expect(steps[5].state).toEqual({ status: "idle", attempt: 2, echoes: [] }) + }) + + test("an older toast's Undo is ignored after a newer rewind", () => { + const steps = replay([rewindTo("msg_3"), rewound(1, "original draft"), rewindTo("msg_2"), rewound(2, "msg_3 text")]) + + const restored = steps[3].state + const stale = transitionSessionRevert(restored, { type: "toast.undo", attempt: 1 }) + + expect(stale.state).toBe(restored) + expect(stale.effects).toEqual([]) + + const current = transitionSessionRevert(restored, { type: "toast.undo", attempt: 2 }) + const operation = { type: "undo-rewind", prior: "msg_3", draft: draft("msg_3 text") } as const + + expect(current.state).toEqual({ + status: "running", + attempt: 3, + echoes: ["msg_3", "msg_2", "msg_3"], + operation, + known: { boundary: "msg_2" }, + }) + expect(current.effects).toEqual([{ type: "toast.dismiss" }, { type: "operation.run", attempt: 3, operation }]) + + const done = transitionSessionRevert(current.state, { type: "undo-rewind.succeeded", attempt: 3, ...delivered }) + + expect(done.state).toEqual({ + status: "idle", + attempt: 3, + echoes: ["msg_3", "msg_2", "msg_3"], + known: { boundary: "msg_3" }, + }) + expect(done.effects).toEqual([ + { type: "draft.restoreSnapshot", draft: draft("msg_3 text") }, + { type: "cursor.select", messageID: "msg_2" }, + ]) + }) + + test("a double-clicked Undo runs once", () => { + const steps = replay([ + rewindTo("msg_3"), + rewound(1, "draft"), + { type: "toast.undo", attempt: 1 }, + { type: "toast.undo", attempt: 1 }, + ]) + + expect(steps[2].effects.filter((effect) => effect.type === "operation.run")).toHaveLength(1) + expect(steps[3].state).toBe(steps[2].state) + expect(steps[3].effects).toEqual([]) + }) + + // A commit lands after every request sent before it, so no echo is still due. + test.each<{ event: SessionRevertEvent; echoes: string[] }>([ + { event: { type: "revert.staged", messageID: "msg_1" }, echoes: ["msg_3"] }, + { event: { type: "revert.cleared" }, echoes: ["msg_3"] }, + { event: { type: "revert.committed" }, echoes: [] }, + ])("$event.type from elsewhere retires the Rewound toast", ({ event, echoes }) => { + const restored = last([rewindTo("msg_3"), rewound(1, "draft")]).state + const next = transitionSessionRevert(restored, event) + + expect(next.state).toEqual({ status: "idle", attempt: 1, echoes }) + expect(next.effects).toEqual([{ type: "toast.dismiss" }]) + expect(transitionSessionRevert(next.state, { type: "toast.undo", attempt: 1 }).effects).toEqual([]) + }) + + test("a queued prompt is withdrawn instead of rewound", () => { + const queued = new Set(["msg_3"]) + const known: SessionRevertState = { status: "idle", attempt: 4, echoes: [], known: { boundary: undefined } } + const start = transitionSessionRevert(known, undo(undefined, queued)) + + expect(start.state).toEqual({ + status: "running", + attempt: 5, + echoes: [], + operation: { type: "withdraw", message: m3 }, + known: { boundary: undefined }, + }) + + const prompt = draft("pinned").prompt + + const done = transitionSessionRevert(start.state, { + type: "withdraw.succeeded", + attempt: 5, + prompt, + messages, + inboxIDs: queued, + }) + + expect(done.state).toEqual({ ...known, attempt: 5 }) + expect(done.effects).toEqual([ + { type: "draft.restoreMessage", message: m3, prompt }, + { type: "cursor.select", messageID: "msg_2" }, + ]) + expect(transitionSessionRevert(known, rewindTo("msg_3", undefined, queued)).state).toEqual(start.state) + }) + + test("a failure returns to idle and keeps the known boundary", () => { + const running = last([undo(), rewound(1, ""), undo()]).state + const failed = transitionSessionRevert(running, { type: "operation.failed", attempt: 2, error: "busy" }) + + // The failed stage sends no echo, so only the first one is still due. + expect(failed.state).toEqual({ status: "idle", attempt: 2, echoes: ["msg_3"], known: { boundary: "msg_3" } }) + expect(failed.effects).toEqual([{ type: "toast.fail", error: "busy" }]) + expect(view(failed.state)).toMatchObject({ busy: false, canUndo: true, canRedo: true }) + }) + + test("a timeout aborts the run and ignores its late result", () => { + const running = last([undo()]).state + const timedOut = transitionSessionRevert(running, { type: "operation.timed-out", attempt: 1 }) + + expect(timedOut.state).toEqual({ status: "idle", attempt: 1, echoes: [] }) + expect(timedOut.effects).toEqual([{ type: "operation.abort" }, { type: "toast.fail" }]) + + for (const event of [ + { type: "operation.failed", attempt: 1, error: "aborted" }, + rewound(1, ""), + ] satisfies SessionRevertEvent[]) { + const late = transitionSessionRevert(timedOut.state, event) + + expect(late.state).toBe(timedOut.state) + expect(late.effects).toEqual([]) + } + }) + + test("a result from an earlier attempt or another operation is ignored", () => { + const running = last([undo(), rewound(1, ""), undo()]).state + + for (const event of [ + rewound(1, ""), + { type: "redo.succeeded", attempt: 2, ...delivered }, + { type: "operation.failed", attempt: 1 }, + { type: "operation.timed-out", attempt: 1 }, + ] satisfies SessionRevertEvent[]) { + expect(transitionSessionRevert(running, event).state).toBe(running) + } + }) + + test("disposal aborts a run and retires a toast", () => { + const running = last([undo()]).state + const restored = last([undo(), rewound(1, "")]).state + const idle = createSessionRevertState() + + expect(transitionSessionRevert(running, { type: "disposed" })).toEqual({ + state: { status: "idle", attempt: 1, echoes: [] }, + effects: [{ type: "operation.abort" }], + }) + expect(transitionSessionRevert(restored, { type: "disposed" })).toEqual({ + state: { status: "idle", attempt: 1, echoes: [] }, + effects: [{ type: "toast.dismiss" }], + }) + expect(transitionSessionRevert(idle, { type: "disposed" }).state).toBe(idle) + }) + + test("ignores requests with nothing to act on", () => { + const idle = createSessionRevertState() + + expect(transitionSessionRevert(idle, undo("msg_1")).state).toBe(idle) + expect(transitionSessionRevert(idle, rewindTo("msg_missing")).state).toBe(idle) + expect(transitionSessionRevert(idle, { type: "redo.requested", revertMessageID: undefined }).state).toBe(idle) + expect(transitionSessionRevert(idle, { type: "toast.undo", attempt: 0 }).state).toBe(idle) + }) +}) + +describe("selectCursorAfterRevert", () => { + test("selects the prompt before the boundary, or the latest delivered prompt without one", () => { + expect(selectCursorAfterRevert(delivered, "msg_3")).toBe("msg_2") + expect(selectCursorAfterRevert(delivered, "msg_1")).toBeUndefined() + expect(selectCursorAfterRevert({ messages, inboxIDs: new Set(["msg_3"]) }, undefined)).toBe("msg_2") + }) +}) diff --git a/packages/app/src/session/revert-machine.ts b/packages/app/src/session/revert-machine.ts new file mode 100644 index 00000000000..35710d9f61e --- /dev/null +++ b/packages/app/src/session/revert-machine.ts @@ -0,0 +1,339 @@ +import type { SessionMessageUser } from "@opencode/client/promise" +import type { ContextItem, Prompt } from "@/composer/state" + +// A hung wait, media pin or revert request fails, so undo and redo cannot stay disabled forever. +export const REVERT_TIMEOUT_MS = 30_000 + +export type RevertDraftSnapshot = { + readonly prompt: Prompt + readonly cursor: number | undefined + readonly context: readonly ContextItem[] +} + +/** The revert boundary our last settled request left on the server, until the event stream reports it. */ +export type KnownRevert = { readonly boundary: string | undefined } + +/** + * The boundary the event stream reports for a revert request this machine sent: the staged message, or `undefined` for + * a clear. Echoes stay in send order until the stream reports them, so a late echo of an earlier request is not taken + * for a change made elsewhere. + */ +export type RevertEcho = string | undefined + +export type SessionRevertOperation = + /** An undelivered prompt has no history to rewind, so it is withdrawn from the inbox instead. */ + | { readonly type: "withdraw"; readonly message: SessionMessageUser } + | { readonly type: "rewind"; readonly message: SessionMessageUser; readonly prior: string | undefined } + | { readonly type: "redo" } + /** The Rewound toast's Undo: restage the earlier boundary and put the draft from before the rewind back. */ + | { readonly type: "undo-rewind"; readonly prior: string | undefined; readonly draft: RevertDraftSnapshot } + +// One Session's rewind, undo and redo. `attempt` advances whenever an operation starts, so a late request result or a +// click on an older Rewound toast is ignored. +export type SessionRevertState = + | { + readonly status: "idle" + readonly attempt: number + readonly echoes: readonly RevertEcho[] + readonly known?: KnownRevert + } + | { + readonly status: "running" + readonly attempt: number + readonly echoes: readonly RevertEcho[] + readonly operation: SessionRevertOperation + readonly known?: KnownRevert + } + /** A rewind landed and its toast can undo it. */ + | { + readonly status: "restored" + readonly attempt: number + readonly echoes: readonly RevertEcho[] + readonly boundary: string + readonly prior: string | undefined + readonly draft: RevertDraftSnapshot + } + +/** The Session's user messages when a request or result is dispatched. */ +type RevertMessages = { + readonly messages: readonly SessionMessageUser[] + /** Messages still waiting in the inbox. */ + readonly inboxIDs: ReadonlySet +} + +export type SessionRevertEvent = + | ({ + readonly type: "rewind.to" + readonly messageID: string + readonly revertMessageID: string | undefined + } & RevertMessages) + | ({ readonly type: "undo.requested"; readonly revertMessageID: string | undefined } & RevertMessages) + | { readonly type: "redo.requested"; readonly revertMessageID: string | undefined } + | { readonly type: "toast.undo"; readonly attempt: number } + | ({ readonly type: "withdraw.succeeded"; readonly attempt: number; readonly prompt: Prompt } & RevertMessages) + | ({ + readonly type: "rewind.succeeded" + readonly attempt: number + readonly draft: RevertDraftSnapshot + } & RevertMessages) + | ({ readonly type: "redo.succeeded"; readonly attempt: number } & RevertMessages) + | ({ readonly type: "undo-rewind.succeeded"; readonly attempt: number } & RevertMessages) + | { readonly type: "operation.failed"; readonly attempt: number; readonly error?: string } + | { readonly type: "operation.timed-out"; readonly attempt: number } + | { readonly type: "revert.staged"; readonly messageID: string } + | { readonly type: "revert.cleared" } + | { readonly type: "revert.committed" } + | { readonly type: "disposed" } + +export type SessionRevertEffect = + | { readonly type: "operation.run"; readonly attempt: number; readonly operation: SessionRevertOperation } + | { readonly type: "operation.abort" } + | { readonly type: "draft.restoreMessage"; readonly message: SessionMessageUser; readonly prompt?: Prompt } + | { readonly type: "draft.restoreSnapshot"; readonly draft: RevertDraftSnapshot } + /** The prompt timeline navigation continues from. */ + | { readonly type: "cursor.select"; readonly messageID: string | undefined } + | { readonly type: "toast.showRewound"; readonly attempt: number } + | { readonly type: "toast.dismiss" } + | { readonly type: "toast.fail"; readonly error?: string } + +export type SessionRevertTransition = { + readonly state: SessionRevertState + readonly effects: readonly SessionRevertEffect[] +} + +export function createSessionRevertState(): SessionRevertState { + return { status: "idle", attempt: 0, echoes: [] } +} + +export function transitionSessionRevert(state: SessionRevertState, event: SessionRevertEvent): SessionRevertTransition { + if (event.type === "rewind.to") { + if (state.status === "running") return unchanged(state) + const message = event.messages.find((item) => item.id === event.messageID) + + if (!message) return unchanged(state) + + return start( + state, + rewindOrWithdraw(message, event.inboxIDs, effectiveRevertMessageID(state, event.revertMessageID)), + ) + } + + if (event.type === "undo.requested") { + if (state.status === "running") return unchanged(state) + const boundary = effectiveRevertMessageID(state, event.revertMessageID) + const message = event.messages.filter((item) => !boundary || item.id < boundary).at(-1) + + if (!message) return unchanged(state) + + return start(state, rewindOrWithdraw(message, event.inboxIDs, boundary)) + } + + if (event.type === "redo.requested") { + if (state.status === "running" || !effectiveRevertMessageID(state, event.revertMessageID)) return unchanged(state) + + return start(state, { type: "redo" }) + } + + if (event.type === "toast.undo") { + if (state.status !== "restored" || event.attempt !== state.attempt) return unchanged(state) + + return start(state, { type: "undo-rewind", prior: state.prior, draft: state.draft }) + } + + if (event.type === "withdraw.succeeded") { + if (state.status !== "running" || state.attempt !== event.attempt || state.operation.type !== "withdraw") + return unchanged(state) + const message = state.operation.message + + return changed(idle(state.attempt, state.echoes, state.known), [ + { type: "draft.restoreMessage", message, prompt: event.prompt }, + { type: "cursor.select", messageID: selectCursorAfterRevert(event, message.id) }, + ]) + } + + if (event.type === "rewind.succeeded") { + if (state.status !== "running" || state.attempt !== event.attempt || state.operation.type !== "rewind") + return unchanged(state) + const message = state.operation.message + + return changed( + { + status: "restored", + attempt: state.attempt, + echoes: state.echoes, + boundary: message.id, + prior: state.operation.prior, + draft: event.draft, + }, + [ + { type: "draft.restoreMessage", message }, + { type: "cursor.select", messageID: selectCursorAfterRevert(event, message.id) }, + { type: "toast.showRewound", attempt: state.attempt }, + ], + ) + } + + if (event.type === "redo.succeeded") { + if (state.status !== "running" || state.attempt !== event.attempt || state.operation.type !== "redo") + return unchanged(state) + + return changed(idle(state.attempt, state.echoes, { boundary: undefined }), [ + { type: "cursor.select", messageID: selectCursorAfterRevert(event, undefined) }, + ]) + } + + if (event.type === "undo-rewind.succeeded") { + if (state.status !== "running" || state.attempt !== event.attempt || state.operation.type !== "undo-rewind") + return unchanged(state) + const operation = state.operation + + return changed(idle(state.attempt, state.echoes, { boundary: operation.prior }), [ + { type: "draft.restoreSnapshot", draft: operation.draft }, + { type: "cursor.select", messageID: selectCursorAfterRevert(event, operation.prior) }, + ]) + } + + if (event.type === "operation.failed") { + if (state.status !== "running" || state.attempt !== event.attempt) return unchanged(state) + + return changed(idle(state.attempt, withoutEcho(state.echoes, state.operation), state.known), [ + { type: "toast.fail", error: event.error }, + ]) + } + + // A request that timed out may still land; its echo then arrives as a change from elsewhere and the store follows it. + if (event.type === "operation.timed-out") { + if (state.status !== "running" || state.attempt !== event.attempt) return unchanged(state) + + return changed(idle(state.attempt, withoutEcho(state.echoes, state.operation), state.known), [ + { type: "operation.abort" }, + { type: "toast.fail" }, + ]) + } + + if (event.type === "disposed") { + if (state.status === "idle") return unchanged(state) + + return changed(idle(state.attempt, []), [ + { type: state.status === "running" ? "operation.abort" : "toast.dismiss" }, + ]) + } + + const echo = + event.type === "revert.committed" + ? -1 + : state.echoes.indexOf(event.type === "revert.staged" ? event.messageID : undefined) + + // The stream reported a request this machine sent, so a restored rewind keeps its toast. The store lags until the + // echo of the last request arrives, so the known boundary stays until then. + if (echo >= 0) { + const echoes = state.echoes.slice(echo + 1) + + if (state.status === "restored" || echoes.length > 0) return changed({ ...state, echoes }) + + return changed(forget(state, echoes)) + } + + // A stage or clear from elsewhere, or a sent prompt committing the revert: the stream is the source of truth again. + // A commit lands after every request sent before it. + const echoes = event.type === "revert.committed" ? [] : state.echoes + + // A newer boundary replaced this rewind, so its Undo would restore a stale draft. + if (state.status === "restored") return changed(idle(state.attempt, echoes), [{ type: "toast.dismiss" }]) + + if (!state.known && echoes === state.echoes) return unchanged(state) + + return changed(forget(state, echoes)) +} + +/** The revert boundary the Session has, counting requests that settled before the event stream reported them. */ +export function effectiveRevertMessageID(state: SessionRevertState, serverRevertMessageID: string | undefined) { + if (state.status === "restored") return state.boundary + + return state.known ? state.known.boundary : serverRevertMessageID +} + +/** The prompt before the boundary, or the latest delivered prompt when nothing is reverted. */ +export function selectCursorAfterRevert(input: RevertMessages, boundary: string | undefined) { + if (boundary) return input.messages.filter((item) => item.id < boundary).at(-1)?.id + + return input.messages.filter((item) => !input.inboxIDs.has(item.id)).at(-1)?.id +} + +export function viewSessionRevert( + state: SessionRevertState, + context: { readonly messages: readonly SessionMessageUser[]; readonly revertMessageID: string | undefined }, +) { + const busy = state.status === "running" + const revertMessageID = effectiveRevertMessageID(state, context.revertMessageID) + + return { + busy, + revertMessageID, + canUndo: !busy && context.messages.some((item) => !revertMessageID || item.id < revertMessageID), + canRedo: !busy && !!revertMessageID, + } +} + +function rewindOrWithdraw( + message: SessionMessageUser, + inboxIDs: ReadonlySet, + prior: string | undefined, +): SessionRevertOperation { + if (inboxIDs.has(message.id)) return { type: "withdraw", message } + + return { type: "rewind", message, prior } +} + +// Claims the Session synchronously, so a second request before the first await is refused. +function start(state: SessionRevertState, operation: SessionRevertOperation): SessionRevertTransition { + const attempt = state.attempt + 1 + const known = state.status === "restored" ? { boundary: state.boundary } : state.known + // The echo is expected from the moment the request is sent: the stream can report it before the response lands. + const echoes = operation.type === "withdraw" ? state.echoes : [...state.echoes, echoOf(operation)] + const run: SessionRevertEffect = { type: "operation.run", attempt, operation } + + return changed( + known + ? { status: "running", attempt, echoes, operation, known } + : { status: "running", attempt, echoes, operation }, + state.status === "restored" ? [{ type: "toast.dismiss" }, run] : [run], + ) +} + +function echoOf(operation: Exclude): RevertEcho { + if (operation.type === "rewind") return operation.message.id + + if (operation.type === "undo-rewind") return operation.prior + + return undefined +} + +// A request that failed sends no echo. +function withoutEcho(echoes: readonly RevertEcho[], operation: SessionRevertOperation) { + if (operation.type === "withdraw") return echoes + const index = echoes.lastIndexOf(echoOf(operation)) + + return index < 0 ? echoes : echoes.filter((_, at) => at !== index) +} + +function forget( + state: Exclude, + echoes: readonly RevertEcho[], +): SessionRevertState { + if (state.status === "idle") return idle(state.attempt, echoes) + + return { status: "running", attempt: state.attempt, echoes, operation: state.operation } +} + +function idle(attempt: number, echoes: readonly RevertEcho[], known?: KnownRevert): SessionRevertState { + return known ? { status: "idle", attempt, echoes, known } : { status: "idle", attempt, echoes } +} + +function changed(state: SessionRevertState, effects: readonly SessionRevertEffect[] = []): SessionRevertTransition { + return { state, effects } +} + +function unchanged(state: SessionRevertState): SessionRevertTransition { + return { state, effects: [] } +} diff --git a/packages/app/src/session/revert.ts b/packages/app/src/session/revert.ts index d0d48966c39..b943008e9f8 100644 --- a/packages/app/src/session/revert.ts +++ b/packages/app/src/session/revert.ts @@ -1,4 +1,6 @@ import type { SessionMessageUser } from "@opencode/client/promise" +import { toaster } from "@opencode/ui/toast" +import { createMemo, createSignal, onCleanup, untrack } from "solid-js" import { useComposerState } from "@/composer/persistence" import { useData } from "@/runtime/server/current" import { useServerSDK } from "@/runtime/server/client" @@ -10,10 +12,23 @@ import { pinPromptMedia } from "@/composer/media" import { clonePrompt, promptLength } from "@/composer/prompt-parts" import { showToast } from "@/shell/notifications/toast" import type { SessionModel } from "./model" +import { + createSessionRevertState, + REVERT_TIMEOUT_MS, + transitionSessionRevert, + viewSessionRevert, + type SessionRevertEffect, + type SessionRevertEvent, + type SessionRevertOperation, + type SessionRevertState, +} from "./revert-machine" +import { selectSessionUserMessages } from "./session-domain" + +const IDLE = createSessionRevertState() export function createSessionRevert(input: { session: SessionModel - setActiveMessage: (message: SessionMessageUser | undefined) => void + setActiveMessage: (messageID: string | undefined) => void }) { const prompt = useComposerState() const server = useServerSDK() @@ -21,162 +36,283 @@ export function createSessionRevert(input: { const location = useWorkspaceLocation() const language = useLanguage() - // oxlint-disable-next-line anti-slop/no-unknown-parameters -- a rejected request is opaque; the toast formats it - const fail = (error: unknown) => - showToast({ - title: language.t("common.requestFailed"), - description: error instanceof Error ? error.message : String(error), + // One revert lifecycle per Session: this stays mounted while tabs switch, and a rewind can settle in the background. + const [sessions, setSessions] = createSignal>>({}) + // The composer and directory a Session's rewind started from, kept while it runs or can be undone, so a result that + // lands after a tab switch still restores the Session it came from. + const targets = new Map() + const runs = new Map() + let rewound: { sessionID: string; id: number } | undefined + + const view = createMemo(() => { + const sessionID = input.session.identity.params.id + + return viewSessionRevert((sessionID && sessions()[sessionID]) || IDLE, { + messages: input.session.history.userMessages(), + revertMessageID: input.session.data.revertMessageID(), }) + }) - const request = async (action: () => Promise) => - action() - .then(() => true) - .catch((error) => { - fail(error) + onCleanup( + data.on("session.revert.staged", (event) => + dispatch(event.data.sessionID, { type: "revert.staged", messageID: event.data.revert.messageID }), + ), + ) + onCleanup(data.on("session.revert.cleared", (event) => dispatch(event.data.sessionID, { type: "revert.cleared" }))) + onCleanup( + data.on("session.revert.committed", (event) => dispatch(event.data.sessionID, { type: "revert.committed" })), + ) + onCleanup( + data.on("session.deleted", (event) => { + dispatch(event.data.sessionID, { type: "disposed" }) + setSessions((all) => Object.fromEntries(Object.entries(all).filter(([id]) => id !== event.data.sessionID))) + }), + ) + onCleanup(() => Object.keys(untrack(sessions)).forEach((sessionID) => dispatch(sessionID, { type: "disposed" }))) - return false - }) - - const messageDraft = (message: SessionMessageUser) => + const messageDraft = (message: SessionMessageUser, directory: string) => extractPromptFromMessage(message, { - directory: location().directory, + directory, attachmentName: language.t("common.attachment"), sessionTitle: (id) => data.session.get(id)?.title, }) const restore = ( - target: ReturnType, + target: RevertTarget, message: SessionMessageUser, - restored = messageDraft(message), + restored = messageDraft(message, target.directory), ) => { - const context = extractPromptContext(message, { directory: location().directory }) + const context = extractPromptContext(message, { directory: target.directory }) - target.set(restored, promptLength(restored)) + target.composer.set(restored, promptLength(restored)) // The restored prompt replaces the draft, so chips from an earlier restore do not ride along. - target.context.replace([...context.comments, ...context.files]) + target.composer.context.replace([...context.comments, ...context.files]) } - const stage = async (message: SessionMessageUser, previous: SessionMessageUser | undefined) => { - const sessionID = input.session.identity.params.id + const snapshot = (sessionID: string) => { + const messages = selectSessionUserMessages(data.session.message.list(sessionID)) - if (!sessionID) return - const owner = input.session.ownership.capture() - const target = prompt.capture() + return { + messages, + inboxIDs: new Set(messages.flatMap((item) => (data.session.input.has(sessionID, item.id) ? [item.id] : []))), + } + } - // An undelivered prompt has no history to rewind. Withdraw it like the TUI - // instead of interrupting the work it is waiting behind. - if (data.session.input.has(sessionID, message.id)) { - // The cancel releases the prompt's blob refs and sweeps blobs nothing else references, so the draft takes - // local copies of its media first; if they cannot load, the prompt stays queued. - const restored = await pinPromptMedia(messageDraft(message)).catch((error) => { - fail(error) + // Requests act on the routed Session; a kept timeline of another Session cannot start one. The promise settles with + // the operation it started, so a message's own action stays pending until then. + const request = async (sessionID: string | undefined, event: (sessionID: string) => SessionRevertEvent) => { + if (!sessionID || sessionID !== input.session.identity.params.id) return + const before = runs.get(sessionID) - return undefined - }) + if (untrack(sessions)[sessionID]?.status !== "running") + targets.set(sessionID, { composer: prompt.capture(), directory: location().directory }) + dispatch(sessionID, event(sessionID)) + const started = runs.get(sessionID) - if (!restored) return + if (started && started !== before) await started.settled.promise + } - if (!(await request(() => server.api.session.inbox.cancel({ sessionID, inboxID: message.id })))) return - restore(target, message, restored) - owner.run(() => input.setActiveMessage(previous)) + function dispatch(sessionID: string, event: SessionRevertEvent) { + untrack(() => { + const current = sessions()[sessionID] ?? createSessionRevertState() + const next = transitionSessionRevert(current, event) + + if (next.state !== current) setSessions((all) => ({ ...all, [sessionID]: next.state })) + next.effects.forEach((effect) => perform(sessionID, effect)) + + if (next.state.status === "idle") targets.delete(sessionID) + }) + } + + function perform(sessionID: string, effect: SessionRevertEffect) { + if (effect.type === "operation.run") return run(sessionID, effect.attempt, effect.operation) + + if (effect.type === "operation.abort") return stop(sessionID) + + if (effect.type === "draft.restoreMessage") { + const target = targets.get(sessionID) + + if (target) restore(target, effect.message, effect.prompt) return } - // Interrupt acknowledges before the execution settles, and staging a busy Session fails. The - // local status can lag the server either way, so always settle first; both are idle no-ops. - // Like the TUI, stop at the first failure instead of waiting on work that was never interrupted. - if (!(await request(() => server.api.session.interrupt({ sessionID })))) return + if (effect.type === "draft.restoreSnapshot") { + const target = targets.get(sessionID) - if (!(await request(() => server.api.experimental.session.wait({ sessionID })))) return + if (!target) return + target.composer.set(effect.draft.prompt, effect.draft.cursor ?? promptLength(effect.draft.prompt)) + target.composer.context.replace([...effect.draft.context]) - // Read before staging: the staged event can land before the request resolves. - const prior = input.session.data.revertMessageID() - - if (!(await request(() => server.api.session.revert.stage({ sessionID, messageID: message.id })))) return - - const draft = { - prompt: clonePrompt(target.current()), - cursor: target.cursor(), - context: target.context.items().map((item) => ({ ...item })), + return } - // Like the TUI, pending inputs are left alone: the revert hides them, committing it drops them, - // and redo delivers them. - restore(target, message) - owner.run(() => input.setActiveMessage(previous)) + // The cursor belongs to the routed Session's timeline, so a Session settling in the background leaves it alone. + if (effect.type === "cursor.select") { + if (input.session.identity.params.id === sessionID) input.setActiveMessage(effect.messageID) - // Rewinding also puts the files back, so offer the way out right where it happened. Undo is bound to - // this Session and its earlier revert, not to whatever the route and the event stream show when clicked. - const undoRewind = async () => { - const undone = prior - ? request(() => server.api.session.revert.stage({ sessionID, messageID: prior })) - : request(() => server.api.session.revert.clear({ sessionID })) + return + } - if (!(await undone)) return - target.set(draft.prompt, draft.cursor ?? promptLength(draft.prompt)) - target.context.replace(draft.context) - owner.run(() => { - const messages = input.session.history.userMessages() - const boundary = prior ? messages.findIndex((item) => item.id === prior) : -1 + // Rewinding also puts the files back, so offer the way out right where it happened. + if (effect.type === "toast.showRewound") { + rewound = { + sessionID, + id: showToast({ + title: language.t("session.rewind.toast.title"), + actions: [ + { + label: language.t("session.rewind.toast.undo"), + onClick: () => dispatch(sessionID, { type: "toast.undo", attempt: effect.attempt }), + }, + ], + }), + } - input.setActiveMessage( - boundary >= 0 - ? messages[boundary - 1] - : messages.filter((item) => !data.session.input.has(sessionID, item.id)).at(-1), - ) + return + } + + // Toasts with the same title share one slot, so only dismiss it while it is still this Session's. + if (effect.type === "toast.dismiss") { + if (rewound?.sessionID !== sessionID) return + toaster.dismiss(rewound.id) + rewound = undefined + + return + } + + showToast({ title: language.t("common.requestFailed"), description: effect.error }) + } + + function run(sessionID: string, attempt: number, operation: SessionRevertOperation) { + stop(sessionID) + const target = targets.get(sessionID) + + // A request captures the target before it dispatches, and a restored rewind keeps it for its Undo. + if (!target) return dispatch(sessionID, { type: "operation.failed", attempt }) + + const active: Run = { + controller: new AbortController(), + timer: setTimeout(() => dispatch(sessionID, { type: "operation.timed-out", attempt }), REVERT_TIMEOUT_MS), + settled: Promise.withResolvers(), + } + + runs.set(sessionID, active) + // Every outcome settles in the machine, which drops a result whose attempt is no longer current. + void execute(sessionID, attempt, operation, target, active.controller.signal) + .then( + (event) => dispatch(sessionID, event), + (cause: unknown) => + dispatch(sessionID, { + type: "operation.failed", + attempt, + error: cause instanceof Error ? cause.message : String(cause), + }), + ) + .finally(() => { + if (runs.get(sessionID) === active) stop(sessionID) }) + } + + function stop(sessionID: string) { + const active = runs.get(sessionID) + + if (!active) return + runs.delete(sessionID) + clearTimeout(active.timer) + active.controller.abort() + active.settled.resolve() + } + + async function execute( + sessionID: string, + attempt: number, + operation: SessionRevertOperation, + target: RevertTarget, + signal: AbortSignal, + ): Promise { + if (operation.type === "withdraw") { + // The cancel releases the prompt's blob refs and sweeps blobs nothing else references, so the draft takes + // local copies of its media first; if they cannot load, the prompt stays queued. + const restored = await pinPromptMedia(messageDraft(operation.message, target.directory)) + signal.throwIfAborted() + await server.api.session.inbox.cancel({ sessionID, inboxID: operation.message.id }, { signal }) + + return { type: "withdraw.succeeded", attempt, prompt: restored, ...snapshot(sessionID) } } - showToast({ - title: language.t("session.rewind.toast.title"), - actions: [{ label: language.t("session.rewind.toast.undo"), onClick: () => void undoRewind() }], - }) - } + if (operation.type === "rewind") { + // Interrupt acknowledges before the execution settles, and staging a busy Session fails. The local status can + // lag the server either way, so always settle first; both are idle no-ops. Like the TUI, stop at the first + // failure instead of waiting on work that was never interrupted. + await server.api.session.interrupt({ sessionID }, { signal }) + await server.api.experimental.session.wait({ sessionID }, { signal }) + await server.api.session.revert.stage({ sessionID, messageID: operation.message.id }, { signal }) - const to = async (messageID: string) => { - const messages = input.session.history.userMessages() - const index = messages.findIndex((message) => message.id === messageID) - const message = messages[index] - - if (!message) return - await stage(message, messages[index - 1]) - } - - const undo = async () => { - const messages = input.session.history.userMessages() - const reverted = input.session.data.revertMessageID() - const boundary = reverted ? messages.findIndex((message) => message.id === reverted) : messages.length - - if (boundary <= 0) return - const message = messages[boundary - 1] - - if (message) await stage(message, messages[boundary - 2]) - } - - const redo = async () => { - const sessionID = input.session.identity.params.id - const reverted = input.session.data.revertMessageID() - - if (!sessionID || !reverted) return - const owner = input.session.ownership.capture() + // Like the TUI, pending inputs are left alone: the revert hides them, committing it drops them, and redo + // delivers them. + return { + type: "rewind.succeeded", + attempt, + draft: { + prompt: clonePrompt(target.composer.current()), + cursor: target.composer.cursor(), + context: target.composer.context.items().map((item) => ({ ...item })), + }, + ...snapshot(sessionID), + } + } // Like the TUI, redo restores every reverted message at once and leaves the composer alone. - if (!(await request(() => server.api.session.revert.clear({ sessionID })))) return - owner.run(() => - input.setActiveMessage( - input.session.history - .userMessages() - .filter((message) => !data.session.input.has(sessionID, message.id)) - .at(-1), - ), - ) + if (operation.type === "redo") { + await server.api.session.revert.clear({ sessionID }, { signal }) + + return { type: "redo.succeeded", attempt, ...snapshot(sessionID) } + } + + await (operation.prior + ? server.api.session.revert.stage({ sessionID, messageID: operation.prior }, { signal }) + : server.api.session.revert.clear({ sessionID }, { signal })) + + return { type: "undo-rewind.succeeded", attempt, ...snapshot(sessionID) } } - /** Puts a sent prompt back in the composer without rewinding the Session. */ - const recall = (message: SessionMessageUser) => restore(prompt.capture(), message) + return { + to: (messageID: string, sessionID = input.session.identity.params.id) => + request(sessionID, (id) => ({ + type: "rewind.to", + messageID, + revertMessageID: data.session.get(id)?.revert?.messageID, + ...snapshot(id), + })), + undo: () => + request(input.session.identity.params.id, (id) => ({ + type: "undo.requested", + revertMessageID: data.session.get(id)?.revert?.messageID, + ...snapshot(id), + })), + redo: () => + request(input.session.identity.params.id, (id) => ({ + type: "redo.requested", + revertMessageID: data.session.get(id)?.revert?.messageID, + })), + /** Puts a sent prompt back in the composer without rewinding the Session. */ + recall: (message: SessionMessageUser) => + restore({ composer: prompt.capture(), directory: location().directory }, message), + canUndo: () => !!input.session.identity.params.id && view().canUndo, + canRedo: () => !!input.session.identity.params.id && view().canRedo, + } +} - return { to, undo, redo, recall } +type RevertTarget = { + composer: ReturnType["capture"]> + directory: string +} + +type Run = { + controller: AbortController + timer: ReturnType + settled: PromiseWithResolvers } export type SessionRevert = ReturnType diff --git a/packages/app/src/session/timeline/interaction.ts b/packages/app/src/session/timeline/interaction.ts index 7170ffdbee4..328abe89cc6 100644 --- a/packages/app/src/session/timeline/interaction.ts +++ b/packages/app/src/session/timeline/interaction.ts @@ -1,4 +1,3 @@ -import type { SessionMessageUser } from "@opencode/client/promise" import { createResizeObserver } from "@solid-primitives/resize-observer" import { useLocation } from "@solidjs/router" import { createEffect, createMemo, createSignal, on, onCleanup, untrack } from "solid-js" @@ -121,8 +120,8 @@ export function createSessionTimelineInteraction(session: SessionModel) { return list.filter((item) => item.top <= line).at(-1)?.id ?? list[0]?.id ?? cursorMessageID(untrack(cursor.state)) } - const setActiveMessage = (message: SessionMessageUser | undefined) => - cursor.dispatch({ type: "message.select", messageID: message?.id, following: untrack(follow.following) }) + const setActiveMessage = (messageID: string | undefined) => + cursor.dispatch({ type: "message.select", messageID, following: untrack(follow.following) }) const jumpThreshold = (element: HTMLDivElement) => Math.max(400, element.clientHeight)