mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 12:12:47 +00:00
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.
This commit is contained in:
+10
-11
@@ -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
|
||||
|
||||
@@ -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-",
|
||||
|
||||
@@ -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<MessageInputRef, MessageInputProps>(
|
||||
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<MessageInputRef, MessageInputProps>(
|
||||
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<MessageInputRef, MessageInputProps>(
|
||||
}, [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);
|
||||
|
||||
@@ -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;
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user