fix(web): focus deferred clarification custom answer (#1871)

This commit is contained in:
Carlos Florêncio
2026-07-23 14:45:18 +01:00
committed by GitHub
parent edee5a03cf
commit 5d889478ea
6 changed files with 110 additions and 5 deletions
+6
View File
@@ -1,3 +1,9 @@
sandbox_mode = "workspace-write"
approval_policy = "never"
[sandbox_workspace_write]
network_access = true
[agents] [agents]
max_threads = 6 max_threads = 6
max_depth = 1 max_depth = 1
@@ -1,6 +1,8 @@
import { createRef } from "react";
import { afterEach, describe, it, expect, vi } from "vitest"; import { afterEach, describe, it, expect, vi } from "vitest";
import { cleanup, render, fireEvent } from "@testing-library/react"; import { cleanup, render, fireEvent } from "@testing-library/react";
import { ClarificationCustomInput } from "./clarification-overlay-parts"; 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 // Mutable pointer state so individual tests can flip to a touch device without
// touching matchMedia internals. // touching matchMedia internals.
@@ -36,6 +38,51 @@ function pressEnter(el: HTMLElement, init: Partial<KeyboardEventInit> = {}): boo
return fireEvent.keyDown(el, { key: "Enter", ...init }); 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", () => { describe("ClarificationCustomInput multiline", () => {
it("renders a textarea so answers can span multiple lines", () => { it("renders a textarea so answers can span multiple lines", () => {
const { getByTestId } = render(<ClarificationCustomInput {...makeProps()} />); const { getByTestId } = render(<ClarificationCustomInput {...makeProps()} />);
@@ -245,11 +245,20 @@ export function ClarificationCustomInput({
<div <div
data-testid="clarification-custom-input" data-testid="clarification-custom-input"
data-active={active ? "true" : "false"} 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( 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 active
? "bg-blue-500/15 border-blue-500/50 text-foreground" ? "bg-blue-500/15 border-blue-500/50 text-foreground"
: "border-dashed border-border/70 bg-muted/30", : "border-dashed border-border/70 bg-muted/30",
isSubmitting ? "cursor-not-allowed" : "cursor-text",
)} )}
> >
<span <span
+5
View File
@@ -357,6 +357,11 @@ export class SessionPage {
return this.page.getByTestId("clarification-input"); 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. */ /** Inline Send button shown next to the custom input on touch devices. */
clarificationCustomSubmit(): Locator { clarificationCustomSubmit(): Locator {
return this.page.getByTestId("clarification-custom-submit"); return this.page.getByTestId("clarification-custom-submit");
+38 -3
View File
@@ -86,6 +86,33 @@ test.describe("Clarification flow", () => {
await expect(session.chat).not.toContainText("linesecond line"); 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 }) => { test("skip clarification", async ({ testPage, apiClient, seedData }) => {
const session = await seedClarificationTask( const session = await seedClarificationTask(
testPage, testPage,
@@ -100,7 +127,7 @@ test.describe("Clarification flow", () => {
await expect(session.idleInput()).toBeVisible({ timeout: 30_000 }); 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, testPage,
apiClient, apiClient,
seedData, seedData,
@@ -154,9 +181,17 @@ test.describe("Clarification flow", () => {
); );
expect(primarySession?.state).toBe("WAITING_FOR_INPUT"); expect(primarySession?.state).toBe("WAITING_FOR_INPUT");
// Agent moved on; a late answer goes through the event fallback as a new prompt. // Agent moved on; a late custom answer remains editable and goes through
await session.clarificationOption("PostgreSQL").click(); // 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.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 ({ 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 }); await expect(session.clarificationOverlay()).toBeVisible({ timeout: 30_000 });
const input = session.clarificationInput(); 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"); await input.pressSequentially("first line");
// On touch, Enter inserts a newline rather than submitting. // On touch, Enter inserts a newline rather than submitting.
await input.press("Enter"); await input.press("Enter");