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
This commit is contained in:
paseo-bot[bot]
2026-10-06 08:19:29 +02:00
parent e3db7c3397
commit e85810e295
2 changed files with 139 additions and 13 deletions
@@ -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<void> {
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<boolean> {
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();
}
});
});
}
+27 -13
View File
@@ -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 (
<Text style={styles.focusHintText} pointerEvents="none">
<Text style={styles.focusHintText} pointerEvents="none" onLayout={onLayout}>
{label}
</Text>
);
@@ -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 (
<View style={styles.textInputScrollWrapper}>
@@ -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}
/>
<FocusHint
visible={props.focusHintVisible}
focusInputKeys={props.focusInputKeys}
label={props.focusHintLabel}
/>
{isFocusHintShown ? (
<FocusHint label={props.focusHintLabel} onLayout={handleFocusHintLayout} />
) : null}
</View>
);
}
@@ -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,