From 4933547d4033e854977b5163827966b6345de32f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 20 Oct 2026 16:40:40 -0700 Subject: [PATCH] fix(app): show generated forms as submitted and send them once (#57537) --- .../session-timeline-tool-projection.spec.ts | 109 +++++++++++++++++- packages/app/e2e/utils/timeline.ts | 8 ++ .../src/shell/routes/session-ui-provider.tsx | 17 ++- packages/plugin-genui/src/genui.ts | 20 +++- packages/plugin-genui/test/genui.test.ts | 22 ++++ packages/session-ui/src/context/data.tsx | 7 +- packages/session-ui/src/genui/genui.css | 18 +++ packages/session-ui/src/genui/reply.test.ts | 60 ++++++++++ packages/session-ui/src/genui/reply.ts | 24 ++++ packages/session-ui/src/genui/surface.tsx | 76 +++++++++--- packages/session-ui/src/genui/tool.tsx | 43 +++++-- packages/ui/src/i18n/en.ts | 2 + 12 files changed, 375 insertions(+), 31 deletions(-) create mode 100644 packages/plugin-genui/test/genui.test.ts create mode 100644 packages/session-ui/src/genui/reply.test.ts create mode 100644 packages/session-ui/src/genui/reply.ts diff --git a/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts b/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts index 5d8a7fa9425..09ec00bc7bf 100644 --- a/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts @@ -1,6 +1,19 @@ import { expect, test } from "@playwright/test" +import type { SessionMessageUser } from "@opencode/client/promise" +import { Option, Schema } from "effect" import { timelinePresets } from "@opencode/session-ui/timeline/detail" -import { assistantMessage, partUpdated, setupTimeline, toolPart, userMessage } from "../utils/timeline" +import { holdRoute } from "../utils/app" +import { + assistantMessage, + inboxEnqueued, + partUpdated, + revertCommitted, + revertStaged, + sessionID, + setupTimeline, + toolPart, + userMessage, +} from "../utils/timeline" test("keeps shell and question failures in their Used group", async ({ page }) => { const shellID = "prt_transition_error_shell" @@ -58,3 +71,97 @@ test("keeps shell and question failures in their Used group", async ({ page }) = function questionInput() { return { questions: [{ header: "Stability", question: "Keep it stable?", options: [] }] } } + +const decodeReplyMetadata = Schema.decodeUnknownOption( + Schema.Struct({ + genuiReply: Schema.Struct({ + callID: Schema.String, + fields: Schema.Record(Schema.String, Schema.Union([Schema.String, Schema.Boolean])), + }), + }), +) + +test("sends a generated form once, keeps it submitted after a reload, and reopens it when the reply is reverted", async ({ page }) => { + const callID = "call_genui_decisions" + + const code = [ + "root = Surface([form])", + "form = Form([scope], actions)", + 'scope = Choice("scope", "Scope", ["A", "B", "C"], "A")', + "actions = Actions([submit])", + 'submit = Button("Submit decisions", "Here are my decisions", "primary")', + ].join("\n") + + const seeded = [ + userMessage(), + assistantMessage([toolPart(callID, "genui_render", "completed", { title: "Decisions", code })]), + ] + + const replies: SessionMessageUser[] = [] + + const timeline = await setupTimeline(page, { + messages: seeded, + pageMessages: () => ({ items: [...seeded, ...replies] }), + onPrompt: ({ body }) => { + replies.push({ + id: String(body.id), + type: "user", + time: { created: 1700000002000 + replies.length }, + text: String(body.text), + metadata: Option.getOrUndefined(decodeReplyMetadata(body.metadata)), + }) + }, + }) + + const form = page.locator('[data-component="genui-form"]') + const submit = form.getByRole("button", { name: "Submit decisions" }) + await form.getByText("B", { exact: true }).click() + const sent = page.waitForResponse(/\/api\/session\/[^/]+\/prompt/) + await submit.click() + await sent + + await expect(submit).toBeDisabled() + await expect(form.getByText("Submitted", { exact: true })).toBeVisible() + await expect(form.getByRole("radio", { name: "B" })).toBeChecked() + await expect(form.getByRole("radio", { name: "A" })).toBeDisabled() + + await page.reload() + await expect(form.getByText("Submitted", { exact: true })).toBeVisible() + await expect(submit).toBeDisabled() + await expect(form.getByRole("radio", { name: "B" })).toBeChecked() + expect(replies.map((reply) => reply.metadata?.genuiReply)).toEqual([{ callID, fields: { scope: "B" } }]) + + // Reverting the reply reopens the form; sending again commits the revert first, then sends once. + const reply = replies[0]! + + await timeline.transport.waitForConnection() + await timeline.send(revertStaged({ sessionID, revert: { messageID: reply.id } })) + await expect(submit).toBeEnabled() + await expect(form.getByText("Submitted", { exact: true })).toHaveCount(0) + + const commit = await holdRoute(page, /\/api\/session\/[^/]+\/revert\/commit/, { method: "POST" }) + const resent = page.waitForResponse(/\/api\/session\/[^/]+\/prompt/) + + await submit.click() + await commit.arrived + expect(replies).toHaveLength(1) + await timeline.send(revertCommitted({ sessionID, to: reply.id })) + commit.release() + await resent + + // The server's admission echo, which restores the row the committed echo may have dropped. + const resend = replies[1]! + + await timeline.send( + inboxEnqueued({ + sessionID, + inboxID: resend.id, + item: { type: "user", payload: { text: resend.text, metadata: resend.metadata }, delivery: "steer" }, + }), + ) + + await expect(submit).toBeDisabled() + await expect(form.getByText("Submitted", { exact: true })).toBeVisible() + expect(replies.map((item) => item.id)).toEqual([reply.id, resend.id]) + expect(commit.requests).toHaveLength(1) +}) diff --git a/packages/app/e2e/utils/timeline.ts b/packages/app/e2e/utils/timeline.ts index cc51c281c9a..b68d378f121 100644 --- a/packages/app/e2e/utils/timeline.ts +++ b/packages/app/e2e/utils/timeline.ts @@ -362,6 +362,14 @@ export function inboxDelivered(data: Extract["data"]) { + return makeEvent("session.revert.staged", data) +} + +export function revertCommitted(data: Extract["data"]) { + return makeEvent("session.revert.committed", data) +} + export function compactionStarted(data: Extract["data"]) { return makeEvent("session.compaction.started", data) } diff --git a/packages/app/src/shell/routes/session-ui-provider.tsx b/packages/app/src/shell/routes/session-ui-provider.tsx index d750d765e44..cb4b12a823c 100644 --- a/packages/app/src/shell/routes/session-ui-provider.tsx +++ b/packages/app/src/shell/routes/session-ui-provider.tsx @@ -12,6 +12,7 @@ import { useTabs, type Tab } from "@/shell/tabs/tabs" import { readLocalImage } from "@/runtime/server/image" import { useLanguage } from "@/runtime/i18n/language" import { showToast } from "@/shell/notifications/toast" +import { selectSessionUserMessages, selectVisibleSessionUserMessages } from "@/session/session-domain" import { createTurnDiffCache } from "@/session/timeline/turn-diffs" import { useSettings } from "@/settings/model" @@ -122,8 +123,20 @@ export function SessionUIProvider( }} onNavigateToSession={navigateToSession} onSessionHref={href} - onPrompt={(input) => - data.session.prompt({ ...input, metadata: { genui: settings.general.generatedUI() } }).then(() => undefined) + onPrompt={async (input) => { + // Like the composer, a staged revert commits first; its committed echo would otherwise drop this reply. + if (data.session.get(input.sessionID)?.revert) + await serverSDK.api.session.revert.commit({ sessionID: input.sessionID }) + await data.session.prompt({ + ...input, + metadata: { ...input.metadata, genui: settings.general.generatedUI() }, + }) + }} + promptMetadata={(sessionID) => + selectVisibleSessionUserMessages( + selectSessionUserMessages(data.session.message.list(sessionID)), + data.session.get(sessionID)?.revert?.messageID, + ).map((message) => message.metadata) } > diff --git a/packages/plugin-genui/src/genui.ts b/packages/plugin-genui/src/genui.ts index 9db6e5eb977..190608be81d 100644 --- a/packages/plugin-genui/src/genui.ts +++ b/packages/plugin-genui/src/genui.ts @@ -1,6 +1,6 @@ export * as GenUI from "./genui.js" -import { Schema } from "effect" +import { Option, Schema } from "effect" export const MAX_CODE = 100_000 /** The model-facing name of the render tool, and the tool name its calls carry in the timeline. */ @@ -31,3 +31,21 @@ export const unfence = (code: string) => .trim() .replace(/^```[\w-]*\n?/u, "") .replace(/\n?```$/u, "") + +/** The prompt metadata key under which the app records a user's reply to a render call. */ +export const REPLY_METADATA = "genuiReply" + +/** A reply to a render call: the call it answers and the form fields it sent, by field name. */ +export const Reply = Schema.Struct({ + callID: Schema.String, + fields: Schema.Record(Schema.String, Schema.Union([Schema.String, Schema.Boolean])), +}) +export type Reply = typeof Reply.Type + +const decodeReply = Schema.decodeUnknownOption(Reply) + +/** The reply a render call already received, found among the metadata of the session's user prompts. */ +export const replyTo = (callID: string, prompts: readonly (Readonly> | undefined)[]) => + prompts + .flatMap((metadata) => Option.toArray(decodeReply(metadata?.[REPLY_METADATA]))) + .find((reply) => reply.callID === callID) diff --git a/packages/plugin-genui/test/genui.test.ts b/packages/plugin-genui/test/genui.test.ts new file mode 100644 index 00000000000..67ab7c5f690 --- /dev/null +++ b/packages/plugin-genui/test/genui.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test" +import { GenUI } from "../src/genui" + +describe("GenUI.replyTo", () => { + test("finds the reply a render call received among the session's prompts", () => { + const reply = { callID: "call_form", fields: { scope: "B", tests: true } } + + expect( + GenUI.replyTo("call_form", [ + undefined, + { genui: true }, + { [GenUI.REPLY_METADATA]: { callID: "call_other", fields: {} } }, + { genui: true, [GenUI.REPLY_METADATA]: reply }, + ]), + ).toEqual(reply) + }) + + test("a call nobody answered, or a malformed reply, has none", () => { + expect(GenUI.replyTo("call_form", [{ genui: true }])).toBeUndefined() + expect(GenUI.replyTo("call_form", [{ [GenUI.REPLY_METADATA]: { callID: "call_form" } }])).toBeUndefined() + }) +}) diff --git a/packages/session-ui/src/context/data.tsx b/packages/session-ui/src/context/data.tsx index af8531f71d7..fb8ce2521db 100644 --- a/packages/session-ui/src/context/data.tsx +++ b/packages/session-ui/src/context/data.tsx @@ -1,6 +1,8 @@ import type { FileDiffInfo, + JsonValue, SessionInfo, + SessionMessageUser, SessionStatus, ShellOutputInput, ShellOutputOutput, @@ -65,7 +67,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ onNavigateToSession?: NavigateToSessionFn onSessionHref?: SessionHrefFn /** Sends a prompt as the user, e.g. a reply from an agent-rendered surface. Absent where the user cannot reply. */ - onPrompt?: (input: { sessionID: string; text: string }) => Promise + onPrompt?: (input: { sessionID: string; text: string; metadata?: { [key: string]: JsonValue } }) => Promise + /** The metadata of the session's user prompts, including ones not delivered yet. */ + promptMetadata?: (sessionID: string) => readonly SessionMessageUser["metadata"][] }) => { return { get store() { @@ -86,6 +90,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ shellOutput: props.shellOutput, turnDiff: props.turnDiff, prompt: props.onPrompt, + promptMetadata: props.promptMetadata, } }, }) diff --git a/packages/session-ui/src/genui/genui.css b/packages/session-ui/src/genui/genui.css index d338caaef45..89b9f56bf3a 100644 --- a/packages/session-ui/src/genui/genui.css +++ b/packages/session-ui/src/genui/genui.css @@ -356,9 +356,27 @@ button[data-slot="genui-row"]:disabled { [data-component="genui-actions"] { display: flex; flex-wrap: wrap; + align-items: center; gap: 8px; } +[data-slot="genui-actions-status"], +[data-slot="genui-actions-error"] { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 13px; + line-height: var(--line-height-compact); +} + +[data-slot="genui-actions-status"] { + color: var(--v2-text-text-muted); +} + +[data-slot="genui-actions-error"] { + color: var(--v2-state-fg-danger); +} + [data-component="genui-form"] { display: flex; flex-direction: column; diff --git a/packages/session-ui/src/genui/reply.test.ts b/packages/session-ui/src/genui/reply.test.ts new file mode 100644 index 00000000000..763dc903835 --- /dev/null +++ b/packages/session-ui/src/genui/reply.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "bun:test" +import { createReplySender } from "./reply" + +describe("createReplySender", () => { + test("a second press while the first reply sends does nothing", async () => { + const sent: string[] = [] + const pending = Promise.withResolvers() + + const sender = createReplySender({ + sent: () => false, + send: (reply: string) => { + sent.push(reply) + + return pending.promise + }, + }) + + sender.send("first") + sender.send("second") + expect(sender.sending()).toBe(true) + pending.resolve() + await pending.promise + expect(sent).toEqual(["first"]) + }) + + test("a reply already in the session blocks another", () => { + const sent: string[] = [] + + const sender = createReplySender({ + sent: () => true, + send: async (reply: string) => { + sent.push(reply) + }, + }) + + sender.send("again") + expect(sent).toEqual([]) + expect(sender.sending()).toBe(false) + }) + + test("a failed send can be retried", async () => { + const attempts: string[] = [] + + const sender = createReplySender({ + sent: () => false, + send: (reply: string) => { + attempts.push(reply) + + return attempts.length === 1 ? Promise.reject(new Error("offline")) : Promise.resolve() + }, + }) + + await sender.send("first") + expect(sender.failed()).toBe(true) + expect(sender.sending()).toBe(false) + sender.send("retry") + expect(sender.failed()).toBe(false) + expect(attempts).toEqual(["first", "retry"]) + }) +}) diff --git a/packages/session-ui/src/genui/reply.ts b/packages/session-ui/src/genui/reply.ts new file mode 100644 index 00000000000..bc4609e3008 --- /dev/null +++ b/packages/session-ui/src/genui/reply.ts @@ -0,0 +1,24 @@ +import { createStore } from "solid-js/store" + +/** + * Sends a surface's reply once. The first press claims the send before its request goes out, so a double click or + * a repeated Enter cannot send it twice, and a reply already in the session blocks any more. A failed send can be + * retried. Returns the send in flight, if this press started one. + */ +export function createReplySender(input: { sent: () => boolean; send: (reply: T) => Promise }) { + const [state, setState] = createStore({ sending: false, failed: false }) + + return { + sending: () => state.sending, + failed: () => state.failed, + send(reply: T) { + if (state.sending || input.sent()) return + setState({ sending: true, failed: false }) + + return input + .send(reply) + .catch(() => setState("failed", true)) + .finally(() => setState("sending", false)) + }, + } +} diff --git a/packages/session-ui/src/genui/surface.tsx b/packages/session-ui/src/genui/surface.tsx index 6538cf1f1f8..90dc31b27b6 100644 --- a/packages/session-ui/src/genui/surface.tsx +++ b/packages/session-ui/src/genui/surface.tsx @@ -5,6 +5,7 @@ import { GenUI } from "@opencode/plugin-genui/genui" import { GenUILang } from "@opencode/plugin-genui/lang" import { Button, type ButtonProps } from "@opencode/ui/button" import { Divider } from "@opencode/ui/divider" +import { useI18n } from "@opencode/ui/context/i18n" import { Icon } from "@opencode/ui/icon" import type { IconName } from "@opencode/ui/icons/catalog" import { RadioGroup, RadioItem } from "@opencode/ui/radio" @@ -38,8 +39,12 @@ export type GenUIReply = { readonly message: string /** Submitted form fields, by label, in form order. */ readonly values?: readonly (readonly [label: string, value: string])[] + /** Every field of the submitted form, by name, so a sent form can show what it sent. */ + readonly fields?: Readonly> } +type FormReply = Pick + type Node = GenUICatalog.Surface["props"]["children"][number] type MetricNode = typeof GenUICatalog.Metric.Type @@ -55,15 +60,18 @@ type Value = string | boolean const Surface = createContext<{ readonly busy: () => boolean + /** The form fields the surface's reply sent, once it has replied. */ + readonly sent: () => Readonly> | undefined + readonly failed: () => boolean /** Replies with text (or `label` when there is none), or runs the actions against the surface's state. */ - readonly act: (behaviour: Behaviour, label: string, values?: GenUIReply["values"]) => void + readonly act: (behaviour: Behaviour, label: string, form?: FormReply) => void readonly open: (url: string) => void }>() const Form = createContext<{ readonly value: (name: string) => Value | undefined readonly set: (name: string, value: Value) => void - readonly values: () => GenUIReply["values"] + readonly reply: () => FormReply }>() // The primary action is the contrast button, as in the app's own dialogs. @@ -102,6 +110,10 @@ export function GenUISurface(props: { streaming?: boolean /** Turns off buttons, rows and form submission, e.g. while a reply is sending. */ busy?: boolean + /** The form fields the surface already replied with. A sent surface shows them and takes no more input. */ + sent?: Readonly> + /** The last reply failed to send. */ + failed?: boolean onReply: (reply: GenUIReply) => void onOpen: (url: string) => void /** Shown when the program has no valid root yet. */ @@ -133,7 +145,7 @@ export function GenUISurface(props: { setStore("root", reconcile(rendered.root ?? undefined, { key: "statementId", merge: true })) }) - const apply = (outcome: GenUILang.Outcome | undefined, values: GenUIReply["values"]) => { + const apply = (outcome: GenUILang.Outcome | undefined, form: FormReply | undefined) => { if (outcome?.type === "set") setStore("changes", (changes) => [ ...changes.filter((change) => change.name !== outcome.name), @@ -143,25 +155,33 @@ export function GenUISurface(props: { if (outcome?.type === "reset") setStore("changes", (changes) => changes.filter((change) => !outcome.names.includes(change.name))) - if (outcome?.type === "reply") props.onReply({ message: outcome.message, values }) + if (outcome?.type === "reply") props.onReply({ message: outcome.message, ...form }) if (outcome?.type === "open") props.onOpen(outcome.url) } // Each action sees the state the previous one left. - const run = (actions: readonly GenUILang.Action[], values: GenUIReply["values"]) => - actions.forEach((action) => apply(GenUILang.perform(action, program(), GenUICatalog.catalog, state()), values)) + const run = (actions: readonly GenUILang.Action[], form: FormReply | undefined) => + actions.forEach((action) => apply(GenUILang.perform(action, program(), GenUICatalog.catalog, state()), form)) - const act = (behaviour: Behaviour, label: string, values?: GenUIReply["values"]) => { - if (behaviour instanceof GenUILang.Action) return run([behaviour], values) + const act = (behaviour: Behaviour, label: string, form?: FormReply) => { + if (behaviour instanceof GenUILang.Action) return run([behaviour], form) - if (isActionList(behaviour)) return run(behaviour, values) + if (isActionList(behaviour)) return run(behaviour, form) - props.onReply({ message: behaviour ?? label, values }) + props.onReply({ message: behaviour ?? label, ...form }) } return ( - props.busy === true, act, open: props.onOpen }}> + props.busy === true, + sent: () => props.sent, + failed: () => props.failed === true, + act, + open: props.onOpen, + }} + > {(root) => (
@@ -526,6 +546,7 @@ function GroupRow(props: { row: typeof GenUICatalog.Row.Type }) { } function Actions(props: { node: typeof GenUICatalog.Actions.Type }) { + const i18n = useI18n() const surface = useContext(Surface) const form = useContext(Form) @@ -555,7 +576,7 @@ function Actions(props: { node: typeof GenUICatalog.Actions.Type }) { size="normal" variant={buttonVariants[button().variant ?? "secondary"]} disabled={surface?.busy()} - onClick={() => surface?.act(button().action, button().label, form?.values())} + onClick={() => surface?.act(button().action, button().label, form?.reply())} > {button().label} @@ -563,11 +584,21 @@ function Actions(props: { node: typeof GenUICatalog.Actions.Type }) { )} + + + + {i18n.t("ui.genui.sent")} + + + + {i18n.t("ui.genui.failed")} +
) } function FormBlock(props: { node: typeof GenUICatalog.Form.Type }) { + const surface = useContext(Surface) const fields = () => props.node.props.fields // Only what the user changed; a field's default comes from its current node, so fields that stream in later @@ -575,6 +606,7 @@ function FormBlock(props: { node: typeof GenUICatalog.Form.Type }) { const [state, setState] = createStore<{ values: { name: string; value: Value }[] }>({ values: [] }) const value = (name: string) => + surface?.sent()?.[name] ?? state.values.find((entry) => entry.name === name)?.value ?? initial(fields().find((field) => field.props.name === name)) @@ -586,12 +618,20 @@ function FormBlock(props: { node: typeof GenUICatalog.Form.Type }) { value={{ value, set, - values: () => - fields().flatMap((field) => { + reply: () => ({ + values: fields().flatMap((field) => { const current = value(field.props.name) return current === undefined || current === "" ? [] : [[field.props.label, String(current)] as const] }), + fields: Object.fromEntries( + fields().flatMap((field) => { + const current = value(field.props.name) + + return current === undefined ? [] : [[field.props.name, current] as const] + }), + ), + }), }} >
event.preventDefault()}> @@ -611,8 +651,10 @@ function initial(field: GenUICatalog.Field | undefined): Value | undefined { } function Field(props: { field: GenUICatalog.Field }): JSX.Element { + const surface = useContext(Surface) const form = useContext(Form) const field = props.field + const sent = () => surface?.sent() !== undefined const text = () => { const current = form?.value(field.props.name) @@ -633,6 +675,7 @@ function Field(props: { field: GenUICatalog.Field }): JSX.Element { class="!w-full" placeholder={field.props.placeholder ?? undefined} value={text()} + disabled={sent()} onInput={(event) => form?.set(field.props.name, event.currentTarget.value)} /> {hint(field.props.hint)} @@ -646,6 +689,7 @@ function Field(props: { field: GenUICatalog.Field }): JSX.Element { class="!w-full" placeholder={field.props.placeholder ?? undefined} value={text()} + disabled={sent()} onInput={(event) => form?.set(field.props.name, event.currentTarget.value)} /> {hint(field.props.hint)} @@ -659,6 +703,7 @@ function Field(props: { field: GenUICatalog.Field }): JSX.Element { class="!w-full" options={[...field.props.options]} current={text() || undefined} + disabled={sent()} onSelect={(option) => form?.set(field.props.name, option ?? "")} /> {hint(field.props.hint)} @@ -673,6 +718,7 @@ function Field(props: { field: GenUICatalog.Field }): JSX.Element { label={field.props.label} hideLabel value={text()} + disabled={sent()} onChange={(option) => form?.set(field.props.name, option)} > {(option) => } @@ -687,6 +733,7 @@ function Field(props: { field: GenUICatalog.Field }): JSX.Element { form?.set(field.props.name, option ?? "")} > @@ -699,6 +746,7 @@ function Field(props: { field: GenUICatalog.Field }): JSX.Element { return ( form?.set(field.props.name, checked)} > diff --git a/packages/session-ui/src/genui/tool.tsx b/packages/session-ui/src/genui/tool.tsx index 8b0fecc0d57..6bae731c424 100644 --- a/packages/session-ui/src/genui/tool.tsx +++ b/packages/session-ui/src/genui/tool.tsx @@ -1,5 +1,4 @@ import { createMemo, Show } from "solid-js" -import { createStore } from "solid-js/store" import { Option, Schema } from "effect" import { GenUI } from "@opencode/plugin-genui/genui" import { useI18n } from "@opencode/ui/context/i18n" @@ -7,27 +6,45 @@ import { Icon } from "@opencode/ui/icon" import { TextShimmer } from "@opencode/ui/text-shimmer" import { useData } from "../context/data" import { ToolRegistry, type ToolProps } from "../tools/tool-renderer" +import { createReplySender } from "./reply" import { genuiReplyText, GenUISurface, type GenUIReply } from "./surface" const decodeInput = Schema.decodeUnknownOption(GenUI.RenderInput) /** A `genui_render` call in the timeline: the surface itself, drawn while its program streams in. */ -function GenUITool(props: ToolProps) { +// ToolDisplay passes the tool call ID along with the props every tool gets. +function GenUITool(props: ToolProps & { id?: string }) { const i18n = useI18n() const data = useData() const input = createMemo(() => Option.getOrElse(decodeInput(props.input), (): GenUI.RenderInput => ({}))) const writing = () => props.status === "streaming" || props.status === "running" || props.status === "pending" - const [state, setState] = createStore({ sending: false }) - const reply = (value: GenUIReply) => { - const prompt = data.prompt + // Durable: the reply is a prompt in the session, so the surface stays sent after a reload or remount. + const sent = createMemo(() => { const sessionID = data.sessionID - if (!prompt || !sessionID || state.sending) return - setState("sending", true) - const heading = i18n.t("ui.genui.submitted", { title: input().title ?? i18n.t("ui.genui.title") }) - void prompt({ sessionID, text: genuiReplyText(value, heading) }).finally(() => setState("sending", false)) - } + if (!sessionID || !props.id) return undefined + + return GenUI.replyTo(props.id, data.promptMetadata?.(sessionID) ?? []) + }) + + const sender = createReplySender({ + sent: () => sent() !== undefined, + send: (value: GenUIReply) => { + const prompt = data.prompt + const sessionID = data.sessionID + + if (!prompt || !sessionID || !props.id) return Promise.resolve() + + const heading = i18n.t("ui.genui.submitted", { title: input().title ?? i18n.t("ui.genui.title") }) + + return prompt({ + sessionID, + text: genuiReplyText(value, heading), + metadata: { [GenUI.REPLY_METADATA]: { callID: props.id, fields: { ...value.fields } } }, + }) + }, + }) return (
@@ -48,8 +65,10 @@ function GenUITool(props: ToolProps) { { if (/^https?:\/\//iu.test(url)) window.open(url, "_blank", "noopener,noreferrer") }} diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index dd6f168fc57..9001ba6dac3 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -201,6 +201,8 @@ const source = { "ui.genui.title": "Generated UI", "ui.genui.writing": "Writing", "ui.genui.submitted": "Submitted in {{title}}:", + "ui.genui.sent": "Submitted", + "ui.genui.failed": "Couldn't send. Try again.", "ui.genui.total": "Total", "ui.genui.actual": "Actual", "ui.genui.projected": "Projected",