diff --git a/apps/mobile/src/features/threads/ComposerPopoverAnchor.android.tsx b/apps/mobile/src/features/threads/ComposerPopoverAnchor.android.tsx new file mode 100644 index 000000000000..1ff71cb29cd6 --- /dev/null +++ b/apps/mobile/src/features/threads/ComposerPopoverAnchor.android.tsx @@ -0,0 +1,70 @@ +import type { ReactNode } from "react"; +import { useEffect, useRef, useState } from "react"; +import { View, type ViewInstance } from "react-native"; + +import { useComposerPopoverHost } from "./ComposerPopoverHost"; + +const COMPOSER_GAP = 8; + +type Frame = { readonly x: number; readonly y: number; readonly width: number }; + +/** + * Places a composer popover just above the composer. Android only delivers + * drags to views inside their parent's bounds, so a popover hung above the + * composer with `bottom-full` takes taps but its list never scrolls. Render it + * into ComposerPopoverHost instead, which shares the composer's keyboard + * transform, so only the composer's resting layout needs measuring. + */ +export function ComposerPopoverAnchor(props: { readonly children: ReactNode }) { + const host = useComposerPopoverHost(); + const anchorRef = useRef(null); + const [frame, setFrame] = useState(null); + + const measure = () => { + const hostView = host?.hostRef.current; + if (!hostView) { + return; + } + anchorRef.current?.measureLayout(hostView, (x, y, width) => { + setFrame((current) => + current?.x === x && current.y === y && current.width === width ? current : { x, y, width }, + ); + }); + }; + + // No dependency array: the composer re-renders when its padding or expanded + // state changes, which moves it without resizing the anchor, so measure and + // re-project after every render. + useEffect(() => { + measure(); + host?.setContent( + frame === null ? null : ( + + {props.children} + + ), + ); + }); + + const setHostContent = host?.setContent; + useEffect(() => () => setHostContent?.(null), [setHostContent]); + + if (host === null) { + return {props.children}; + } + + // Covers the composer, so its onLayout fires whenever the composer resizes. + return ( + + ); +} diff --git a/apps/mobile/src/features/threads/ComposerPopoverAnchor.tsx b/apps/mobile/src/features/threads/ComposerPopoverAnchor.tsx new file mode 100644 index 000000000000..0552854b6e57 --- /dev/null +++ b/apps/mobile/src/features/threads/ComposerPopoverAnchor.tsx @@ -0,0 +1,7 @@ +import type { ReactNode } from "react"; +import { View } from "react-native"; + +/** Places a composer popover just above the composer. */ +export function ComposerPopoverAnchor(props: { readonly children: ReactNode }) { + return {props.children}; +} diff --git a/apps/mobile/src/features/threads/ComposerPopoverHost.tsx b/apps/mobile/src/features/threads/ComposerPopoverHost.tsx new file mode 100644 index 000000000000..cefa3a1f5fed --- /dev/null +++ b/apps/mobile/src/features/threads/ComposerPopoverHost.tsx @@ -0,0 +1,49 @@ +import type { ReactNode, RefObject } from "react"; +import { createContext, useContext, useMemo, useRef, useState } from "react"; +import { View, type ViewInstance } from "react-native"; + +type ComposerPopoverHostValue = { + readonly hostRef: RefObject; + readonly setContent: (content: ReactNode) => void; + /** Changes when the host resizes, which moves the bottom-anchored composer. */ + readonly layoutVersion: number; +}; + +const ComposerPopoverHostContext = createContext(null); + +export function useComposerPopoverHost() { + return useContext(ComposerPopoverHostContext); +} + +/** + * Full-size layer around the composer for its popovers. Mount it inside the + * composer's KeyboardStickyView: popovers placed here move with the composer + * and sit inside a parent that covers them, so Android delivers their scroll + * gestures. It wraps the composer because measureLayout needs an ancestor. + * Pass `hidden` whenever the composer itself is hidden: popovers live outside + * the composer's subtree, so they don't hide with it. + */ +export function ComposerPopoverHost(props: { + readonly hidden: boolean; + readonly children: ReactNode; +}) { + const hostRef = useRef(null); + const [content, setContent] = useState(null); + const [layoutVersion, setLayoutVersion] = useState(0); + const value = useMemo(() => ({ hostRef, setContent, layoutVersion }), [layoutVersion]); + + return ( + + setLayoutVersion((version) => version + 1)} + > + {props.children} + {props.hidden ? null : content} + + + ); +} diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 298bc07479d7..6873926fd5dd 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -102,6 +102,7 @@ import { type ComposerSendPresentation, } from "./composerSendPresentation"; import { ComposerCommandPopover } from "./ComposerCommandPopover"; +import { ComposerPopoverAnchor } from "./ComposerPopoverAnchor"; import { ComposerQueuedEditAttachments } from "./ComposerQueuedEdit"; import { useComposerCommandMenu } from "./use-composer-command-menu"; import { @@ -773,7 +774,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer {!voiceInput.isBusy && composerMenu.trigger && (composerMenu.items.length > 0 || composerMenu.trigger.kind === "pull-request") ? ( - + - + ) : null} {selectedProviderStatus?.compatibilityAdvisory?.message && diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index ea6353578302..79990884085d 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -137,6 +137,7 @@ import { COMPOSER_TRANSITION_DURATION_MS, ThreadComposer, } from "./ThreadComposer"; +import { ComposerPopoverHost } from "./ComposerPopoverHost"; import { ThreadFeed, type ThreadFeedHistoryControls } from "./ThreadFeed"; import { useThreadTurnSubagents } from "./ThreadAgentsSheet"; import { ComposerQueuedEditBanner } from "./ComposerQueuedEdit"; @@ -559,6 +560,9 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const [collapsedUserInputRequestId, setCollapsedUserInputRequestId] = useState(null); const activeUserInputRequestId = props.activePendingUserInput?.requestId ?? null; + // A pending user-input request or a failed creation owns the composer slot. + const composerSlotHidden = + activeUserInputRequestId !== null || props.creationState?.kind === "failed"; // The open /usage-limits panel for this thread, model and turn. Only the open // moment is stored: the rows read live provider data, so a redeemed reset // credit or refreshed probe shows through. Anything that spends quota closes @@ -1172,265 +1176,263 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread style={{ position: "absolute", bottom: 0, left: 0, right: 0, top: 0 }} offset={{ closed: 0, opened: 0 }} > - {/* The fixed sticky host gives this bottom-anchored child a stable + - {/* Hidden (not unmounted) while a user-input request owns the + {/* Hidden (not unmounted) while a user-input request owns the composer slot, so composer drafts and editor state survive. A rejected creation has no thread to send to; the failure card owns the slot instead. */} - - {isProviderSubagent ? ( - - - navigation.navigate("Thread", { - environmentId: String(props.environmentId), - threadId: String(props.selectedThread.lineage.parentThreadId), - }) - } - /> - - ) : ( - <> - - - )} + + {isProviderSubagent ? ( + + + navigation.navigate("Thread", { + environmentId: String(props.environmentId), + threadId: String(props.selectedThread.lineage.parentThreadId), + }) + } + /> + + ) : ( + <> + + + )} + - - + + ) : null}