From 6db47b3c37f702b8647a6c8667b685bfe74091f0 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 15 Oct 2026 08:59:49 +0700 Subject: [PATCH] fix(app): keep the keyboard closed after mobile sidebar dismissal (#6945) Separate composer keyboard eligibility from pane focus restoration. A sidebar dismisses the keyboard without hiding the pane, so its close must not restore editing focus. --- docs/floating-panels.md | 9 +- .../app/e2e/browser/composer-focus.spec.ts | 90 +++++++++++++++++++ .../e2e/browser/composer-whitespace.spec.ts | 12 +-- packages/app/e2e/support/helpers/composer.ts | 6 ++ packages/app/e2e/support/helpers/sidebar.ts | 38 ++++++++ packages/app/src/composer/index.tsx | 7 +- packages/app/src/composer/input/input.tsx | 26 +++--- packages/app/src/composer/keyboard-scope.tsx | 19 +++- 8 files changed, 181 insertions(+), 26 deletions(-) diff --git a/docs/floating-panels.md b/docs/floating-panels.md index cb51c8f7dd..9241cec56d 100644 --- a/docs/floating-panels.md +++ b/docs/floating-panels.md @@ -217,7 +217,14 @@ 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. +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. 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 b79cfd9255..cbc710a94a 100644 --- a/packages/app/e2e/browser/composer-focus.spec.ts +++ b/packages/app/e2e/browser/composer-focus.spec.ts @@ -1,13 +1,30 @@ import { expect, test, type Page } from "../support/fixtures"; import { + composerLocator, + blurComposerInput, + enterComposerFullscreen, + exitComposerFullscreen, expectComposerDraft, expectComposerFocused, + expectComposerFullscreenControls, expectComposerNotFocused, expectComposerVisible, + expectWorkspaceHeaderVisible, + resizeComposerViewport, submitMessage, typeIntoFocusedComposer, } from "../support/helpers/composer"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; +import { + closeMobileAgentSidebar, + closeMobileExplorer, + expectMobileAgentSidebarHidden, + expectMobileAgentSidebarVisible, + expectMobileExplorerHidden, + expectMobileExplorerVisible, + swipeOpenMobileAgentSidebar, + swipeOpenMobileExplorer, +} from "../support/helpers/sidebar"; test("submitting a message leaves the composer ready for the next message", async ({ page }) => { const agent = await seedMockAgentWorkspace({ @@ -76,3 +93,76 @@ test.describe("touch screen wide enough for the desktop layout", () => { } }); }); + +const COMPACT_PANELS = [ + { + name: "agent list", + open: swipeOpenMobileAgentSidebar, + expectOpen: expectMobileAgentSidebarVisible, + close: closeMobileAgentSidebar, + expectClosed: expectMobileAgentSidebarHidden, + }, + { + name: "Explorer", + open: swipeOpenMobileExplorer, + expectOpen: expectMobileExplorerVisible, + close: closeMobileExplorer, + expectClosed: expectMobileExplorerHidden, + }, +] as const; + +for (const panel of COMPACT_PANELS) { + test(`closing the compact ${panel.name} does not restore composer focus`, async ({ page }) => { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "composer-focus-panel-", + title: `Composer focus under ${panel.name}`, + }); + const draft = "Line one\nLine two\nLine three"; + try { + await resizeComposerViewport(page, "portrait"); + await openAgentRoute(page, agent); + await expectComposerVisible(page); + + await test.step("an untouched editor stays untouched", async () => { + await expectComposerNotFocused(page); + await panel.open(page); + await panel.expectOpen(page); + await panel.close(page); + await panel.expectClosed(page); + await expectComposerNotFocused(page); + }); + + await test.step("an inline editor blurred under the panel stays blurred", async () => { + await composerLocator(page).fill(draft); + await composerLocator(page).click(); + await expectComposerFocused(page); + await panel.open(page); + await panel.expectOpen(page); + await blurComposerInput(page); + await panel.close(page); + await panel.expectClosed(page); + await expectComposerNotFocused(page); + await expectComposerDraft(page, draft); + }); + + await test.step("a fullscreen editor blurred under the panel stays blurred", async () => { + await composerLocator(page).click(); + await enterComposerFullscreen(page); + await expectComposerFullscreenControls(page); + await expectComposerFocused(page); + await panel.open(page); + await panel.expectOpen(page); + await blurComposerInput(page); + await panel.close(page); + await panel.expectClosed(page); + await expectComposerNotFocused(page); + await expectComposerFullscreenControls(page); + await expectComposerDraft(page, draft); + await exitComposerFullscreen(page); + await expectWorkspaceHeaderVisible(page); + }); + } finally { + await agent.cleanup(); + } + }); +} diff --git a/packages/app/e2e/browser/composer-whitespace.spec.ts b/packages/app/e2e/browser/composer-whitespace.spec.ts index 0c74fc7630..ca8704ed25 100644 --- a/packages/app/e2e/browser/composer-whitespace.spec.ts +++ b/packages/app/e2e/browser/composer-whitespace.spec.ts @@ -37,6 +37,7 @@ import { closeMobileAgentSidebar, expectMobileAgentSidebarHidden, expectMobileAgentSidebarVisible, + swipeOpenMobileAgentSidebar, } from "../support/helpers/sidebar"; async function composerHeight(page: Page): Promise { @@ -165,17 +166,6 @@ test("composer growth keeps a bottom-pinned chat at the bottom", async ({ page } } }); -async function swipeOpenMobileAgentSidebar(page: Page): Promise { - const viewport = page.viewportSize(); - if (!viewport) throw new Error("viewport size is required"); - const y = Math.round(viewport.height / 2); - await page.mouse.move(8, y); - await page.mouse.down(); - await page.mouse.move(40, y, { steps: 4 }); - await page.mouse.move(Math.round(viewport.width * 0.7), y, { steps: 12 }); - await page.mouse.up(); -} - /** Freeze the page's timers and animation frames until `page.clock.resume()`. */ async function pausePageClock(page: Page): Promise { const now = Date.now(); diff --git a/packages/app/e2e/support/helpers/composer.ts b/packages/app/e2e/support/helpers/composer.ts index efd9266657..d4ef8fc2f4 100644 --- a/packages/app/e2e/support/helpers/composer.ts +++ b/packages/app/e2e/support/helpers/composer.ts @@ -56,6 +56,12 @@ export async function expectComposerNotFocused(page: Page): Promise { await expect(composerInput(page)).not.toBeFocused(); } +/** Blur the editor without anything else taking focus. */ +export async function blurComposerInput(page: Page): Promise { + await composerInput(page).blur(); + await expectComposerNotFocused(page); +} + 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/e2e/support/helpers/sidebar.ts b/packages/app/e2e/support/helpers/sidebar.ts index 1909b4f31b..500ce8bd41 100644 --- a/packages/app/e2e/support/helpers/sidebar.ts +++ b/packages/app/e2e/support/helpers/sidebar.ts @@ -250,3 +250,41 @@ export async function expectMobileAgentSidebarVisible(page: Page): Promise export async function expectMobileAgentSidebarHidden(page: Page): Promise { await expect(page.getByTestId("sidebar-sessions")).not.toBeInViewport({ timeout: 5_000 }); } + +/** Drag from the left edge: the only way to open the list while fullscreen hides the header. */ +export async function swipeOpenMobileAgentSidebar(page: Page): Promise { + const viewport = page.viewportSize(); + if (!viewport) throw new Error("viewport size is required"); + const y = Math.round(viewport.height / 2); + await page.mouse.move(8, y); + await page.mouse.down(); + await page.mouse.move(40, y, { steps: 4 }); + await page.mouse.move(Math.round(viewport.width * 0.7), y, { steps: 12 }); + await page.mouse.up(); +} + +/** Drag from the right edge to open the compact Explorer over the agent. */ +export async function swipeOpenMobileExplorer(page: Page): Promise { + const viewport = page.viewportSize(); + if (!viewport) throw new Error("viewport size is required"); + const y = Math.round(viewport.height / 2); + await page.mouse.move(viewport.width - 8, y); + await page.mouse.down(); + await page.mouse.move(viewport.width - 40, y, { steps: 4 }); + await page.mouse.move(Math.round(viewport.width * 0.3), y, { steps: 12 }); + await page.mouse.up(); +} + +export async function closeMobileExplorer(page: Page): Promise { + const closeButton = page.getByTestId("explorer-close"); + await expect(closeButton).toBeInViewport({ ratio: 1, timeout: 5_000 }); + await closeButton.click(); +} + +export async function expectMobileExplorerVisible(page: Page): Promise { + await expect(page.getByTestId("explorer-header")).toBeInViewport({ ratio: 1, timeout: 5_000 }); +} + +export async function expectMobileExplorerHidden(page: Page): Promise { + await expect(page.getByTestId("explorer-header")).not.toBeInViewport({ timeout: 5_000 }); +} diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 21bf7f6679..10d670fcae 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -1196,11 +1196,16 @@ export function Composer({ isPaneFocused, ...props }: ComposerProps) { // A compact overlay panel (agent list, explorer) covers the pane without taking its // focus. The composer gives up input from the moment the user asks for the panel, // not when its motion settles, so the exit action and autocomplete never race it. + // Pane focus stays put, so the keyboard the panel dismisses is a blur in the + // user's own pane: closing the panel does not bring focus back. const isCompact = useIsCompactFormFactor(); const overlayPanelRequested = usePanelStore((state) => state.mobilePanel.target !== "agent"); const coveredByOverlayPanel = isCompact && overlayPanelRequested; return ( - + diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index a1b8c0fdbb..01051bd52c 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -524,8 +524,8 @@ function MessageInputAutoFocus({ autoFocusKey: string | undefined; textInputRef: React.MutableRefObject; }) { - const { isActiveComposer } = useComposerKeyboardScope(); - useAutoFocusOnWebEffect(textInputRef, enabled && isActiveComposer, autoFocusKey); + const { isPaneFocused } = useComposerKeyboardScope(); + useAutoFocusOnWebEffect(textInputRef, enabled && isPaneFocused, autoFocusKey); return null; } @@ -1236,7 +1236,7 @@ export const MessageInput = forwardRef( textReplacement, submitLabel, } = resolveMessageInputProps(props); - const { isActiveComposer } = useComposerKeyboardScope(); + const { isPaneFocused } = useComposerKeyboardScope(); const mode = resolveComposerInputMode(inputMode); const { t } = useTranslation(); const isCompact = useIsCompactFormFactor(); @@ -1264,8 +1264,8 @@ export const MessageInput = forwardRef( const focusIntentRef = useRef(false); const valueRef = useRef(value); const selectionRef = useRef({ start: value.length, end: value.length }); - const activeComposerRef = useRef(isActiveComposer); - activeComposerRef.current = isActiveComposer; + const paneFocusedRef = useRef(isPaneFocused); + paneFocusedRef.current = isPaneFocused; const [webTextArea, setWebTextArea] = useState(null); const appliedTextReplacementKeyRef = useRef(textReplacement.key); const webTextareaRef = useRef(null); @@ -1285,9 +1285,13 @@ 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. useLayoutEffect(() => { - if (isActiveComposer && focusIntentRef.current) textInputRef.current?.focus(); - }, [isActiveComposer]); + if (isPaneFocused && focusIntentRef.current) 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 // text back; the session brings the caret and focus intent back. On web the @@ -1305,7 +1309,7 @@ export const MessageInput = forwardRef( textInputRef.current?.setSelection(selection); if (retained.focused) { focusIntentRef.current = true; - if (activeComposerRef.current) textInputRef.current?.focus(); + if (paneFocusedRef.current) textInputRef.current?.focus(); } } return () => { @@ -1807,13 +1811,11 @@ export const MessageInput = forwardRef( }, [onFocusChange]); const handleInputBlur = useCallback(() => { - // Fullscreen is nothing but the editor, so a blur there (a retained tab being - // hidden, the keyboard dismissed) keeps the intent: showing it again refocuses. - if (activeComposerRef.current && !isFullscreen) focusIntentRef.current = false; + if (paneFocusedRef.current) focusIntentRef.current = false; isInputFocusedRef.current = false; setIsInputFocused(false); onFocusChange?.(false); - }, [isFullscreen, onFocusChange]); + }, [onFocusChange]); const handlePasteError = useCallback( (message: string) => { diff --git a/packages/app/src/composer/keyboard-scope.tsx b/packages/app/src/composer/keyboard-scope.tsx index b254c53a51..20b57dde28 100644 --- a/packages/app/src/composer/keyboard-scope.tsx +++ b/packages/app/src/composer/keyboard-scope.tsx @@ -7,18 +7,35 @@ import { } from "react"; 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. + */ + isPaneFocused: boolean; + /** + * The composer takes input right now: keyboard actions, shortcuts, autocomplete, + * and the fullscreen exit action. False while a compact overlay panel covers the + * pane, which does not take pane focus. + */ isActiveComposer: boolean; } const ComposerKeyboardScopeContext = createContext({ + isPaneFocused: false, isActiveComposer: false, }); export function ComposerKeyboardScopeProvider({ + isPaneFocused, isActiveComposer, children, }: PropsWithChildren): ReactElement { - const value = useMemo(() => ({ isActiveComposer }), [isActiveComposer]); + const value = useMemo( + () => ({ isPaneFocused, isActiveComposer }), + [isPaneFocused, isActiveComposer], + ); return ( {children}