From 1032f6a5538dd24f08bd99037b9f9906f4c90b45 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Wed, 7 Oct 2026 04:10:40 -0400 Subject: [PATCH 1/2] fix(chat): preserve text after composer send failures Signed-off-by: Rudy Celekli --- web/components/ai-elements/prompt-input.tsx | 13 ++ .../ai-elements/tests/prompt-input.test.tsx | 116 +++++++++++++++++- 2 files changed, 128 insertions(+), 1 deletion(-) diff --git a/web/components/ai-elements/prompt-input.tsx b/web/components/ai-elements/prompt-input.tsx index fc1e0612..1457e9bd 100644 --- a/web/components/ai-elements/prompt-input.tsx +++ b/web/components/ai-elements/prompt-input.tsx @@ -927,6 +927,15 @@ export const PromptInput = ({ form.reset(); } + const restoreText = () => { + if (usingProvider) return; + const input = form.elements.namedItem("message"); + if (input instanceof HTMLTextAreaElement && input.value === "") { + input.value = text; + input.dispatchEvent(new Event("input", { bubbles: true })); + } + }; + try { // Convert blob URLs to data URLs asynchronously const convertedFiles: FileUIPart[] = await Promise.all( @@ -955,6 +964,7 @@ export const PromptInput = ({ } } catch { // Don't clear on error - user may want to retry + restoreText(); } } else { // Sync function completed without throwing, clear inputs @@ -965,6 +975,7 @@ export const PromptInput = ({ } } catch { // Don't clear on error - user may want to retry + restoreText(); } }, [usingProvider, controller, files, onSubmit, clear] @@ -1086,8 +1097,10 @@ export const PromptInputTextarea = ({ }); }; form.addEventListener("reset", handleReset); + textarea.addEventListener("input", handleReset); return () => { form.removeEventListener("reset", handleReset); + textarea.removeEventListener("input", handleReset); }; }, []); diff --git a/web/components/ai-elements/tests/prompt-input.test.tsx b/web/components/ai-elements/tests/prompt-input.test.tsx index 9e12a01c..07ef76e4 100644 --- a/web/components/ai-elements/tests/prompt-input.test.tsx +++ b/web/components/ai-elements/tests/prompt-input.test.tsx @@ -4,8 +4,25 @@ import { type ComponentType, type ReactNode, } from "react"; +import type * as JSXDevelopmentRuntime from "react/jsx-dev-runtime"; +import { z } from "zod"; import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const captured = vi.hoisted(() => ({ + forms: new Array(), +})); + +vi.mock("react/jsx-dev-runtime", async (importOriginal) => { + const runtime = await importOriginal(); + return { + ...runtime, + jsxDEV: (...args: Parameters) => { + if (args[0] === "form") captured.forms.push(args[1]); + return runtime.jsxDEV(...args); + }, + }; +}); vi.mock("motion/react", () => { // oxlint-disable-next-line unicorn/consistent-function-scoping -- Vitest hoists mock factories above module-scope component values. @@ -68,6 +85,11 @@ import { } from "@web/components/ai-elements/prompt-input"; describe("prompt input", () => { + afterEach(() => { + vi.unstubAllGlobals(); + captured.forms.length = 0; + }); + it("anchors the compact submit button without dropping footer children", () => { const markup = renderToStaticMarkup( undefined}> @@ -109,4 +131,96 @@ describe("prompt input", () => { ); expect(regularMarkup).not.toContain('data-motion-element="div"'); }); + + it.each(["async", "sync"] as const)( + "restores the submitted text after %s send failure", + async (mode) => { + const onSubmit = + mode === "async" + ? () => Promise.reject(new Error("Send unavailable")) + : () => { + throw new Error("Send unavailable"); + }; + const input = submitDraft(onSubmit, "Keep my travel notes"); + + await vi.waitFor(() => { + expect(input.value).toBe("Keep my travel notes"); + }); + expect(input.dispatchEvent).toHaveBeenCalledWith( + expect.objectContaining({ bubbles: true, type: "input" }) + ); + } + ); + + it("leaves successfully submitted text cleared", async () => { + const onSubmit = vi.fn<() => Promise>(() => Promise.resolve()); + const input = submitDraft(onSubmit, "Send my travel notes"); + + await vi.waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith( + { files: [], text: "Send my travel notes" }, + expect.anything() + ); + }); + expect(input.value).toBe(""); + expect(input.dispatchEvent).not.toHaveBeenCalled(); + }); + + it("keeps a newer draft when the previous send fails", async () => { + const pending = Promise.withResolvers(); + const onSubmit = vi.fn<() => Promise>(() => pending.promise); + const input = submitDraft(onSubmit, "First draft"); + await vi.waitFor(() => { + expect(onSubmit).toHaveBeenCalledOnce(); + }); + input.value = "Newer draft"; + pending.reject(new Error("Send unavailable")); + await pending.promise.catch(() => undefined); + + expect(input.value).toBe("Newer draft"); + expect(input.dispatchEvent).not.toHaveBeenCalled(); + }); }); + +function submitDraft( + onSubmit: ComponentProps["onSubmit"], + text: string +) { + // Exercise the actual rendered submit callback with a small DOM boundary. + // The native browser proof separately covers real FormData, textarea and events. + class Textarea { + value = text; + dispatchEvent = vi.fn(() => true); + } + const input = new Textarea(); + vi.stubGlobal("HTMLTextAreaElement", Textarea); + const NativeFormData = FormData; + vi.stubGlobal( + "FormData", + class extends NativeFormData { + constructor() { + super(); + this.append("message", input.value); + } + } + ); + const form = { + elements: { namedItem: () => input }, + reset: () => { + input.value = ""; + }, + }; + renderToStaticMarkup( + + + + ); + const { onSubmit: submit } = z + .object({ onSubmit: z.function() }) + .parse(captured.forms.at(-1)); + submit({ + currentTarget: form, + preventDefault: vi.fn<() => void>(), + }); + return input; +} From d8e12f11f134f5f037da6a265ce13729a7952631 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Wed, 7 Oct 2026 04:15:59 -0400 Subject: [PATCH 2/2] test(chat): declare captured submit callback input Signed-off-by: Rudy Celekli --- web/components/ai-elements/tests/prompt-input.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/components/ai-elements/tests/prompt-input.test.tsx b/web/components/ai-elements/tests/prompt-input.test.tsx index 07ef76e4..53381678 100644 --- a/web/components/ai-elements/tests/prompt-input.test.tsx +++ b/web/components/ai-elements/tests/prompt-input.test.tsx @@ -216,7 +216,9 @@ function submitDraft( ); const { onSubmit: submit } = z - .object({ onSubmit: z.function() }) + .object({ + onSubmit: z.function({ input: [z.unknown()], output: z.void() }), + }) .parse(captured.forms.at(-1)); submit({ currentTarget: form,