fix(app): show generated forms as submitted and send them once (#57537)

This commit is contained in:
Kit Langton
2026-10-20 16:40:40 -07:00
committed by GitHub
Unverified
parent 8afeea33af
commit 4933547d40
12 changed files with 375 additions and 31 deletions
@@ -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)
})
+8
View File
@@ -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}>
+19 -1
View File
@@ -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)
+22
View File
@@ -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()
})
})
+6 -1
View File
@@ -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,
}
},
})
+18
View File
@@ -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"])
})
})
+24
View File
@@ -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))
},
}
}
+62 -14
View File
@@ -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)}
>
+31 -12
View File
@@ -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")
}}
+2
View File
@@ -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",