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]
|
[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
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
Reference in New Issue
Block a user