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:
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user