mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-15 20:52:01 +03:00
fix(app): drive rewind, undo and redo with one explicit state machine (#61907)
This commit is contained in:
committed by
GitHub
Unverified
parent
483969a884
commit
b6f3294e9d
@@ -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({
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user