mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 19:46:16 +03:00
fix(app): show generated forms as submitted and send them once (#57537)
This commit is contained in:
committed by
GitHub
Unverified
parent
8afeea33af
commit
4933547d40
@@ -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)
|
||||
})
|
||||
|
||||
@@ -362,6 +362,14 @@ export function inboxDelivered(data: Extract<OpenCodeEvent, { type: "session.inb
|
||||
return makeEvent("session.inbox.delivered", data)
|
||||
}
|
||||
|
||||
export function revertStaged(data: Extract<OpenCodeEvent, { type: "session.revert.staged" }>["data"]) {
|
||||
return makeEvent("session.revert.staged", data)
|
||||
}
|
||||
|
||||
export function revertCommitted(data: Extract<OpenCodeEvent, { type: "session.revert.committed" }>["data"]) {
|
||||
return makeEvent("session.revert.committed", data)
|
||||
}
|
||||
|
||||
export function compactionStarted(data: Extract<OpenCodeEvent, { type: "session.compaction.started" }>["data"]) {
|
||||
return makeEvent("session.compaction.started", data)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
>
|
||||
<MarkdownProvider readImage={readImage()} openSession={openReferencedSession} sessionTitle={sessionTitle}>
|
||||
|
||||
@@ -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<Record<string, unknown>> | undefined)[]) =>
|
||||
prompts
|
||||
.flatMap((metadata) => Option.toArray(decodeReply(metadata?.[REPLY_METADATA])))
|
||||
.find((reply) => reply.callID === callID)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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<void>
|
||||
onPrompt?: (input: { sessionID: string; text: string; metadata?: { [key: string]: JsonValue } }) => Promise<void>
|
||||
/** 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,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<void>()
|
||||
|
||||
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"])
|
||||
})
|
||||
})
|
||||
@@ -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<T>(input: { sent: () => boolean; send: (reply: T) => Promise<void> }) {
|
||||
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))
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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<Record<string, Value>>
|
||||
}
|
||||
|
||||
type FormReply = Pick<GenUIReply, "values" | "fields">
|
||||
|
||||
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<Record<string, Value>> | 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<Record<string, Value>>
|
||||
/** 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 (
|
||||
<Surface.Provider value={{ busy: () => props.busy === true, act, open: props.onOpen }}>
|
||||
<Surface.Provider
|
||||
value={{
|
||||
busy: () => props.busy === true,
|
||||
sent: () => props.sent,
|
||||
failed: () => props.failed === true,
|
||||
act,
|
||||
open: props.onOpen,
|
||||
}}
|
||||
>
|
||||
<Show when={store.root} fallback={props.fallback}>
|
||||
{(root) => (
|
||||
<div data-component="genui-surface">
|
||||
@@ -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}
|
||||
</Button>
|
||||
@@ -563,11 +584,21 @@ function Actions(props: { node: typeof GenUICatalog.Actions.Type }) {
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
<Show when={surface?.sent()}>
|
||||
<span data-slot="genui-actions-status">
|
||||
<Icon name="check" size="small" />
|
||||
{i18n.t("ui.genui.sent")}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={surface?.failed()}>
|
||||
<span data-slot="genui-actions-error">{i18n.t("ui.genui.failed")}</span>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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]
|
||||
}),
|
||||
),
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<form data-component="genui-form" onSubmit={(event) => 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)}
|
||||
>
|
||||
<For each={field.props.options}>{(option) => <RadioItem value={option} label={option} />}</For>
|
||||
@@ -687,6 +733,7 @@ function Field(props: { field: GenUICatalog.Field }): JSX.Element {
|
||||
<SegmentedControl
|
||||
class="segmented-control-v2--full-width"
|
||||
value={text() || null}
|
||||
disabled={sent()}
|
||||
onChange={(option) => form?.set(field.props.name, option ?? "")}
|
||||
>
|
||||
<For each={field.props.options}>
|
||||
@@ -699,6 +746,7 @@ function Field(props: { field: GenUICatalog.Field }): JSX.Element {
|
||||
return (
|
||||
<Switch
|
||||
checked={form?.value(field.props.name) === true}
|
||||
disabled={sent()}
|
||||
description={field.props.hint ?? undefined}
|
||||
onChange={(checked) => form?.set(field.props.name, checked)}
|
||||
>
|
||||
|
||||
@@ -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 (
|
||||
<div data-component="genui-inline" data-writing={writing() || undefined}>
|
||||
@@ -48,8 +65,10 @@ function GenUITool(props: ToolProps) {
|
||||
<GenUISurface
|
||||
code={code()}
|
||||
streaming={writing()}
|
||||
busy={!data.prompt || state.sending}
|
||||
onReply={reply}
|
||||
busy={!data.prompt || !props.id || sender.sending() || sent() !== undefined}
|
||||
sent={sent()?.fields}
|
||||
failed={sender.failed()}
|
||||
onReply={sender.send}
|
||||
onOpen={(url) => {
|
||||
if (/^https?:\/\//iu.test(url)) window.open(url, "_blank", "noopener,noreferrer")
|
||||
}}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user