feat: allow multiline custom answers in clarification overlay (#1566)

* feat: allow multiline custom answers in clarification overlay

Swap the single-line custom-answer input for an auto-growing textarea so users
can type multi-line replies to ask_user_question. Enter submits, Shift+Enter
inserts a newline; the box grows to ~6 lines then scrolls. Adds unit and e2e
coverage.

* fix: address review feedback on multiline clarification input

- Always preventDefault on desktop plain Enter so an empty draft can't leak a
  stray newline before the trim guard (Claude/CodeRabbit).
- On coarse-pointer devices let Enter insert a newline and submit via the
  existing Submit button; hide the keyboard hints on touch (mobile parity).
- Preserve newlines in the resolved clarification history message via
  whitespace-pre-wrap + items-start.
- Strengthen e2e to guard against fused lines; add Ctrl+Enter and touch tests.

* fix: touch submit button and IME guard for clarification input

- Guard against submitting on the Enter that confirms an IME candidate
  (e.nativeEvent.isComposing) on CJK keyboards.
- Add an inline Send button on coarse-pointer devices so single-question custom
  answers (which have no overlay Submit button) have a touch-reachable send
  path. Fixes the regression from switching touch Enter to newline.

* refactor: extract clarification custom-input controls

- Pull the Enter/⇧↵ hints and touch Send button into a CustomInputControls
  subcomponent to shrink ClarificationCustomInput.
- Compute the trimmed draft once and reuse it across the keydown handler and the
  Send button (disabled state, click, styling) so the conditions can't drift.
- Add a test asserting the touch Send button is disabled while isSubmitting.

* fix: ignore auto-repeat Enter in clarification custom input

A held Enter key emits auto-repeat keydown events; guard the handler with
e.repeat so it can't fire onSubmit more than once. Adds a regression test.

* fix: suppress newline on repeat Enter and add mobile clarification e2e

- Repeat Enter on desktop now calls preventDefault before bailing, so a held key
  can't leak newlines into the current or next question's textarea after the
  carousel advances; it still never re-fires onSubmit/finalize.
- Add a mobile-chrome (Pixel 5) e2e covering the touch path: Enter inserts a
  newline and the inline Send button submits the multiline answer.

* test: consolidate clarification e2e seed helper

Extract the shared seed → navigate → waitForLoad flow into
e2e/helpers/clarification.ts and delegate from the clarification, resize, and
mobile specs instead of duplicating it in each.
This commit is contained in:
José Almeida
2026-07-01 21:36:58 +01:00
committed by GitHub
parent a30dd39566
commit 5efeae4ece
8 changed files with 449 additions and 67 deletions
@@ -0,0 +1,191 @@
import { afterEach, describe, it, expect, vi } from "vitest";
import { cleanup, render, fireEvent } from "@testing-library/react";
import { ClarificationCustomInput } from "./clarification-overlay-parts";
// Mutable pointer state so individual tests can flip to a touch device without
// touching matchMedia internals.
const { pointer } = vi.hoisted(() => ({ pointer: { isFinePointer: true } }));
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
useResponsiveBreakpoint: () => pointer,
}));
afterEach(() => {
cleanup();
pointer.isFinePointer = true;
});
const INPUT_TESTID = "clarification-input";
const TOUCH_SUBMIT_TESTID = "clarification-custom-submit";
const MULTILINE = "line one\nline two";
function makeProps(overrides: Partial<Parameters<typeof ClarificationCustomInput>[0]> = {}) {
return {
draft: "",
isSubmitting: false,
committedText: null,
active: false,
onChange: vi.fn(),
onSubmit: vi.fn(),
onRequestFinalSubmit: vi.fn(),
...overrides,
};
}
// fireEvent.keyDown returns false when a handler called preventDefault.
function pressEnter(el: HTMLElement, init: Partial<KeyboardEventInit> = {}): boolean {
return fireEvent.keyDown(el, { key: "Enter", ...init });
}
describe("ClarificationCustomInput multiline", () => {
it("renders a textarea so answers can span multiple lines", () => {
const { getByTestId } = render(<ClarificationCustomInput {...makeProps()} />);
expect(getByTestId(INPUT_TESTID).tagName).toBe("TEXTAREA");
});
it("submits the trimmed draft on plain Enter", () => {
const onSubmit = vi.fn();
const { getByTestId } = render(
<ClarificationCustomInput {...makeProps({ draft: " hello ", onSubmit })} />,
);
const notDefaulted = pressEnter(getByTestId(INPUT_TESTID));
expect(notDefaulted).toBe(false); // preventDefault fired
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit).toHaveBeenCalledWith("hello");
});
it("swallows plain Enter on an empty draft without inserting a stray newline", () => {
const onSubmit = vi.fn();
const { getByTestId } = render(
<ClarificationCustomInput {...makeProps({ draft: " ", onSubmit })} />,
);
const notDefaulted = pressEnter(getByTestId(INPUT_TESTID));
expect(notDefaulted).toBe(false); // preventDefault fired → no phantom newline
expect(onSubmit).not.toHaveBeenCalled();
});
it("does NOT submit on Shift+Enter — the newline falls through to the textarea", () => {
const onSubmit = vi.fn();
const onRequestFinalSubmit = vi.fn();
const { getByTestId } = render(
<ClarificationCustomInput
{...makeProps({ draft: "line one", onSubmit, onRequestFinalSubmit })}
/>,
);
const notDefaulted = pressEnter(getByTestId(INPUT_TESTID), { shiftKey: true });
expect(notDefaulted).toBe(true); // default not prevented → newline inserted
expect(onSubmit).not.toHaveBeenCalled();
expect(onRequestFinalSubmit).not.toHaveBeenCalled();
});
it("preserves inner newlines when submitting a multi-line draft (trims ends only)", () => {
const onSubmit = vi.fn();
const { getByTestId } = render(
<ClarificationCustomInput {...makeProps({ draft: `\n${MULTILINE}\n`, onSubmit })} />,
);
pressEnter(getByTestId(INPUT_TESTID));
expect(onSubmit).toHaveBeenCalledWith(MULTILINE);
});
it("finalizes the bundle on Cmd+Enter without per-question submit", () => {
const onSubmit = vi.fn();
const onRequestFinalSubmit = vi.fn();
const { getByTestId } = render(
<ClarificationCustomInput
{...makeProps({ draft: "answer", onSubmit, onRequestFinalSubmit })}
/>,
);
pressEnter(getByTestId(INPUT_TESTID), { metaKey: true });
expect(onRequestFinalSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit).not.toHaveBeenCalled();
});
it("finalizes the bundle on Ctrl+Enter without per-question submit", () => {
const onSubmit = vi.fn();
const onRequestFinalSubmit = vi.fn();
const { getByTestId } = render(
<ClarificationCustomInput
{...makeProps({ draft: "answer", onSubmit, onRequestFinalSubmit })}
/>,
);
pressEnter(getByTestId(INPUT_TESTID), { ctrlKey: true });
expect(onRequestFinalSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit).not.toHaveBeenCalled();
});
it("ignores auto-repeat Enter but still suppresses the newline (no double submit)", () => {
const onSubmit = vi.fn();
const { getByTestId } = render(
<ClarificationCustomInput {...makeProps({ draft: "answer", onSubmit })} />,
);
const notDefaulted = pressEnter(getByTestId(INPUT_TESTID), { repeat: true });
// preventDefault still fires so a held key can't leak a newline into this or
// the next question's textarea, but onSubmit does not run again.
expect(notDefaulted).toBe(false);
expect(onSubmit).not.toHaveBeenCalled();
});
it("does not submit while an IME candidate is being composed", () => {
const onSubmit = vi.fn();
const { getByTestId } = render(
<ClarificationCustomInput {...makeProps({ draft: "候補", onSubmit })} />,
);
const notDefaulted = pressEnter(getByTestId(INPUT_TESTID), { isComposing: true });
expect(notDefaulted).toBe(true); // default not prevented → IME confirms candidate
expect(onSubmit).not.toHaveBeenCalled();
});
});
describe("ClarificationCustomInput on touch devices", () => {
it("Enter inserts a newline instead of submitting", () => {
pointer.isFinePointer = false;
const onSubmit = vi.fn();
const { getByTestId } = render(
<ClarificationCustomInput {...makeProps({ draft: "line one", onSubmit })} />,
);
const notDefaulted = pressEnter(getByTestId(INPUT_TESTID));
expect(notDefaulted).toBe(true); // default not prevented → newline inserted
expect(onSubmit).not.toHaveBeenCalled();
});
it("hides the keyboard hints on touch devices", () => {
pointer.isFinePointer = false;
const { queryByText } = render(<ClarificationCustomInput {...makeProps()} />);
expect(queryByText("⇧↵ newline")).toBeNull();
});
it("shows a Send button on touch that submits the trimmed draft", () => {
pointer.isFinePointer = false;
const onSubmit = vi.fn();
const { getByTestId } = render(
<ClarificationCustomInput {...makeProps({ draft: `${MULTILINE} `, onSubmit })} />,
);
fireEvent.click(getByTestId(TOUCH_SUBMIT_TESTID));
expect(onSubmit).toHaveBeenCalledWith(MULTILINE);
});
it("disables the touch Send button for an empty draft", () => {
pointer.isFinePointer = false;
const onSubmit = vi.fn();
const { getByTestId } = render(
<ClarificationCustomInput {...makeProps({ draft: " ", onSubmit })} />,
);
const button = getByTestId(TOUCH_SUBMIT_TESTID) as HTMLButtonElement;
expect(button.disabled).toBe(true);
fireEvent.click(button);
expect(onSubmit).not.toHaveBeenCalled();
});
it("disables the touch Send button while a submit is in flight", () => {
pointer.isFinePointer = false;
const onSubmit = vi.fn();
const { getByTestId } = render(
<ClarificationCustomInput
{...makeProps({ draft: "answer", isSubmitting: true, onSubmit })}
/>,
);
const button = getByTestId(TOUCH_SUBMIT_TESTID) as HTMLButtonElement;
expect(button.disabled).toBe(true);
fireEvent.click(button);
expect(onSubmit).not.toHaveBeenCalled();
});
});
@@ -1,9 +1,16 @@
"use client";
import { IconCheck, IconCornerDownLeft, IconArrowLeft, IconArrowRight } from "@tabler/icons-react";
import { useLayoutEffect, useRef } from "react";
import { Textarea } from "@kandev/ui/textarea";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { cn } from "@/lib/utils";
import type { ClarificationOption } from "@/lib/types/http";
// Grow the custom-answer box up to ~6 lines, then scroll internally so the
// clarification overlay stays compact.
const MAX_CUSTOM_INPUT_HEIGHT = 160;
export function stepClassName(active: boolean, answered: boolean): string {
if (active) {
return "bg-blue-500 text-white border-blue-500 shadow-[0_0_0_3px_rgba(59,130,246,0.18)]";
@@ -155,6 +162,58 @@ type CustomInputProps = {
onRequestFinalSubmit?: () => void;
};
// Trailing controls for the custom-answer row. Hardware keyboards get the
// Enter/⇧↵ hints; touch devices get an inline Send button, because the overlay's
// own Submit button only renders for multi-question bundles and single-question
// custom answers would otherwise have no touch-reachable send path.
function CustomInputControls({
isFinePointer,
trimmed,
isSubmitting,
onSubmit,
}: {
isFinePointer: boolean;
trimmed: string;
isSubmitting: boolean;
onSubmit: (text: string) => void;
}) {
if (isFinePointer) {
return (
<div className="mt-0.5 flex flex-shrink-0 items-center gap-1">
<kbd
aria-hidden="true"
className="select-none flex items-center gap-1 font-mono text-[10px] px-1.5 py-0.5 rounded border border-border bg-background text-muted-foreground"
>
<IconCornerDownLeft className="h-2.5 w-2.5" />
Enter
</kbd>
<span aria-hidden="true" className="select-none text-[10px] text-muted-foreground/60">
⇧↵ newline
</span>
</div>
);
}
const canSend = trimmed.length > 0 && !isSubmitting;
return (
<button
type="button"
onClick={() => onSubmit(trimmed)}
disabled={!canSend}
data-testid="clarification-custom-submit"
aria-label="Send answer"
className={cn(
"mt-0.5 flex flex-shrink-0 items-center gap-1 text-xs px-2 py-1 rounded font-medium transition-colors",
canSend
? "bg-blue-500 text-white hover:bg-blue-500/90 cursor-pointer"
: "bg-muted text-muted-foreground cursor-not-allowed",
)}
>
Send
<IconCornerDownLeft className="h-3 w-3" />
</button>
);
}
export function ClarificationCustomInput({
draft,
isSubmitting,
@@ -164,25 +223,42 @@ export function ClarificationCustomInput({
onSubmit,
onRequestFinalSubmit,
}: CustomInputProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const trimmed = draft.trim();
// Touch keyboards have no Shift+Enter chord, so on coarse-pointer devices we
// let Enter insert a newline and rely on the always-visible Submit button to
// send — otherwise the multiline feature would be desktop-only.
const { isFinePointer } = useResponsiveBreakpoint();
// Auto-grow to fit content (WebKit lacks CSS field-sizing, so measure in JS)
// and clamp to MAX_CUSTOM_INPUT_HEIGHT, after which the box scrolls.
useLayoutEffect(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, MAX_CUSTOM_INPUT_HEIGHT)}px`;
}, [draft]);
return (
<div
data-testid="clarification-custom-input"
data-active={active ? "true" : "false"}
className={cn(
"mt-2.5 flex items-center gap-2 px-3 py-2 rounded-lg border transition-colors",
"mt-2.5 flex items-start gap-2 px-3 py-2 rounded-lg border transition-colors",
active
? "bg-blue-500/15 border-blue-500/50 text-foreground"
: "border-dashed border-border/70 bg-muted/30",
)}
>
<span
className={cn("text-xs", active ? "text-blue-500" : "text-muted-foreground")}
className={cn("mt-0.5 text-xs", active ? "text-blue-500" : "text-muted-foreground")}
aria-hidden="true"
>
↳
</span>
<input
type="text"
<Textarea
ref={textareaRef}
rows={1}
placeholder={
committedText !== null ? "Press Enter to update your answer…" : "Or type a custom answer…"
}
@@ -190,32 +266,42 @@ export function ClarificationCustomInput({
onChange={(e) => onChange(e.target.value)}
disabled={isSubmitting}
data-testid="clarification-input"
className="flex-1 text-sm bg-transparent placeholder:text-muted-foreground/60 focus:outline-none"
style={{ maxHeight: MAX_CUSTOM_INPUT_HEIGHT }}
className="flex-1 min-h-0 resize-none overflow-y-auto border-0 bg-transparent p-0 text-sm shadow-none focus-visible:ring-0 placeholder:text-muted-foreground/60"
onKeyDown={(e) => {
if (e.key !== "Enter" || e.shiftKey || e.altKey) return;
// Shift+Enter (and Alt+Enter) fall through so the textarea inserts a
// newline instead of submitting. isComposing ignores the Enter that
// confirms an IME candidate (CJK keyboards).
if (e.key !== "Enter" || e.shiftKey || e.altKey || e.nativeEvent.isComposing) return;
if (e.metaKey || e.ctrlKey) {
// Cmd/Ctrl+Enter only asks the parent to finalize. The draft was
// already live-recorded via onChange, so we skip the per-question
// commit path (which would also advance the carousel one step on
// multi-question bundles — wasted state churn before submit).
// e.repeat guards a held key against firing multiple finalizes.
e.preventDefault();
onRequestFinalSubmit?.();
if (!e.repeat) onRequestFinalSubmit?.();
return;
}
if (draft.trim()) {
e.preventDefault();
onSubmit(draft.trim());
// On touch devices Enter inserts a newline (submit via the button).
if (!isFinePointer) return;
// Plain Enter submits on desktop. preventDefault runs unconditionally
// (including on auto-repeat) so a held key never leaks stray newlines
// into this — or, once the carousel advances, the next — textarea, and
// so an empty/whitespace draft can't leak one before the trim guard.
e.preventDefault();
if (!e.repeat && trimmed) {
onSubmit(trimmed);
}
}}
/>
<kbd
aria-hidden="true"
className="select-none flex items-center gap-1 font-mono text-[10px] px-1.5 py-0.5 rounded border border-border bg-background text-muted-foreground"
>
<IconCornerDownLeft className="h-2.5 w-2.5" />
Enter
</kbd>
{active && <IconCheck className="h-3.5 w-3.5 text-blue-500 flex-shrink-0" />}
<CustomInputControls
isFinePointer={isFinePointer}
trimmed={trimmed}
isSubmitting={isSubmitting}
onSubmit={onSubmit}
/>
{active && <IconCheck className="mt-0.5 h-3.5 w-3.5 text-blue-500 flex-shrink-0" />}
</div>
);
}
@@ -84,9 +84,10 @@ export function ClarificationRequestMessage({ comment }: ClarificationRequestMes
{/* Answer - indented below question */}
{isAnswered && (
<div className="mt-1 ml-3 flex items-center gap-1.5 text-xs text-foreground/80">
<div className="mt-1 ml-3 flex items-start gap-1.5 text-xs text-foreground/80">
{getStatusIndicator()}
{getAnswerSummary()}
{/* pre-wrap preserves newlines from multiline custom answers. */}
<span className="whitespace-pre-wrap">{getAnswerSummary()}</span>
{metadata.agent_disconnected && (
<span className="text-muted-foreground">· sent as new message</span>
)}
+51
View File
@@ -0,0 +1,51 @@
import { type Page } from "@playwright/test";
import type { SeedData } from "../fixtures/test-base";
import type { ApiClient } from "./api-client";
import { SessionPage } from "../pages/session-page";
type SeedOptions = {
/** Mock-agent scenario slug, e.g. "clarification" or "simple-message". */
scenario: string;
/**
* Wait for the chat to go idle after load. Leave false for blocking
* clarification scenarios where the agent parks on the MCP call and the idle
* input never appears until the question is answered.
*/
waitForIdle?: boolean;
};
/**
* Create a task + session running a mock-agent scenario, navigate to it, and
* return a ready SessionPage. Shared by the clarification specs (overlay,
* resize, and mobile) so the seed → navigate → waitForLoad flow lives in one
* place.
*/
export async function seedClarificationSession(
testPage: Page,
apiClient: ApiClient,
seedData: SeedData,
title: string,
{ scenario, waitForIdle = false }: SeedOptions,
): Promise<SessionPage> {
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
title,
seedData.agentProfileId,
{
description: `/e2e:${scenario}`,
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id");
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
if (waitForIdle) await session.waitForChatIdle();
return session;
}
+5
View File
@@ -348,6 +348,11 @@ export class SessionPage {
return this.page.getByTestId("clarification-input");
}
/** Inline Send button shown next to the custom input on touch devices. */
clarificationCustomSubmit(): Locator {
return this.page.getByTestId("clarification-custom-submit");
}
/** Deferred notice shown when agent has disconnected from clarification. */
clarificationDeferredNotice(): Locator {
return this.page.getByTestId("clarification-deferred-notice");
@@ -2,41 +2,24 @@ import { type Page } from "@playwright/test";
import { test, expect } from "../../fixtures/test-base";
import type { SeedData } from "../../fixtures/test-base";
import type { ApiClient } from "../../helpers/api-client";
import { seedClarificationSession } from "../../helpers/clarification";
import { SessionPage } from "../../pages/session-page";
/**
* Helper to create a regular task, navigate to it, and wait for idle.
* Mirrors clarification.spec.ts's `seedClarificationTask`, but uses a non-
* blocking scenario so we reach the idle input and can drive the slash
* commands `/ask-single` and `/ask-multiple` from inside the session.
* Create a regular task, navigate to it, and wait for idle. Uses a non-blocking
* scenario so we reach the idle input and can drive the slash commands
* `/ask-single` and `/ask-multiple` from inside the session.
*/
async function seedTaskAndWaitForIdle(
function seedTaskAndWaitForIdle(
testPage: Page,
apiClient: ApiClient,
seedData: SeedData,
title: string,
): Promise<SessionPage> {
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
title,
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id");
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await session.waitForChatIdle();
return session;
return seedClarificationSession(testPage, apiClient, seedData, title, {
scenario: "simple-message",
waitForIdle: true,
});
}
test.describe("Mock agent clarification slash commands", () => {
+37 -21
View File
@@ -3,6 +3,7 @@ import { test, expect } from "../../fixtures/test-base";
import { useRegularMode } from "../../helpers/regular-mode";
import type { SeedData } from "../../fixtures/test-base";
import type { ApiClient } from "../../helpers/api-client";
import { seedClarificationSession } from "../../helpers/clarification";
import { SessionPage } from "../../pages/session-page";
import { KanbanPage } from "../../pages/kanban-page";
@@ -10,33 +11,14 @@ import { KanbanPage } from "../../pages/kanban-page";
* Seed a task + session with a clarification scenario and navigate to the session page.
* Does NOT wait for idle input — the agent will be blocked on the clarification MCP call.
*/
async function seedClarificationTask(
function seedClarificationTask(
testPage: Page,
apiClient: ApiClient,
seedData: SeedData,
title: string,
scenario: string,
): Promise<SessionPage> {
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
title,
seedData.agentProfileId,
{
description: `/e2e:${scenario}`,
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id");
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
return session;
return seedClarificationSession(testPage, apiClient, seedData, title, { scenario });
}
// Exercises the regular task-create dialog (New Task in the sidebar); run with office off.
@@ -64,6 +46,40 @@ test.describe("Clarification flow", () => {
await expect(session.chat).toContainText(/You answered|selected_option/);
});
test("custom answer accepts multiple lines via Shift+Enter", async ({
testPage,
apiClient,
seedData,
}) => {
const session = await seedClarificationTask(
testPage,
apiClient,
seedData,
"Clarification Multiline",
"clarification",
);
await expect(session.clarificationOverlay()).toBeVisible({ timeout: 30_000 });
// The custom answer is a textarea: Shift+Enter inserts a newline, plain
// Enter submits the whole multi-line draft.
const input = session.clarificationInput();
await input.click();
await input.pressSequentially("first line");
await input.press("Shift+Enter");
await input.pressSequentially("second line");
await expect(input).toHaveValue("first line\nsecond line");
await input.press("Enter");
await expect(session.idleInput()).toBeVisible({ timeout: 30_000 });
await expect(session.chat).toContainText("first line");
await expect(session.chat).toContainText("second line");
// The two lines must stay separated: if the newline were dropped the reply
// would render as the fused "first linesecond line".
await expect(session.chat).not.toContainText("linesecond line");
});
test("skip clarification", async ({ testPage, apiClient, seedData }) => {
const session = await seedClarificationTask(
testPage,
@@ -0,0 +1,49 @@
import { test, expect } from "../../fixtures/test-base";
import { seedClarificationSession } from "../../helpers/clarification";
/**
* Mobile parity for the multiline custom clarification answer. On a coarse-pointer
* device Enter inserts a newline instead of submitting, and the send affordance is
* the inline "Send" button (there is no overlay Submit button for single-question
* bundles). Runs under the Pixel 5 `mobile-chrome` project.
*/
test.describe("Mobile clarification multiline answer", () => {
test.describe.configure({ retries: 1, timeout: 120_000 });
test("Enter inserts a newline and the Send button submits the multiline answer", async ({
testPage,
apiClient,
seedData,
}) => {
const session = await seedClarificationSession(
testPage,
apiClient,
seedData,
"Mobile Clarify",
{
scenario: "clarification",
},
);
await expect(session.clarificationOverlay()).toBeVisible({ timeout: 30_000 });
const input = session.clarificationInput();
await input.click();
await input.pressSequentially("first line");
// On touch, Enter inserts a newline rather than submitting.
await input.press("Enter");
await input.pressSequentially("second line");
await expect(input).toHaveValue("first line\nsecond line");
// The overlay is still open — Enter did not submit.
await expect(session.clarificationOverlay()).toBeVisible();
// The inline Send button is the touch send affordance.
await expect(session.clarificationCustomSubmit()).toBeVisible();
await session.clarificationCustomSubmit().tap();
await expect(session.idleInput()).toBeVisible({ timeout: 30_000 });
await expect(session.chat).toContainText("first line");
await expect(session.chat).toContainText("second line");
await expect(session.chat).not.toContainText("linesecond line");
});
});