Просмотр исходного кода

fix(tui): always dismiss stale forms

Kit Langton 1 месяц назад
Родитель
Сommit
ad264cca37

+ 8 - 0
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()) })

+ 2 - 10
packages/tui/src/routes/home.tsx

@@ -95,17 +95,9 @@ export function Home() {
           {(_) => {
             const form = forms()[0]
             return form ? (
-              <box
-                position="absolute"
-                zIndex={2000}
-                left={0}
-                right={0}
-                bottom={1}
-                paddingLeft={2}
-                paddingRight={2}
-              >
+              <box position="absolute" zIndex={2000} left={0} right={0} bottom={1} paddingLeft={2} paddingRight={2}>
                 <box width="100%">
-                  <FormPrompt form={form} />
+                  <FormPrompt form={form} onDismiss={() => data.session.form.dismiss(form.sessionID, form.id)} />
                 </box>
               </box>
             ) : null

+ 16 - 7
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)

+ 6 - 1
packages/tui/src/routes/session/index.tsx

@@ -918,7 +918,12 @@ export function Session() {
                     <Show when={forms()[0]?.id} keyed>
                       {(_) => {
                         const form = forms()[0]
-                        return form ? <FormPrompt form={form} /> : null
+                        return form ? (
+                          <FormPrompt
+                            form={form}
+                            onDismiss={() => data.session.form.dismiss(form.sessionID, form.id)}
+                          />
+                        ) : null
                       }}
                     </Show>
                   </Match>

+ 19 - 19
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_replied_1",
+      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: 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<string, string>, costs: Record<string, number> = {}) {
   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<typeof useData>
   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")

+ 34 - 9
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 (
       <TestTuiContexts
@@ -81,7 +91,9 @@ async function mountForm(root: string, width = 80) {
                 <KVProvider>
                   <ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
                     <ToastProvider>
-                      <FormPrompt form={form} />
+                      <Show when={visible()}>
+                        <FormPrompt form={form} onDismiss={() => setVisible(false)} />
+                      </Show>
                     </ToastProvider>
                   </ThemeProvider>
                 </KVProvider>
@@ -96,9 +108,22 @@ async function mountForm(root: string, width = 80) {
   const app = await testRender(() => <Harness />, { 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)