diff --git a/apps/web/src/components/ChatView.browser.test.tsx b/apps/web/src/components/ChatView.browser.test.tsx index 5535e76953a28..46557548fec6b 100644 --- a/apps/web/src/components/ChatView.browser.test.tsx +++ b/apps/web/src/components/ChatView.browser.test.tsx @@ -24,7 +24,7 @@ import { threadWithHistory, type FakeDriver, } from "../../test/fakeEnvironment"; -import { act, render } from "../../test/render"; +import { act, fakeTimers, render } from "../../test/render"; // The socket is the only fake layer; see test/appScenarios.renders.tsx. vi.mock("../../../../packages/client-runtime/src/connection/driver.ts", async (importOriginal) => { @@ -258,3 +258,48 @@ it("Enter on an empty prompt resumes an interrupted thread", async () => { ), ); }); + +it("opening a thread or returning to the window on a touch tablet does not focus the composer", async () => { + const realMatchMedia = window.matchMedia.bind(window); + using _touch = vi + .spyOn(window, "matchMedia") + .mockImplementation((query) => realMatchMedia(query === "(pointer: coarse)" ? "all" : query)); + const first = threadWithHistory("thread-29469-a", 1); + const second = threadWithHistory("thread-29469-b", 1); + using _environment = installFakeEnvironment([first, second]); + // iPad Pro 11 in landscape: the desktop layout, above max-sm. + await page.viewport(1194, 834); + __setClientSettingsForTests({ + ...DEFAULT_CLIENT_SETTINGS, + onboardingCompletedAt: "2026-01-01T00:00:00.000Z", + }); + const router = getRouter( + createMemoryHistory({ initialEntries: [`/${ENVIRONMENT_ID}/${first.id}`] }), + ); + await router.load(); + await using _app = await render(, { + // AppRoot brings its own router. + router: false, + }); + const composer = page.getByTestId("composer-editor"); + await expect.element(page.getByText(`Answer 0 for ${first.id}.`)).toBeVisible(); + + // Fake frames hold the next thread entry's focus frame until the test advances it. + using _timers = fakeTimers(); + await act(() => + router.navigate({ + to: "/$environmentId/$threadId", + params: { environmentId: ENVIRONMENT_ID, threadId: second.id }, + }), + ); + await expect.element(page.getByText(`Answer 0 for ${second.id}.`)).toBeVisible(); + vi.advanceTimersToNextFrame(); + // Focusing the editor raises the on-screen keyboard, which shrinks the page on iPadOS. + expect(document.activeElement).not.toBe(composer.element()); + + // ChatView waits two frames after window focus before refocusing the composer. + window.dispatchEvent(new FocusEvent("focus")); + vi.advanceTimersToNextFrame(); + vi.advanceTimersToNextFrame(); + expect(document.activeElement).not.toBe(composer.element()); +}); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 79c4b8ceb2fc9..515a63b1d173c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -423,6 +423,8 @@ type ChatViewProps = const DismissedFallbackWindowSchema = Schema.NullOr(Schema.String); +const ignoreComposerFocus = () => {}; + export default function ChatView(props: ChatViewProps) { const { environmentId, @@ -713,6 +715,8 @@ export default function ChatView(props: ChatViewProps) { }, [isWorkspaceFileDragActive]); const prefersPlanSidebarSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); const isMobileViewport = useMediaQuery("max-sm"); + // Focusing the composer without a tap raises the on-screen keyboard on touch devices. + const isCoarsePointer = useMediaQuery({ pointer: "coarse" }); const [workspaceLayoutElement, setWorkspaceLayoutElement] = useState(null); const threadPanelPopoverAnchorRef = useRef(null); const [terminalUiLaunchContext, setTerminalUiLaunchContext] = @@ -2146,12 +2150,20 @@ export default function ChatView(props: ChatViewProps) { workspaceLayoutElement, !embedded, terminalUiState.terminalOpen, - focusComposerStable, + isCoarsePointer ? ignoreComposerFocus : focusComposerStable, ); - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- window focus listener that refocuses the composer after the window regains focus + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- window focus listener that refocuses the composer after the window regains focus on a fine pointer useEffect(() => { - if (embedded || !activeThread?.id || terminalUiState.terminalOpen || isMobileViewport) return; + if ( + embedded || + !activeThread?.id || + terminalUiState.terminalOpen || + isMobileViewport || + isCoarsePointer + ) { + return; + } let frame: number | null = null; const onWindowFocus = () => { if (frame !== null) window.cancelAnimationFrame(frame); @@ -2178,6 +2190,7 @@ export default function ChatView(props: ChatViewProps) { activeThread?.id, embedded, focusComposerStable, + isCoarsePointer, isMobileViewport, terminalUiState.terminalOpen, ]);