Fix autocomplete popover anchoring

Position portal content relative to its render host so the composer anchor stays correct when surrounding layout shifts. This preserves the Portal surface needed for Android scrolling and touch behavior.
This commit is contained in:
Mohamed Boudra
2026-05-26 11:17:03 +07:00
parent bf69ebeddf
commit c31ab074e9
5 changed files with 177 additions and 75 deletions
+40 -43
View File
@@ -7,18 +7,18 @@ canonical files and are trying to add or change one.
## Canonical files ## Canonical files
| File | Use case | | File | Use case |
| ---------------------------------------- | -------------------------------------------------------------- | | ---------------------------------------- | ----------------------------------------------------------------- |
| `components/ui/combobox.tsx` | Anchored picker with search; mobile falls back to bottom sheet | | `components/ui/combobox.tsx` | Anchored picker with search; mobile falls back to bottom sheet |
| `components/ui/tooltip.tsx` | Non-interactive hover/long-press tooltip | | `components/ui/tooltip.tsx` | Non-interactive hover/long-press tooltip |
| `components/workspace-hover-card.tsx` | Desktop-web hover card with measure + computePosition + Portal | | `components/workspace-hover-card.tsx` | Desktop-web hover card with measure + computePosition + Portal |
| `components/ui/autocomplete-popover.tsx` | Inline slash-command autocomplete above a focused TextInput | | `components/ui/autocomplete-popover.tsx` | Slash-command autocomplete anchored to the focused composer input |
Each handles a different mix of concerns: combobox owns input focus, tooltip is Each handles a different mix of concerns: combobox owns input focus, tooltip is
non-interactive, hover-card is web-only desktop, autocomplete coexists with a non-interactive, hover-card is web-only desktop, autocomplete keeps the composer
focused TextInput. There is no shared "floating panel" primitive yet — when a input focused while its scrollable list lives in a Portal. There is no shared
fifth use case shows up we can revisit; until then prefer copying the closest "floating panel" primitive yet — when a fifth use case shows up we can revisit;
file and trimming. The four gotchas below are what each of them re-learned. until then prefer copying the closest file and trimming.
## Gotcha 1 — Android touch hit-test by parent bounds ## Gotcha 1 — Android touch hit-test by parent bounds
@@ -43,12 +43,11 @@ Two escape hatches in the codebase:
hoists the React subtree to a fixed mount point whose bounds cover the hoists the React subtree to a fixed mount point whose bounds cover the
screen. Same window, same IME, hit-test works because the new parent is screen. Same window, same IME, hit-test works because the new parent is
full-screen. This is the right default when you must keep IME attachment. full-screen. This is the right default when you must keep IME attachment.
Choose the host by layer: app-global overlays use the root host; composer Choose the host by layer: app-global overlays use the root host; content
autocomplete uses the content floating-panel host so sliding sidebars cover overlays can use the current `FloatingPanelPortalHost` so sliding sidebars
it. cover them.
Choose Modal vs Portal by whether you need the underlying input to keep its Choose Modal vs Portal by whether the underlying input can lose its keyboard.
keyboard.
## Gotcha 2 — Portal breaks lifecycle and coordinate-system inheritance ## Gotcha 2 — Portal breaks lifecycle and coordinate-system inheritance
@@ -60,8 +59,7 @@ quietly relying on:
component may stay mounted (offscreen, in a tab) — the popover stays with it. component may stay mounted (offscreen, in a tab) — the popover stays with it.
Gate `visible` on a screen-focus signal. For panes inside `agent-panel`, the Gate `visible` on a screen-focus signal. For panes inside `agent-panel`, the
`isPaneFocused` prop already exists and flips on pane switches; pass `isPaneFocused` prop already exists and flips on pane switches; pass
`visible={isYourOwnVisible && isPaneFocused}`. See `visible={isYourOwnVisible && isPaneFocused}`.
`composer.tsx:1604` and `autocomplete-popover.tsx`.
- **Transforms.** The composer is wrapped in a Reanimated `Animated.View` with - **Transforms.** The composer is wrapped in a Reanimated `Animated.View` with
`translateY: -keyboardShift` (see `use-keyboard-shift-style.ts`). The chat `translateY: -keyboardShift` (see `use-keyboard-shift-style.ts`). The chat
content has the same transform applied (`agent-panel.tsx:939`). They move content has the same transform applied (`agent-panel.tsx:939`). They move
@@ -69,11 +67,12 @@ quietly relying on:
the composer tree — it does not get that transform unless you apply it the composer tree — it does not get that transform unless you apply it
yourself. yourself.
- **Layering.** The default root host renders after app content, so it sits - **Layering.** The default root host renders after app content, so it sits
above compact sidebars. Autocomplete should use above compact sidebars. Content overlays that must sit below sidebars should
`useFloatingPanelPortalHostName()` and render through the current use the current `FloatingPanelPortalHost`.
`FloatingPanelPortalHost` instead. The app shell provides a default content - **Coordinate systems.** `measureInWindow` gives window coordinates. A Portal
host; workspace screens provide a per-workspace host between the center pane renders inside its host, not necessarily at window origin. Position anchored
and explorer sidebar so both compact sidebars cover autocomplete. content relative to the host: `anchorRect - hostRect`. This is what
`measureFloatingPanelPortalHost()` is for.
The fix for transforms is Gotcha 3. The fix for transforms is Gotcha 3.
@@ -85,7 +84,8 @@ properties, and Android's `getLocationInWindow` reads transformed coords). In
practice it's racy — the measurement may snapshot mid-animation, and on Android practice it's racy — the measurement may snapshot mid-animation, and on Android
with Reanimated worklets the result is not always stable. with Reanimated worklets the result is not always stable.
Do not try to track the keyboard by re-measuring on every frame. Instead, If the panel cannot stay inside the transformed ancestor, do not try to track
the keyboard by re-measuring on every frame. Instead,
**slave the popover's transform to the same SharedValue the composer uses**: **slave the popover's transform to the same SharedValue the composer uses**:
1. Snapshot `openShift = shift.value` at the moment you measure the anchor. 1. Snapshot `openShift = shift.value` at the moment you measure the anchor.
@@ -95,31 +95,29 @@ Do not try to track the keyboard by re-measuring on every frame. Instead,
When `shift` equals `openShift`, the translate is 0 and the popover sits at When `shift` equals `openShift`, the translate is 0 and the popover sits at
the measured position. When the keyboard moves afterward, the delta translates the measured position. When the keyboard moves afterward, the delta translates
the popover by exactly the amount the composer translates. They move in the popover by exactly the amount the composer translates. They move in
lockstep, no re-measurement needed. See `autocomplete-popover.tsx` — lockstep, no re-measurement needed.
`openShift` / `shift` / `animatedTransformStyle`.
Re-measure on `Keyboard.addListener('keyboardDidShow'|'keyboardDidHide')` only Re-measure on `Keyboard.addListener('keyboardDidShow'|'keyboardDidHide')` only
to refresh the snapshot if the keyboard was mid-transition when the popover to refresh the snapshot if the keyboard was mid-transition when the popover
opened. opened.
## Gotcha 4 — Status-bar offset for Portal-based positioning on Android ## Gotcha 4 — Host-relative positioning before platform offsets
On Android, `measureInWindow` returns coords **below** the status bar (the The generic anchored-overlay rule is:
status bar is system-owned space). A Portal overlay positioned at `top: 0`
inside the `PortalProvider` may start at the **top of the window** (above the
status bar) depending on the wrapper chain. The result: position the popover
at the measured `rect.y` and it sits `StatusBar.currentHeight` higher than the
anchor on Android.
Mirror what tooltip does: shift the measured rect down by the status bar height 1. Measure the anchor with `measureInWindow`.
on Android only. 2. Measure the Portal host with `measureFloatingPanelPortalHost(hostName)`.
3. Position with anchor coordinates relative to the host:
```ts ```ts
const statusBarOffset = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0; left = anchorRect.x - hostRect.x;
setAnchorRect({ ...rect, y: rect.y + statusBarOffset }); bottom = hostRect.height - (anchorRect.y - hostRect.y) + offset;
``` ```
See `tooltip.tsx` and `autocomplete-popover.tsx`. iOS and web do not need this. Do this before adding any platform offset. If anchor and host are both measured
with `measureInWindow`, Android's status-bar coordinate behavior cancels out.
Only add a status-bar offset when the render surface is not measured in the same
coordinate system. See `tooltip.tsx` for that separate case.
## Gotcha 5 — The two-measurement flash ## Gotcha 5 — The two-measurement flash
@@ -165,18 +163,17 @@ Before you write a new one, ask:
If no, use Portal. If no, use Portal.
2. **Does the panel need to dismiss on screen change?** Almost always yes — 2. **Does the panel need to dismiss on screen change?** Almost always yes —
gate `visible` on an upstream focus prop (`isPaneFocused` or similar). gate `visible` on an upstream focus prop (`isPaneFocused` or similar).
3. **Does the panel sit above something that moves with the keyboard?** If 3. **Is the panel rendered in a Portal host?** Measure the host too. Never use
raw window coordinates as local Portal coordinates.
4. **Does the panel sit above something that moves with the keyboard?** If
yes, slave a Reanimated transform to the same SharedValue (Gotcha 3). yes, slave a Reanimated transform to the same SharedValue (Gotcha 3).
If no, you can probably skip the transform entirely. If no, you can probably skip the transform entirely.
4. **Will the panel's content height vary?** If yes, you need both 5. **Will the panel's content height vary?** If yes, you need both
`anchorRect` and `contentSize` for positioning → apply Gotcha 5 (return `anchorRect` and `contentSize` for positioning → apply Gotcha 5 (return
null until anchor, then opacity-0 until contentSize). If no — content has null until anchor, then opacity-0 until contentSize). If no — content has
a known fixed max height — you might be able to use bottom-anchored a known fixed max height — you might be able to use bottom-anchored
positioning (`bottom: windowHeight - anchor.y + gap`) and skip the positioning (`bottom: windowHeight - anchor.y + gap`) and skip the
`contentSize` round-trip entirely. **But only if the height is genuinely `contentSize` round-trip entirely. **But only if the height is genuinely
bounded** — autocomplete's inner detail card escapes the 220-cap bounded**. Verify before you commit.
container, which is why bottom-anchored looked clean but rendered
wrong. Verify before you commit.
5. **Are you on Android with Portal?** Add the status-bar offset (Gotcha 4).
Then copy the closest canonical file and trim. Then copy the closest canonical file and trim.
@@ -471,6 +471,33 @@ test.describe("Composer autocomplete", () => {
} }
}); });
test("stays anchored to the composer when the desktop sidebar is open", async ({ page }) => {
await installListCommandsStub(page);
const agent = await openReadyMockAgent(page);
try {
await expect(page.getByTestId("sidebar-sessions")).toBeVisible({ timeout: 30_000 });
const input = composerLocator(page);
await expect(input).toBeEditable({ timeout: 30_000 });
await input.fill("/");
const popover = page.getByTestId("composer-autocomplete-popover");
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
const composerBox = await page.getByTestId("message-input-root").boundingBox();
const popoverBox = await popover.boundingBox();
expect(composerBox).not.toBeNull();
expect(popoverBox).not.toBeNull();
expect(Math.abs(popoverBox!.x - composerBox!.x)).toBeLessThanOrEqual(4);
expect(Math.abs(popoverBox!.width - composerBox!.width)).toBeLessThanOrEqual(4);
} finally {
await agent.cleanup();
}
});
test.describe("compact sidebar layering", () => { test.describe("compact sidebar layering", () => {
test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true }); test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
+2 -1
View File
@@ -1594,6 +1594,7 @@ export function Composer({
const githubEmptyText = githubSearchResultsQuery.isFetching const githubEmptyText = githubSearchResultsQuery.isFetching
? "Searching..." ? "Searching..."
: "No results found."; : "No results found.";
const autocompleteVisible = autocomplete.isVisible && isPaneFocused;
return ( return (
<Animated.View style={composerContainerStyle}> <Animated.View style={composerContainerStyle}>
@@ -1606,7 +1607,7 @@ export function Composer({
<View ref={messageInputContainerRef} style={styles.messageInputContainer}> <View ref={messageInputContainerRef} style={styles.messageInputContainer}>
<AutocompletePopover <AutocompletePopover
visible={autocomplete.isVisible && isPaneFocused} visible={autocompleteVisible}
anchorRef={messageInputContainerRef} anchorRef={messageInputContainerRef}
options={autocomplete.options} options={autocomplete.options}
selectedIndex={autocomplete.selectedIndex} selectedIndex={autocomplete.selectedIndex}
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState, type ReactElement } from "react"; import { useEffect, useMemo, useState, type ReactElement, type RefObject } from "react";
import { Keyboard, Platform, StatusBar, View, useWindowDimensions } from "react-native"; import { Keyboard, View, useWindowDimensions } from "react-native";
import { Portal } from "@gorhom/portal"; import { Portal } from "@gorhom/portal";
import Animated, { import Animated, {
useAnimatedStyle, useAnimatedStyle,
@@ -10,7 +10,10 @@ import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { Autocomplete, type AutocompleteOption } from "@/components/ui/autocomplete"; import { Autocomplete, type AutocompleteOption } from "@/components/ui/autocomplete";
import { useFloatingPanelPortalHostName } from "@/components/ui/floating-panel-portal"; import {
measureFloatingPanelPortalHost,
useFloatingPanelPortalHostName,
} from "@/components/ui/floating-panel-portal";
import { SPACING } from "@/styles/theme"; import { SPACING } from "@/styles/theme";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
@@ -23,6 +26,13 @@ interface Rect {
height: number; height: number;
} }
interface RelativeAnchorRect {
x: number;
y: number;
width: number;
hostHeight: number;
}
function measureElement(element: View): Promise<Rect> { function measureElement(element: View): Promise<Rect> {
return new Promise((resolve) => { return new Promise((resolve) => {
element.measureInWindow((x, y, width, height) => { element.measureInWindow((x, y, width, height) => {
@@ -33,7 +43,7 @@ function measureElement(element: View): Promise<Rect> {
interface AutocompletePopoverProps { interface AutocompletePopoverProps {
visible: boolean; visible: boolean;
anchorRef: React.RefObject<View | null>; anchorRef: RefObject<View | null>;
options: readonly AutocompleteOption[]; options: readonly AutocompleteOption[];
selectedIndex: number; selectedIndex: number;
onSelect: (option: AutocompleteOption) => void; onSelect: (option: AutocompleteOption) => void;
@@ -54,8 +64,8 @@ export function AutocompletePopover({
loadingText, loadingText,
emptyText, emptyText,
}: AutocompletePopoverProps): ReactElement | null { }: AutocompletePopoverProps): ReactElement | null {
const [anchorRect, setAnchorRect] = useState<Rect | null>(null); const [relativeAnchorRect, setRelativeAnchorRect] = useState<RelativeAnchorRect | null>(null);
const { height: windowHeight } = useWindowDimensions(); const windowDimensions = useWindowDimensions();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const portalHostName = useFloatingPanelPortalHostName(); const portalHostName = useFloatingPanelPortalHostName();
@@ -65,57 +75,69 @@ export function AutocompletePopover({
bottomInsetSV.value = insets.bottom; bottomInsetSV.value = insets.bottom;
}, [bottomInsetSV, insets.bottom]); }, [bottomInsetSV, insets.bottom]);
// Same shift formula as useKeyboardShiftStyle({mode: "translate"}), so the popover
// tracks the composer's keyboard translate in lockstep.
const shift = useDerivedValue(() => const shift = useDerivedValue(() =>
Math.max(0, Math.abs(rawKeyboardHeight.value) - bottomInsetSV.value), Math.max(0, Math.abs(rawKeyboardHeight.value) - bottomInsetSV.value),
); );
// Snapshot of `shift` at the moment we measured the anchor. Translate applied to the
// popover is `openShift - shift`, so when shift == openShift the popover sits at the
// measured position; when keyboard moves the popover translates with the composer.
const openShift = useSharedValue(0); const openShift = useSharedValue(0);
useEffect(() => { useEffect(() => {
if (!visible) { if (!visible || (options.length > 0 && selectedIndex < 0)) {
setAnchorRect(null); setRelativeAnchorRect(null);
return; return;
} }
let cancelled = false; let cancelled = false;
// measureInWindow on Android returns coords below the status bar, while the Portal
// overlay starts at the top of the window. Mirror tooltip.tsx and shift the rect
// down by the status bar height to keep both in the same coord system.
const statusBarOffset = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
const remeasure = () => { const remeasure = () => {
const element = anchorRef.current; const anchorElement = anchorRef.current;
if (!element) return; if (!anchorElement) return;
void measureElement(element).then((rect) => { void Promise.all([
if (cancelled) return undefined; measureElement(anchorElement),
setAnchorRect({ ...rect, y: rect.y + statusBarOffset }); measureFloatingPanelPortalHost(portalHostName),
]).then(([anchorRect, hostRect]) => {
if (cancelled || !hostRect) return undefined;
setRelativeAnchorRect({
x: anchorRect.x - hostRect.x,
y: anchorRect.y - hostRect.y,
width: anchorRect.width,
hostHeight: hostRect.height,
});
openShift.value = shift.value; openShift.value = shift.value;
return undefined; return undefined;
}); });
}; };
remeasure(); remeasure();
const raf = requestAnimationFrame(remeasure);
const subscriptions = (["keyboardDidShow", "keyboardDidHide"] as const).map((event) => const subscriptions = (["keyboardDidShow", "keyboardDidHide"] as const).map((event) =>
Keyboard.addListener(event, () => requestAnimationFrame(remeasure)), Keyboard.addListener(event, () => requestAnimationFrame(remeasure)),
); );
return () => { return () => {
cancelled = true; cancelled = true;
cancelAnimationFrame(raf);
for (const sub of subscriptions) sub.remove(); for (const sub of subscriptions) sub.remove();
}; };
}, [visible, anchorRef, openShift, shift]); }, [
visible,
options.length,
selectedIndex,
anchorRef,
portalHostName,
openShift,
shift,
windowDimensions.width,
windowDimensions.height,
]);
const baseStyle = useMemo(() => { const baseStyle = useMemo(() => {
if (!anchorRect) return null; if (!relativeAnchorRect) return null;
return inlineUnistylesStyle({ return inlineUnistylesStyle({
position: "absolute" as const, position: "absolute" as const,
bottom: windowHeight - anchorRect.y + OFFSET_FROM_ANCHOR, bottom: relativeAnchorRect.hostHeight - relativeAnchorRect.y + OFFSET_FROM_ANCHOR,
left: anchorRect.x, left: relativeAnchorRect.x,
width: anchorRect.width, width: relativeAnchorRect.width,
}); });
}, [anchorRect, windowHeight]); }, [relativeAnchorRect]);
const animatedTransformStyle = useAnimatedStyle(() => ({ const animatedTransformStyle = useAnimatedStyle(() => ({
transform: [{ translateY: openShift.value - shift.value }], transform: [{ translateY: openShift.value - shift.value }],
@@ -126,7 +148,8 @@ export function AutocompletePopover({
[baseStyle, animatedTransformStyle], [baseStyle, animatedTransformStyle],
); );
if (!visible || !anchorRect || !baseStyle) return null; if (!visible || !relativeAnchorRect || !baseStyle) return null;
if (options.length > 0 && selectedIndex < 0) return null;
return ( return (
<Portal hostName={portalHostName}> <Portal hostName={portalHostName}>
@@ -1,10 +1,41 @@
import { createContext, type ReactElement, type ReactNode, useContext } from "react"; import {
createContext,
type ReactElement,
type ReactNode,
useContext,
useEffect,
useRef,
} from "react";
import { StyleSheet, View } from "react-native";
import { PortalHost } from "@gorhom/portal"; import { PortalHost } from "@gorhom/portal";
export const DEFAULT_FLOATING_PANEL_PORTAL_HOST = "content-floating-panels"; export const DEFAULT_FLOATING_PANEL_PORTAL_HOST = "content-floating-panels";
const FloatingPanelPortalHostNameContext = createContext(DEFAULT_FLOATING_PANEL_PORTAL_HOST); const FloatingPanelPortalHostNameContext = createContext(DEFAULT_FLOATING_PANEL_PORTAL_HOST);
interface Rect {
x: number;
y: number;
width: number;
height: number;
}
const portalHostElements = new Map<string, View>();
function measureElement(element: View): Promise<Rect> {
return new Promise((resolve) => {
element.measureInWindow((x, y, width, height) => {
resolve({ x, y, width, height });
});
});
}
export function measureFloatingPanelPortalHost(name: string): Promise<Rect | null> {
const element = portalHostElements.get(name);
if (!element) return Promise.resolve(null);
return measureElement(element);
}
export function FloatingPanelPortalHostNameProvider({ export function FloatingPanelPortalHostNameProvider({
hostName, hostName,
children, children,
@@ -28,5 +59,28 @@ export function FloatingPanelPortalHost({
}: { }: {
name?: string; name?: string;
}): ReactElement { }): ReactElement {
return <PortalHost name={name} />; const hostRef = useRef<View>(null);
useEffect(() => {
const host = hostRef.current;
if (!host) return undefined;
portalHostElements.set(name, host);
return () => {
if (portalHostElements.get(name) === host) {
portalHostElements.delete(name);
}
};
}, [name]);
return (
<View ref={hostRef} collapsable={false} pointerEvents="box-none" style={styles.host}>
<PortalHost name={name} />
</View>
);
} }
const styles = StyleSheet.create({
host: {
...StyleSheet.absoluteFillObject,
},
});