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