diff --git a/packages/app/e2e/browser/composer-focus.spec.ts b/packages/app/e2e/browser/composer-focus.spec.ts index 897e536340..b79cfd9255 100644 --- a/packages/app/e2e/browser/composer-focus.spec.ts +++ b/packages/app/e2e/browser/composer-focus.spec.ts @@ -1,7 +1,8 @@ -import { test } from "../support/fixtures"; +import { expect, test, type Page } from "../support/fixtures"; import { expectComposerDraft, expectComposerFocused, + expectComposerNotFocused, expectComposerVisible, submitMessage, typeIntoFocusedComposer, @@ -27,3 +28,51 @@ test("submitting a message leaves the composer ready for the next message", asyn await agent.cleanup(); } }); + +test("opening an agent focuses the composer when a mouse drives the UI", async ({ page }) => { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "composer-focus-open-", + title: "Composer focus on open", + }); + + try { + await openAgentRoute(page, agent); + await expectComposerFocused(page); + } finally { + await agent.cleanup(); + } +}); + +async function waitForAnimationFrames(page: Page, count: number): Promise { + await page.evaluate(async (frames) => { + for (let frame = 0; frame < frames; frame += 1) { + await new Promise((resolve) => requestAnimationFrame(resolve)); + } + }, count); +} + +test.describe("touch screen wide enough for the desktop layout", () => { + // A tablet or an unfolded foldable gets the desktop layout, but focusing the + // composer there raises the on-screen keyboard over the conversation. + test.use({ viewport: { width: 1024, height: 768 }, isMobile: true, hasTouch: true }); + + test("opening an agent leaves the composer unfocused", async ({ page }) => { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "composer-focus-touch-", + title: "Composer focus on touch", + }); + + try { + await openAgentRoute(page, agent); + await expectComposerVisible(page); + expect( + await page.evaluate(() => window.matchMedia("(hover: hover) and (pointer: fine)").matches), + ).toBe(false); + // Autofocus makes its first attempt on the next animation frame. + await waitForAnimationFrames(page, 4); + await expectComposerNotFocused(page); + } finally { + await agent.cleanup(); + } + }); +}); diff --git a/packages/app/e2e/support/helpers/composer.ts b/packages/app/e2e/support/helpers/composer.ts index 6e78e0c66b..ab53705477 100644 --- a/packages/app/e2e/support/helpers/composer.ts +++ b/packages/app/e2e/support/helpers/composer.ts @@ -52,6 +52,10 @@ export async function expectComposerFocused(page: Page): Promise { await expect(composerInput(page)).toBeFocused(); } +export async function expectComposerNotFocused(page: Page): Promise { + await expect(composerInput(page)).not.toBeFocused(); +} + export async function submitMessage(page: Page, text: string): Promise { const input = composerInput(page); await expect(input).toBeEditable({ timeout: 30_000 }); diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 57b9e3fb44..007051fe1c 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -27,6 +27,7 @@ import { import { useTranslation } from "react-i18next"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; +import { useHasFinePointer } from "@/hooks/use-fine-pointer"; import { useShallow } from "zustand/shallow"; import { ArrowUp, @@ -1312,6 +1313,7 @@ function ComposerContentImpl({ const isCompactFormFactor = useIsCompactFormFactor(); const isCompactLayout = resolveCompactLayout(isCompactLayoutOverride, isCompactFormFactor); const isDesktopWebBreakpoint = resolveIsDesktopWebBreakpoint(isCompactFormFactor); + const hasFinePointer = useHasFinePointer(); const isDesktopLayout = resolveIsDesktopWebBreakpoint(isCompactLayout); const messagePlaceholder = resolveMessagePlaceholder(inputMode, isDesktopLayout, t, placeholder); const hasText = useSyncExternalStore( @@ -2383,7 +2385,8 @@ function ComposerContentImpl({ { disabled: isSubmitLoadingVisible }, ); - const messageInputAutoFocus = autoFocus && isDesktopWebBreakpoint; + // Focusing the composer on a touch screen raises the on-screen keyboard over the conversation. + const messageInputAutoFocus = autoFocus && isDesktopWebBreakpoint && hasFinePointer; const submitLoadingPressHandler = isAgentRunning ? handleCancelAgent : undefined; const sendErrorNode = useMemo( () =>