diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.browser.test.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.browser.test.tsx index e6eecd6ad6f86..788416a545f0e 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.browser.test.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.browser.test.tsx @@ -16,6 +16,7 @@ vi.mock(import("../ChatMarkdown"), async (importOriginal) => ({ import { act, fakeTimers, render } from "../../../test/render"; import { makeThreadProjectionFixture } from "../../test-fixtures"; +import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; import { usePendingThreadRequests } from "./usePendingThreadRequests"; const threadRef = { environmentId: EnvironmentId.make("env"), threadId: ThreadId.make("thread") }; @@ -181,3 +182,90 @@ it("isolates drafts by environment, thread and request and discards only resolve ); } }); + +it("answers an option with Space on keyup, once per press, and not after focus leaves", async () => { + const onToggleOption = vi.fn(); + await using screen = await render( + {}} + onDismiss={() => {}} + />, + ); + const option = screen.getByRole("button", { name: /Incremental/ }).element() as HTMLElement; + const key = (type: "keydown" | "keyup", key: string, repeat = false) => + act(() => { + option.dispatchEvent(new KeyboardEvent(type, { key, repeat, bubbles: true })); + }); + + option.focus(); + await key("keydown", " "); + await key("keydown", " ", true); + await key("keydown", " ", true); + expect(onToggleOption).not.toHaveBeenCalled(); + await key("keyup", " "); + expect(onToggleOption).toHaveBeenCalledTimes(1); + + await key("keydown", " "); + await act(() => option.blur()); + option.focus(); + await key("keyup", " "); + expect(onToggleOption).toHaveBeenCalledTimes(1); + + await key("keydown", "Enter"); + expect(onToggleOption).toHaveBeenCalledTimes(2); + + const modifiedEnter = new KeyboardEvent("keydown", { + key: "Enter", + metaKey: true, + shiftKey: true, + bubbles: true, + cancelable: true, + }); + await act(() => { + option.dispatchEvent(modifiedEnter); + }); + expect(modifiedEnter.defaultPrevented).toBe(false); + expect(onToggleOption).toHaveBeenCalledTimes(2); +}); + +it("ignores a pointer click that ends a text selection but still answers assistive activation", async () => { + const onToggleOption = vi.fn(); + await using screen = await render( + {}} + onDismiss={() => {}} + />, + ); + const option = screen.getByRole("button", { name: /Incremental/ }).element() as HTMLElement; + const range = document.createRange(); + range.selectNodeContents(option); + window.getSelection()!.removeAllRanges(); + window.getSelection()!.addRange(range); + const click = (detail: number) => + act(() => { + option.dispatchEvent(new MouseEvent("click", { detail, bubbles: true })); + }); + + await click(1); + expect(onToggleOption).not.toHaveBeenCalled(); + await click(0); + expect(onToggleOption).toHaveBeenCalledTimes(1); +}); diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx index 99f80d8e50813..56f831083de72 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx @@ -85,6 +85,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( const canRespond = prompt.responseCapability !== "not_resumable"; const responseDisabled = disabled || isResponding || !canRespond; const autoAdvanceTimerRef = useRef(null); + const spacePressedOptionRef = useRef(null); const advance = useStableCallback(onAdvance); const [optimisticSingleSelect, setOptimisticSingleSelect] = useState<{ questionId: string; @@ -250,7 +251,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( isOptimisticallySelected || draftSelectedOptions?.[index] === true; const shortcutKey = index < 9 ? index + 1 : null; const className = cn( - "group flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left outline-none transition-colors duration-150 focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-primary/25", + "group flex w-full select-text items-center gap-2 rounded-md px-2.5 py-2 text-left outline-none transition-colors duration-150 focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-primary/25", isSelected ? "bg-muted/55 text-foreground" : "bg-transparent text-foreground/85 hover:bg-muted/30", @@ -278,18 +279,55 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( ) : null} ); + // Chromium cannot start a text selection inside a native + ); })} diff --git a/apps/web/test/fixtures/pending-user-input/README.md b/apps/web/test/fixtures/pending-user-input/README.md new file mode 100644 index 0000000000000..18fa86914fa0f --- /dev/null +++ b/apps/web/test/fixtures/pending-user-input/README.md @@ -0,0 +1,19 @@ +# Pending question evidence fixtures + +Run `node apps/web/test/fixtures/pending-user-input/request.mjs multi` or +`node apps/web/test/fixtures/pending-user-input/request.mjs single` from the repo +root. Paste the output into a Claude thread in an isolated development server. +The provider must only ask the question; it must not execute the example command. + +`multi` exercises wrapped labels and descriptions, inline question code, an empty +description, and a description identical to its label. Select **Wait** for the +matched stills, leaving the other options unselected. Keep the request pending +while testing selection, clipboard paste, ordinary clicks, keyboard activation, +and collapse/reopen. `single` has a nonterminal and terminal question. Reissue it +for each activation method because the terminal answer consumes the request. + +These are public synthetic inputs, not database rows or provider credentials. +Provider-generated request/question identifiers can differ. Claude's four-option +limit prevents this live fixture from covering the ninth/tenth option. Permission +disabled, in-flight responding, and nonresumable states require separate real +runtime conditions; changing these inputs does not establish those conditions. diff --git a/apps/web/test/fixtures/pending-user-input/questions.json b/apps/web/test/fixtures/pending-user-input/questions.json new file mode 100644 index 0000000000000..3850ad3f0fa94 --- /dev/null +++ b/apps/web/test/fixtures/pending-user-input/questions.json @@ -0,0 +1,78 @@ +{ + "multi": [ + { + "id": "selection-test", + "header": "Selection test", + "question": "Which command should handle ticket T3-16821? Inspect `git status --short` before proceeding.", + "multiSelect": true, + "options": [ + { + "label": "Inspect a long command and its harmless temporary working directory", + "description": "Run git status --short in /tmp/repro\nKeep ticket T3-16821 pending until review is complete." + }, + { + "label": "Wait", + "description": "Leave ticket T3-16821 pending" + }, + { + "label": "No description", + "description": "" + }, + { + "label": "Same description", + "description": "Same description" + } + ] + } + ], + "single": [ + { + "id": "single-first", + "header": "First question", + "question": "Which command should handle ticket T3-16821? Inspect `git status --short` before proceeding.", + "multiSelect": false, + "options": [ + { + "label": "Inspect a long command and its harmless temporary working directory", + "description": "Run git status --short in /tmp/repro\nKeep ticket T3-16821 pending until review is complete." + }, + { + "label": "Wait", + "description": "Leave ticket T3-16821 pending" + }, + { + "label": "No description", + "description": "" + }, + { + "label": "Same description", + "description": "Same description" + } + ] + }, + { + "id": "single-final", + "header": "Final question", + "question": "Which harmless choice should finish ticket T3-16821?", + "multiSelect": false, + "options": [ + { + "label": "Inspect a long command and its harmless temporary working directory", + "description": "Run git status --short in /tmp/repro\nKeep ticket T3-16821 pending until review is complete." + }, + { + "label": "Wait", + "description": "Leave ticket T3-16821 pending" + }, + { + "label": "No description", + "description": "" + }, + { + "label": "Same description", + "description": "Same description" + } + ] + } + ] +} diff --git a/apps/web/test/fixtures/pending-user-input/request.mjs b/apps/web/test/fixtures/pending-user-input/request.mjs new file mode 100644 index 0000000000000..99c7d85213037 --- /dev/null +++ b/apps/web/test/fixtures/pending-user-input/request.mjs @@ -0,0 +1,17 @@ +import * as NodeFS from "node:fs"; + +const fixtures = JSON.parse( + NodeFS.readFileSync(new URL("./questions.json", import.meta.url), "utf8"), +); +const shape = process.argv[2] ?? "multi"; +if (shape !== "multi" && shape !== "single") { + throw new Error("Usage: node request.mjs [multi|single]"); +} + +console.log( + "Call only AskUserQuestion with the questions below. Do not execute commands, edit files, " + + "or use other tools. Preserve the text exactly, including the description newline. " + + "The id fields are fixture identifiers; omit them if the provider schema disallows them. " + + "Leave the request pending. When answered, acknowledge the choices without taking action.\n\n" + + JSON.stringify({ questions: fixtures[shape] }, null, 2), +);