feat(tui): wire up undo/redo and revert for V2 sessions (#34263)

This commit is contained in:
Dax 2026-06-27 20:04:53 -04:00 committed by GitHub
commit d1d7ebc2c6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 478 additions and 87 deletions

View file

@ -13,6 +13,7 @@ import { useLocal } from "../context/local"
import { createDebouncedSignal } from "../util/signal"
import { useToast } from "../ui/toast"
import { useCommandShortcut } from "../keymap"
import { DialogSessionRename } from "./dialog-session-rename"
import { Spinner } from "./spinner"
export function DialogSessionList() {
@ -121,7 +122,8 @@ export function DialogSessionList() {
{
command: "session.rename",
title: "rename",
onTrigger: () => unavailable("Renaming"),
onTrigger: (option: { value: string }) =>
DialogSessionRename.show(dialog, option.value, data.session.get(option.value)?.title),
},
]}
footerHints={quickSwitchFooterHints()}

View file

@ -1,31 +1,33 @@
import { DialogPrompt } from "../ui/dialog-prompt"
import { useDialog } from "../ui/dialog"
import { useSync } from "../context/sync"
import { createMemo } from "solid-js"
import { type DialogContext, useDialog } from "../ui/dialog"
import { useSDK } from "../context/sdk"
import { useToast } from "../ui/toast"
import { errorMessage } from "../util/error"
interface DialogSessionRenameProps {
session: string
}
export function DialogSessionRename(props: DialogSessionRenameProps) {
export function DialogSessionRename(props: { sessionID: string; currentTitle?: string }) {
const dialog = useDialog()
const sync = useSync()
const sdk = useSDK()
const session = createMemo(() => sync.session.get(props.session))
const toast = useToast()
return (
<DialogPrompt
title="Rename Session"
value={session()?.title}
title="Rename session"
placeholder="Session title"
value={props.currentTitle}
onConfirm={(value) => {
void sdk.client.session.update({
sessionID: props.session,
title: value,
})
dialog.clear()
const title = value.trim()
if (!title) return
void sdk.client.v2.session
.rename({ sessionID: props.sessionID, title }, { throwOnError: true })
.then(() => dialog.clear())
.catch((error) =>
toast.show({ message: `Failed to rename session: ${errorMessage(error)}`, variant: "error", duration: 5000 }),
)
}}
onCancel={() => dialog.clear()}
/>
)
}
DialogSessionRename.show = (dialog: DialogContext, sessionID: string, currentTitle?: string) =>
dialog.replace(() => <DialogSessionRename sessionID={sessionID} currentTitle={currentTitle} />)

View file

@ -1104,6 +1104,13 @@ export function Prompt(props: PromptProps) {
{ throwOnError: true },
)
}
if (session?.revert) {
const revertResult = await sdk.client.v2.session.revert.commit({ sessionID })
if (revertResult.error) {
toast.show({ title: "Failed to commit revert", message: errorMessage(revertResult.error), variant: "error" })
return false
}
}
const result = await sdk.client.v2.session.prompt({
sessionID,
prompt: {

View file

@ -169,6 +169,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
})
})
break
case "session.next.renamed":
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "title", event.data.title)
break
case "session.next.prompted": {
setStore("session", "status", event.data.sessionID, "running")
message.update(event.data.sessionID, (draft, index) => {
@ -405,6 +409,15 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
case "session.next.compaction.started":
setStore("session", "status", event.data.sessionID, "running")
break
case "session.next.revert.staged":
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "revert", event.data.revert)
break
case "session.next.revert.cleared":
case "session.next.revert.committed":
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "revert", undefined)
break
case "session.next.compaction.delta":
break
case "session.next.compaction.ended":

View file

@ -3,11 +3,14 @@ import { useData } from "../../context/data"
import { DialogSelect } from "../../ui/dialog-select"
import { useClipboard } from "../../context/clipboard"
import { useToast } from "../../ui/toast"
import { useSDK } from "../../context/sdk"
import { errorMessage } from "../../util/error"
export function DialogMessage(props: { messageID: string; sessionID: string; setPrompt?: unknown }) {
const data = useData()
const clipboard = useClipboard()
const toast = useToast()
const sdk = useSDK()
const message = createMemo(() =>
data.session.message.get(props.sessionID, props.messageID),
)
@ -20,8 +23,12 @@ export function DialogMessage(props: { messageID: string; sessionID: string; set
title: "Revert",
value: "session.revert",
description: "undo messages and file changes",
onSelect: (dialog) => {
toast.show({ message: "Reverting is not implemented for V2 sessions yet", variant: "error", duration: 5000 })
onSelect: async (dialog) => {
const result = await sdk.client.v2.session.revert.stage({
sessionID: props.sessionID,
messageID: props.messageID,
})
if (result.error) toast.show({ message: errorMessage(result.error), variant: "error", duration: 5000 })
dialog.clear()
},
},

View file

@ -42,6 +42,7 @@ import { useSDK } from "../../context/sdk"
import { useEditorContext } from "../../context/editor"
import { openEditor } from "../../editor"
import { useDialog } from "../../ui/dialog"
import { DialogSessionRename } from "../../component/dialog-session-rename"
import { TodoItem } from "../../component/todo-item"
import { DialogMessage } from "./dialog-message"
import { Sidebar } from "./sidebar"
@ -335,7 +336,7 @@ export function Session() {
value: "session.rename",
category: "Session",
slash: { name: "rename" },
run: () => unavailable("Renaming"),
run: () => DialogSessionRename.show(dialog, route.sessionID, session()?.title),
},
{
title: "Jump to message",
@ -377,7 +378,28 @@ export function Session() {
value: "session.undo",
category: "Session",
slash: { name: "undo" },
run: () => unavailable("Undo"),
run: () => {
void (async () => {
const boundary = session()?.revert?.messageID
const list = messages()
let target: string | undefined
for (let i = list.length - 1; i >= 0; i--) {
const message = list[i]
if (message.type !== "user" || !message.text.trim()) continue
if (boundary && message.id >= boundary) continue
target = message.id
break
}
if (!target) {
toast.show({ message: "Nothing to undo", variant: "error", duration: 3000 })
dialog.clear()
return
}
const result = await sdk.client.v2.session.revert.stage({ sessionID: route.sessionID, messageID: target })
if (result.error) toast.show({ message: errorMessage(result.error), variant: "error", duration: 5000 })
dialog.clear()
})()
},
},
{
title: "Redo",
@ -385,7 +407,13 @@ export function Session() {
category: "Session",
enabled: !!session()?.revert?.messageID,
slash: { name: "redo" },
run: () => unavailable("Redo"),
run: () => {
void (async () => {
const result = await sdk.client.v2.session.revert.clear({ sessionID: route.sessionID })
if (result.error) toast.show({ message: errorMessage(result.error), variant: "error", duration: 5000 })
dialog.clear()
})()
},
},
{
title: sidebarVisible() ? "Hide sidebar" : "Show sidebar",
@ -848,6 +876,11 @@ export function Session() {
/>
)}
</For>
<Show when={session()?.revert?.messageID}>
<RevertMessage
count={messages().filter((message) => message.id > session()!.revert!.messageID).length}
/>
</Show>
</scrollbox>
<box flexShrink={0}>
<Show when={permissions().length > 0}>
@ -1136,9 +1169,10 @@ function CompactionMessage() {
function RevertMessage(props: { count: number }) {
const { theme } = useTheme()
const dialog = useDialog()
const renderer = useRenderer()
const route = useRouteData("session")
const sdk = useSDK()
const toast = useToast()
const renderer = useRenderer()
const [hover, setHover] = createSignal(false)
return (
<box
@ -1146,8 +1180,10 @@ function RevertMessage(props: { count: number }) {
onMouseOut={() => setHover(false)}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
toast.show({ message: "Redo is not implemented for V2 sessions yet", variant: "error", duration: 5000 })
dialog.clear()
void (async () => {
const result = await sdk.client.v2.session.revert.clear({ sessionID: route.sessionID })
if (result.error) toast.show({ message: errorMessage(result.error), variant: "error", duration: 5000 })
})()
}}
flexShrink={0}
border={["left"]}
@ -1156,7 +1192,7 @@ function RevertMessage(props: { count: number }) {
>
<box paddingTop={1} paddingBottom={1} paddingLeft={2} backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}>
<text fg={theme.textMuted}>{props.count} message{props.count === 1 ? "" : "s"} reverted</text>
<text fg={theme.textMuted}>Redo is not implemented for V2 sessions yet</text>
<text fg={theme.textMuted}>Click to redo</text>
</box>
</box>
)

View file

@ -23,6 +23,13 @@ export type SessionRow =
export function createSessionRows(sessionID: Accessor<string>) {
const data = useData()
const [rows, setRows] = createStore<SessionRow[]>([])
const revertBoundary = () => data.session.get(sessionID())?.revert?.messageID
function reduce() {
const messages = data.session.message.list(sessionID())
const boundary = revertBoundary()
return reduceSessionRows(boundary ? messages.filter((message) => message.id <= boundary) : messages)
}
createEffect(() => {
const pending = new Set(
@ -44,17 +51,24 @@ export function createSessionRows(sessionID: Accessor<string>) {
createEffect(
on(sessionID, (id) => {
setRows(reconcile(reduceSessionRows(data.session.message.list(id))))
setRows(reconcile(reduce()))
void data.session.message.refresh(id).then(
() => {
if (sessionID() !== id) return
setRows(reconcile(reduceSessionRows(data.session.message.list(id))))
setRows(reconcile(reduce()))
},
() => undefined,
)
}),
)
// Re-reduce when the revert boundary changes (stage/clear/commit).
createEffect(
on(revertBoundary, () => {
setRows(reconcile(reduce()))
}),
)
const appendMessage = (messageID: string) =>
setRows(
produce((draft) => {