From 7fdda410b81e0c11c5b4b5304bde860e40e1760a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 19:45:06 +0000 Subject: [PATCH 1/2] test(web): tapping outside the picker's Permissions menu on a touch tablet focuses the composer Failing reproduction for #26447. Co-Authored-By: Claude Opus 5.5 --- ...omposerPermissionsSection.browser.test.tsx | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/apps/web/src/components/chat/ComposerPermissionsSection.browser.test.tsx b/apps/web/src/components/chat/ComposerPermissionsSection.browser.test.tsx index 9dadb999da85..bb03e19dd130 100644 --- a/apps/web/src/components/chat/ComposerPermissionsSection.browser.test.tsx +++ b/apps/web/src/components/chat/ComposerPermissionsSection.browser.test.tsx @@ -7,12 +7,14 @@ import { render } from "../../../test/render"; import { ComposerHandleContext, type ComposerHandleRef } from "../../composerHandleContext"; import { ComposerContextPermissions } from "../ComposerContextPermissions"; import { Menu, MenuPopup, MenuTrigger } from "../ui/menu"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { focusRequestedPermission, markOpenToPermissions, useComposerMenuProps, } from "./composerEventScope"; import { ComposerFooterModeControls, ComposerPermissionsSection } from "./ComposerFooterControls"; +import { ComposerPickerFooter } from "./ComposerModelEffort"; import { runtimeModeConfig, runtimeModeOptions } from "./runtimeModeConfig"; const options = runtimeModeOptions.map((mode) => ({ mode, ...runtimeModeConfig[mode] })); @@ -95,6 +97,49 @@ it("returns focus to the editor after a pick", async () => { await vi.waitFor(() => expect(handle.focusAtEnd).toHaveBeenCalled()); }); +// Mirrors the model picker: a composer popover whose footer nests the Permissions menu. +function PickerWithPermissions() { + const [mode, setMode] = useState("full-access"); + const composerMenuProps = useComposerMenuProps(); + return ( + <> +

Timeline

+ + Model + + + + + + ); +} + +it("on a touch tablet, tapping outside the Permissions menu leaves the editor unfocused", async () => { + const realMatchMedia = window.matchMedia.bind(window); + using _touch = vi + .spyOn(window, "matchMedia") + .mockImplementation((query) => realMatchMedia(query === "(pointer: coarse)" ? "all" : query)); + const { handle, ui } = withComposer(); + await using screen = await render(ui, { router: false }); // no routes involved + await userEvent.click(screen.getByRole("button", { name: "Model" })); + await userEvent.click(page.getByRole("button", { name: "Permissions: Full access" })); + const fullAccess = page.getByRole("menuitemradio", { name: "Full access" }); + await expect.element(fullAccess).toBeInTheDocument(); + const permissionsTrigger = page.getByRole("button", { name: "Permissions: Full access" }); + // The first outside tap closes the Permissions menu, the second the picker around it. + await userEvent.click(document.body, { position: { x: 1, y: 1 } }); + await expect.element(fullAccess).not.toBeInTheDocument(); + await userEvent.click(screen.getByText("Timeline")); + await expect.element(permissionsTrigger).not.toBeInTheDocument(); + // Focusing the editor after a tap opens the on-screen keyboard on Android tablets. + expect(handle.focusAtEnd).not.toHaveBeenCalled(); +}); + it("opens on the checked permission when composer.mode marked the trigger", async () => { await using screen = await render(, { router: false }); // no routes involved const trigger = screen.getByRole("button", { name: "More" }); From a77f632a3691d5e6619354035a0a601b6c1430b0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 19:51:09 +0000 Subject: [PATCH 2/2] fix(web): closing a composer menu on a touch tablet no longer opens the keyboard Composer menus return focus to the editor when they close. On a touch device that focus lands inside the tap's user activation, so Chrome on Android raised the on-screen keyboard, for example after tapping outside the model picker once its Permissions menu had been opened. useComposerMenuProps now leaves finalFocus unset on a coarse pointer, so the menu returns focus to its own trigger, matching the coarse-pointer check ChatView already applies to thread open and window focus. Fixes pingdotgg/t3code#26447 Co-Authored-By: Claude Fable 5.2 --- .../src/components/chat/composerEventScope.ts | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/chat/composerEventScope.ts b/apps/web/src/components/chat/composerEventScope.ts index a0271b4a8257..b71c338e61ec 100644 --- a/apps/web/src/components/chat/composerEventScope.ts +++ b/apps/web/src/components/chat/composerEventScope.ts @@ -1,4 +1,5 @@ import { useComposerHandleContext } from "../../composerHandleContext"; +import { useMediaQuery } from "../../hooks/useMediaQuery"; const COMPOSER_FLOATING_LAYER_SELECTOR = [ '[data-composer-drawer-layer="true"]', @@ -11,19 +12,22 @@ export const composerFloatingLayerProps = { export function useComposerMenuProps() { const composerRef = useComposerHandleContext(); + const isCoarsePointer = useMediaQuery({ pointer: "coarse" }); return { ...composerFloatingLayerProps, - finalFocus: composerRef - ? () => { - const activeElement = document.activeElement; - if (activeElement !== document.body && !isInsideComposerFloatingLayer(activeElement)) { + // Focusing the editor on a touch device opens the on-screen keyboard, so the menu keeps its own focus return. + finalFocus: + composerRef && !isCoarsePointer + ? () => { + const activeElement = document.activeElement; + if (activeElement !== document.body && !isInsideComposerFloatingLayer(activeElement)) { + return false; + } + composerRef.current?.focusAtEnd(); return false; } - composerRef.current?.focusAtEnd(); - return false; - } - : undefined, + : undefined, }; }