fix(app): drive rewind, undo and redo with one explicit state machine (#61907)

This commit is contained in:
Luke Parker
2026-11-04 22:00:21 -06:00
committed by GitHub
Unverified
parent 483969a884
commit b6f3294e9d
7 changed files with 991 additions and 125 deletions
@@ -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[] = []
@@ -22,7 +22,7 @@ import { sideTabShortcuts } from "@/runtime/plugin/closed-tabs"
type SessionCommandSource = {
identity: SessionModel["identity"]
data: Pick<SessionModel["data"], "info" | "revertMessageID">
data: Pick<SessionModel["data"], "info">
history: Pick<SessionModel["history"], "visibleUserMessages">
layout: SessionModel["layout"]
ownership: SessionModel["ownership"]
@@ -36,7 +36,7 @@ export type SessionCommandContext = {
move: () => Promise<void>
}
navigateMessageByOffset: (offset: number) => void
revert: Pick<SessionRevert, "undo" | "redo">
revert: Pick<SessionRevert, "undo" | "redo" | "canUndo" | "canRedo">
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({
+3 -1
View File
@@ -243,7 +243,9 @@ export function createActiveSessionRegion(input: {
setActiveMessage: input.timeline.actions.setActiveMessage,
})
const revertMessage: NonNullable<SessionUserActions["revert"]> = ({ messageID }) => revert.to(messageID)
const revertMessage: NonNullable<SessionUserActions["revert"]> = (target) =>
revert.to(target.messageID, target.sessionID)
const fork = useSessionFork()
const forkMessage: NonNullable<SessionUserActions["fork"]> = (target) => fork(target).then(() => undefined)
@@ -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<string>() }
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<string>()): SessionRevertEvent => ({
type: "undo.requested",
revertMessageID,
messages,
inboxIDs,
})
const rewindTo = (messageID: string, revertMessageID?: string, inboxIDs = new Set<string>()): 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<SessionRevertTransition[]>((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")
})
})
+339
View File
@@ -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<string>
}
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<string>,
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<SessionRevertOperation, { type: "withdraw" }>): 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<SessionRevertState, { status: "restored" }>,
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: [] }
}
+253 -117
View File
@@ -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<Readonly<Record<string, SessionRevertState>>>({})
// 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<string, RevertTarget>()
const runs = new Map<string, Run>()
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 <A>(action: () => Promise<A>) =>
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<typeof prompt.capture>,
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<void>(),
}
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<SessionRevertEvent> {
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<ReturnType<typeof useComposerState>["capture"]>
directory: string
}
type Run = {
controller: AbortController
timer: ReturnType<typeof setTimeout>
settled: PromiseWithResolvers<void>
}
export type SessionRevert = ReturnType<typeof createSessionRevert>
@@ -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)