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]
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
+5
View File
@@ -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");
+38 -3
View File
@@ -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");