Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/FEATURE-NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
122 changes: 122 additions & 0 deletions web/src/app/chat/ui/EmptyStatePrompts.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Parameters<typeof ProtocolPillForm>[0]> = {}) {
return render(
<ProtocolPillForm
pill={pill}
onRun={noop}
onCancel={noop}
onDescribe={noop}
onStartChat={noop}
{...handlers}
/>,
);
}

// The prompt-preview body: the <p> 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();
Expand Down Expand Up @@ -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();
Expand Down
28 changes: 24 additions & 4 deletions web/src/app/chat/ui/EmptyStatePrompts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useState } from "react";
import {
asInputText,
asText,
formInitialValues,
isPillReady,
Expand Down Expand Up @@ -201,7 +202,9 @@ function GeneratedPrompt({ text }: { text: string }) {
<span className="text-[0.66rem] font-semibold uppercase tracking-[0.1em] text-[var(--color-text-muted)]">
Prompt preview
</span>
<p className="text-[0.8rem] leading-snug text-[var(--color-text-secondary)]">{text}</p>
<p className="whitespace-pre-wrap text-[0.8rem] leading-snug text-[var(--color-text-secondary)]">
{text}
</p>
</div>
);
}
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -310,11 +313,28 @@ 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, so a
// two-word client name could never be entered. Trimming happens once,
// in pillToPrompt.
<input
type="text"
className={INPUT_CLASS}
placeholder={field.placeholder}
value={asText(value)}
value={asInputText(value)}
onChange={(e) => 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.
<textarea
rows={4}
className={`${INPUT_CLASS} min-h-[5.5rem] resize-y`}
placeholder={field.placeholder}
value={asInputText(value)}
onChange={(e) => onChange(e.target.value)}
/>
) : null}
Expand Down
74 changes: 74 additions & 0 deletions web/src/app/chat/ui/protocolPills.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
asInputText,
asText,
DEFAULT_PILLS,
formInitialValues,
Expand Down Expand Up @@ -43,6 +44,24 @@ const FALLBACK_PILL: ProtocolPill = {
// no promptTemplate → neutral fallback
};

// A pill with a multi-line field — the shape the KPI / "additional context"
// inputs take. Exercises the textarea type end to end through the helpers.
const TEXTAREA_PILL: ProtocolPill = {
id: "wrap",
section: "Reporting",
type: "form",
icon: "layers",
title: "End-of-campaign wrap",
desc: "Summarize a finished campaign.",
cta: "Build wrap",
fields: [
{ key: "client", label: "Client", type: "text", required: true },
{ key: "kpis", label: "KPIs", type: "textarea", required: true },
{ key: "context", label: "Context", type: "textarea" },
],
promptTemplate: "Wrap {client}.\nKPIs: {kpis}\nContext: {context}",
};

describe("asText", () => {
it("trims strings and stringifies numbers, blanks everything else", () => {
expect(asText(" hi ")).toBe("hi");
Expand All @@ -53,6 +72,23 @@ describe("asText", () => {
});
});

describe("asInputText", () => {
it("keeps a string exactly as typed — trailing spaces and line breaks included", () => {
// The controlled-input view: trimming here is what ate the space between
// "Meridian" and "Auto" on every keystroke.
expect(asInputText("Meridian ")).toBe("Meridian ");
expect(asInputText(" hi ")).toBe(" hi ");
expect(asInputText("CTR, goal 0.15%\nCPA, under $40")).toBe("CTR, goal 0.15%\nCPA, under $40");
});

it("falls back to asText for non-string values", () => {
expect(asInputText(14)).toBe("14");
expect(asInputText(true)).toBe("");
expect(asInputText({ from: "a", to: "b" })).toBe("");
expect(asInputText(undefined)).toBe("");
});
});

describe("formInitialValues", () => {
it("honors field defaults and type fallbacks", () => {
const v = formInitialValues(FALLBACK_PILL);
Expand All @@ -63,6 +99,12 @@ describe("formInitialValues", () => {
const t = formInitialValues(TEMPLATE_PILL);
expect(t.window).toBe("last week"); // explicit default
});

it("starts a textarea blank, like text", () => {
const v = formInitialValues(TEXTAREA_PILL);
expect(v.kpis).toBe("");
expect(v.context).toBe("");
});
});

describe("isPillReady", () => {
Expand All @@ -87,6 +129,13 @@ describe("isPillReady", () => {
const pill: ProtocolPill = { ...TEMPLATE_PILL, fields: [], promptTemplate: undefined };
expect(isPillReady(pill, formInitialValues(pill))).toBe(true);
});

it("gates a required textarea on non-whitespace content", () => {
const base = { ...formInitialValues(TEXTAREA_PILL), client: "Acme" };
expect(isPillReady(TEXTAREA_PILL, base)).toBe(false);
expect(isPillReady(TEXTAREA_PILL, { ...base, kpis: " \n " })).toBe(false);
expect(isPillReady(TEXTAREA_PILL, { ...base, kpis: "CTR, goal 0.15%" })).toBe(true);
});
});

describe("getPill", () => {
Expand All @@ -112,6 +161,22 @@ describe("pillToPrompt — string template", () => {
const pill: ProtocolPill = { ...TEMPLATE_PILL, promptTemplate: "Summarize the attached document." };
expect(pillToPrompt(pill, {})).toBe("Summarize the attached document.");
});

it("trims a text value at assembly time, so a typed trailing space never reaches the prompt", () => {
const v = { ...formInitialValues(TEMPLATE_PILL), client: "Meridian Auto " };
expect(pillToPrompt(TEMPLATE_PILL, v)).toBe("Build a report for Meridian Auto covering last week.");
});

it("interpolates a textarea value with its interior line breaks intact", () => {
const v = {
...formInitialValues(TEXTAREA_PILL),
client: "Acme",
kpis: "CTR, goal 0.15%\nCPA, conversions / spend, under $40\n",
};
expect(pillToPrompt(TEXTAREA_PILL, v)).toBe(
"Wrap Acme.\nKPIs: CTR, goal 0.15%\nCPA, conversions / spend, under $40\nContext: {context}",
);
});
});

describe("pillToPrompt — neutral fallback (no template)", () => {
Expand All @@ -135,6 +200,15 @@ describe("pillToPrompt — neutral fallback (no template)", () => {
expect(out).not.toContain("Client:");
expect(out).not.toContain("Flight:");
});

it("renders a textarea as a Label: value line, exactly like text", () => {
const pill: ProtocolPill = { ...TEXTAREA_PILL, promptTemplate: undefined };
const v = { ...formInitialValues(pill), client: "Acme", kpis: "CTR, goal 0.15%\nCPA, under $40" };
const out = pillToPrompt(pill, v);
expect(out).toContain("Client: Acme");
expect(out).toContain("KPIs: CTR, goal 0.15%\nCPA, under $40");
expect(out).not.toContain("Context:"); // blank textarea omitted like a blank text
});
});

describe("DEFAULT_PILLS — neutral fallback catalog", () => {
Expand Down
Loading