From b8bcd204a918dde8fd996362cae284eee83241f0 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 08:15:20 +0200 Subject: [PATCH] Dismiss touch composer focus when switching tabs (#7352) * fix(app): dismiss touch composer focus when switching tabs A retained tab can hide and return before Chromium paints, preserving the focused textarea without another focus call. Blur web touch editors when their pane becomes inactive so returning leaves the conversation ready to read. Exercise the same mounted editor through a second edit and keyboard navigation cycle. * test(app): reproduce touch tab focus race before paint --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../app/e2e/browser/composer-focus.spec.ts | 30 +++++----- packages/app/e2e/support/helpers/composer.ts | 56 +++++++++++++++++++ packages/app/src/composer/input/input.tsx | 7 ++- 3 files changed, 78 insertions(+), 15 deletions(-) diff --git a/packages/app/e2e/browser/composer-focus.spec.ts b/packages/app/e2e/browser/composer-focus.spec.ts index c81624ff7d..5557e9e9ac 100644 --- a/packages/app/e2e/browser/composer-focus.spec.ts +++ b/packages/app/e2e/browser/composer-focus.spec.ts @@ -1,6 +1,7 @@ import { expect, test, type Page } from "../support/fixtures"; import { blurComposerInput, + roundTripComposerTabBeforePaint, composerLocator, enterComposerFullscreen, exitComposerFullscreen, @@ -17,7 +18,6 @@ import { 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, @@ -205,19 +205,21 @@ test.describe("touch screen wide enough for the desktop layout", () => { 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"); + // Repeat after an explicit edit so retained focus from an earlier visit is + // also dismissed when the same mounted editor becomes inactive again. + for (const draft of ["Keep this touch draft", "Edit this touch draft again"]) { + await composerLocator(page).fill(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 roundTripComposerTabBeforePaint(page, "Second touch chat"); + await expectComposerDraft(page, draft); + await expectComposerNotFocused(page); + await composerLocator(page).tap(); + await expectComposerFocused(page); + await typeIntoFocusedComposer(page, " after return"); + await expectComposerDraft(page, `${draft} after return`); + } } finally { await workspace.cleanup(); } diff --git a/packages/app/e2e/support/helpers/composer.ts b/packages/app/e2e/support/helpers/composer.ts index d4ef8fc2f4..971b1ad1b9 100644 --- a/packages/app/e2e/support/helpers/composer.ts +++ b/packages/app/e2e/support/helpers/composer.ts @@ -501,3 +501,59 @@ export async function expectNoCollapsedVisibleComposer(page: Page): Promise frame.root < 42 || frame.input < 42)).toEqual([]); } + +/** Exercise the quick-return race before a hidden pane gets a rendering frame. */ +export async function roundTripComposerTabBeforePaint( + page: Page, + activeChatTitle: string, +): Promise { + const activeTab = page.getByRole("button", { name: activeChatTitle, exact: true }); + await expect(activeTab).toHaveAttribute("aria-selected", "true"); + const result = await activeTab.evaluate((tab) => { + const editor = document.activeElement; + return new Promise<{ returnedBeforePaint: boolean; focused: boolean }>((resolve) => { + let switchedAway = false; + let painted = false; + let frame = 0; + const shortcut = (code: "BracketLeft" | "BracketRight") => { + document.dispatchEvent( + new KeyboardEvent("keydown", { + key: code === "BracketLeft" ? "{" : "}", + code, + altKey: true, + shiftKey: true, + bubbles: true, + cancelable: true, + }), + ); + }; + // Watch selection commits without reading layout: a layout read could make + // Chromium blur the hidden editor and erase the condition under test. + const observer = new MutationObserver(() => { + if (!switchedAway && tab.getAttribute("aria-selected") === "false") { + switchedAway = true; + frame = requestAnimationFrame(() => { + painted = true; + }); + // Deliver the return shortcut before rendering, without a Playwright + // round trip allowing the browser to paint the inactive tab first. + shortcut("BracketRight"); + } else if (switchedAway && tab.getAttribute("aria-selected") === "true") { + observer.disconnect(); + cancelAnimationFrame(frame); + return resolve({ + returnedBeforePaint: !painted, + focused: document.activeElement === editor, + }); + } + }); + observer.observe(tab, { attributes: true, attributeFilter: ["aria-selected"] }); + shortcut("BracketLeft"); + }); + }); + expect( + result.returnedBeforePaint, + "tab round trip must finish before rendering the hidden pane", + ).toBe(true); + expect(result.focused, "returning on touch must leave the composer unfocused").toBe(false); +} diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index 9fdab3fa28..2c104924cc 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -1295,7 +1295,12 @@ export const MessageInput = forwardRef( // Read pointer capability when restoring, without making a pointer change itself // a focus trigger. useLayoutEffect(() => { - if (isPaneFocused && focusIntentRef.current && hasFinePointer()) { + const finePointer = hasFinePointer(); + if (!isPaneFocused && isWeb && !finePointer) { + // Hiding a retained tab only blurs its editor after the browser renders. + // A quick return before that frame would otherwise keep touch focus. + textInputRef.current?.blur(); + } else if (isPaneFocused && focusIntentRef.current && finePointer) { textInputRef.current?.focus(); } }, [isPaneFocused]);