From ad264cca371c6ac5040c5162b81e95a673666a65 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 12 Jul 2026 21:29:17 -0400 Subject: [PATCH] fix(tui): always dismiss stale forms --- packages/tui/src/context/data.tsx | 8 +++++ packages/tui/src/routes/home.tsx | 12 ++----- packages/tui/src/routes/session/form.tsx | 23 ++++++++---- packages/tui/src/routes/session/index.tsx | 7 +++- packages/tui/test/cli/tui/data.test.tsx | 38 ++++++++++---------- packages/tui/test/cli/tui/form.test.tsx | 43 ++++++++++++++++++----- 6 files changed, 85 insertions(+), 46 deletions(-) diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index c5f4fddd85..faef8e6103 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -950,6 +950,14 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ const key = locationKey(ref) return forms?.filter((form) => form.location && locationKey(form.location) === key) }, + dismiss(sessionID: string, formID: string) { + setStore( + "session", + "form", + sessionID, + (store.session.form[sessionID] ?? []).filter((form) => form.id !== formID), + ) + }, async refresh(sessionID: string, ref?: LocationRef) { if (sessionID === "global") { const response = await sdk.api.form.request.list({ location: locationQuery(ref ?? defaultLocation()) }) diff --git a/packages/tui/src/routes/home.tsx b/packages/tui/src/routes/home.tsx index 623772fcb6..b20eb2ce01 100644 --- a/packages/tui/src/routes/home.tsx +++ b/packages/tui/src/routes/home.tsx @@ -95,17 +95,9 @@ export function Home() { {(_) => { const form = forms()[0] return form ? ( - + - + data.session.form.dismiss(form.sessionID, form.id)} /> ) : null diff --git a/packages/tui/src/routes/session/form.tsx b/packages/tui/src/routes/session/form.tsx index d09673d775..f52da47f87 100644 --- a/packages/tui/src/routes/session/form.tsx +++ b/packages/tui/src/routes/session/form.tsx @@ -4,7 +4,13 @@ import { useRenderer, useTerminalDimensions } from "@opentui/solid" import type { ScrollBoxRenderable, TextareaRenderable } from "@opentui/core" import open from "open" import { selectedForeground, tint, useTheme } from "../../context/theme" -import type { FormField, FormValue } from "@opencode-ai/client" +import { + isFormAlreadySettledError, + isFormNotFoundError, + isSessionNotFoundError, + type FormField, + type FormValue, +} from "@opencode-ai/client" import type { FormWithLocation } from "../../context/data" import { useSDK } from "../../context/sdk" import { useClipboard } from "../../context/clipboard" @@ -144,7 +150,7 @@ function requestOptions(form: FormWithLocation) { } } -export function FormPrompt(props: { form: FormWithLocation }) { +export function FormPrompt(props: { form: FormWithLocation; onDismiss: () => void }) { const sdk = useSDK() const { theme } = useTheme() const renderer = useRenderer() @@ -478,7 +484,13 @@ export function FormPrompt(props: { form: FormWithLocation }) { } function cancel() { - void sdk.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }, requestOptions(props.form)) + props.onDismiss() + void sdk.api.form + .cancel({ sessionID: props.form.sessionID, formID: props.form.id }, requestOptions(props.form)) + .catch((error) => { + if (isFormNotFoundError(error) || isFormAlreadySettledError(error) || isSessionNotFoundError(error)) return + toast.error(error) + }) } function openExternal() { @@ -581,10 +593,7 @@ export function FormPrompt(props: { form: FormWithLocation }) { group: "Form", cmd: () => { if (textual()) { - void sdk.api.form.cancel( - { sessionID: props.form.sessionID, formID: props.form.id }, - requestOptions(props.form), - ) + cancel() return } setStore("editing", false) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index eda25233b9..80058730ba 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -918,7 +918,12 @@ export function Session() { {(_) => { const form = forms()[0] - return form ? : null + return form ? ( + data.session.form.dismiss(form.sessionID, form.id)} + /> + ) : null }} diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index 416085bdb4..58925c0dea 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -1073,9 +1073,7 @@ test("tracks session status from active sessions and execution events", async () return message?.type === "compaction" && message.status === "running" && message.summary === "Streamed summary" }) expect(data.session.compaction.list("session-manual")).toEqual([]) - const compactionRow = manualRows.find( - (row) => row.type === "message" && row.messageID === "message-compaction", - ) + const compactionRow = manualRows.find((row) => row.type === "message" && row.messageID === "message-compaction") emitEvent(events, { id: "evt_manual_compaction_ended", created: 3, @@ -1117,9 +1115,7 @@ test("tracks session status from active sessions and execution events", async () const message = data.session.message.get("session-live", "msg_compaction_started") return message?.type === "compaction" && message.status === "running" && message.summary === "Live summary" }) - const autoCompactionRow = rows.find( - (row) => row.type === "message" && row.messageID === "msg_compaction_started", - ) + const autoCompactionRow = rows.find((row) => row.type === "message" && row.messageID === "msg_compaction_started") emitEvent(events, { id: "evt_compaction_ended", @@ -1192,10 +1188,7 @@ test("restores queued compaction from durable pending input", async () => { try { await wait(() => data.session.compaction.list(sessionID).length === 2) - expect(data.session.compaction.list(sessionID)).toEqual([ - "message-compaction-queued", - "message-compaction-later", - ]) + expect(data.session.compaction.list(sessionID)).toEqual(["message-compaction-queued", "message-compaction-later"]) await wait(() => rows.filter((row) => row.type === "compaction-queued").length === 2) expect(rows.filter((row) => row.type === "compaction-queued")).toEqual([ { type: "compaction-queued", inputID: "message-compaction-queued" }, @@ -1736,9 +1729,20 @@ test("adds, dismisses, and refreshes form requests", async () => { }) await wait(() => data.session.form.list("ses_1")?.length === 1) + data.session.form.dismiss("ses_1", "frm_1") + await wait(() => data.session.form.list("ses_1")?.length === 0) + + emitEvent(events, { + id: "evt_form_created_after_local_dismiss", + created: 2, + type: "form.created", + data: { form: { id: "frm_1", sessionID: "ses_1", title: "Input requested", fields: formFields } }, + }) + await wait(() => data.session.form.list("ses_1")?.length === 1) + emitEvent(events, { id: "evt_form_replied_1", - created: 2, + created: 3, type: "form.replied", data: { sessionID: "ses_1", id: "frm_1", answer: {} }, }) @@ -1746,13 +1750,13 @@ test("adds, dismisses, and refreshes form requests", async () => { emitEvent(events, { id: "evt_form_created_2", - created: 3, + created: 4, type: "form.created", data: { form: { id: "frm_2", sessionID: "ses_1", title: "Input requested", fields: formFields } }, }) emitEvent(events, { id: "evt_form_cancelled_2", - created: 4, + created: 5, type: "form.cancelled", data: { sessionID: "ses_1", id: "frm_2" }, }) @@ -2357,8 +2361,7 @@ function sessionInfo(id: string, parentID: string | undefined, cost = 0) { async function mountData(parents: Record, costs: Record = {}) { const calls = createFetch((url) => { const match = url.pathname.match(/^\/api\/session\/([^/]+)$/) - if (match && match[1] !== "active") - return json({ data: sessionInfo(match[1], parents[match[1]], costs[match[1]]) }) + if (match && match[1] !== "active") return json({ data: sessionInfo(match[1], parents[match[1]], costs[match[1]]) }) }) let data!: ReturnType let ready!: () => void @@ -2427,10 +2430,7 @@ test("indexes arbitrarily deep nesting under a single root", async () => { }) test("totals family cost for roots and keeps subagent cost scoped", async () => { - const { data, app } = await mountData( - { grandchild: "child", child: "root" }, - { root: 1, child: 2, grandchild: 3 }, - ) + const { data, app } = await mountData({ grandchild: "child", child: "root" }, { root: 1, child: 2, grandchild: 3 }) try { await data.session.refresh("grandchild") await data.session.refresh("child") diff --git a/packages/tui/test/cli/tui/form.test.tsx b/packages/tui/test/cli/tui/form.test.tsx index 0a28ba8de3..52b9add398 100644 --- a/packages/tui/test/cli/tui/form.test.tsx +++ b/packages/tui/test/cli/tui/form.test.tsx @@ -4,7 +4,7 @@ import { testRender, useRenderer } from "@opentui/solid" import { expect, test } from "bun:test" import { mkdir } from "node:fs/promises" import path from "node:path" -import { onCleanup } from "solid-js" +import { createSignal, onCleanup, Show } from "solid-js" import { ClipboardProvider } from "../../../src/context/clipboard" import type { FormWithLocation } from "../../../src/context/data" import { KVProvider } from "../../../src/context/kv" @@ -24,16 +24,25 @@ async function mountForm(root: string, width = 80) { await Bun.write(path.join(state, "kv.json"), "{}") const replies: unknown[] = [] + const cancellations: string[] = [] const copied: string[] = [] const events = createEventStream() const transport = createFetch( (url, request) => - url.pathname === "/api/session/ses_test/form/frm_test/reply" - ? request.json().then((answer) => { - replies.push(answer) - return new Response(null, { status: 204 }) - }) - : undefined, + url.pathname === "/api/session/ses_test/form/frm_test/cancel" + ? (() => { + cancellations.push("frm_test") + return Response.json( + { _tag: "FormNotFoundError", id: "frm_test", message: "Form not found: frm_test" }, + { status: 404 }, + ) + })() + : url.pathname === "/api/session/ses_test/form/frm_test/reply" + ? request.json().then((answer) => { + replies.push(answer) + return new Response(null, { status: 204 }) + }) + : undefined, events, ) const config = createTuiResolvedConfig() @@ -57,6 +66,7 @@ async function mountForm(root: string, width = 80) { const keymap = createDefaultOpenTuiKeymap(renderer) const off = registerOpencodeKeymap(keymap, renderer, config) onCleanup(off) + const [visible, setVisible] = createSignal(true) return ( Promise.resolve({}) }}> - + + setVisible(false)} /> + @@ -96,9 +108,22 @@ async function mountForm(root: string, width = 80) { const app = await testRender(() => , { width, height: 20, kittyKeyboard: true }) app.renderer.start() await app.waitForFrame((frame) => frame.includes("Authorization required")) - return { app, copied, replies } + return { app, cancellations, copied, replies } } +test("dismisses locally when the server no longer has the form", async () => { + await using tmp = await tmpdir() + const prompt = await mountForm(tmp.path) + try { + prompt.app.mockInput.pressEscape() + await prompt.app.waitForFrame((frame) => !frame.includes("Authorization required")) + await prompt.app.waitFor(() => prompt.cancellations.length === 1) + expect(prompt.cancellations).toEqual(["frm_test"]) + } finally { + prompt.app.renderer.destroy() + } +}) + test("requires explicit acknowledgement before submitting an external field", async () => { await using tmp = await tmpdir() const prompt = await mountForm(tmp.path)