fix(app): optimistic revert/restore

This commit is contained in:
Adam
2026-03-12 15:04:27 -05:00
parent 02c75821a8
commit f0542fae7a
4 changed files with 98 additions and 28 deletions
+79 -23
View File
@@ -1,6 +1,7 @@
import type { Project, UserMessage } from "@opencode-ai/sdk/v2" import type { Project, UserMessage } from "@opencode-ai/sdk/v2"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { import {
batch,
onCleanup, onCleanup,
Show, Show,
Match, Match,
@@ -291,6 +292,7 @@ export default function Page() {
git: false, git: false,
pendingMessage: undefined as string | undefined, pendingMessage: undefined as string | undefined,
restoring: undefined as string | undefined, restoring: undefined as string | undefined,
reverting: false,
reviewSnap: false, reviewSnap: false,
scrollGesture: 0, scrollGesture: 0,
scroll: { scroll: {
@@ -1243,6 +1245,24 @@ export default function Page() {
}) })
} }
const merge = (next: NonNullable<ReturnType<typeof info>>) =>
sync.set("session", (list) => {
const idx = list.findIndex((item) => item.id === next.id)
if (idx < 0) return list
const out = list.slice()
out[idx] = next
return out
})
const roll = (sessionID: string, next: NonNullable<ReturnType<typeof info>>["revert"]) =>
sync.set("session", (list) => {
const idx = list.findIndex((item) => item.id === sessionID)
if (idx < 0) return list
const out = list.slice()
out[idx] = { ...out[idx], revert: next }
return out
})
const busy = (sessionID: string) => { const busy = (sessionID: string) => {
if (sync.data.session_status[sessionID]?.type !== "idle") return true if (sync.data.session_status[sessionID]?.type !== "idle") return true
return (sync.data.message[sessionID] ?? []).some( return (sync.data.message[sessionID] ?? []).some(
@@ -1275,42 +1295,77 @@ export default function Page() {
} }
const revert = (input: { sessionID: string; messageID: string }) => { const revert = (input: { sessionID: string; messageID: string }) => {
if (ui.reverting || ui.restoring) return
const prev = prompt.current().slice()
const last = info()?.revert
const value = draft(input.messageID) const value = draft(input.messageID)
batch(() => {
setUi("reverting", true)
roll(input.sessionID, { messageID: input.messageID })
prompt.set(value)
})
return halt(input.sessionID) return halt(input.sessionID)
.then(() => sdk.client.session.revert(input)) .then(() => sdk.client.session.revert(input))
.then(() => { .then((result) => {
prompt.set(value) if (result.data) merge(result.data)
})
.catch((err) => {
batch(() => {
roll(input.sessionID, last)
prompt.set(prev)
})
fail(err)
})
.finally(() => {
setUi("reverting", false)
}) })
.catch(fail)
} }
const restore = (id: string) => { const restore = (id: string) => {
const sessionID = params.id const sessionID = params.id
if (!sessionID || ui.restoring) return if (!sessionID || ui.restoring || ui.reverting) return
const next = userMessages().find((item) => item.id > id) const next = userMessages().find((item) => item.id > id)
setUi("restoring", id) const prev = prompt.current().slice()
const last = info()?.revert
batch(() => {
setUi("restoring", id)
setUi("reverting", true)
roll(sessionID, next ? { messageID: next.id } : undefined)
if (next) {
prompt.set(draft(next.id))
return
}
prompt.reset()
})
const task = !next const task = !next
? halt(sessionID) ? halt(sessionID).then(() => sdk.client.session.unrevert({ sessionID }))
.then(() => sdk.client.session.unrevert({ sessionID })) : halt(sessionID).then(() =>
.then(() => { sdk.client.session.revert({
prompt.reset() sessionID,
}) messageID: next.id,
: halt(sessionID) }),
.then(() => )
sdk.client.session.revert({
sessionID,
messageID: next.id,
}),
)
.then(() => {
prompt.set(draft(next.id))
})
return task.catch(fail).finally(() => { return task
setUi("restoring", (value) => (value === id ? undefined : value)) .then((result) => {
}) if (result.data) merge(result.data)
})
.catch((err) => {
batch(() => {
roll(sessionID, last)
prompt.set(prev)
})
fail(err)
})
.finally(() => {
batch(() => {
setUi("restoring", (value) => (value === id ? undefined : value))
setUi("reverting", false)
})
})
} }
const rolled = createMemo(() => { const rolled = createMemo(() => {
@@ -1487,6 +1542,7 @@ export default function Page() {
? { ? {
items: rolled(), items: rolled(),
restoring: ui.restoring, restoring: ui.restoring,
disabled: ui.reverting,
onRestore: restore, onRestore: restore,
} }
: undefined : undefined
@@ -24,6 +24,7 @@ export function SessionComposerRegion(props: {
revert?: { revert?: {
items: { id: string; text: string }[] items: { id: string; text: string }[]
restoring?: string restoring?: string
disabled?: boolean
onRestore: (id: string) => void onRestore: (id: string) => void
} }
setPromptDockRef: (el: HTMLDivElement) => void setPromptDockRef: (el: HTMLDivElement) => void
@@ -156,6 +157,7 @@ export function SessionComposerRegion(props: {
<SessionRevertDock <SessionRevertDock
items={revert.items} items={revert.items}
restoring={revert.restoring} restoring={revert.restoring}
disabled={revert.disabled}
onRestore={revert.onRestore} onRestore={revert.onRestore}
/> />
</div> </div>
@@ -195,7 +197,12 @@ export function SessionComposerRegion(props: {
"margin-top": `${-36 * value()}px`, "margin-top": `${-36 * value()}px`,
}} }}
> >
<SessionRevertDock items={revert.items} restoring={revert.restoring} onRestore={revert.onRestore} /> <SessionRevertDock
items={revert.items}
restoring={revert.restoring}
disabled={revert.disabled}
onRestore={revert.onRestore}
/>
</div> </div>
)} )}
</Show> </Show>
@@ -1,4 +1,4 @@
import { For, Show, createMemo } from "solid-js" import { For, Show, createEffect, createMemo } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { DockTray } from "@opencode-ai/ui/dock-surface" import { DockTray } from "@opencode-ai/ui/dock-surface"
@@ -8,11 +8,18 @@ import { useLanguage } from "@/context/language"
export function SessionRevertDock(props: { export function SessionRevertDock(props: {
items: { id: string; text: string }[] items: { id: string; text: string }[]
restoring?: string restoring?: string
disabled?: boolean
onRestore: (id: string) => void onRestore: (id: string) => void
}) { }) {
const language = useLanguage() const language = useLanguage()
const [store, setStore] = createStore({ const [store, setStore] = createStore({
collapsed: false, collapsed: true,
})
createEffect(() => {
props.items.length
props.items[0]?.id
setStore("collapsed", true)
}) })
const toggle = () => setStore("collapsed", (value) => !value) const toggle = () => setStore("collapsed", (value) => !value)
@@ -77,7 +84,7 @@ export function SessionRevertDock(props: {
size="small" size="small"
variant="secondary" variant="secondary"
class="shrink-0" class="shrink-0"
disabled={!!props.restoring} disabled={props.disabled || !!props.restoring}
onClick={() => props.onRestore(item.id)} onClick={() => props.onRestore(item.id)}
> >
{language.t("session.revertDock.restore")} {language.t("session.revertDock.restore")}
+1 -1
View File
@@ -128,7 +128,7 @@ export const dict: Record<string, string> = {
"ui.message.copy": "Copy", "ui.message.copy": "Copy",
"ui.message.copyMessage": "Copy message", "ui.message.copyMessage": "Copy message",
"ui.message.forkMessage": "Fork to new session", "ui.message.forkMessage": "Fork to new session",
"ui.message.revertMessage": "Reset to this point", "ui.message.revertMessage": "Revert message",
"ui.message.copyResponse": "Copy response", "ui.message.copyResponse": "Copy response",
"ui.message.copied": "Copied", "ui.message.copied": "Copied",
"ui.message.interrupted": "Interrupted", "ui.message.interrupted": "Interrupted",