fix(tui): dismiss stale forms after failed reply
This commit is contained in:
parent
c0ed0106b1
commit
68dcc35491
4 changed files with 64 additions and 33 deletions
|
|
@ -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} onNotFound={() => data.session.form.refresh(form.sessionID, form.location)} />
|
||||
</box>
|
||||
</box>
|
||||
) : null
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ 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 { isFormNotFoundError, 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 +144,7 @@ function requestOptions(form: FormWithLocation) {
|
|||
}
|
||||
}
|
||||
|
||||
export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
export function FormPrompt(props: { form: FormWithLocation; onNotFound: () => Promise<void> }) {
|
||||
const sdk = useSDK()
|
||||
const { theme } = useTheme()
|
||||
const renderer = useRenderer()
|
||||
|
|
@ -295,6 +295,19 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||
setStore("error", "")
|
||||
}
|
||||
|
||||
function replyFailed(error: unknown) {
|
||||
if (isFormNotFoundError(error)) {
|
||||
void props.onNotFound().catch(() => setStore("error", error.message))
|
||||
return
|
||||
}
|
||||
setStore(
|
||||
"error",
|
||||
typeof error === "object" && error !== null && "message" in error && typeof error.message === "string"
|
||||
? error.message
|
||||
: "Invalid answer",
|
||||
)
|
||||
}
|
||||
|
||||
function replySingle(field: Field, value: FormValue) {
|
||||
sdk.api.form
|
||||
.reply(
|
||||
|
|
@ -305,14 +318,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||
},
|
||||
requestOptions(props.form),
|
||||
)
|
||||
.catch((error: unknown) => {
|
||||
setStore(
|
||||
"error",
|
||||
typeof error === "object" && error !== null && "message" in error && typeof error.message === "string"
|
||||
? error.message
|
||||
: "Invalid answer",
|
||||
)
|
||||
})
|
||||
.catch(replyFailed)
|
||||
}
|
||||
|
||||
function pick(value: FormValue, customValue?: string) {
|
||||
|
|
@ -544,14 +550,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
|||
},
|
||||
requestOptions(props.form),
|
||||
)
|
||||
.catch((error: unknown) => {
|
||||
setStore(
|
||||
"error",
|
||||
typeof error === "object" && error !== null && "message" in error && typeof error.message === "string"
|
||||
? error.message
|
||||
: "Invalid answer",
|
||||
)
|
||||
})
|
||||
.catch(replyFailed)
|
||||
}
|
||||
|
||||
onMount(() => onCleanup(modeStack.push(FORM_MODE)))
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
onNotFound={() => data.session.form.refresh(form.sessionID, form.location)}
|
||||
/>
|
||||
) : null
|
||||
}}
|
||||
</Show>
|
||||
</Match>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -18,12 +18,13 @@ import { TestTuiContexts } from "../../fixture/tui-environment"
|
|||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
import { createApi, createClient, createEventStream, createFetch } from "../../fixture/tui-sdk"
|
||||
|
||||
async function mountForm(root: string, width = 80) {
|
||||
async function mountForm(root: string, width = 80, missing = false) {
|
||||
const state = path.join(root, "state")
|
||||
await mkdir(state, { recursive: true })
|
||||
await Bun.write(path.join(state, "kv.json"), "{}")
|
||||
|
||||
const replies: unknown[] = []
|
||||
const notFound: string[] = []
|
||||
const copied: string[] = []
|
||||
const events = createEventStream()
|
||||
const transport = createFetch(
|
||||
|
|
@ -31,6 +32,11 @@ async function mountForm(root: string, width = 80) {
|
|||
url.pathname === "/api/session/ses_test/form/frm_test/reply"
|
||||
? request.json().then((answer) => {
|
||||
replies.push(answer)
|
||||
if (missing)
|
||||
return Response.json(
|
||||
{ _tag: "FormNotFoundError", id: "frm_test", message: "Form not found: frm_test" },
|
||||
{ status: 404 },
|
||||
)
|
||||
return new Response(null, { status: 204 })
|
||||
})
|
||||
: undefined,
|
||||
|
|
@ -57,6 +63,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 +88,16 @@ 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}
|
||||
onNotFound={() => {
|
||||
notFound.push(form.id)
|
||||
setVisible(false)
|
||||
return Promise.resolve()
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</KVProvider>
|
||||
|
|
@ -96,9 +112,28 @@ 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, copied, notFound, replies }
|
||||
}
|
||||
|
||||
test("dismisses a form that no longer exists when submitting", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const prompt = await mountForm(tmp.path, 80, true)
|
||||
try {
|
||||
prompt.app.mockInput.pressKey("right")
|
||||
prompt.app.mockInput.pressKey("left")
|
||||
prompt.app.mockInput.pressKey("c")
|
||||
await prompt.app.waitForFrame((frame) => frame.includes("press enter to confirm"))
|
||||
prompt.app.mockInput.pressEnter()
|
||||
await prompt.app.waitForFrame((frame) => frame.includes("Acknowledged"))
|
||||
prompt.app.mockInput.pressEnter()
|
||||
|
||||
await prompt.app.waitForFrame((frame) => !frame.includes("Authorization required"))
|
||||
expect(prompt.notFound).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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue