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 `<Portal>` 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.
This commit is contained in:
Mohamed Boudra
2026-05-22 20:44:37 +07:00
parent 789a559b31
commit 008e4e846f
4 changed files with 364 additions and 34 deletions
+14 -34
View File
@@ -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<typeof useAgentAutocomplete>,
): ReactElement | null {
if (!autocomplete.isVisible) return null;
return (
<View style={styles.autocompletePopover} pointerEvents="box-none">
<Autocomplete
options={autocomplete.options}
selectedIndex={autocomplete.selectedIndex}
isLoading={autocomplete.isLoading}
errorMessage={autocomplete.errorMessage}
loadingText={autocomplete.loadingText}
emptyText={autocomplete.emptyText}
onSelect={autocomplete.onSelectOption}
/>
</View>
);
}
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<View>(null);
const isSubmitBusy = isProcessing || isSubmitLoading;
const messageInputAutoFocus = autoFocus && isDesktopWebBreakpoint;
@@ -1614,8 +1592,18 @@ export function Composer({
{queueList}
{sendErrorNode}
<View style={styles.messageInputContainer}>
{autocompletePopover}
<View ref={messageInputContainerRef} style={styles.messageInputContainer}>
<AutocompletePopover
visible={autocomplete.isVisible && isPaneFocused}
anchorRef={messageInputContainerRef}
options={autocomplete.options}
selectedIndex={autocomplete.selectedIndex}
onSelect={autocomplete.onSelectOption}
isLoading={autocomplete.isLoading}
errorMessage={autocomplete.errorMessage}
loadingText={autocomplete.loadingText}
emptyText={autocomplete.emptyText}
/>
{/* MessageInput handles everything: text, dictation, attachments, all buttons */}
<StableMessageInput
@@ -1712,14 +1700,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
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,
@@ -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<Rect> {
return new Promise((resolve) => {
element.measureInWindow((x, y, width, height) => {
resolve({ x, y, width, height });
});
});
}
interface AutocompletePopoverProps {
visible: boolean;
anchorRef: React.RefObject<View | null>;
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<Rect | null>(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 (
<Portal>
<View style={styles.overlay} pointerEvents="box-none">
<Animated.View style={composedStyle} onLayout={handleLayout}>
<Autocomplete
options={options}
selectedIndex={selectedIndex}
onSelect={onSelect}
isLoading={isLoading}
errorMessage={errorMessage}
loadingText={loadingText}
emptyText={emptyText}
/>
</Animated.View>
</View>
</Portal>
);
}
const styles = StyleSheet.create(() => ({
overlay: {
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
},
}));