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,