diff --git a/src/browser/components/ChatPane/ChatPane.tsx b/src/browser/components/ChatPane/ChatPane.tsx index c585d2c2a1..93dce33284 100644 --- a/src/browser/components/ChatPane/ChatPane.tsx +++ b/src/browser/components/ChatPane/ChatPane.tsx @@ -93,7 +93,6 @@ import { ChatDockSurface, useChatDockColumnWidthClass, } from "./chatDockColumn"; -import { resolveComposerControlFocusTarget } from "./composerControlFocus"; import { useTranscriptDensity } from "@/browser/hooks/useTranscriptDensity"; import { useReviews } from "@/browser/hooks/useReviews"; import { ReviewsBanner } from "../ReviewsBanner/ReviewsBanner"; @@ -727,21 +726,6 @@ const ChatPaneContent: React.FC = (props) => { [handleScrollContainerMouseDown, isComposerDockEvent] ); - const handleComposerDockMouseDown = (event: React.MouseEvent) => { - if (event.defaultPrevented || event.button !== 0) { - return; - } - const control = resolveComposerControlFocusTarget(event.target, composerDockRef.current); - if (!control) { - return; - } - // Suppressing the default keeps WebKit from walking focus up to the scrollport - // (see composerControlFocus); the control then takes focus the way it already - // does on Chromium. - event.preventDefault(); - control.focus({ preventScroll: true }); - }; - const handleTranscriptTouchMove = markUserScrollIntent; const handleTranscriptKeyDown = useCallback( @@ -1669,7 +1653,6 @@ const ChatPaneContent: React.FC = (props) => {
` on click; WebKit hands focus to the nearest - * mouse-focusable ancestor instead. Here that ancestor is the `tabIndex={0}` - * transcript scrollport the composer dock lives inside, and focusing that - * container highlights the entire transcript column. Callers resolve the control - * the user actually pressed so they can claim focus for it. - */ - -// Deliberately an allowlist: text entry and banner prose inside the dock must keep -// the browser's native caret placement and drag-select. `role="option"` rows are -// listed because the model/agent dropdowns build them from plain divs. -const CONTROL_SELECTOR = 'button, a[href], [role="button"], [role="option"]'; - -export function resolveComposerControlFocusTarget( - target: EventTarget | null, - dock: HTMLElement | null -): HTMLElement | null { - if (!dock || !(target instanceof Element) || !dock.contains(target)) { - return null; - } - const control = target.closest(CONTROL_SELECTOR); - return control && dock.contains(control) ? control : null; -} diff --git a/src/browser/stories/App.phoneViewports.stories.tsx b/src/browser/stories/App.phoneViewports.stories.tsx index d0c36e8679..7ec8e039a8 100644 --- a/src/browser/stories/App.phoneViewports.stories.tsx +++ b/src/browser/stories/App.phoneViewports.stories.tsx @@ -32,7 +32,7 @@ const IPHONE_16E = { height: 844, } as const; -// NOTE: Mux's mobile UI tweaks are gated on `@media (max-width: 768px) and (pointer: coarse)`. +// NOTE: Some phone-specific UI tweaks are gated on `@media (max-width: 768px) and (pointer: coarse)`. // Pixel does not emulate touch, so `pointer: coarse` never matches during snapshot // capture and touch-only affordances (hidden right sidebar, mobile header) are a // known coverage gap; these stories still validate the narrow-width layout. diff --git a/src/browser/styles/globals.css b/src/browser/styles/globals.css index 2dfb922a11..5808385466 100644 --- a/src/browser/styles/globals.css +++ b/src/browser/styles/globals.css @@ -1127,21 +1127,15 @@ body, background-color: var(--color-surface-primary); } -/* Mobile improvements - only apply to touch devices */ -@media (max-width: 768px) and (pointer: coarse) { - html { - -webkit-text-size-adjust: 100%; - touch-action: manipulation; - } - +/* iPadOS reports pointer: coarse even with a trackpad attached, so selection suppression + must not be limited to phone widths. */ +@media (pointer: coarse) { body { - font-size: 15px; - /* User request: prevent accidental text highlighting while tapping/scrolling on touch devices. */ -webkit-user-select: none; user-select: none; } - /* Keep selection/caret behavior in editable controls on touch devices. */ + /* Body selection is suppressed above, so editable controls must opt back in. */ input, textarea, [contenteditable="true"], @@ -1149,6 +1143,17 @@ body, -webkit-user-select: text; user-select: text; } +} + +@media (max-width: 768px) and (pointer: coarse) { + html { + -webkit-text-size-adjust: 100%; + touch-action: manipulation; + } + + body { + font-size: 15px; + } .mobile-hide-shortcut-hints { display: none !important; diff --git a/tests/ui/chat/composerControlFocus.test.ts b/tests/ui/chat/composerControlFocus.test.ts deleted file mode 100644 index 948abeb012..0000000000 --- a/tests/ui/chat/composerControlFocus.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import "../dom"; - -import { fireEvent, waitFor } from "@testing-library/react"; - -jest.mock("lottie-react", () => ({ - __esModule: true, - default: () => null, -})); - -import { preloadTestModules } from "../../ipc/setup"; -import { createAppHarness } from "../harness"; -import { resolveComposerControlFocusTarget } from "@/browser/components/ChatPane/composerControlFocus"; - -describe("resolveComposerControlFocusTarget", () => { - function buildDock() { - const dock = document.createElement("div"); - const control = document.createElement("button"); - const label = document.createElement("span"); - control.append(label); - const option = document.createElement("div"); - option.setAttribute("role", "option"); - const textarea = document.createElement("textarea"); - const hint = document.createElement("span"); - dock.append(control, option, textarea, hint); - - const outside = document.createElement("button"); - document.body.append(dock, outside); - - return { dock, control, label, option, textarea, hint, outside }; - } - - afterEach(() => { - document.body.replaceChildren(); - }); - - test("resolves the enclosing control from a nested target", () => { - const { dock, control, label, option } = buildDock(); - expect(resolveComposerControlFocusTarget(label, dock)).toBe(control); - expect(resolveComposerControlFocusTarget(option, dock)).toBe(option); - }); - - test("ignores non-control targets and targets outside the dock", () => { - const { dock, textarea, hint, outside, control } = buildDock(); - expect(resolveComposerControlFocusTarget(textarea, dock)).toBeNull(); - expect(resolveComposerControlFocusTarget(hint, dock)).toBeNull(); - expect(resolveComposerControlFocusTarget(outside, dock)).toBeNull(); - expect(resolveComposerControlFocusTarget(control, null)).toBeNull(); - }); -}); - -describe("Composer control focus", () => { - beforeAll(async () => { - await preloadTestModules(); - }); - - test("claims focus for the pressed control instead of the transcript scrollport", async () => { - const app = await createAppHarness({ branchPrefix: "composer-control-focus" }); - - try { - const messageWindow = app.view.container.querySelector( - '[data-testid="message-window"]' - ); - const dock = app.view.container.querySelector( - '[data-testid="chat-composer-dock"]' - ); - if (!messageWindow || !dock) throw new Error("Message window or composer dock not found"); - - // The dock lives inside a focusable scrollport, which is what lets WebKit - // hand it focus when a composer button is pressed. - expect(messageWindow.contains(dock)).toBe(true); - expect(messageWindow.tabIndex).toBe(0); - - const trigger = dock.querySelector( - '[data-component="ModelSelectorGroup"] button' - ); - if (!trigger) throw new Error("Model selector trigger not found"); - const label = trigger.querySelector("span") ?? trigger; - - expect(fireEvent.mouseDown(label)).toBe(false); - expect(document.activeElement).toBe(trigger); - - const textarea = dock.querySelector("textarea"); - if (!textarea) throw new Error("Composer textarea not found"); - expect(fireEvent.mouseDown(textarea)).toBe(true); - - // The suppressed default must not cost the picker its click. - fireEvent.click(trigger); - const options = await waitFor(() => { - const found = dock.querySelectorAll('[role="option"]'); - if (found.length === 0) throw new Error("Model options did not render"); - return found; - }); - - const option = options[0]; - fireEvent.mouseDown(option); - fireEvent.click(option); - await waitFor(() => { - expect(dock.querySelectorAll('[role="option"]')).toHaveLength(0); - }); - } finally { - await app.dispose(); - } - }, 60_000); -});