import { Button } from "../../components/ui/Button"; import { QuestionAttachments } from "./QuestionAttachments"; import type { EnvironmentId, RuntimeRequestId } from "@t3tools/contracts"; import type { ThreadUserInputQuestion } from "@t3tools/client-runtime/state/thread-requests"; import { useRef } from "react"; import { Platform, Pressable, ScrollView, View, type LayoutChangeEvent } from "react-native"; import Animated, { Easing, FadeInUp, FadeOutDown, useAnimatedStyle, useSharedValue, type DerivedValue, type SharedValue, } from "react-native-reanimated"; import { USER_INPUT_TOGGLE_DURATION_MS } from "./pendingUserInputLayout"; import { SymbolView } from "../../components/ui/AppSymbol"; import { AppText as Text } from "../../components/ui/AppText"; import { ControlPill } from "../../components/ui/ControlPill"; import { cn } from "../../lib/cn"; import { SelectableMarkdownText } from "../../native/SelectableMarkdownText"; import { useOpenPullRequest } from "../../native/useOpenPullRequest"; import { useMarkdownPreviewStyles } from "../files/FileMarkdownPreview"; import { useLiveThreadLinkLabels } from "../../state/entities"; import { isPendingUserInputOptionSelected, type PendingUserInputDraftAnswer, } from "@t3tools/client-runtime/state/pending-user-input"; import type { PendingUserInput } from "../../lib/threadActivity"; export interface PendingUserInputCardProps { readonly environmentId: EnvironmentId; readonly canOperateThread: boolean; readonly pendingUserInput: PendingUserInput; readonly maxHeight: DerivedValue; readonly collapsed: boolean; readonly onToggleCollapsed: () => void; readonly onStopThread?: () => void; readonly cardProgress?: SharedValue; readonly cardCoverage?: SharedValue; readonly onInputFocusChange?: (focused: boolean) => void; readonly drafts: Record; readonly answers: Record> | null; readonly respondingUserInputId: RuntimeRequestId | null; readonly onSelectOption: ( requestId: RuntimeRequestId, question: ThreadUserInputQuestion, value: string, ) => void; readonly onChangeCustomAnswer: ( requestId: RuntimeRequestId, questionId: string, customAnswer: string, ) => void; readonly onSubmit: () => Promise; readonly onDismiss: () => Promise; } /** Android cannot hit-test overlays beyond their parent's bounds; render its expanded card in-flow. */ const EXPANDED_CARD_IS_OVERLAY = Platform.OS === "ios"; function QuestionText(props: { readonly markdown: string; readonly environmentId: EnvironmentId }) { const styles = useMarkdownPreviewStyles(); const markdown = useLiveThreadLinkLabels(props.markdown, props.environmentId); const openLink = useOpenPullRequest(); return ( void openLink(href)} /> ); } export function PendingUserInputCard(props: PendingUserInputCardProps) { const questionCount = props.pendingUserInput.questions.length; const canRespond = props.pendingUserInput.responseCapability !== "not_resumable"; const isResponding = props.respondingUserInputId === props.pendingUserInput.requestId; const responseDisabled = !canRespond || isResponding; const cardCoverage = props.cardCoverage; const barHeightRef = useRef(0); const cardHeightRef = useRef(0); const cardHeight = useSharedValue(0); const notifyCoverage = () => { if (!cardCoverage) { return; } // The card resizes with the keyboard every frame, so easing each sample would leave the // feed inset and floating controls trailing it. cardCoverage.set(Math.max(0, cardHeightRef.current - barHeightRef.current)); }; const handleBarLayout = (event: LayoutChangeEvent) => { barHeightRef.current = event.nativeEvent.layout.height; notifyCoverage(); }; const handleCardLayout = (event: LayoutChangeEvent) => { cardHeightRef.current = event.nativeEvent.layout.height; cardHeight.set(event.nativeEvent.layout.height); notifyCoverage(); }; const cardProgress = props.cardProgress; const maxHeight = props.maxHeight; const cardAnimatedStyle = useAnimatedStyle(() => { const progress = cardProgress === undefined ? 1 : cardProgress.value; return { maxHeight: maxHeight.value, transform: [{ translateY: (1 - progress) * cardHeight.value }], }; }); const cardMaxHeightStyle = useAnimatedStyle(() => ({ maxHeight: maxHeight.value })); const overlayHeightStyle = useAnimatedStyle(() => ({ height: maxHeight.value })); // Keep the iOS card mounted so expansion starts on the UI thread without waiting for React layout. const renderCard = EXPANDED_CARD_IS_OVERLAY || !props.collapsed; const showBar = props.collapsed || EXPANDED_CARD_IS_OVERLAY; const bar = showBar ? ( User input needed {questionCount} question{questionCount === 1 ? "" : "s"} {props.onStopThread ? ( ) : null} ) : null; const card = renderCard ? ( User input needed Fill in the pending answers {!canRespond ? ( The provider process for this request is no longer available. Interrupt or restart the run to continue. ) : null} {props.pendingUserInput.questions.map((question) => { const draft = props.drafts[question.id]; return ( {question.header} {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} ); })} {question.allowCustomAnswer !== false ? ( props.onChangeCustomAnswer(props.pendingUserInput.requestId, question.id, value) } onInputFocusChange={props.onInputFocusChange} /> ) : null} ); })}