| 1 | import { RequestActionButton } from "./RequestActionButton"; |
| 2 | import { QuestionAttachments } from "./QuestionAttachments"; |
| 3 | import type { RuntimeRequestId } from "@t3tools/contracts"; |
| 4 | import type { ThreadUserInputQuestion } from "@t3tools/client-runtime/state/thread-requests"; |
| 5 | import { useCallback, useRef } from "react"; |
| 6 | import { Platform, Pressable, ScrollView, View, type LayoutChangeEvent } from "react-native"; |
| 7 | import Animated, { |
| 8 | Easing, |
| 9 | FadeInUp, |
| 10 | FadeOutDown, |
| 11 | LinearTransition, |
| 12 | useAnimatedStyle, |
| 13 | useSharedValue, |
| 14 | withTiming, |
| 15 | type SharedValue, |
| 16 | } from "react-native-reanimated"; |
| 17 | |
| 18 | import { USER_INPUT_TOGGLE_DURATION_MS } from "./pendingUserInputLayout"; |
| 19 | |
| 20 | import { SymbolView } from "../../components/AppSymbol"; |
| 21 | import { AppText as Text } from "../../components/AppText"; |
| 22 | import { ControlPill } from "../../components/ControlPill"; |
| 23 | import { cn } from "../../lib/cn"; |
| 24 | import { SelectableMarkdownText } from "../../native/SelectableMarkdownText"; |
| 25 | import { useOpenPullRequest } from "../../native/useOpenPullRequest"; |
| 26 | import { useMarkdownPreviewStyles } from "../files/FileMarkdownPreview"; |
| 27 | import { |
| 28 | isPendingUserInputOptionSelected, |
| 29 | type PendingUserInput, |
| 30 | type PendingUserInputDraftAnswer, |
| 31 | } from "../../lib/threadActivity"; |
| 32 | |
| 33 | export interface PendingUserInputCardProps { |
| 34 | readonly canOperateThread: boolean; |
| 35 | readonly pendingUserInput: PendingUserInput; |
| 36 | /** |
| 37 | * Constant while a request is pending (it reserves keyboard space), so the |
| 38 | * keyboard transition is pure translation; changes only on rare discrete |
| 39 | * corrections, which the layout transition smooths. |
| 40 | */ |
| 41 | readonly maxHeight: number; |
| 42 | readonly collapsed: boolean; |
| 43 | readonly onToggleCollapsed: () => void; |
| 44 | /** Renders a stop control on the collapsed bar, which replaces the composer. */ |
| 45 | readonly onStopThread?: () => void; |
| 46 | /** |
| 47 | * 0 collapsed → 1 expanded. Slides the iOS overlay card down behind the |
| 48 | * collapsed bar (inside a clipping window) on the UI thread; the host |
| 49 | * animates it directly from the tap handler so the card and the feed |
| 50 | * inset glide start the same frame. |
| 51 | */ |
| 52 | readonly cardProgress?: SharedValue<number>; |
| 53 | /** |
| 54 | * Receives how far the expanded card extends above the bar footprint |
| 55 | * (written from onLayout with no re-render); the host adds it to the |
| 56 | * thread feed's end inset so the end of the chat stays visible above the |
| 57 | * card. |
| 58 | */ |
| 59 | readonly cardCoverage?: SharedValue<number>; |
| 60 | /** Fires on custom-answer focus/blur; hosts use it to vet stale keyboard state. */ |
| 61 | readonly onInputFocusChange?: (focused: boolean) => void; |
| 62 | readonly drafts: Record<string, PendingUserInputDraftAnswer>; |
| 63 | readonly answers: Record<string, string | ReadonlyArray<string>> | null; |
| 64 | readonly respondingUserInputId: RuntimeRequestId | null; |
| 65 | readonly onSelectOption: ( |
| 66 | requestId: RuntimeRequestId, |
| 67 | question: ThreadUserInputQuestion, |
| 68 | value: string, |
| 69 | ) => void; |
| 70 | readonly onChangeCustomAnswer: ( |
| 71 | requestId: RuntimeRequestId, |
| 72 | questionId: string, |
| 73 | customAnswer: string, |
| 74 | ) => void; |
| 75 | readonly onSubmit: () => Promise<unknown>; |
| 76 | /** Closes an async question without a reply. Hidden for native callback questions. */ |
| 77 | readonly onDismiss: () => Promise<unknown>; |
| 78 | } |
| 79 | |
| 80 | /** |
| 81 | * On iOS the collapsed bar is the PERMANENT in-flow footprint — the expanded |
| 82 | * card is an absolutely-positioned overlay rising above it. The overlay's |
| 83 | * measured height (which drives the thread feed's bottom inset) therefore |
| 84 | * never changes on collapse/expand, so the transcript stays perfectly still |
| 85 | * while the card animates over it. |
| 86 | * |
| 87 | * Android cannot use the overlay: it does not hit-test touches outside a |
| 88 | * parent's bounds, which made everything above the bar-sized wrapper |
| 89 | * untouchable. There the expanded card renders in-flow instead (the wrapper |
| 90 | * grows with it, and the host skips the coverage inset since the measured |
| 91 | * overlay already includes the card). |
| 92 | */ |
| 93 | const EXPANDED_CARD_IS_OVERLAY = Platform.OS === "ios"; |
| 94 | |
| 95 | const CARD_LAYOUT_TRANSITION = LinearTransition.duration(200); |
| 96 | |
| 97 | /** Renders question text as markdown so agent-provided links open on tap. */ |
| 98 | function QuestionText(props: { readonly markdown: string }) { |
| 99 | const styles = useMarkdownPreviewStyles(); |
| 100 | const openLink = useOpenPullRequest(); |
| 101 | return ( |
| 102 | <SelectableMarkdownText |
| 103 | markdown={props.markdown} |
| 104 | textStyle={styles.nativeTextStyle} |
| 105 | onLinkPress={(href) => void openLink(href)} |
| 106 | /> |
| 107 | ); |
| 108 | } |
| 109 | |
| 110 | export function PendingUserInputCard(props: PendingUserInputCardProps) { |
| 111 | const questionCount = props.pendingUserInput.questions.length; |
| 112 | // Message responses start a new run and remain available after the provider exits. |
| 113 | const canRespond = props.pendingUserInput.responseCapability !== "not_resumable"; |
| 114 | const isResponding = props.respondingUserInputId === props.pendingUserInput.requestId; |
| 115 | const responseDisabled = !canRespond || isResponding; |
| 116 | |
| 117 | const cardCoverage = props.cardCoverage; |
| 118 | const barHeightRef = useRef(0); |
| 119 | const cardHeightRef = useRef(0); |
| 120 | // Measured card height, written straight from onLayout: the collapse slide |
| 121 | // distance. Not animated — it only changes on discrete relayouts. |
| 122 | const cardHeight = useSharedValue(0); |
| 123 | const notifyCoverage = useCallback(() => { |
| 124 | if (!cardCoverage) { |
| 125 | return; |
| 126 | } |
| 127 | const coverage = Math.max(0, cardHeightRef.current - barHeightRef.current); |
| 128 | if (coverage === cardCoverage.value) { |
| 129 | return; |
| 130 | } |
| 131 | if (cardCoverage.value === 0) { |
| 132 | // First measurement lands while the list is doing its initial |
| 133 | // end-pin (thread opened onto a pending request); animating it from |
| 134 | // zero would move the end anchor out from under that scroll. |
| 135 | cardCoverage.value = coverage; |
| 136 | return; |
| 137 | } |
| 138 | // Animated so a coverage change at rest (discrete max-height |
| 139 | // corrections) glides the feed instead of stepping it; toggle timing is |
| 140 | // owned by the host's progress values. |
| 141 | cardCoverage.value = withTiming(coverage, { |
| 142 | duration: USER_INPUT_TOGGLE_DURATION_MS, |
| 143 | easing: Easing.out(Easing.cubic), |
| 144 | }); |
| 145 | }, [cardCoverage]); |
| 146 | const handleBarLayout = useCallback( |
| 147 | (event: LayoutChangeEvent) => { |
| 148 | barHeightRef.current = event.nativeEvent.layout.height; |
| 149 | notifyCoverage(); |
| 150 | }, |
| 151 | [notifyCoverage], |
| 152 | ); |
| 153 | const handleCardLayout = useCallback( |
| 154 | (event: LayoutChangeEvent) => { |
| 155 | cardHeightRef.current = event.nativeEvent.layout.height; |
| 156 | cardHeight.value = event.nativeEvent.layout.height; |
| 157 | notifyCoverage(); |
| 158 | }, |
| 159 | [cardHeight, notifyCoverage], |
| 160 | ); |
| 161 | const cardProgress = props.cardProgress; |
| 162 | // No opacity: fading an opaque card over the live transcript reads as a |
| 163 | // crossfade (card text, transcript, and bar all half-visible at once). |
| 164 | // Instead the card stays opaque and slides its full height down past the |
| 165 | // clipping window's bottom edge, so the transcript is only revealed where |
| 166 | // the card has physically left. |
| 167 | const cardAnimatedStyle = useAnimatedStyle(() => { |
| 168 | const progress = cardProgress === undefined ? 1 : cardProgress.value; |
| 169 | return { |
| 170 | transform: [{ translateY: (1 - progress) * cardHeight.value }], |
| 171 | }; |
| 172 | }); |
| 173 | |
| 174 | // On iOS the card stays MOUNTED while collapsed (hidden via the animated |
| 175 | // style): expanding animates existing views on the UI thread the same |
| 176 | // frame the host starts the progress timing, instead of paying a React |
| 177 | // mount + layout before anything moves. |
| 178 | const renderCard = EXPANDED_CARD_IS_OVERLAY || !props.collapsed; |
| 179 | const showBar = props.collapsed || EXPANDED_CARD_IS_OVERLAY; |
| 180 | // The bar renders UNDER the card (earlier in JSX), always opaque: while |
| 181 | // expanded the opaque card covers it, and during the collapse slide the |
| 182 | // card's top edge wipes past and reveals it — no opacity handoff, so no |
| 183 | // crossfade frames. |
| 184 | const bar = showBar ? ( |
| 185 | <View |
| 186 | onLayout={handleBarLayout} |
| 187 | pointerEvents={props.collapsed ? "auto" : "none"} |
| 188 | accessibilityElementsHidden={!props.collapsed} |
| 189 | importantForAccessibility={props.collapsed ? "auto" : "no-hide-descendants"} |
| 190 | className="flex-row items-center gap-2 rounded-full border border-border bg-card-alt py-1.5 pl-4 pr-1.5" |
| 191 | > |
| 192 | <Pressable |
| 193 | accessibilityRole="button" |
| 194 | accessibilityLabel={`Expand user input, ${questionCount} question${ |
| 195 | questionCount === 1 ? "" : "s" |
| 196 | }`} |
| 197 | onPress={props.onToggleCollapsed} |
| 198 | className="min-h-10 flex-1 flex-row items-center gap-2 active:opacity-70" |
| 199 | > |
| 200 | <Text className="font-t3-bold text-2xs uppercase tracking-[1.1px] text-foreground-secondary"> |
| 201 | User input needed |
| 202 | </Text> |
| 203 | <Text className="font-sans text-xs text-foreground-muted"> |
| 204 | {questionCount} question{questionCount === 1 ? "" : "s"} |
| 205 | </Text> |
| 206 | <View className="flex-1" /> |
| 207 | <SymbolView |
| 208 | name="chevron.up" |
| 209 | size={12} |
| 210 | tintColorClassName={"accent-icon-subtle"} |
| 211 | type="monochrome" |
| 212 | /> |
| 213 | </Pressable> |
| 214 | {props.onStopThread ? ( |
| 215 | <ControlPill |
| 216 | accessibilityLabel="Stop" |
| 217 | icon="stop.fill" |
| 218 | variant="danger" |
| 219 | className="h-9 w-9" |
| 220 | disabled={!props.canOperateThread} |
| 221 | onPress={props.onStopThread} |
| 222 | /> |
| 223 | ) : null} |
| 224 | </View> |
| 225 | ) : null; |
| 226 | const card = renderCard ? ( |
| 227 | // The surface is opaque on purpose: the card floats over the thread |
| 228 | // feed with no blur behind it, so a translucent background renders |
| 229 | // the questions on top of whatever message happens to sit underneath. |
| 230 | <Animated.View |
| 231 | onLayout={handleCardLayout} |
| 232 | pointerEvents={props.collapsed ? "none" : "auto"} |
| 233 | accessibilityElementsHidden={props.collapsed} |
| 234 | importantForAccessibility={props.collapsed ? "no-hide-descendants" : "auto"} |
| 235 | entering={ |
| 236 | EXPANDED_CARD_IS_OVERLAY |
| 237 | ? undefined |
| 238 | : FadeInUp.duration(USER_INPUT_TOGGLE_DURATION_MS).easing(Easing.out(Easing.cubic)) |
| 239 | } |
| 240 | exiting={ |
| 241 | EXPANDED_CARD_IS_OVERLAY |
| 242 | ? undefined |
| 243 | : FadeOutDown.duration(USER_INPUT_TOGGLE_DURATION_MS).easing(Easing.out(Easing.cubic)) |
| 244 | } |
| 245 | layout={CARD_LAYOUT_TRANSITION} |
| 246 | className="overflow-hidden gap-2.5 rounded-[20px] border border-border bg-card-alt p-4" |
| 247 | style={ |
| 248 | EXPANDED_CARD_IS_OVERLAY |
| 249 | ? [{ maxHeight: props.maxHeight }, cardAnimatedStyle] |
| 250 | : { maxHeight: props.maxHeight } |
| 251 | } |
| 252 | > |
| 253 | <Pressable |
| 254 | accessibilityRole="button" |
| 255 | accessibilityLabel="Collapse user input" |
| 256 | onPress={props.onToggleCollapsed} |
| 257 | className="flex-row items-start gap-2" |
| 258 | > |
| 259 | <View className="flex-1 gap-2.5"> |
| 260 | <Text className="font-t3-bold text-2xs uppercase tracking-[1.1px] text-foreground-secondary"> |
| 261 | User input needed |
| 262 | </Text> |
| 263 | <Text className="font-t3-bold text-lg text-foreground">Fill in the pending answers</Text> |
| 264 | </View> |
| 265 | <View className="h-8 w-8 items-center justify-center rounded-full bg-subtle-strong"> |
| 266 | <SymbolView |
| 267 | name="chevron.down" |
| 268 | size={13} |
| 269 | tintColorClassName={"accent-icon-subtle"} |
| 270 | type="monochrome" |
| 271 | /> |
| 272 | </View> |
| 273 | </Pressable> |
| 274 | <ScrollView |
| 275 | bounces={false} |
| 276 | className="min-h-0" |
| 277 | contentContainerClassName="gap-2.5 pb-1" |
| 278 | keyboardShouldPersistTaps="handled" |
| 279 | nestedScrollEnabled |
| 280 | showsVerticalScrollIndicator |
| 281 | style={{ flexShrink: 1 }} |
| 282 | > |
| 283 | {!canRespond ? ( |
| 284 | <Text className="font-sans text-sm leading-5 text-foreground-muted"> |
| 285 | The provider process for this request is no longer available. Interrupt or restart the |
| 286 | run to continue. |
| 287 | </Text> |
| 288 | ) : null} |
| 289 | {props.pendingUserInput.questions.map((question) => { |
| 290 | const draft = props.drafts[question.id]; |
| 291 | return ( |
| 292 | <View key={question.id} className="gap-2 pt-1"> |
| 293 | <Text className="font-t3-bold text-xs uppercase tracking-[1px] text-foreground-muted"> |
| 294 | {question.header} |
| 295 | </Text> |
| 296 | <QuestionText markdown={question.question} /> |
| 297 | <View className="gap-2"> |
| 298 | {question.options.map((option) => { |
| 299 | const optionValue = option.value ?? option.label.trim(); |
| 300 | const selected = isPendingUserInputOptionSelected(question, draft, optionValue); |
| 301 | const description = |
| 302 | option.description !== option.label ? option.description : undefined; |
| 303 | return ( |
| 304 | <Pressable |
| 305 | key={optionValue} |
| 306 | accessibilityRole={question.multiSelect ? "checkbox" : "radio"} |
| 307 | accessibilityState={{ checked: selected, disabled: responseDisabled }} |
| 308 | disabled={responseDisabled} |
| 309 | className={cn( |
| 310 | "min-h-12 w-full rounded-2xl border px-3.5 py-3", |
| 311 | selected ? "border-primary bg-primary/10" : "border-border bg-input", |
| 312 | )} |
| 313 | onPress={() => |
| 314 | props.onSelectOption( |
| 315 | props.pendingUserInput.requestId, |
| 316 | question, |
| 317 | optionValue, |
| 318 | ) |
| 319 | } |
| 320 | > |
| 321 | <View className="min-w-0 flex-1 gap-0.5"> |
| 322 | <Text |
| 323 | className={cn( |
| 324 | "font-t3-bold text-sm", |
| 325 | selected ? "text-foreground" : "text-foreground-secondary", |
| 326 | )} |
| 327 | > |
| 328 | {option.label} |
| 329 | </Text> |
| 330 | {description ? ( |
| 331 | <Text className="font-sans text-sm leading-5 text-foreground-muted"> |
| 332 | {description} |
| 333 | </Text> |
| 334 | ) : null} |
| 335 | </View> |
| 336 | </Pressable> |
| 337 | ); |
| 338 | })} |
| 339 | </View> |
| 340 | {question.allowCustomAnswer !== false ? ( |
| 341 | <QuestionAttachments |
| 342 | requestId={props.pendingUserInput.requestId} |
| 343 | question={question} |
| 344 | questions={props.pendingUserInput.questions} |
| 345 | disabled={responseDisabled} |
| 346 | value={draft?.customAnswer ?? ""} |
| 347 | onChangeText={(value) => |
| 348 | props.onChangeCustomAnswer(props.pendingUserInput.requestId, question.id, value) |
| 349 | } |
| 350 | onInputFocusChange={props.onInputFocusChange} |
| 351 | /> |
| 352 | ) : null} |
| 353 | </View> |
| 354 | ); |
| 355 | })} |
| 356 | </ScrollView> |
| 357 | <RequestActionButton |
| 358 | label="Submit answers" |
| 359 | size="large" |
| 360 | tone={props.answers ? "primary" : "secondary"} |
| 361 | disabled={!props.canOperateThread || responseDisabled || props.answers === null} |
| 362 | onPress={() => void props.onSubmit()} |
| 363 | /> |
| 364 | {props.pendingUserInput.dismissible ? ( |
| 365 | <Pressable |
| 366 | accessibilityRole="button" |
| 367 | className="items-center justify-center rounded-2xl px-4 py-2.5 active:opacity-70" |
| 368 | disabled={isResponding} |
| 369 | onPress={() => void props.onDismiss()} |
| 370 | > |
| 371 | <Text className="font-t3-bold text-sm text-foreground-muted"> |
| 372 | Dismiss without answering |
| 373 | </Text> |
| 374 | </Pressable> |
| 375 | ) : null} |
| 376 | {!props.canOperateThread ? ( |
| 377 | <Text className="font-sans text-xs text-foreground-tertiary"> |
| 378 | This connection cannot submit answers. |
| 379 | </Text> |
| 380 | ) : null} |
| 381 | </Animated.View> |
| 382 | ) : null; |
| 383 | return ( |
| 384 | <View className="relative"> |
| 385 | {bar} |
| 386 | {EXPANDED_CARD_IS_OVERLAY ? ( |
| 387 | // Clipping window for the collapse slide: same footprint as the |
| 388 | // expanded card, bottom edge on the bar's bottom edge. The sliding |
| 389 | // card exits through the bottom edge instead of drawing over the |
| 390 | // composer area, wiping the bar (and the transcript) into view. |
| 391 | <View |
| 392 | pointerEvents={props.collapsed ? "none" : "box-none"} |
| 393 | className="absolute inset-x-0 bottom-0 justify-end overflow-hidden" |
| 394 | style={{ height: props.maxHeight }} |
| 395 | > |
| 396 | {card} |
| 397 | </View> |
| 398 | ) : ( |
| 399 | card |
| 400 | )} |
| 401 | </View> |
| 402 | ); |
| 403 | } |