fix(web): focus deferred clarification custom answer (#1871)
This commit is contained in:
@@ -1,3 +1,9 @@
|
||||
sandbox_mode = "workspace-write"
|
||||
approval_policy = "never"
|
||||
|
||||
[sandbox_workspace_write]
|
||||
network_access = true
|
||||
|
||||
[agents]
|
||||
max_threads = 6
|
||||
max_depth = 1
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createRef } from "react";
|
||||
import { afterEach, describe, it, expect, vi } from "vitest";
|
||||
import { cleanup, render, fireEvent } from "@testing-library/react";
|
||||
import { ClarificationCustomInput } from "./clarification-overlay-parts";
|
||||
import { routePanelMouseDown } from "./route-panel-mouse-down";
|
||||
|
||||
// Mutable pointer state so individual tests can flip to a touch device without
|
||||
// touching matchMedia internals.
|
||||
@@ -36,6 +38,51 @@ function pressEnter(el: HTMLElement, init: Partial<KeyboardEventInit> = {}): boo
|
||||
return fireEvent.keyDown(el, { key: "Enter", ...init });
|
||||
}
|
||||
|
||||
function renderInPanel(overrides: Partial<Parameters<typeof ClarificationCustomInput>[0]> = {}) {
|
||||
const panelRef = createRef<HTMLDivElement>();
|
||||
return render(
|
||||
<div
|
||||
data-testid="panel"
|
||||
tabIndex={-1}
|
||||
ref={panelRef}
|
||||
onMouseDown={(event) => routePanelMouseDown(event, panelRef)}
|
||||
>
|
||||
<ClarificationCustomInput {...makeProps(overrides)} />
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("ClarificationCustomInput row focus", () => {
|
||||
it("focuses the textarea when the non-interactive row surface is pressed", () => {
|
||||
const { getByTestId } = renderInPanel();
|
||||
|
||||
const notDefaulted = fireEvent.mouseDown(getByTestId("clarification-custom-input"));
|
||||
|
||||
expect(notDefaulted).toBe(false);
|
||||
expect(document.activeElement).toBe(getByTestId(INPUT_TESTID));
|
||||
});
|
||||
|
||||
it("does not focus the disabled textarea from the row surface", () => {
|
||||
const { getByTestId } = renderInPanel({ isSubmitting: true });
|
||||
|
||||
fireEvent.mouseDown(getByTestId("clarification-custom-input"));
|
||||
|
||||
expect(document.activeElement).not.toBe(getByTestId(INPUT_TESTID));
|
||||
});
|
||||
|
||||
it("does not route a touch Send press through the textarea", () => {
|
||||
pointer.isFinePointer = false;
|
||||
const onSubmit = vi.fn();
|
||||
const { getByTestId } = renderInPanel({ draft: "answer", onSubmit });
|
||||
|
||||
fireEvent.mouseDown(getByTestId(TOUCH_SUBMIT_TESTID));
|
||||
fireEvent.click(getByTestId(TOUCH_SUBMIT_TESTID));
|
||||
|
||||
expect(document.activeElement).not.toBe(getByTestId(INPUT_TESTID));
|
||||
expect(onSubmit).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ClarificationCustomInput multiline", () => {
|
||||
it("renders a textarea so answers can span multiple lines", () => {
|
||||
const { getByTestId } = render(<ClarificationCustomInput {...makeProps()} />);
|
||||
|
||||
@@ -245,11 +245,20 @@ export function ClarificationCustomInput({
|
||||
<div
|
||||
data-testid="clarification-custom-input"
|
||||
data-active={active ? "true" : "false"}
|
||||
onMouseDown={(event) => {
|
||||
if (isSubmitting) return;
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.closest("textarea, button")) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
textareaRef.current?.focus({ preventScroll: true });
|
||||
}}
|
||||
className={cn(
|
||||
"mt-2.5 flex items-start gap-2 px-3 py-2 rounded-lg border transition-colors",
|
||||
"mt-2.5 flex min-h-11 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",
|
||||
isSubmitting ? "cursor-not-allowed" : "cursor-text",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
|
||||
@@ -357,6 +357,11 @@ export class SessionPage {
|
||||
return this.page.getByTestId("clarification-input");
|
||||
}
|
||||
|
||||
/** Apparent custom-answer row surrounding the textarea. */
|
||||
clarificationCustomInput(): Locator {
|
||||
return this.activeChat().getByTestId("clarification-custom-input");
|
||||
}
|
||||
|
||||
/** Inline Send button shown next to the custom input on touch devices. */
|
||||
clarificationCustomSubmit(): Locator {
|
||||
return this.page.getByTestId("clarification-custom-submit");
|
||||
|
||||
@@ -86,6 +86,33 @@ test.describe("Clarification flow", () => {
|
||||
await expect(session.chat).not.toContainText("linesecond line");
|
||||
});
|
||||
|
||||
test("custom answer row ignores a hidden stale session chat", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const session = await seedClarificationTask(
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
"Clarification Active Chat Locator",
|
||||
"clarification",
|
||||
);
|
||||
|
||||
await expect(session.clarificationOverlay()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Dockview can retain a prior session's chat in the DOM after its tab is
|
||||
// hidden. The custom-answer row must come from the visible chat only.
|
||||
await testPage.locator("body").evaluate((body) => {
|
||||
body.insertAdjacentHTML(
|
||||
"beforeend",
|
||||
'<div data-testid="session-chat" style="display: none"><div data-testid="clarification-custom-input">stale custom answer</div></div>',
|
||||
);
|
||||
});
|
||||
|
||||
await expect(session.clarificationCustomInput()).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("skip clarification", async ({ testPage, apiClient, seedData }) => {
|
||||
const session = await seedClarificationTask(
|
||||
testPage,
|
||||
@@ -100,7 +127,7 @@ test.describe("Clarification flow", () => {
|
||||
await expect(session.idleInput()).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
test("timeout detaches clarification but keeps overlay for deferred answer", async ({
|
||||
test("timeout detaches clarification and accepts a custom deferred answer", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
@@ -154,9 +181,17 @@ test.describe("Clarification flow", () => {
|
||||
);
|
||||
expect(primarySession?.state).toBe("WAITING_FOR_INPUT");
|
||||
|
||||
// Agent moved on; a late answer goes through the event fallback as a new prompt.
|
||||
await session.clarificationOption("PostgreSQL").click();
|
||||
// Agent moved on; a late custom answer remains editable and goes through
|
||||
// the event fallback as a new prompt.
|
||||
const input = session.clarificationInput();
|
||||
await expect(input).toBeEnabled();
|
||||
await session.clarificationCustomInput().click({ position: { x: 4, y: 4 } });
|
||||
await expect(input).toBeFocused();
|
||||
await input.pressSequentially("Use the embedded database for this task");
|
||||
await expect(input).toHaveValue("Use the embedded database for this task");
|
||||
await input.press("Enter");
|
||||
await expect(session.idleInput()).toBeVisible({ timeout: 30_000 });
|
||||
await expect(session.chat).toContainText("Use the embedded database for this task");
|
||||
});
|
||||
|
||||
test("options render label and description on separate rows", async ({
|
||||
|
||||
@@ -28,7 +28,10 @@ test.describe("Mobile clarification multiline answer", () => {
|
||||
await expect(session.clarificationOverlay()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const input = session.clarificationInput();
|
||||
await input.click();
|
||||
// Tap the apparent row surface, outside the textarea itself. The whole row
|
||||
// is the mobile touch target and should transfer focus into the textarea.
|
||||
await session.clarificationCustomInput().tap({ position: { x: 4, y: 4 } });
|
||||
await expect(input).toBeFocused();
|
||||
await input.pressSequentially("first line");
|
||||
// On touch, Enter inserts a newline rather than submitting.
|
||||
await input.press("Enter");
|
||||
|
||||
Reference in New Issue
Block a user