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,
]);