From ac4f16728167cd5800d62cc1486e744d4c3bcfcf Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 15 Sep 2026 13:02:12 +0000 Subject: [PATCH 1/3] Keep typed spaces in pill text inputs and add a textarea pill field type The empty-state quick-start form rendered its text input through asText(), which trims. Because the input is controlled, "Meridian " was stored with its trailing space but re-rendered as "Meridian", so the space vanished on every keystroke and a two-word client name could not be typed. Inputs now render the raw string (asInputText); trimming happens once, in pillToPrompt. The number input was checked and is unaffected (its stored value is a number or "", never a padded string). Add "textarea" to PillFieldType: a full-row, four-line box with the same controlled-value handling, for free-text inputs such as a KPI list. Prompt assembly treats it exactly like text (interior line breaks survive, ends are trimmed), and the Prompt Preview now keeps line breaks so a multi-line template reads as written. Tests: the "Meridian Auto" keystroke round-trip (fails on the old renderer), raw-in-the-box / trimmed-in-the-prompt, textarea render + full-row span + multi-line round-trip into preview and prompt, and the helper/assembly cases in protocolPills.test.ts. Design note in docs/FEATURE-NOTES.md. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01MBAMDChYX61tgyYGaPWwG3 --- docs/FEATURE-NOTES.md | 16 +++ .../app/chat/ui/EmptyStatePrompts.test.tsx | 122 ++++++++++++++++++ web/src/app/chat/ui/EmptyStatePrompts.tsx | 27 +++- web/src/app/chat/ui/protocolPills.test.ts | 74 +++++++++++ web/src/app/chat/ui/protocolPills.ts | 24 +++- 5 files changed, 254 insertions(+), 9 deletions(-) diff --git a/docs/FEATURE-NOTES.md b/docs/FEATURE-NOTES.md index 24e41d2d3..d3ef210ce 100644 --- a/docs/FEATURE-NOTES.md +++ b/docs/FEATURE-NOTES.md @@ -13,6 +13,22 @@ what/why that becomes the release notes (there is no changelog file, ADR-0061). Only add a bullet here if the feature is too small for its own page — and never add feature notes back into `AGENTS.md`. +- **Protocol-pill `textarea` field + the controlled-input trimming fix**: the + chat empty-state quick-start cards (`empty_state.cards[]` in a bundle + manifest, rendered by `web/src/app/chat/ui/EmptyStatePrompts.tsx` from the + `ProtocolPill` shape in `protocolPills.ts`) gained a sixth field type, + `textarea` — a full-row, four-line box for free text such as a KPI list or + "anything else I should know"; `pillToPrompt` treats it exactly like `text` + (interior line breaks survive into the prompt, only the ends are trimmed), + and the Prompt Preview now keeps line breaks. The field types are `text`, + `textarea`, `select`, `number`, `daterange`, `toggle`; there is no + repeatable-row type and no validation beyond `required`. Fix shipped with + it: the text input rendered `asText(value)`, which **trims**, so a + controlled re-render swallowed the trailing space on every keystroke and a + two-word client name could not be typed. Inputs now render the raw string + (`asInputText`) and trimming happens once, at prompt assembly. The `number` + input was checked and is unaffected (its stored value is a number or the + empty string, never a padded string). - **Operations connector defaults (#1333)**: new task forms now seed their visible MCP selection from the catalog's `enabled` flags, matching Chat while preserving saved selections on edit. See diff --git a/web/src/app/chat/ui/EmptyStatePrompts.test.tsx b/web/src/app/chat/ui/EmptyStatePrompts.test.tsx index 35ee20834..2971c9466 100644 --- a/web/src/app/chat/ui/EmptyStatePrompts.test.tsx +++ b/web/src/app/chat/ui/EmptyStatePrompts.test.tsx @@ -34,8 +34,50 @@ const CONVERSATION_PILL: ProtocolPill = { fields: [{ key: "client", label: "Client name", type: "text" }], }; +// A pill with multi-line inputs — the KPI-list / "anything else" shape. +const TEXTAREA_PILL: ProtocolPill = { + id: "wrap", + section: "Reporting", + type: "form", + icon: "layers", + title: "Build a wrap", + desc: "Summarize a finished campaign.", + cta: "Build wrap", + fields: [ + { key: "client", label: "Client name", type: "text", required: true }, + { + key: "kpis", + label: "KPIs and goals", + type: "textarea", + placeholder: "CTR, goal 0.15%", + hint: "One per line.", + }, + { key: "audience", label: "Audience", type: "select", options: ["Client", "Internal"] }, + ], + promptTemplate: "Wrap {client}.\nKPIs: {kpis}\nAudience: {audience}", +}; + const noop = () => {}; +function renderForm(pill: ProtocolPill, handlers: Partial[0]> = {}) { + return render( + , + ); +} + +// The prompt-preview body: the

that follows the "Prompt preview" kicker. +function previewText(): string { + const kicker = screen.getByText(/prompt preview/i); + return kicker.nextElementSibling?.textContent ?? ""; +} + describe("EmptyStatePrompts", () => { it("renders a card per config-sourced pill and reports the picked id", () => { const onPick = vi.fn(); @@ -112,6 +154,86 @@ describe("ProtocolPillForm — form pill", () => { }); }); +describe("ProtocolPillForm — text input keeps typed spaces", () => { + // Regression: the text input rendered `asText(value)`, which trims. Because + // the input is controlled, "Meridian " was stored with its trailing space but + // re-rendered as "Meridian", so the space vanished on every keystroke and + // "Meridian Auto" could never be typed. + it("round-trips \"Meridian Auto\" through the controlled input, keystroke by keystroke", () => { + const onRun = vi.fn(); + renderForm(FORM_PILL, { onRun }); + const input = screen.getByLabelText(/client name/i); + + fireEvent.change(input, { target: { value: "Meridian" } }); + expect(input).toHaveValue("Meridian"); + + // The keystroke that used to be eaten: a trailing space must survive the + // re-render so the next character lands after it. + fireEvent.change(input, { target: { value: "Meridian " } }); + expect(input).toHaveValue("Meridian "); + + fireEvent.change(input, { target: { value: "Meridian Auto" } }); + expect(input).toHaveValue("Meridian Auto"); + + // …and the space shows up in the Prompt Preview and the submitted prompt. + expect(previewText()).toBe("Build a report for Meridian Auto."); + fireEvent.click(screen.getByRole("button", { name: /run report/i })); + expect(onRun).toHaveBeenCalledWith("Build a report for Meridian Auto."); + }); + + it("keeps the raw value in the box but trims it once at prompt-assembly time", () => { + const onRun = vi.fn(); + renderForm(FORM_PILL, { onRun }); + const input = screen.getByLabelText(/client name/i); + + fireEvent.change(input, { target: { value: "Meridian Auto " } }); + expect(input).toHaveValue("Meridian Auto "); // untouched while editing + expect(previewText()).toBe("Build a report for Meridian Auto."); // trimmed in the prompt + + fireEvent.click(screen.getByRole("button", { name: /run report/i })); + expect(onRun).toHaveBeenCalledWith("Build a report for Meridian Auto."); + }); +}); + +describe("ProtocolPillForm — textarea field", () => { + it("renders a multi-row textarea spanning the full row, with placeholder and hint", () => { + renderForm(TEXTAREA_PILL); + const box = screen.getByLabelText(/kpis and goals/i); + expect(box.tagName).toBe("TEXTAREA"); + expect(box).toHaveAttribute("rows", "4"); + expect(box).toHaveAttribute("placeholder", "CTR, goal 0.15%"); + expect(screen.getByText("One per line.")).toBeInTheDocument(); + // Full row, like text/daterange — not paired up like select/number/toggle. + expect(box.closest("label")).toHaveClass("sm:col-span-2"); + expect(screen.getByLabelText(/audience/i).closest("label")).not.toHaveClass("sm:col-span-2"); + }); + + it("accepts multiple lines and carries them into the preview and the prompt", () => { + const onRun = vi.fn(); + renderForm(TEXTAREA_PILL, { onRun }); + const multi = "CTR, goal 0.15%\nCPA, conversions / spend, under $40\nUnallocated conversions, report separately"; + + fireEvent.change(screen.getByLabelText(/client name/i), { target: { value: "Meridian Auto" } }); + const box = screen.getByLabelText(/kpis and goals/i); + fireEvent.change(box, { target: { value: multi } }); + expect(box).toHaveValue(multi); // line breaks survive the controlled re-render + + const expected = `Wrap Meridian Auto.\nKPIs: ${multi}\nAudience: Client`; + expect(previewText()).toBe(expected); + // The preview keeps the template's and the value's line breaks visible. + expect(screen.getByText(/prompt preview/i).nextElementSibling).toHaveClass("whitespace-pre-wrap"); + + fireEvent.click(screen.getByRole("button", { name: /build wrap/i })); + expect(onRun).toHaveBeenCalledWith(expected); + }); + + it("leaves the token in place while the textarea is blank (the agent sees what was intended)", () => { + renderForm(TEXTAREA_PILL); + fireEvent.change(screen.getByLabelText(/client name/i), { target: { value: "Acme" } }); + expect(previewText()).toBe("Wrap Acme.\nKPIs: {kpis}\nAudience: Client"); + }); +}); + describe("ProtocolPillForm — conversation pill", () => { it("routes the skip link to the conversational starter", () => { const onStartChat = vi.fn(); diff --git a/web/src/app/chat/ui/EmptyStatePrompts.tsx b/web/src/app/chat/ui/EmptyStatePrompts.tsx index 9c78b4139..22587079e 100644 --- a/web/src/app/chat/ui/EmptyStatePrompts.tsx +++ b/web/src/app/chat/ui/EmptyStatePrompts.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { + asInputText, asText, formInitialValues, isPillReady, @@ -201,7 +202,9 @@ function GeneratedPrompt({ text }: { text: string }) { Prompt preview -

{text}

+

+ {text} +

); } @@ -235,9 +238,9 @@ function FieldGrid({ const INPUT_CLASS = "w-full rounded-[var(--radius-md)] border border-[var(--color-border-strong)] bg-[var(--color-bg)] px-2.5 py-2 text-[0.85rem] text-[var(--color-text-primary)] outline-none transition placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-accent)] focus-visible:shadow-[var(--focus-ring)]"; -// text/daterange want the full row; select/number/toggle pair up. +// text/textarea/daterange want the full row; select/number/toggle pair up. function fieldSpansRow(field: PillField): boolean { - return field.type === "text" || field.type === "daterange"; + return field.type === "text" || field.type === "textarea" || field.type === "daterange"; } function Field({ @@ -310,11 +313,27 @@ function Field({ ) : null} {field.type === "text" ? ( + // Controlled inputs render the RAW string: `asText` trims, and a + // trimmed re-render swallows the space the user just typed (#3a). + // Trimming happens once, in pillToPrompt. onChange(e.target.value)} + /> + ) : null} + + {field.type === "textarea" ? ( + // Multi-line free text (KPI lists, "anything else I should know"). + // Same controlled-value handling as the text input; line breaks + // survive into the prompt, only the ends are trimmed. +