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 777238f7635e8..924b815c10b74 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.browser.test.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.browser.test.tsx @@ -20,6 +20,7 @@ import { act, fakeTimers, render } from "../../../test/render"; import { useComposerDraftStore } from "../../composerDraftStore"; import { makeThreadProjectionFixture } from "../../test-fixtures"; import { toastManager } from "../ui/toast"; +import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; import { usePendingThreadRequests } from "./usePendingThreadRequests"; const threadRef = { environmentId: EnvironmentId.make("env"), threadId: ThreadId.make("thread") }; @@ -265,3 +266,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 c752e78c1ba6f..d67a31a391941 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx @@ -87,6 +87,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; @@ -275,7 +276,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( isOptimisticallySelected || draftSelectedOptions?.[index] === true; const className = cn( questionTextAlign, - "group flex w-full items-start gap-2.5 rounded-md bg-transparent py-1.5 ps-2 sm:ps-1.5 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-start gap-2.5 rounded-md bg-transparent py-1.5 ps-2 sm:ps-1.5 outline-none transition-colors duration-150 focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-primary/25", isSelected ? "text-foreground" : "text-foreground/70 hover:bg-muted/30", (disabled || isResponding) && "opacity-50 cursor-not-allowed", !disabled && !isResponding && "cursor-pointer", @@ -310,19 +311,56 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( ) : null} ); + // Chromium cannot start a text selection inside a native + ); })}