From e85810e2959807d88e597afe5d8faea23ddf98af Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 08:19:29 +0200 Subject: [PATCH] fix(app): keep the composer focus hint off the placeholder The web composer draws its focus shortcut hint over the input's top-right corner. When the placeholder and the hint did not fit on one line, as in French at a phone width or with the longer desktop placeholder, the hint was drawn on top of the end of the placeholder. The input now reserves the hint's width as right padding while the hint shows, so the placeholder wraps before it. Closes #6191 --- .../e2e/browser/composer-focus-hint.spec.ts | 112 ++++++++++++++++++ packages/app/src/composer/input/input.tsx | 40 +++++-- 2 files changed, 139 insertions(+), 13 deletions(-) create mode 100644 packages/app/e2e/browser/composer-focus-hint.spec.ts diff --git a/packages/app/e2e/browser/composer-focus-hint.spec.ts b/packages/app/e2e/browser/composer-focus-hint.spec.ts new file mode 100644 index 0000000000..24c714888d --- /dev/null +++ b/packages/app/e2e/browser/composer-focus-hint.spec.ts @@ -0,0 +1,112 @@ +import type { Locator } from "@playwright/test"; +import { expect, test, type Page } from "../support/fixtures"; +import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; + +const APP_SETTINGS_KEY = "@paseo:app-settings"; + +async function useLanguage(page: Page, language: string): Promise { + await page.addInitScript( + ({ key, language: value }) => { + localStorage.setItem(key, JSON.stringify({ language: value })); + }, + { key: APP_SETTINGS_KEY, language }, + ); +} + +/** + * Lays the placeholder out the way the textarea does, in a mirror element with + * the same box and font, and reports whether any of its lines reach the hint. + */ +async function placeholderOverlaps(input: Locator, hint: Locator): Promise { + const hintBox = await hint.boundingBox(); + if (!hintBox) throw new Error("Focus hint has no layout box"); + return input.evaluate((element, box) => { + const textarea = element as HTMLTextAreaElement; + const style = getComputedStyle(textarea); + const rect = textarea.getBoundingClientRect(); + const mirror = document.createElement("div"); + for (const property of [ + "boxSizing", + "paddingTop", + "paddingRight", + "paddingBottom", + "paddingLeft", + "borderTopWidth", + "borderRightWidth", + "borderBottomWidth", + "borderLeftWidth", + "fontFamily", + "fontSize", + "fontWeight", + "letterSpacing", + "lineHeight", + "textIndent", + "wordSpacing", + ] as const) { + mirror.style[property] = style[property]; + } + Object.assign(mirror.style, { + position: "fixed", + left: `${rect.left}px`, + top: `${rect.top}px`, + width: `${rect.width}px`, + borderStyle: "solid", + borderColor: "transparent", + whiteSpace: "pre-wrap", + overflowWrap: "break-word", + visibility: "hidden", + }); + const text = document.createTextNode(textarea.placeholder); + mirror.appendChild(text); + document.body.appendChild(mirror); + const range = document.createRange(); + range.selectNodeContents(text); + const lines = Array.from(range.getClientRects()); + mirror.remove(); + return lines.some( + (line) => + line.right > box.x && + line.left < box.x + box.width && + line.bottom > box.y && + line.top < box.y + box.height, + ); + }, hintBox); +} + +const FRENCH_LAYOUTS = [ + { name: "phone width", width: 390, placeholder: "Message, @fichiers, /commandes" }, + { + name: "desktop width", + width: 1024, + placeholder: + "Écrivez à l’agent, mentionnez des @fichiers ou utilisez les /commandes et /skills", + }, +]; + +for (const layout of FRENCH_LAYOUTS) { + test.describe(`French composer at ${layout.name}`, () => { + test.use({ viewport: { width: layout.width, height: 844 } }); + + test("the focus hint does not cover the placeholder", async ({ page }) => { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "composer-focus-hint-", + title: "Composer focus hint", + }); + + try { + await useLanguage(page, "fr"); + await openAgentRoute(page, agent); + const input = page.getByRole("textbox", { name: "Écrire à l’agent…" }).first(); + await expect(input).toBeVisible(); + await expect(input).toHaveAttribute("placeholder", layout.placeholder); + await input.evaluate((element) => (element as HTMLTextAreaElement).blur()); + + const hint = page.getByText(/pour saisir$/).first(); + await expect(hint).toBeVisible(); + expect(await placeholderOverlaps(input, hint)).toBe(false); + } finally { + await agent.cleanup(); + } + }); + }); +} diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index e15a00018e..2f6ba1b29e 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -608,18 +608,14 @@ function MessageInputOverlay({ } function FocusHint({ - visible, - focusInputKeys, label, + onLayout, }: { - visible: boolean; - focusInputKeys: ShortcutChord | null | undefined; label: string; + onLayout: (event: LayoutChangeEvent) => void; }) { - const { isActiveComposer } = useComposerKeyboardScope(); - if (!isActiveComposer || !visible || !focusInputKeys || !label.trim()) return null; return ( - + {label} ); @@ -654,6 +650,25 @@ interface ComposerTextSurfaceProps { * state of this composer rather than a second one. */ function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElement { + const { isActiveComposer } = useComposerKeyboardScope(); + const [focusHintWidth, setFocusHintWidth] = useState(0); + const handleFocusHintLayout = useCallback((event: LayoutChangeEvent) => { + setFocusHintWidth(event.nativeEvent.layout.width); + }, []); + const isFocusHintShown = + isActiveComposer && + props.focusHintVisible && + Boolean(props.focusInputKeys) && + props.focusHintLabel.trim().length > 0; + // The hint sits over the input's top-right corner, so the placeholder wraps + // before it instead of running underneath. + const textInputStyle = useMemo( + () => + isFocusHintShown + ? [props.textInputStyle, { paddingRight: focusHintWidth }] + : props.textInputStyle, + [focusHintWidth, isFocusHintShown, props.textInputStyle], + ); if (props.readOnly) { return ( @@ -674,7 +689,7 @@ function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElemen accessibilityLabel={props.accessibilityLabel} onFocus={props.onFocus} onBlur={props.onBlur} - style={props.textInputStyle} + style={textInputStyle} multiline scrollEnabled={props.scrollEnabled} editable={props.editable} @@ -684,11 +699,9 @@ function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElemen onPasteError={props.onPasteError} autoFocus={props.autoFocus} /> - + {isFocusHintShown ? ( + + ) : null} ); } @@ -1955,6 +1968,7 @@ const styles = StyleSheet.create((theme: Theme) => ({ position: "absolute", top: 0, right: 0, + paddingLeft: theme.spacing[2], fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted, opacity: 0.5,