mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-06 17:19:49 -04:00
Compare commits
1 Commits
v2
...
form-coalescing
| Author | SHA1 | Date | |
|---|---|---|---|
| ff736fbbc8 |
@@ -1937,9 +1937,23 @@ export type IntegrationInfo = {
|
||||
connections: Array<ConnectionInfo>
|
||||
}
|
||||
|
||||
export type FormInfo = { id: string; sessionID: string; title: string; metadata?: FormMetadata; fields: FormFields }
|
||||
export type FormInfo = {
|
||||
id: string
|
||||
sessionID: string
|
||||
title: string
|
||||
coalesce?: string
|
||||
metadata?: FormMetadata
|
||||
fields: FormFields
|
||||
}
|
||||
|
||||
export type FormInfo1 = { id: string; sessionID: string; title: string; metadata?: FormMetadata1; fields: FormFields1 }
|
||||
export type FormInfo1 = {
|
||||
id: string
|
||||
sessionID: string
|
||||
title: string
|
||||
coalesce?: string
|
||||
metadata?: FormMetadata1
|
||||
fields: FormFields1
|
||||
}
|
||||
|
||||
export type SessionInputAdmitted = {
|
||||
id: string
|
||||
|
||||
@@ -132,6 +132,7 @@ export const layer = Layer.effect(
|
||||
id,
|
||||
sessionID: input.sessionID,
|
||||
title: input.title,
|
||||
...(input.coalesce === undefined ? {} : { coalesce: input.coalesce }),
|
||||
...(input.metadata === undefined ? {} : { metadata: input.metadata }),
|
||||
fields: input.fields,
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ export const Plugin = {
|
||||
const response = yield* forms.ask({
|
||||
sessionID: context.sessionID,
|
||||
title: "Web Search",
|
||||
coalesce: `${context.messageID}:websearch-consent`,
|
||||
metadata: { kind: "websearch.provider" },
|
||||
fields: [
|
||||
{
|
||||
@@ -91,6 +92,7 @@ export const Plugin = {
|
||||
? yield* forms.ask({
|
||||
sessionID: context.sessionID,
|
||||
title: "Choose a web search provider",
|
||||
coalesce: `${context.messageID}:websearch-provider`,
|
||||
metadata: { kind: "websearch.provider" },
|
||||
fields: [
|
||||
{
|
||||
|
||||
@@ -15,6 +15,7 @@ const input = {
|
||||
id: formID,
|
||||
sessionID: SessionSchema.ID.make("ses_test"),
|
||||
title: "Test form",
|
||||
coalesce: "test-form",
|
||||
fields: [{ key: "name", type: "string", required: true }],
|
||||
} satisfies Form.CreateInput
|
||||
|
||||
@@ -32,6 +33,7 @@ describe("Form", () => {
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
const fiber = yield* service.ask(input).pipe(Effect.forkScoped)
|
||||
const form = yield* Deferred.await(created)
|
||||
expect(form.coalesce).toBe("test-form")
|
||||
|
||||
yield* service.cancel(form.id)
|
||||
|
||||
|
||||
@@ -241,6 +241,7 @@ describe("WebSearchTool registration", () => {
|
||||
{
|
||||
sessionID,
|
||||
title: "Web Search",
|
||||
coalesce: "msg_tool_test:websearch-consent",
|
||||
metadata: { kind: "websearch.provider" },
|
||||
fields: [
|
||||
{
|
||||
@@ -298,6 +299,7 @@ describe("WebSearchTool registration", () => {
|
||||
expect(formRequests[1]).toEqual({
|
||||
sessionID,
|
||||
title: "Choose a web search provider",
|
||||
coalesce: "msg_tool_test:websearch-provider",
|
||||
metadata: { kind: "websearch.provider" },
|
||||
fields: [
|
||||
{
|
||||
|
||||
@@ -124,6 +124,9 @@ const InfoBase = {
|
||||
// on non-session owners anywhere else.
|
||||
sessionID: Schema.String,
|
||||
title: Schema.String,
|
||||
coalesce: Schema.String.pipe(optional).annotate({
|
||||
description: "Client-local key for displaying equivalent pending forms once and broadcasting one response.",
|
||||
}),
|
||||
metadata: Metadata.pipe(optional),
|
||||
}
|
||||
|
||||
|
||||
@@ -93,13 +93,13 @@ export function Home() {
|
||||
<box width="100%" flexShrink={0}>
|
||||
<PluginSlot name="home.footer" input={{}} mode="replace" />
|
||||
</box>
|
||||
<Show when={forms()[0]?.id} keyed>
|
||||
<Show when={forms()[0]?.coalesce ?? forms()[0]?.id} keyed>
|
||||
{(_) => {
|
||||
const form = forms()[0]
|
||||
return form ? (
|
||||
<box position="absolute" zIndex={2000} left={0} right={0} bottom={1} paddingLeft={2} paddingRight={2}>
|
||||
<box width="100%">
|
||||
<FormPrompt form={form} />
|
||||
<FormPrompt form={form} forms={forms()} />
|
||||
</box>
|
||||
</box>
|
||||
) : null
|
||||
|
||||
@@ -42,7 +42,7 @@ function requestOptions(form: FormWithLocation) {
|
||||
}
|
||||
}
|
||||
|
||||
export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
export function FormPrompt(props: { form: FormWithLocation; forms?: readonly FormWithLocation[] }) {
|
||||
const client = useClient()
|
||||
const themes = useThemes()
|
||||
const theme = useTheme("elevated")
|
||||
@@ -69,6 +69,11 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
let textarea: TextareaRenderable | undefined
|
||||
let review: ScrollBoxRenderable | undefined
|
||||
|
||||
const forms = createMemo(() => {
|
||||
if (!props.form.coalesce) return [props.form]
|
||||
return (props.forms ?? [props.form]).filter((form) => form.coalesce === props.form.coalesce)
|
||||
})
|
||||
|
||||
const message = createMemo(() => {
|
||||
const value = props.form.metadata?.["message"]
|
||||
return typeof value === "string" ? value : undefined
|
||||
@@ -180,24 +185,30 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
setStore("error", "")
|
||||
}
|
||||
|
||||
function replySingle(field: FormAnswerField, value: FormValue) {
|
||||
client.api.form
|
||||
.reply(
|
||||
{
|
||||
sessionID: props.form.sessionID,
|
||||
formID: props.form.id,
|
||||
answer: { [field.key]: value },
|
||||
},
|
||||
requestOptions(props.form),
|
||||
function reply(answer: Record<string, FormValue>) {
|
||||
Promise.all(
|
||||
forms().map((form) =>
|
||||
client.api.form.reply(
|
||||
{
|
||||
sessionID: form.sessionID,
|
||||
formID: form.id,
|
||||
answer,
|
||||
},
|
||||
requestOptions(form),
|
||||
),
|
||||
),
|
||||
).catch((error: unknown) => {
|
||||
setStore(
|
||||
"error",
|
||||
typeof error === "object" && error !== null && "message" in error && typeof error.message === "string"
|
||||
? error.message
|
||||
: "Invalid answer",
|
||||
)
|
||||
.catch((error: unknown) => {
|
||||
setStore(
|
||||
"error",
|
||||
typeof error === "object" && error !== null && "message" in error && typeof error.message === "string"
|
||||
? error.message
|
||||
: "Invalid answer",
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function replySingle(field: FormAnswerField, value: FormValue) {
|
||||
reply({ [field.key]: value })
|
||||
}
|
||||
|
||||
function pick(value: FormValue, customValue?: string) {
|
||||
@@ -350,7 +361,8 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
void client.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }, requestOptions(props.form))
|
||||
for (const form of forms())
|
||||
void client.api.form.cancel({ sessionID: form.sessionID, formID: form.id }, requestOptions(form))
|
||||
}
|
||||
|
||||
function openExternal() {
|
||||
@@ -402,28 +414,14 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
setStore("error", formValidateValue(invalid, store.answers[invalid.key]) ?? "Invalid answer")
|
||||
return
|
||||
}
|
||||
client.api.form
|
||||
.reply(
|
||||
{
|
||||
sessionID: props.form.sessionID,
|
||||
formID: props.form.id,
|
||||
answer: Object.fromEntries(
|
||||
fields().flatMap((field) => {
|
||||
const value = store.answers[field.key]
|
||||
return value === undefined ? [] : [[field.key, value] as const]
|
||||
}),
|
||||
),
|
||||
},
|
||||
requestOptions(props.form),
|
||||
)
|
||||
.catch((error: unknown) => {
|
||||
setStore(
|
||||
"error",
|
||||
typeof error === "object" && error !== null && "message" in error && typeof error.message === "string"
|
||||
? error.message
|
||||
: "Invalid answer",
|
||||
)
|
||||
})
|
||||
reply(
|
||||
Object.fromEntries(
|
||||
fields().flatMap((field) => {
|
||||
const value = store.answers[field.key]
|
||||
return value === undefined ? [] : [[field.key, value] as const]
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
onMount(() => onCleanup(keymap.mode.push(FORM_MODE)))
|
||||
|
||||
@@ -1026,10 +1026,10 @@ export function Session() {
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={forms().length > 0}>
|
||||
<Show when={forms()[0]?.id} keyed>
|
||||
<Show when={forms()[0]?.coalesce ?? forms()[0]?.id} keyed>
|
||||
{(_) => {
|
||||
const form = forms()[0]
|
||||
return form ? <FormPrompt form={form} /> : null
|
||||
return form ? <FormPrompt form={form} forms={forms()} /> : null
|
||||
}}
|
||||
</Show>
|
||||
</Match>
|
||||
|
||||
@@ -15,7 +15,7 @@ import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
import { createApi, createEventStream, createFetch } from "../../fixture/tui-client"
|
||||
|
||||
async function mountForm(root: string, width = 80) {
|
||||
async function mountForm(root: string, width = 80, coalesce = false) {
|
||||
const state = path.join(root, "state")
|
||||
await mkdir(state, { recursive: true })
|
||||
|
||||
@@ -24,7 +24,7 @@ async function mountForm(root: string, width = 80) {
|
||||
const events = createEventStream()
|
||||
const transport = createFetch(
|
||||
(url, request) =>
|
||||
url.pathname === "/api/session/ses_test/form/frm_test/reply"
|
||||
/^\/api\/session\/ses_test\/form\/frm_(?:test|other)\/reply$/.test(url.pathname)
|
||||
? request.json().then((answer) => {
|
||||
replies.push(answer)
|
||||
return new Response(null, { status: 204 })
|
||||
@@ -37,6 +37,7 @@ async function mountForm(root: string, width = 80) {
|
||||
id: "frm_test",
|
||||
sessionID: "ses_test",
|
||||
title: "Authorization required",
|
||||
...(coalesce ? { coalesce: "authorization" } : {}),
|
||||
fields: [
|
||||
{
|
||||
key: "authorization",
|
||||
@@ -71,7 +72,7 @@ async function mountForm(root: string, width = 80) {
|
||||
<ClientProvider api={createApi(transport.fetch)}>
|
||||
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
|
||||
<ToastProvider>
|
||||
<FormPrompt form={form} />
|
||||
<FormPrompt form={form} forms={coalesce ? [form, { ...form, id: "frm_other" }] : undefined} />
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</ClientProvider>
|
||||
@@ -126,3 +127,24 @@ test("includes external acknowledgements in progress", async () => {
|
||||
prompt.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("replies to every coalesced form", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const prompt = await mountForm(tmp.path, 80, true)
|
||||
try {
|
||||
prompt.app.mockInput.pressKey("right")
|
||||
await prompt.app.waitForFrame((frame) => frame.includes("(acknowledgement required)"))
|
||||
prompt.app.mockInput.pressEnter()
|
||||
await prompt.app.waitForFrame((frame) => frame.includes("External action must be acknowledged"))
|
||||
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.waitFor(() => prompt.replies.length === 2)
|
||||
expect(prompt.replies).toEqual([{ answer: { authorization: true } }, { answer: { authorization: true } }])
|
||||
} finally {
|
||||
prompt.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user