From c6500aefb23cfae5c4742ad58c82ed1202ce0026 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 2 Nov 2026 17:09:44 +0000 Subject: [PATCH 1/5] test(web): opening a thread on a touch tablet does not focus the composer Reproduces #29469: on a coarse-pointer device at desktop width, opening a thread by URL or from the sidebar focuses the composer, which raises the on-screen keyboard. A fine-pointer test pins the intended desktop autofocus. Co-Authored-By: Claude Opus 5.5 --- .../src/components/ChatView.browser.test.tsx | 58 ++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatView.browser.test.tsx b/apps/web/src/components/ChatView.browser.test.tsx index 96eb837db4d69..5b240236fca10 100644 --- a/apps/web/src/components/ChatView.browser.test.tsx +++ b/apps/web/src/components/ChatView.browser.test.tsx @@ -4,7 +4,7 @@ import { ThreadId } from "@t3tools/contracts"; import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; import type * as Context from "effect/Context"; import { expect, it, vi } from "vite-plus/test"; -import { page } from "vite-plus/test/browser"; +import { page, userEvent } from "vite-plus/test/browser"; import { AppRoot } from "~/AppRoot"; // The route loads ChatView lazily, and its first transform (React Compiler over ~4k lines) @@ -17,6 +17,7 @@ import { ENVIRONMENT_ID, PROJECT_ID, installFakeEnvironment, + threadWithHistory, type FakeDriver, } from "../../test/fakeEnvironment"; import { render } from "../../test/render"; @@ -65,3 +66,58 @@ it("a new draft does not load its reserved thread before the server lists it", a // A 404 here marks the thread deleted, and the view never loads it once the send creates it. expect(threadRequests).toEqual([]); }); + +const TOUCH_TABLET_QUERIES: Readonly> = { + "(pointer: coarse)": true, + "(pointer: fine)": false, + "(hover: none)": true, + "(hover: hover)": false, +}; + +async function openThreadAtTabletSize(path: string) { + // 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: [path] })); + await router.load(); + return render(, { + // AppRoot brings its own router. + router: false, + }); +} + +it("opening a thread with a fine pointer focuses the composer", async () => { + const thread = threadWithHistory("thread-29469-fine", 1); + using _environment = installFakeEnvironment([thread]); + await using _app = await openThreadAtTabletSize(`/${ENVIRONMENT_ID}/${thread.id}`); + + await expect.element(page.getByTestId("composer-editor")).toHaveFocus(); +}); + +it("opening a thread on a touch tablet does not focus the composer", async () => { + const realMatchMedia = window.matchMedia.bind(window); + using _touch = vi + .spyOn(window, "matchMedia") + .mockImplementation((query) => + query in TOUCH_TABLET_QUERIES + ? realMatchMedia(TOUCH_TABLET_QUERIES[query] ? "all" : "not all") + : realMatchMedia(query), + ); + const first = threadWithHistory("thread-29469-a", 1); + const second = threadWithHistory("thread-29469-b", 1); + using _environment = installFakeEnvironment([first, second]); + await using _app = await openThreadAtTabletSize(`/${ENVIRONMENT_ID}/${first.id}`); + const composer = page.getByTestId("composer-editor"); + + // Focusing the editor raises the on-screen keyboard, which shrinks the page on iPadOS. + await expect.element(page.getByText(`Answer 0 for ${first.id}.`)).toBeVisible(); + await expect.element(composer).toBeVisible(); + await expect.element(composer).not.toHaveFocus(); + + await userEvent.click(page.getByRole("treeitem").filter({ hasText: second.title })); + await expect.element(page.getByText(`Answer 0 for ${second.id}.`)).toBeVisible(); + await expect.element(composer).not.toHaveFocus(); +}); From 4af36ec957699b3dc312e7183242967cc95dfcbc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 2 Nov 2026 17:21:45 +0000 Subject: [PATCH 2/5] fix(web): opening a thread on a touch tablet no longer focuses the composer On a coarse pointer at desktop width (iPad, Android tablet), opening a thread or returning to the window focused the composer programmatically, which raised the on-screen keyboard. Both effects now skip on `(pointer: coarse)`; fine-pointer autofocus is unchanged. The touch test switches threads through the router: the shell subscription outlives each render, so the sidebar lists only the file's first test's threads. Co-Authored-By: Claude Opus 5.5 --- .../src/components/ChatView.browser.test.tsx | 18 ++++++++----- apps/web/src/components/ChatView.tsx | 27 +++++++++++++++---- 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.test.tsx b/apps/web/src/components/ChatView.browser.test.tsx index 5b240236fca10..a229a3fdd2154 100644 --- a/apps/web/src/components/ChatView.browser.test.tsx +++ b/apps/web/src/components/ChatView.browser.test.tsx @@ -4,7 +4,7 @@ import { ThreadId } from "@t3tools/contracts"; import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; import type * as Context from "effect/Context"; import { expect, it, vi } from "vite-plus/test"; -import { page, userEvent } from "vite-plus/test/browser"; +import { page } from "vite-plus/test/browser"; import { AppRoot } from "~/AppRoot"; // The route loads ChatView lazily, and its first transform (React Compiler over ~4k lines) @@ -74,14 +74,13 @@ const TOUCH_TABLET_QUERIES: Readonly> = { "(hover: hover)": false, }; -async function openThreadAtTabletSize(path: string) { +async function openAtTabletSize(router: ReturnType) { // 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: [path] })); await router.load(); return render(, { // AppRoot brings its own router. @@ -92,7 +91,10 @@ async function openThreadAtTabletSize(path: string) { it("opening a thread with a fine pointer focuses the composer", async () => { const thread = threadWithHistory("thread-29469-fine", 1); using _environment = installFakeEnvironment([thread]); - await using _app = await openThreadAtTabletSize(`/${ENVIRONMENT_ID}/${thread.id}`); + const router = getRouter( + createMemoryHistory({ initialEntries: [`/${ENVIRONMENT_ID}/${thread.id}`] }), + ); + await using _app = await openAtTabletSize(router); await expect.element(page.getByTestId("composer-editor")).toHaveFocus(); }); @@ -109,7 +111,10 @@ it("opening a thread on a touch tablet does not focus the composer", async () => const first = threadWithHistory("thread-29469-a", 1); const second = threadWithHistory("thread-29469-b", 1); using _environment = installFakeEnvironment([first, second]); - await using _app = await openThreadAtTabletSize(`/${ENVIRONMENT_ID}/${first.id}`); + const router = getRouter( + createMemoryHistory({ initialEntries: [`/${ENVIRONMENT_ID}/${first.id}`] }), + ); + await using _app = await openAtTabletSize(router); const composer = page.getByTestId("composer-editor"); // Focusing the editor raises the on-screen keyboard, which shrinks the page on iPadOS. @@ -117,7 +122,8 @@ it("opening a thread on a touch tablet does not focus the composer", async () => await expect.element(composer).toBeVisible(); await expect.element(composer).not.toHaveFocus(); - await userEvent.click(page.getByRole("treeitem").filter({ hasText: second.title })); + // The shell subscription outlives each render, so the sidebar lists only the first test's threads. + router.history.push(`/${ENVIRONMENT_ID}/${second.id}`); await expect.element(page.getByText(`Answer 0 for ${second.id}.`)).toBeVisible(); await expect.element(composer).not.toHaveFocus(); }); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6fd54db6bd52a..608009ff6cfd2 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -690,6 +690,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] = @@ -2121,20 +2123,34 @@ export default function ChatView(props: ChatViewProps) { setTimelineAnchor, }); - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- schedules a frame to focus the composer DOM element when a thread opens with the terminal closed + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- schedules a frame to focus the composer DOM element when a thread opens with the terminal closed on a fine pointer useEffect(() => { - if (embedded || !activeThread?.id || terminalUiState.terminalOpen) return; + if (embedded || !activeThread?.id || terminalUiState.terminalOpen || isCoarsePointer) return; const frame = window.requestAnimationFrame(() => { focusComposerStable(); }); return () => { window.cancelAnimationFrame(frame); }; - }, [activeThread?.id, embedded, focusComposerStable, terminalUiState.terminalOpen]); + }, [ + activeThread?.id, + embedded, + focusComposerStable, + isCoarsePointer, + terminalUiState.terminalOpen, + ]); - // 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); @@ -2161,6 +2177,7 @@ export default function ChatView(props: ChatViewProps) { activeThread?.id, embedded, focusComposerStable, + isCoarsePointer, isMobileViewport, terminalUiState.terminalOpen, ]); From c60432be464ac14f228c47a5a113e56e2a1ee827 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 2 Nov 2026 17:41:05 +0000 Subject: [PATCH 3/5] test(web): cover window return and thread switch focus on touch tablets Co-Authored-By: Claude Opus 5.5 --- .../src/components/ChatView.browser.test.tsx | 39 +++++++++++-------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.test.tsx b/apps/web/src/components/ChatView.browser.test.tsx index a229a3fdd2154..121b2d986309b 100644 --- a/apps/web/src/components/ChatView.browser.test.tsx +++ b/apps/web/src/components/ChatView.browser.test.tsx @@ -20,7 +20,7 @@ import { threadWithHistory, type FakeDriver, } from "../../test/fakeEnvironment"; -import { render } from "../../test/render"; +import { 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) => { @@ -67,13 +67,6 @@ it("a new draft does not load its reserved thread before the server lists it", a expect(threadRequests).toEqual([]); }); -const TOUCH_TABLET_QUERIES: Readonly> = { - "(pointer: coarse)": true, - "(pointer: fine)": false, - "(hover: none)": true, - "(hover: hover)": false, -}; - async function openAtTabletSize(router: ReturnType) { // iPad Pro 11 in landscape: the desktop layout, above max-sm. await page.viewport(1194, 834); @@ -88,26 +81,35 @@ async function openAtTabletSize(router: ReturnType) { }); } -it("opening a thread with a fine pointer focuses the composer", async () => { +// ChatView waits two frames after window focus before refocusing the composer. +function returnToWindow() { + window.dispatchEvent(new FocusEvent("focus")); + vi.advanceTimersToNextFrame(); + vi.advanceTimersToNextFrame(); +} + +it("opening a thread or returning to the window with a fine pointer focuses the composer", async () => { const thread = threadWithHistory("thread-29469-fine", 1); using _environment = installFakeEnvironment([thread]); const router = getRouter( createMemoryHistory({ initialEntries: [`/${ENVIRONMENT_ID}/${thread.id}`] }), ); await using _app = await openAtTabletSize(router); + const composer = page.getByTestId("composer-editor"); + + await expect.element(composer).toHaveFocus(); - await expect.element(page.getByTestId("composer-editor")).toHaveFocus(); + composer.element().blur(); + using _timers = fakeTimers(); + returnToWindow(); + await expect.element(composer).toHaveFocus(); }); -it("opening a thread on a touch tablet does not focus the composer", 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) => - query in TOUCH_TABLET_QUERIES - ? realMatchMedia(TOUCH_TABLET_QUERIES[query] ? "all" : "not all") - : realMatchMedia(query), - ); + .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]); @@ -122,8 +124,11 @@ it("opening a thread on a touch tablet does not focus the composer", async () => await expect.element(composer).toBeVisible(); await expect.element(composer).not.toHaveFocus(); + // Fake frames hold the thread switch's focus frame until returnToWindow runs it. + using _timers = fakeTimers(); // The shell subscription outlives each render, so the sidebar lists only the first test's threads. router.history.push(`/${ENVIRONMENT_ID}/${second.id}`); await expect.element(page.getByText(`Answer 0 for ${second.id}.`)).toBeVisible(); - await expect.element(composer).not.toHaveFocus(); + returnToWindow(); + expect(document.activeElement).not.toBe(composer.element()); }); From 4119d319590cea25960cf47ca9d04a24156ce2a8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 2 Nov 2026 18:00:38 +0000 Subject: [PATCH 4/5] test(web): hold the first thread's focus frame before asserting on touch tablets Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/ChatView.browser.test.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.test.tsx b/apps/web/src/components/ChatView.browser.test.tsx index 121b2d986309b..774772652871d 100644 --- a/apps/web/src/components/ChatView.browser.test.tsx +++ b/apps/web/src/components/ChatView.browser.test.tsx @@ -116,16 +116,17 @@ it("opening a thread or returning to the window on a touch tablet does not focus const router = getRouter( createMemoryHistory({ initialEntries: [`/${ENVIRONMENT_ID}/${first.id}`] }), ); + // Fake frames hold each thread entry's focus frame until the test advances it. + using _timers = fakeTimers(); await using _app = await openAtTabletSize(router); const composer = page.getByTestId("composer-editor"); // Focusing the editor raises the on-screen keyboard, which shrinks the page on iPadOS. await expect.element(page.getByText(`Answer 0 for ${first.id}.`)).toBeVisible(); await expect.element(composer).toBeVisible(); - await expect.element(composer).not.toHaveFocus(); + vi.advanceTimersToNextFrame(); + expect(document.activeElement).not.toBe(composer.element()); - // Fake frames hold the thread switch's focus frame until returnToWindow runs it. - using _timers = fakeTimers(); // The shell subscription outlives each render, so the sidebar lists only the first test's threads. router.history.push(`/${ENVIRONMENT_ID}/${second.id}`); await expect.element(page.getByText(`Answer 0 for ${second.id}.`)).toBeVisible(); From 1ea368f18ba357a4889001e80f9c26cdcf09f665 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 2 Nov 2026 18:13:00 +0000 Subject: [PATCH 5/5] test(web): acquire fake timers after mounting in the touch tablet focus test Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/ChatView.browser.test.tsx | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.test.tsx b/apps/web/src/components/ChatView.browser.test.tsx index 774772652871d..e5669a771b9ed 100644 --- a/apps/web/src/components/ChatView.browser.test.tsx +++ b/apps/web/src/components/ChatView.browser.test.tsx @@ -116,20 +116,19 @@ it("opening a thread or returning to the window on a touch tablet does not focus const router = getRouter( createMemoryHistory({ initialEntries: [`/${ENVIRONMENT_ID}/${first.id}`] }), ); - // Fake frames hold each thread entry's focus frame until the test advances it. - using _timers = fakeTimers(); await using _app = await openAtTabletSize(router); const composer = page.getByTestId("composer-editor"); - - // Focusing the editor raises the on-screen keyboard, which shrinks the page on iPadOS. await expect.element(page.getByText(`Answer 0 for ${first.id}.`)).toBeVisible(); - await expect.element(composer).toBeVisible(); - vi.advanceTimersToNextFrame(); - expect(document.activeElement).not.toBe(composer.element()); + // Fake frames hold the next thread entry's focus frame until the test advances it. + using _timers = fakeTimers(); // The shell subscription outlives each render, so the sidebar lists only the first test's threads. router.history.push(`/${ENVIRONMENT_ID}/${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()); + returnToWindow(); expect(document.activeElement).not.toBe(composer.element()); });