fix(tui): always dismiss stale forms
This commit is contained in:
parent
c0ed0106b1
commit
ad264cca37
6 changed files with 85 additions and 46 deletions
|
|
@ -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()) })
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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<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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue