From 5efeae4ecef5ea8db7c20b8b4f2d28b953538b53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= <57680069+zeval@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:36:58 +0100 Subject: [PATCH] feat: allow multiline custom answers in clarification overlay (#1566) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- .../chat/clarification-custom-input.test.tsx | 191 ++++++++++++++++++ .../task/chat/clarification-overlay-parts.tsx | 122 +++++++++-- .../clarification-request-message.tsx | 5 +- apps/web/e2e/helpers/clarification.ts | 51 +++++ apps/web/e2e/pages/session-page.ts | 5 + .../tests/chat/clarification-resize.spec.ts | 35 +--- apps/web/e2e/tests/chat/clarification.spec.ts | 58 ++++-- .../tests/chat/mobile-clarification.spec.ts | 49 +++++ 8 files changed, 449 insertions(+), 67 deletions(-) create mode 100644 apps/web/components/task/chat/clarification-custom-input.test.tsx create mode 100644 apps/web/e2e/helpers/clarification.ts create mode 100644 apps/web/e2e/tests/chat/mobile-clarification.spec.ts diff --git a/apps/web/components/task/chat/clarification-custom-input.test.tsx b/apps/web/components/task/chat/clarification-custom-input.test.tsx new file mode 100644 index 0000000000..70de0a5af6 --- /dev/null +++ b/apps/web/components/task/chat/clarification-custom-input.test.tsx @@ -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[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 = {}): boolean { + return fireEvent.keyDown(el, { key: "Enter", ...init }); +} + +describe("ClarificationCustomInput multiline", () => { + it("renders a textarea so answers can span multiple lines", () => { + const { getByTestId } = render(); + expect(getByTestId(INPUT_TESTID).tagName).toBe("TEXTAREA"); + }); + + it("submits the trimmed draft on plain Enter", () => { + const onSubmit = vi.fn(); + const { getByTestId } = render( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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(); + 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( + , + ); + 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( + , + ); + 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( + , + ); + const button = getByTestId(TOUCH_SUBMIT_TESTID) as HTMLButtonElement; + expect(button.disabled).toBe(true); + fireEvent.click(button); + expect(onSubmit).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/components/task/chat/clarification-overlay-parts.tsx b/apps/web/components/task/chat/clarification-overlay-parts.tsx index e7cfbc55ae..105e0a74bb 100644 --- a/apps/web/components/task/chat/clarification-overlay-parts.tsx +++ b/apps/web/components/task/chat/clarification-overlay-parts.tsx @@ -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 ( +
+ + +
+ ); + } + const canSend = trimmed.length > 0 && !isSubmitting; + return ( + + ); +} + export function ClarificationCustomInput({ draft, isSubmitting, @@ -164,25 +223,42 @@ export function ClarificationCustomInput({ onSubmit, onRequestFinalSubmit, }: CustomInputProps) { + const textareaRef = useRef(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 (
- 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); } }} /> - - {active && } + + {active && }
); } diff --git a/apps/web/components/task/chat/messages/clarification-request-message.tsx b/apps/web/components/task/chat/messages/clarification-request-message.tsx index 4ae7b57dd9..6aaf7080be 100644 --- a/apps/web/components/task/chat/messages/clarification-request-message.tsx +++ b/apps/web/components/task/chat/messages/clarification-request-message.tsx @@ -84,9 +84,10 @@ export function ClarificationRequestMessage({ comment }: ClarificationRequestMes {/* Answer - indented below question */} {isAnswered && ( -
+
{getStatusIndicator()} - {getAnswerSummary()} + {/* pre-wrap preserves newlines from multiline custom answers. */} + {getAnswerSummary()} {metadata.agent_disconnected && ( · sent as new message )} diff --git a/apps/web/e2e/helpers/clarification.ts b/apps/web/e2e/helpers/clarification.ts new file mode 100644 index 0000000000..621ee45652 --- /dev/null +++ b/apps/web/e2e/helpers/clarification.ts @@ -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 { + 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; +} diff --git a/apps/web/e2e/pages/session-page.ts b/apps/web/e2e/pages/session-page.ts index ff2968b2f2..5564702b5c 100644 --- a/apps/web/e2e/pages/session-page.ts +++ b/apps/web/e2e/pages/session-page.ts @@ -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"); diff --git a/apps/web/e2e/tests/chat/clarification-resize.spec.ts b/apps/web/e2e/tests/chat/clarification-resize.spec.ts index 0feb0364ad..553ac9c1ad 100644 --- a/apps/web/e2e/tests/chat/clarification-resize.spec.ts +++ b/apps/web/e2e/tests/chat/clarification-resize.spec.ts @@ -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 { - 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", () => { diff --git a/apps/web/e2e/tests/chat/clarification.spec.ts b/apps/web/e2e/tests/chat/clarification.spec.ts index bc7a02c5fe..f38819fb25 100644 --- a/apps/web/e2e/tests/chat/clarification.spec.ts +++ b/apps/web/e2e/tests/chat/clarification.spec.ts @@ -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 { - 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, diff --git a/apps/web/e2e/tests/chat/mobile-clarification.spec.ts b/apps/web/e2e/tests/chat/mobile-clarification.spec.ts new file mode 100644 index 0000000000..53aef5e3a3 --- /dev/null +++ b/apps/web/e2e/tests/chat/mobile-clarification.spec.ts @@ -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"); + }); +});