From 6017706cc147ae64c7b0f0a31fa94e304912b364 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 16 Oct 2026 17:27:06 +0700 Subject: [PATCH] fix(app): avoid restoring composer focus on touch navigation (#7130) Limit implicit pane and remount focus replay to the existing fine-pointer policy. Fullscreen keeps one editor mounted, so touch navigation does not need the restoration introduced for the former portal handoff. --- docs/floating-panels.md | 21 ++++--- .../app/e2e/browser/composer-focus.spec.ts | 56 +++++++++++++++++++ packages/app/src/composer/input/input.tsx | 16 ++++-- packages/app/src/composer/keyboard-scope.tsx | 6 +- 4 files changed, 78 insertions(+), 21 deletions(-) diff --git a/docs/floating-panels.md b/docs/floating-panels.md index 1af71ac4c9..c183dc6615 100644 --- a/docs/floating-panels.md +++ b/docs/floating-panels.md @@ -204,10 +204,11 @@ still-mounted content, paints its own surface under the safe areas, and turns the surface translation off. The composer's single tree keeps the editing surface at the same position in both modes, so the mounted editor, its native buffer, selection, and focus survive the toggle and Send's clear always reaches -the live input. The one remount left is a viewport crossing the compact -breakpoint: the app shell swaps its gesture wrapper and the workspace screen +the live input. A viewport crossing the compact breakpoint still remounts it: +the app shell swaps its gesture wrapper and the workspace screen mounts tabs in different trees, so every screen below remounts. The draft owner -restores the text and the editing session restores the caret and focus intent. +restores the text and the editing session restores the caret. Focus restoration +is limited to mouse-driven UIs, using the same fine-pointer policy as autofocus. New workspace keys its sessions by draft in the app-level store for the same reason. The centered form honors fullscreen by style alone: no centering, no translation, setup fields hidden, slot filling the stationary viewport. The @@ -217,14 +218,12 @@ sheet suspends pane focus, and the composer gives up input from the moment the user asks for the compact agent list or explorer, before the panel's motion settles. Pane focus itself stays put under those panels: the agent panel reads it as "the user is in this chat" for attention clearing, and opening the agent -list must not clear the attention it shows. Focus restoration follows pane -focus, not the active composer. The editor keeps its focus intent only across -a blur that lands while its pane is not the user's (a retained tab hidden, a -sheet over it) and refocuses when the pane comes back. A blur while the pane is -theirs drops the intent in every layout mode, and the keyboard the compact -panels dismiss when they settle is such a blur, so closing the agent list or -explorer never reopens the keyboard. Keying restoration on the active composer -made that dismiss look like a hidden-tab blur and refocused on close. +list must not clear the attention it shows. On mouse-driven UIs, focus restoration +follows pane focus, not the active composer. Keying it on the active composer +made sidebar dismissal look like a hidden-tab blur and refocused on close. +Native and touch-web navigation must not replay old editor focus, including +when blur arrives after pane deactivation or the editor unmounts before blur. +Fullscreen toggles keep the editor mounted and need no focus restoration. The band beside the inline editor where the fullscreen glyph sits is the input's own right padding, so a tap anywhere in it focuses the editor whether or not the glyph shows. On web the slot is a fixed box over the visible viewport, which is diff --git a/packages/app/e2e/browser/composer-focus.spec.ts b/packages/app/e2e/browser/composer-focus.spec.ts index 9119791806..c81624ff7d 100644 --- a/packages/app/e2e/browser/composer-focus.spec.ts +++ b/packages/app/e2e/browser/composer-focus.spec.ts @@ -15,6 +15,9 @@ import { typeIntoFocusedComposer, } from "../support/helpers/composer"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; +import { createMockIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { switchComposerTabWithKeyboard } from "../support/helpers/composer"; import { closeMobileAgentSidebar, closeMobileExplorer, @@ -167,6 +170,59 @@ test.describe("touch screen wide enough for the desktop layout", () => { // composer there raises the on-screen keyboard over the conversation. test.use({ viewport: { width: 1024, height: 768 }, isMobile: true, hasTouch: true }); + test("remounting an inline composer does not restore focus on touch", async ({ page }) => { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "composer-touch-remount-", + title: "Touch remount", + }); + try { + await openAgentRoute(page, agent); + await composerLocator(page).fill("Keep this draft through resize"); + await expectComposerFocused(page); + await resizeComposerViewport(page, "portrait"); + await expectComposerDraft(page, "Keep this draft through resize"); + await expectComposerNotFocused(page); + await composerLocator(page).tap(); + await expectComposerFocused(page); + } finally { + await agent.cleanup(); + } + }); + + test("returning to an inactive inline composer does not restore focus on touch", async ({ + page, + }) => { + const workspace = await seedWorkspace({ repoPrefix: "composer-touch-retained-" }); + try { + const first = await createMockIdleAgent(workspace.client, { + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title: "First touch chat", + }); + const second = await createMockIdleAgent(workspace.client, { + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title: "Second touch chat", + }); + await openWorkspaceWithAgents(page, [first, second]); + await composerLocator(page).fill("Keep this touch draft"); + await expectComposerFocused(page); + // An external keyboard changes tabs without a pointer click blurring the + // editor first. The device still has a touch-primary focus policy. + await switchComposerTabWithKeyboard(page, "previous"); + await expectComposerDraft(page, ""); + await switchComposerTabWithKeyboard(page, "next"); + await expectComposerDraft(page, "Keep this touch draft"); + await expectComposerNotFocused(page); + await composerLocator(page).tap(); + await expectComposerFocused(page); + await typeIntoFocusedComposer(page, " after return"); + await expectComposerDraft(page, "Keep this touch draft after return"); + } finally { + await workspace.cleanup(); + } + }); + test("opening an agent leaves the composer unfocused", async ({ page }) => { const agent = await seedMockAgentWorkspace({ repoPrefix: "composer-focus-touch-", diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index 7346095d5b..c64d87aba1 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -28,6 +28,7 @@ import { useTranslation } from "react-i18next"; import { ICON_SIZE, type Theme } from "@/styles/theme"; import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native"; import { useDictation } from "@/hooks/use-dictation"; +import { hasFinePointer } from "@/hooks/use-fine-pointer"; import { DictationOverlay } from "@/components/dictation-controls"; import { RealtimeVoiceOverlay } from "@/components/realtime-voice-overlay"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; @@ -1289,12 +1290,14 @@ export const MessageInput = forwardRef( webTextareaRef.current = element; setWebTextArea(element); }, []); - // Focus intent outlives a blur only while the pane is not the user's pane (a retained - // tab hidden, a sheet over it); the pane coming back restores focus. A blur while the - // pane is theirs drops it, whatever the layout mode: the user dismissed the keyboard, - // or a compact overlay panel did on their behalf, and nothing later refocuses. + // Only mouse-driven UIs restore editing focus on navigation. Touch users return + // to the conversation; replaying old focus would reopen their software keyboard. + // Read pointer capability when restoring, without making a pointer change itself + // a focus trigger. useLayoutEffect(() => { - if (isPaneFocused && focusIntentRef.current) textInputRef.current?.focus(); + if (isPaneFocused && focusIntentRef.current && hasFinePointer()) { + textInputRef.current?.focus(); + } }, [isPaneFocused]); // The compact and desktop screens mount the editor in different trees, so a // viewport that crosses the breakpoint remounts it. The draft owner brings the @@ -1311,7 +1314,7 @@ export const MessageInput = forwardRef( retainedSelectionRef.current = retained.selection; selectionRef.current = selection; textInputRef.current?.setSelection(selection); - if (retained.focused) { + if (retained.focused && hasFinePointer()) { focusIntentRef.current = true; if (paneFocusedRef.current) textInputRef.current?.focus(); } @@ -1818,6 +1821,7 @@ export const MessageInput = forwardRef( }, [onFocusChange]); const handleInputBlur = useCallback(() => { + // A blur in the active pane is a dismissal; only hiding the pane preserves intent. if (paneFocusedRef.current) focusIntentRef.current = false; isInputFocusedRef.current = false; setIsInputFocused(false); diff --git a/packages/app/src/composer/keyboard-scope.tsx b/packages/app/src/composer/keyboard-scope.tsx index 20b57dde28..f8087bf420 100644 --- a/packages/app/src/composer/keyboard-scope.tsx +++ b/packages/app/src/composer/keyboard-scope.tsx @@ -8,10 +8,8 @@ import { interface ComposerKeyboardScopeValue { /** - * The pane this composer sits in is the user's pane. Focus restoration follows it: - * the editor keeps its focus intent across blurs that happen while the pane is not - * the user's (a retained tab hidden, a sheet over it) and brings focus back when - * the pane is theirs again. + * The pane this composer sits in is the user's pane. Mouse-driven focus + * restoration follows it; touch navigation does not restore editor focus. */ isPaneFocused: boolean; /**