From 008e4e846f46d4cc509e0d29edce8104647f60e7 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 22 May 2026 20:44:37 +0700 Subject: [PATCH] fix(app): make slash command popover interactive on Android The popover was positioned `bottom: 100%` of its parent inside the composer and worked on iOS/web; Android's hit-test by parent bounds drops touches on children that overflow their parent, so taps and scrolls went through to the chat behind it. Move the popover into a `` at the app root and apply the same Reanimated keyboard SharedValue as the composer so it tracks the keyboard in lockstep. New `docs/floating-panels.md` captures the gotchas (Android hit-test, Portal lifecycle/transforms, status-bar offset, the two-measurement flash) and the canonical files. --- CLAUDE.md | 1 + docs/floating-panels.md | 173 +++++++++++++++++ packages/app/src/components/composer.tsx | 48 ++--- .../components/ui/autocomplete-popover.tsx | 176 ++++++++++++++++++ 4 files changed, 364 insertions(+), 34 deletions(-) create mode 100644 docs/floating-panels.md create mode 100644 packages/app/src/components/ui/autocomplete-popover.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 7c4ddae04..e5a679074 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,6 +32,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the | [docs/design.md](docs/design.md) | Theme tokens — colors, fonts, spacing, radii, icons | | [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it | | [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order | +| [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash | | [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer | | [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end | | [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries | diff --git a/docs/floating-panels.md b/docs/floating-panels.md new file mode 100644 index 000000000..f6329cd32 --- /dev/null +++ b/docs/floating-panels.md @@ -0,0 +1,173 @@ +# Floating Panels + +Anchored popovers — tooltips, hover cards, dropdowns, autocompletes — that visually +float above an anchor element on iOS, Android, and web. This doc captures the +non-obvious traps. It is **not** a tutorial; it assumes you have seen the +canonical files and are trying to add or change one. + +## Canonical files + +| File | Use case | +| ---------------------------------------- | -------------------------------------------------------------- | +| `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/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 | + +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 +focused TextInput. There is no shared "floating panel" primitive yet — when a +fifth use case shows up we can revisit; until then prefer copying the closest +file and trimming. The four gotchas below are what each of them re-learned. + +## Gotcha 1 — Android touch hit-test by parent bounds + +On Android, a child View whose bounds fall outside its parent's bounds renders +correctly (with `overflow: visible`, the default) but **does not receive touch +events**. `ViewGroup.dispatchTouchEvent` filters touches by the parent's hit +rect first, then iterates children. A touch in the overflowing region never +reaches the parent, let alone the child. iOS and web do not share this rule — +iOS hit-test descends into overflowing children, web uses standard CSS pointer +events. This is the bug that put autocomplete on this path: the popover was +positioned `bottom: 100%` of its parent and worked on iOS/web for months; +Android touches sailed straight through to the chat scroll view behind it. + +Two escape hatches in the codebase: + +- **`Modal`** (combobox, tooltip on native) — opens a new Android window, so + hit-testing starts fresh in that window. Side effect: a Modal opening on + Android can detach the IME from an underlying TextInput. Fine for combobox + (it has its own input) and tooltip (no input). **Not** fine for autocomplete + (the composer's input must stay focused so the user keeps typing). +- **`` from `@gorhom/portal`** (hover-card, autocomplete-popover) — + hoists the React subtree to a fixed mount point (`PortalHost name="root"` in + `app/_layout.tsx`) whose bounds cover the 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. + +Choose Modal vs Portal by whether you need the underlying input to keep its +keyboard. + +## Gotcha 2 — Portal breaks lifecycle and coordinate-system inheritance + +A Portal escapes Android's hit-test, but it also escapes two things you were +quietly relying on: + +- **Lifecycle.** The portal'd subtree mounts at the app root, not inside your + component's natural ancestor chain. When the user navigates away, your + 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 + `isPaneFocused` prop already exists and flips on pane switches; pass + `visible={isYourOwnVisible && isPaneFocused}`. See + `composer.tsx:1604` and `autocomplete-popover.tsx`. +- **Transforms.** The composer is wrapped in a Reanimated `Animated.View` with + `translateY: -keyboardShift` (see `use-keyboard-shift-style.ts`). The chat + content has the same transform applied (`agent-panel.tsx:939`). They move + together because they share the SharedValue. A portal'd popover is at the + app root — it does not get that transform unless you apply it yourself. + +The fix for transforms is Gotcha 3. + +## Gotcha 3 — Reanimated transforms vs `measureInWindow` + +`measureInWindow` returns the view's _current_ screen position. In theory that +includes Reanimated-applied transforms (Reanimated updates native view +properties, and Android's `getLocationInWindow` reads transformed coords). In +practice it's racy — the measurement may snapshot mid-animation, and on Android +with Reanimated worklets the result is not always stable. + +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**: + +1. Snapshot `openShift = shift.value` at the moment you measure the anchor. +2. Apply `useAnimatedStyle(() => ({ transform: [{ translateY: openShift.value - shift.value }] }))` + to the popover wrapper. + +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 popover by exactly the amount the composer translates. They move in +lockstep, no re-measurement needed. See `autocomplete-popover.tsx` — +`openShift` / `shift` / `animatedTransformStyle`. + +Re-measure on `Keyboard.addListener('keyboardDidShow'|'keyboardDidHide')` only +to refresh the snapshot if the keyboard was mid-transition when the popover +opened. + +## Gotcha 4 — Status-bar offset for Portal-based positioning on Android + +On Android, `measureInWindow` returns coords **below** the status bar (the +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 +on Android only. + +```ts +const statusBarOffset = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0; +setAnchorRect({ ...rect, y: rect.y + statusBarOffset }); +``` + +See `tooltip.tsx` and `autocomplete-popover.tsx`. iOS and web do not need this. + +## Gotcha 5 — The two-measurement flash + +If your popover needs `top` (or `left`) computed from both: + +- the anchor's screen position (`anchorRect` from `measureInWindow`), **and** +- the popover's own size (`contentSize` from `onLayout`), + +then a naïve implementation will flash through three positions on every open: + +1. **Frame 1** — render with `top: -9999` (or any placeholder) while waiting + for either measurement. Wrapper has no `width`, so the inner content lays + out at its natural (often narrow) intrinsic width. +2. **Frame 2** — `anchorRect` lands. Wrapper now has `width: anchorRect.width`. + But the stale `onLayout` from frame 1 has already set `contentSize` to the + narrow-width dimensions. `top = anchorRect.y - wrongHeight - gap` — visible + at the wrong spot. +3. **Frame 3** — real `onLayout` fires with the correct width. `contentSize` + updates. Position snaps to the right place. + +The visible jump in frame 2 is the flash. Two pieces solve it, and you need +both: + +- **Do not mount the floating content until `anchorRect` is set.** Return + `null` until then. This prevents the bad-width onLayout from happening at + all. +- **Once `anchorRect` is set but `contentSize` isn't, render the wrapper with + the final width but `opacity: 0`.** The first visible paint is at the + correct position. This is the combobox pattern — + `shouldHideDesktopContent` at `combobox.tsx:481, 876`. **Do not** use + `top: -9999` as the placeholder; the layout work still happens at -9999 and + any subsequent state-flash is visible when you flip back. + +The "render invisible to measure, then reveal" pattern is the canonical +solution to chicken-and-egg positioning in this codebase. Reach for it before +anything fancier. + +## Recipe for a new anchored panel + +Before you write a new one, ask: + +1. **Can the underlying input lose its keyboard?** If yes, use Modal (simpler). + If no, use Portal. +2. **Does the panel need to dismiss on screen change?** Almost always yes — + gate `visible` on an upstream focus prop (`isPaneFocused` or similar). +3. **Does the panel sit above something that moves with the keyboard?** If + yes, slave a Reanimated transform to the same SharedValue (Gotcha 3). + If no, you can probably skip the transform entirely. +4. **Will the panel's content height vary?** If yes, you need both + `anchorRect` and `contentSize` for positioning → apply Gotcha 5 (return + 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 + positioning (`bottom: windowHeight - anchor.y + gap`) and skip the + `contentSize` round-trip entirely. **But only if the height is genuinely + bounded** — autocomplete's inner detail card escapes the 220-cap + 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. diff --git a/packages/app/src/components/composer.tsx b/packages/app/src/components/composer.tsx index 9d3eabe21..1212e201c 100644 --- a/packages/app/src/components/composer.tsx +++ b/packages/app/src/components/composer.tsx @@ -62,7 +62,7 @@ import { useToast } from "@/contexts/toast-context"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Shortcut } from "@/components/ui/shortcut"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; -import { Autocomplete } from "@/components/ui/autocomplete"; +import { AutocompletePopover } from "@/components/ui/autocomplete-popover"; import { useAgentAutocomplete } from "@/hooks/use-agent-autocomplete"; import { useHostRuntimeAgentDirectoryStatus, @@ -256,25 +256,6 @@ function renderQueueList(args: RenderQueueListArgs): ReactElement | null { ); } -function renderAutocompletePopover( - autocomplete: ReturnType, -): ReactElement | null { - if (!autocomplete.isVisible) return null; - return ( - - - - ); -} - interface RenderComposerAttachmentPillArgs { attachment: ComposerAttachment; index: number; @@ -1589,10 +1570,7 @@ export function Composer({ [handleEditQueuedMessage, handleSendQueuedNow, queuedMessages], ); - const autocompletePopover = useMemo( - () => renderAutocompletePopover(autocomplete), - [autocomplete], - ); + const messageInputContainerRef = useRef(null); const isSubmitBusy = isProcessing || isSubmitLoading; const messageInputAutoFocus = autoFocus && isDesktopWebBreakpoint; @@ -1614,8 +1592,18 @@ export function Composer({ {queueList} {sendErrorNode} - - {autocompletePopover} + + {/* MessageInput handles everything: text, dictation, attachments, all buttons */} ({ width: "100%", gap: theme.spacing[3], }, - autocompletePopover: { - position: "absolute", - left: 0, - right: 0, - bottom: "100%", - marginBottom: theme.spacing[3], - zIndex: 30, - }, cancelButton: { width: 28, height: 28, diff --git a/packages/app/src/components/ui/autocomplete-popover.tsx b/packages/app/src/components/ui/autocomplete-popover.tsx new file mode 100644 index 000000000..abca645e5 --- /dev/null +++ b/packages/app/src/components/ui/autocomplete-popover.tsx @@ -0,0 +1,176 @@ +import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react"; +import { Keyboard, Platform, StatusBar, View, type LayoutChangeEvent } from "react-native"; +import { Portal } from "@gorhom/portal"; +import Animated, { + useAnimatedStyle, + useDerivedValue, + useSharedValue, +} from "react-native-reanimated"; +import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { StyleSheet } from "react-native-unistyles"; +import { Autocomplete, type AutocompleteOption } from "@/components/ui/autocomplete"; +import { SPACING } from "@/styles/theme"; +import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; + +const OFFSET_FROM_ANCHOR = SPACING[3]; + +interface Rect { + x: number; + y: number; + width: number; + height: number; +} + +function measureElement(element: View): Promise { + return new Promise((resolve) => { + element.measureInWindow((x, y, width, height) => { + resolve({ x, y, width, height }); + }); + }); +} + +interface AutocompletePopoverProps { + visible: boolean; + anchorRef: React.RefObject; + options: readonly AutocompleteOption[]; + selectedIndex: number; + onSelect: (option: AutocompleteOption) => void; + isLoading?: boolean; + errorMessage?: string; + loadingText?: string; + emptyText?: string; +} + +export function AutocompletePopover({ + visible, + anchorRef, + options, + selectedIndex, + onSelect, + isLoading, + errorMessage, + loadingText, + emptyText, +}: AutocompletePopoverProps): ReactElement | null { + const [anchorRect, setAnchorRect] = useState(null); + const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null); + const insets = useSafeAreaInsets(); + + const { height: rawKeyboardHeight } = useReanimatedKeyboardAnimation(); + const bottomInsetSV = useSharedValue(insets.bottom); + useEffect(() => { + bottomInsetSV.value = 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(() => + 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); + + useEffect(() => { + if (!visible) { + setAnchorRect(null); + setContentSize(null); + return; + } + 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 element = anchorRef.current; + if (!element) return; + void measureElement(element).then((rect) => { + if (cancelled) return undefined; + setAnchorRect({ ...rect, y: rect.y + statusBarOffset }); + openShift.value = shift.value; + return undefined; + }); + }; + + remeasure(); + const subscriptions = (["keyboardDidShow", "keyboardDidHide"] as const).map((event) => + Keyboard.addListener(event, () => requestAnimationFrame(remeasure)), + ); + + return () => { + cancelled = true; + for (const sub of subscriptions) sub.remove(); + }; + }, [visible, anchorRef, openShift, shift]); + + const handleLayout = useCallback((event: LayoutChangeEvent) => { + const { width, height } = event.nativeEvent.layout; + setContentSize({ width, height }); + }, []); + + const baseStyle = useMemo(() => { + if (!anchorRect) return null; + if (!contentSize) { + // Have the anchor, waiting on the popover's own height. Render with the + // final width so the inner Autocomplete lays out at its final size, but + // stay invisible — the first visible paint will already be at the correct + // top. Mirrors combobox.tsx `shouldHideDesktopContent`. See + // docs/floating-panels.md "the two-measurement flash". + return inlineUnistylesStyle({ + position: "absolute" as const, + top: 0, + left: anchorRect.x, + width: anchorRect.width, + opacity: 0, + }); + } + return inlineUnistylesStyle({ + position: "absolute" as const, + top: anchorRect.y - contentSize.height - OFFSET_FROM_ANCHOR, + left: anchorRect.x, + width: anchorRect.width, + }); + }, [anchorRect, contentSize]); + + const animatedTransformStyle = useAnimatedStyle(() => ({ + transform: [{ translateY: openShift.value - shift.value }], + })); + + const composedStyle = useMemo( + () => [baseStyle, animatedTransformStyle], + [baseStyle, animatedTransformStyle], + ); + + if (!visible || !anchorRect || !baseStyle) return null; + + return ( + + + + + + + + ); +} + +const styles = StyleSheet.create(() => ({ + overlay: { + position: "absolute", + top: 0, + right: 0, + bottom: 0, + left: 0, + }, +}));