1import { RequestActionButton } from "./RequestActionButton";
2import { QuestionAttachments } from "./QuestionAttachments";
3import type { RuntimeRequestId } from "@t3tools/contracts";
4import type { ThreadUserInputQuestion } from "@t3tools/client-runtime/state/thread-requests";
5import { useCallback, useRef } from "react";
6import { Platform, Pressable, ScrollView, View, type LayoutChangeEvent } from "react-native";
7import Animated, {
8 Easing,
9 FadeInUp,
10 FadeOutDown,
11 LinearTransition,
12 useAnimatedStyle,
13 useSharedValue,
14 withTiming,
15 type SharedValue,
16} from "react-native-reanimated";
17
18import { USER_INPUT_TOGGLE_DURATION_MS } from "./pendingUserInputLayout";
19
20import { SymbolView } from "../../components/AppSymbol";
21import { AppText as Text } from "../../components/AppText";
22import { ControlPill } from "../../components/ControlPill";
23import { cn } from "../../lib/cn";
24import { SelectableMarkdownText } from "../../native/SelectableMarkdownText";
25import { useOpenPullRequest } from "../../native/useOpenPullRequest";
26import { useMarkdownPreviewStyles } from "../files/FileMarkdownPreview";
27import {
28 isPendingUserInputOptionSelected,
29 type PendingUserInput,
30 type PendingUserInputDraftAnswer,
31} from "../../lib/threadActivity";
32
33export 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 */
93const EXPANDED_CARD_IS_OVERLAY = Platform.OS === "ios";
94
95const CARD_LAYOUT_TRANSITION = LinearTransition.duration(200);
96
97/** Renders question text as markdown so agent-provided links open on tap. */
98function 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
110export 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}