import { QuestionAttachments } from "./QuestionAttachments"; import type { ApprovalRequestId, UserInputQuestion } from "@t3tools/contracts"; import { useCallback, useRef } from "react"; import { Platform, Pressable, ScrollView, View, type LayoutChangeEvent } from "react-native"; import Animated, { Easing, FadeInUp, FadeOutDown, LinearTransition, useAnimatedStyle, useSharedValue, withTiming, type SharedValue, } from "react-native-reanimated"; import { USER_INPUT_TOGGLE_DURATION_MS } from "./pendingUserInputLayout"; import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text } from "../../components/AppText"; import { ControlPill } from "../../components/ControlPill"; import { cn } from "../../lib/cn"; import { isPendingUserInputOptionSelected, type PendingUserInput, type PendingUserInputDraftAnswer, } from "../../lib/threadActivity"; export interface PendingUserInputCardProps { readonly pendingUserInput: PendingUserInput; /** * Constant while a request is pending (it reserves keyboard space), so the * keyboard transition is pure translation; changes only on rare discrete * corrections, which the layout transition smooths. */ readonly maxHeight: number; readonly collapsed: boolean; readonly onToggleCollapsed: () => void; /** Renders a stop control on the collapsed bar, which replaces the composer. */ readonly onStopThread?: () => void; /** * 0 collapsed → 1 expanded. Slides the iOS overlay card down behind the * collapsed bar (inside a clipping window) on the UI thread; the host * animates it directly from the tap handler so the card and the feed * inset glide start the same frame. */ readonly cardProgress?: SharedValue; /** * Receives how far the expanded card extends above the bar footprint * (written from onLayout with no re-render); the host adds it to the * thread feed's end inset so the end of the chat stays visible above the * card. */ readonly cardCoverage?: SharedValue; /** Fires on custom-answer focus/blur; hosts use it to vet stale keyboard state. */ readonly onInputFocusChange?: (focused: boolean) => void; readonly drafts: Record; readonly answers: Record> | null; readonly respondingUserInputId: ApprovalRequestId | null; readonly onSelectOption: ( requestId: ApprovalRequestId, question: UserInputQuestion, value: string, ) => void; readonly onChangeCustomAnswer: ( requestId: ApprovalRequestId, questionId: string, customAnswer: string, ) => void; readonly onSubmit: () => Promise; /** Closes an async question without a reply. Hidden for native callback questions. */ readonly onDismiss: () => Promise; } /** * On iOS the collapsed bar is the PERMANENT in-flow footprint — the expanded * card is an absolutely-positioned overlay rising above it. The overlay's * measured height (which drives the thread feed's bottom inset) therefore * never changes on collapse/expand, so the transcript stays perfectly still * while the card animates over it. * * Android cannot use the overlay: it does not hit-test touches outside a * parent's bounds, which made everything above the bar-sized wrapper * untouchable. There the expanded card renders in-flow instead (the wrapper * grows with it, and the host skips the coverage inset since the measured * overlay already includes the card). */ const EXPANDED_CARD_IS_OVERLAY = Platform.OS === "ios"; const CARD_LAYOUT_TRANSITION = LinearTransition.duration(200); export function PendingUserInputCard(props: PendingUserInputCardProps) { const questionCount = props.pendingUserInput.questions.length; const cardCoverage = props.cardCoverage; const barHeightRef = useRef(0); const cardHeightRef = useRef(0); // Measured card height, written straight from onLayout: the collapse slide // distance. Not animated — it only changes on discrete relayouts. const cardHeight = useSharedValue(0); const notifyCoverage = useCallback(() => { if (!cardCoverage) { return; } const coverage = Math.max(0, cardHeightRef.current - barHeightRef.current); if (coverage === cardCoverage.value) { return; } if (cardCoverage.value === 0) { // First measurement lands while the list is doing its initial // end-pin (thread opened onto a pending request); animating it from // zero would move the end anchor out from under that scroll. cardCoverage.value = coverage; return; } // Animated so a coverage change at rest (discrete max-height // corrections) glides the feed instead of stepping it; toggle timing is // owned by the host's progress values. cardCoverage.value = withTiming(coverage, { duration: USER_INPUT_TOGGLE_DURATION_MS, easing: Easing.out(Easing.cubic), }); }, [cardCoverage]); const handleBarLayout = useCallback( (event: LayoutChangeEvent) => { barHeightRef.current = event.nativeEvent.layout.height; notifyCoverage(); }, [notifyCoverage], ); const handleCardLayout = useCallback( (event: LayoutChangeEvent) => { cardHeightRef.current = event.nativeEvent.layout.height; cardHeight.value = event.nativeEvent.layout.height; notifyCoverage(); }, [cardHeight, notifyCoverage], ); const cardProgress = props.cardProgress; // No opacity: fading an opaque card over the live transcript reads as a // crossfade (card text, transcript, and bar all half-visible at once). // Instead the card stays opaque and slides its full height down past the // clipping window's bottom edge, so the transcript is only revealed where // the card has physically left. const cardAnimatedStyle = useAnimatedStyle(() => { const progress = cardProgress === undefined ? 1 : cardProgress.value; return { transform: [{ translateY: (1 - progress) * cardHeight.value }], }; }); // On iOS the card stays MOUNTED while collapsed (hidden via the animated // style): expanding animates existing views on the UI thread the same // frame the host starts the progress timing, instead of paying a React // mount + layout before anything moves. const renderCard = EXPANDED_CARD_IS_OVERLAY || !props.collapsed; const showBar = props.collapsed || EXPANDED_CARD_IS_OVERLAY; // The bar renders UNDER the card (earlier in JSX), always opaque: while // expanded the opaque card covers it, and during the collapse slide the // card's top edge wipes past and reveals it — no opacity handoff, so no // crossfade frames. const bar = showBar ? ( User input needed {questionCount} question{questionCount === 1 ? "" : "s"} {props.onStopThread ? ( ) : null} ) : null; const card = renderCard ? ( // The surface is opaque on purpose: the card floats over the thread // feed with no blur behind it, so a translucent background renders // the questions on top of whatever message happens to sit underneath. User input needed Fill in the pending answers {props.pendingUserInput.questions.map((question) => { const draft = props.drafts[question.id]; return ( {question.header} {question.question} {question.options.map((option) => { const optionValue = option.value ?? option.label.trim(); const selected = isPendingUserInputOptionSelected(question, draft, optionValue); const description = option.description !== option.label ? option.description : undefined; return ( props.onSelectOption( props.pendingUserInput.requestId, question, optionValue, ) } > {option.label} {description ? ( {description} ) : null} ); })} props.onChangeCustomAnswer(props.pendingUserInput.requestId, question.id, value) } onInputFocusChange={props.onInputFocusChange} /> ); })} void props.onSubmit()} > Submit answers {props.pendingUserInput.dismissible ? ( void props.onDismiss()} > Dismiss without answering ) : null} ) : null; return ( {bar} {EXPANDED_CARD_IS_OVERLAY ? ( // Clipping window for the collapse slide: same footprint as the // expanded card, bottom edge on the bar's bottom edge. The sliding // card exits through the bottom edge instead of drawing over the // composer area, wiping the bar (and the transcript) into view. {card} ) : ( card )} ); }