Compare commits

..

1 Commits

Author SHA1 Message Date
Aiden Cline ea74a84ea3 fix(tui): scope prompt drafts to sessions 2026-08-09 18:00:00 +00:00
5 changed files with 108 additions and 89 deletions
+32 -14
View File
@@ -130,7 +130,7 @@ function formatEditorContext(selection: EditorSelection) {
return `<system-reminder>${ranges.join("\n")} This may or may not be relevant to the current task.</system-reminder>\n`
}
let stashed: { prompt: PromptInfo; cursor: number } | undefined
const drafts = new Map<string | undefined, { prompt: PromptInfo; cursor: number }>()
function argumentSlash(input: string, commands: readonly KeymapCommand[]) {
const head = parseSlashHead(input, /\s/)
@@ -600,22 +600,40 @@ export function Prompt(props: PromptProps) {
},
}
onMount(() => {
const saved = stashed
stashed = undefined
if (store.prompt.text) return
if (saved && saved.prompt.text) {
input.setText(saved.prompt.text)
setStore("prompt", saved.prompt)
restoreExtmarksFromPrompt(saved.prompt)
input.cursorOffset = saved.cursor
function saveDraft(sessionID: string | undefined) {
if (!store.prompt.text) {
drafts.delete(sessionID)
return
}
})
drafts.set(sessionID, { prompt: unwrap(store.prompt), cursor: input.cursorOffset })
}
function restoreDraft(sessionID: string | undefined) {
const saved = drafts.get(sessionID)
drafts.delete(sessionID)
ref.reset()
if (!saved?.prompt.text) return
ref.set(saved.prompt)
input.cursorOffset = saved.cursor
}
let draftSessionID = props.sessionID
onMount(() => restoreDraft(draftSessionID))
createEffect(
on(
() => props.sessionID,
(sessionID) => {
saveDraft(draftSessionID)
draftSessionID = sessionID
restoreDraft(sessionID)
},
{ defer: true },
),
)
onCleanup(() => {
if (store.prompt.text) {
stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
}
saveDraft(draftSessionID)
setInputTarget(undefined)
props.ref?.(undefined)
})
+7 -13
View File
@@ -84,7 +84,6 @@ import { usePathFormatter } from "../../context/path-format"
import { useLocation } from "../../context/location"
import { PluginSlot } from "../../plugin/render"
import { usePlugin } from "../../plugin/context"
import { undoMessage } from "./undo"
import {
cacheReuseDrop,
createSessionRows,
@@ -657,24 +656,19 @@ export function Session() {
group: "Session",
slash: { name: "undo" },
run: () => {
const admitted = pendingUsers().at(-1)
const boundary = session()?.revert?.messageID
const message = admitted
? { id: admitted.id, ...admitted.data }
: messages().findLast(
(message): message is SessionMessageUser =>
message.type === "user" && !!message.text.trim() && (!boundary || message.id < boundary),
)
const message = messages().findLast(
(message): message is SessionMessageUser =>
message.type === "user" && !!message.text.trim() && (!boundary || message.id < boundary),
)
if (!message) {
toast.show({ message: "Nothing to undo", variant: "error", duration: 3000 })
dialog.clear()
return
}
void undoMessage(client.api, {
sessionID: route.sessionID,
messageID: message.id,
pending: admitted !== undefined,
}).catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }))
void client.api.session.revert
.stage({ sessionID: route.sessionID, messageID: message.id })
.catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }))
prompt()?.set({
...projectedPromptInput(message),
pasted: [],
-14
View File
@@ -1,14 +0,0 @@
import type { OpenCodeClient } from "@opencode-ai/client"
export async function undoMessage(
client: OpenCodeClient,
input: { readonly sessionID: string; readonly messageID: string; readonly pending: boolean },
) {
const revert = () => client.session.revert.stage(input).then(() => undefined)
if (!input.pending) return revert()
return client.session.pending.cancel({ sessionID: input.sessionID, inputID: input.messageID }).catch((error) => {
if (typeof error !== "object" || error === null || !("_tag" in error) || error._tag !== "ConflictError") throw error
return revert()
})
}
+69
View File
@@ -294,3 +294,72 @@ test("session startup prompt is submitted exactly once", async () => {
await server.stop()
}
})
test("new session does not inherit the current session prompt draft", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
const events = createEventStream()
const cwd = process.cwd()
const location = { directory: cwd, project: { id: "project", directory: cwd } }
const session = {
id: "dummy",
title: "Demo session",
projectID: "project",
location: { directory: cwd },
agent: "build",
model: { providerID: "provider", id: "model" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
}
const calls = createFetch((url) => {
if (url.pathname === "/api/location") return json(location)
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
if (url.pathname === "/api/session/dummy") return json({ data: session })
if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} })
if (url.pathname === "/api/session/dummy/pending") return json({ data: [] })
if (url.pathname === "/api/session/dummy/permission") return json({ data: [] })
if (url.pathname === "/api/agent")
return json({ location, data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }] })
if (url.pathname === "/api/model")
return json({ location, data: [{ id: "model", providerID: "provider", name: "Model", variants: [] }] })
}, events)
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
try {
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({}), update: async () => ({}) },
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: "dummy" },
log: () => {},
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
)
await Promise.race([
(async () => {
while (!setup.renderer.currentFocusedEditor) await Bun.sleep(10)
})(),
Bun.sleep(2_000).then(() => {
throw new Error("session prompt did not focus")
}),
])
await setup.mockInput.typeText("keep this draft")
expect(setup.renderer.currentFocusedEditor?.plainText).toBe("keep this draft")
setup.mockInput.pressKey("x", { ctrl: true })
await Bun.sleep(10)
setup.mockInput.pressKey("n")
await Bun.sleep(20)
expect(setup.renderer.currentFocusedEditor?.plainText).toBe("")
setup.renderer.destroy()
await task
} finally {
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
await server.stop()
}
})
-48
View File
@@ -1,48 +0,0 @@
import { expect, test } from "bun:test"
import { OpenCode } from "@opencode-ai/client"
import { undoMessage } from "../../../src/routes/session/undo"
test.each([
{ name: "projected", pending: false, cancelStatus: 204, expected: ["revert"] },
{ name: "pending", pending: true, cancelStatus: 204, expected: ["cancel"] },
{ name: "promoted race", pending: true, cancelStatus: 409, expected: ["cancel", "revert"] },
])("undo routes $name messages", async ({ pending, cancelStatus, expected }) => {
const calls: string[] = []
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: Object.assign(
async (input: URL | RequestInfo, init?: BunFetchRequestInit | RequestInit) => {
const request = input instanceof Request ? input : new Request(input, init)
const operation = request.method === "DELETE" ? "cancel" : "revert"
calls.push(operation)
if (operation === "cancel") {
if (cancelStatus === 409)
return Response.json({ _tag: "ConflictError", message: "Input was promoted" }, { status: 409 })
return new Response(null, { status: 204 })
}
return Response.json({ data: { messageID: "msg_user" } })
},
{ preconnect: fetch.preconnect },
),
})
await undoMessage(client, { sessionID: "ses_test", messageID: "msg_user", pending })
expect(calls).toEqual([...expected])
})
test("undo does not reinterpret transport failures as promotion races", async () => {
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: Object.assign(
async () => {
throw new Error("offline")
},
{ preconnect: fetch.preconnect },
),
})
await expect(
undoMessage(client, { sessionID: "ses_test", messageID: "msg_user", pending: true }),
).rejects.toMatchObject({ reason: "Transport" })
})