Compare commits

...

1 Commits

Author SHA1 Message Date
James Long ff736fbbc8 feat(tui): coalesce equivalent forms 2026-08-06 17:48:30 +00:00
10 changed files with 94 additions and 50 deletions
+16 -2
View File
@@ -1937,9 +1937,23 @@ export type IntegrationInfo = {
connections: Array<ConnectionInfo> 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 = { export type SessionInputAdmitted = {
id: string id: string
+1
View File
@@ -132,6 +132,7 @@ export const layer = Layer.effect(
id, id,
sessionID: input.sessionID, sessionID: input.sessionID,
title: input.title, title: input.title,
...(input.coalesce === undefined ? {} : { coalesce: input.coalesce }),
...(input.metadata === undefined ? {} : { metadata: input.metadata }), ...(input.metadata === undefined ? {} : { metadata: input.metadata }),
fields: input.fields, fields: input.fields,
} }
@@ -59,6 +59,7 @@ export const Plugin = {
const response = yield* forms.ask({ const response = yield* forms.ask({
sessionID: context.sessionID, sessionID: context.sessionID,
title: "Web Search", title: "Web Search",
coalesce: `${context.messageID}:websearch-consent`,
metadata: { kind: "websearch.provider" }, metadata: { kind: "websearch.provider" },
fields: [ fields: [
{ {
@@ -91,6 +92,7 @@ export const Plugin = {
? yield* forms.ask({ ? yield* forms.ask({
sessionID: context.sessionID, sessionID: context.sessionID,
title: "Choose a web search provider", title: "Choose a web search provider",
coalesce: `${context.messageID}:websearch-provider`,
metadata: { kind: "websearch.provider" }, metadata: { kind: "websearch.provider" },
fields: [ fields: [
{ {
+2
View File
@@ -15,6 +15,7 @@ const input = {
id: formID, id: formID,
sessionID: SessionSchema.ID.make("ses_test"), sessionID: SessionSchema.ID.make("ses_test"),
title: "Test form", title: "Test form",
coalesce: "test-form",
fields: [{ key: "name", type: "string", required: true }], fields: [{ key: "name", type: "string", required: true }],
} satisfies Form.CreateInput } satisfies Form.CreateInput
@@ -32,6 +33,7 @@ describe("Form", () => {
yield* Effect.addFinalizer(() => unsubscribe) yield* Effect.addFinalizer(() => unsubscribe)
const fiber = yield* service.ask(input).pipe(Effect.forkScoped) const fiber = yield* service.ask(input).pipe(Effect.forkScoped)
const form = yield* Deferred.await(created) const form = yield* Deferred.await(created)
expect(form.coalesce).toBe("test-form")
yield* service.cancel(form.id) yield* service.cancel(form.id)
@@ -241,6 +241,7 @@ describe("WebSearchTool registration", () => {
{ {
sessionID, sessionID,
title: "Web Search", title: "Web Search",
coalesce: "msg_tool_test:websearch-consent",
metadata: { kind: "websearch.provider" }, metadata: { kind: "websearch.provider" },
fields: [ fields: [
{ {
@@ -298,6 +299,7 @@ describe("WebSearchTool registration", () => {
expect(formRequests[1]).toEqual({ expect(formRequests[1]).toEqual({
sessionID, sessionID,
title: "Choose a web search provider", title: "Choose a web search provider",
coalesce: "msg_tool_test:websearch-provider",
metadata: { kind: "websearch.provider" }, metadata: { kind: "websearch.provider" },
fields: [ fields: [
{ {
+3
View File
@@ -124,6 +124,9 @@ const InfoBase = {
// on non-session owners anywhere else. // on non-session owners anywhere else.
sessionID: Schema.String, sessionID: Schema.String,
title: 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), metadata: Metadata.pipe(optional),
} }
+2 -2
View File
@@ -93,13 +93,13 @@ export function Home() {
<box width="100%" flexShrink={0}> <box width="100%" flexShrink={0}>
<PluginSlot name="home.footer" input={{}} mode="replace" /> <PluginSlot name="home.footer" input={{}} mode="replace" />
</box> </box>
<Show when={forms()[0]?.id} keyed> <Show when={forms()[0]?.coalesce ?? forms()[0]?.id} keyed>
{(_) => { {(_) => {
const form = forms()[0] const form = forms()[0]
return form ? ( 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%"> <box width="100%">
<FormPrompt form={form} /> <FormPrompt form={form} forms={forms()} />
</box> </box>
</box> </box>
) : null ) : null
+25 -27
View File
@@ -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 client = useClient()
const themes = useThemes() const themes = useThemes()
const theme = useTheme("elevated") const theme = useTheme("elevated")
@@ -69,6 +69,11 @@ export function FormPrompt(props: { form: FormWithLocation }) {
let textarea: TextareaRenderable | undefined let textarea: TextareaRenderable | undefined
let review: ScrollBoxRenderable | 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 message = createMemo(() => {
const value = props.form.metadata?.["message"] const value = props.form.metadata?.["message"]
return typeof value === "string" ? value : undefined return typeof value === "string" ? value : undefined
@@ -180,17 +185,19 @@ export function FormPrompt(props: { form: FormWithLocation }) {
setStore("error", "") setStore("error", "")
} }
function replySingle(field: FormAnswerField, value: FormValue) { function reply(answer: Record<string, FormValue>) {
client.api.form Promise.all(
.reply( forms().map((form) =>
client.api.form.reply(
{ {
sessionID: props.form.sessionID, sessionID: form.sessionID,
formID: props.form.id, formID: form.id,
answer: { [field.key]: value }, answer,
}, },
requestOptions(props.form), requestOptions(form),
) ),
.catch((error: unknown) => { ),
).catch((error: unknown) => {
setStore( setStore(
"error", "error",
typeof error === "object" && error !== null && "message" in error && typeof error.message === "string" typeof error === "object" && error !== null && "message" in error && typeof error.message === "string"
@@ -200,6 +207,10 @@ export function FormPrompt(props: { form: FormWithLocation }) {
}) })
} }
function replySingle(field: FormAnswerField, value: FormValue) {
reply({ [field.key]: value })
}
function pick(value: FormValue, customValue?: string) { function pick(value: FormValue, customValue?: string) {
const current = answerField() const current = answerField()
if (!current) return if (!current) return
@@ -350,7 +361,8 @@ export function FormPrompt(props: { form: FormWithLocation }) {
} }
function cancel() { 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() { function openExternal() {
@@ -402,28 +414,14 @@ export function FormPrompt(props: { form: FormWithLocation }) {
setStore("error", formValidateValue(invalid, store.answers[invalid.key]) ?? "Invalid answer") setStore("error", formValidateValue(invalid, store.answers[invalid.key]) ?? "Invalid answer")
return return
} }
client.api.form reply(
.reply( Object.fromEntries(
{
sessionID: props.form.sessionID,
formID: props.form.id,
answer: Object.fromEntries(
fields().flatMap((field) => { fields().flatMap((field) => {
const value = store.answers[field.key] const value = store.answers[field.key]
return value === undefined ? [] : [[field.key, value] as const] 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",
)
})
} }
onMount(() => onCleanup(keymap.mode.push(FORM_MODE))) onMount(() => onCleanup(keymap.mode.push(FORM_MODE)))
+2 -2
View File
@@ -1026,10 +1026,10 @@ export function Session() {
</Show> </Show>
</Match> </Match>
<Match when={forms().length > 0}> <Match when={forms().length > 0}>
<Show when={forms()[0]?.id} keyed> <Show when={forms()[0]?.coalesce ?? forms()[0]?.id} keyed>
{(_) => { {(_) => {
const form = forms()[0] const form = forms()[0]
return form ? <FormPrompt form={form} /> : null return form ? <FormPrompt form={form} forms={forms()} /> : null
}} }}
</Show> </Show>
</Match> </Match>
+25 -3
View File
@@ -15,7 +15,7 @@ import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { createApi, createEventStream, createFetch } from "../../fixture/tui-client" 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") const state = path.join(root, "state")
await mkdir(state, { recursive: true }) await mkdir(state, { recursive: true })
@@ -24,7 +24,7 @@ async function mountForm(root: string, width = 80) {
const events = createEventStream() const events = createEventStream()
const transport = createFetch( const transport = createFetch(
(url, request) => (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) => { ? request.json().then((answer) => {
replies.push(answer) replies.push(answer)
return new Response(null, { status: 204 }) return new Response(null, { status: 204 })
@@ -37,6 +37,7 @@ async function mountForm(root: string, width = 80) {
id: "frm_test", id: "frm_test",
sessionID: "ses_test", sessionID: "ses_test",
title: "Authorization required", title: "Authorization required",
...(coalesce ? { coalesce: "authorization" } : {}),
fields: [ fields: [
{ {
key: "authorization", key: "authorization",
@@ -71,7 +72,7 @@ async function mountForm(root: string, width = 80) {
<ClientProvider api={createApi(transport.fetch)}> <ClientProvider api={createApi(transport.fetch)}>
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}> <ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
<ToastProvider> <ToastProvider>
<FormPrompt form={form} /> <FormPrompt form={form} forms={coalesce ? [form, { ...form, id: "frm_other" }] : undefined} />
</ToastProvider> </ToastProvider>
</ThemeProvider> </ThemeProvider>
</ClientProvider> </ClientProvider>
@@ -126,3 +127,24 @@ test("includes external acknowledgements in progress", async () => {
prompt.app.renderer.destroy() 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()
}
})