1import { Button } from "../../components/ui/Button";
2import { QuestionAttachments } from "./QuestionAttachments";
3import type { EnvironmentId, RuntimeRequestId, ThreadId } from "@t3tools/contracts";
4import type { ThreadUserInputQuestion } from "@t3tools/client-runtime/state/thread-requests";
5import {
6 markdownProseDirection,
7 textDirection,
8 type TextDirection,
9} from "@t3tools/shared/textDirection";
10import { useNavigation } from "@react-navigation/native";
11import { useRef } from "react";
12import { Platform, Pressable, ScrollView, View, type LayoutChangeEvent } from "react-native";
13import Animated, {
14 useAnimatedStyle,
15 useSharedValue,
16 type DerivedValue,
17 type SharedValue,
18} from "react-native-reanimated";
19
20import { SymbolView } from "../../components/ui/AppSymbol";
21import { AppText as Text } from "../../components/ui/AppText";
22import { ControlPill } from "../../components/ui/ControlPill";
23import { cn } from "../../lib/cn";
24import { resolveMarkdownLinkPresentation } from "../../lib/markdownLinks";
25import { SelectableMarkdownText } from "../../native/SelectableMarkdownText";
26import { useOpenPullRequest } from "../../native/useOpenPullRequest";
27import { useMarkdownPreviewStyles } from "../files/FileMarkdownPreview";
28import {
29 fileRoutePathSegments,
30 resolveFileLinkPath,
31 resolveWorkspaceRelativeFilePath,
32} from "../files/filePath";
33import { useLiveThreadLinkLabels } from "../../state/entities";
34import {
35 isPendingUserInputOptionSelected,
36 type PendingUserInputDraftAnswer,
37} from "@t3tools/client-runtime/state/pending-user-input";
38import type { PendingUserInput } from "../../lib/threadActivity";
39
40export interface PendingUserInputCardProps {
41 readonly environmentId: EnvironmentId;
42 readonly threadId: ThreadId;
43 readonly workspaceRoot: string | null;
44 readonly canOperateThread: boolean;
45 readonly pendingUserInput: PendingUserInput;
46 readonly maxHeight: DerivedValue<number>;
47 readonly collapsed: boolean;
48 readonly onToggleCollapsed: () => void;
49 readonly onStopThread?: () => void;
50 readonly cardProgress: SharedValue<number>;
51 readonly cardCoverage: SharedValue<number>;
52 readonly onInputFocusChange?: (focused: boolean) => void;
53 readonly drafts: Record<string, PendingUserInputDraftAnswer>;
54 readonly answers: Record<string, string | ReadonlyArray<string>> | null;
55 readonly respondingUserInputId: RuntimeRequestId | null;
56 readonly onSelectOption: (
57 requestId: RuntimeRequestId,
58 question: ThreadUserInputQuestion,
59 value: string,
60 ) => void;
61 readonly onChangeCustomAnswer: (
62 requestId: RuntimeRequestId,
63 questionId: string,
64 customAnswer: string,
65 ) => void;
66 readonly onSubmit: () => Promise<unknown>;
67 readonly onDismiss: () => Promise<unknown>;
68}
69
70function proseDirectionStyle(text: string, setDirection: TextDirection | undefined) {
71 const direction = textDirection(text);
72 return {
73 ...(direction ? { direction, writingDirection: direction } : null),
74 textAlign: (direction === "rtl") === (setDirection === "rtl") ? "left" : "right",
75 } as const;
76}
77
78function QuestionText(props: {
79 readonly markdown: string;
80 readonly environmentId: EnvironmentId;
81 readonly threadId: ThreadId;
82 readonly workspaceRoot: string | null;
83 readonly setDirection: TextDirection | undefined;
84 readonly preserveSoftBreaks?: boolean;
85}) {
86 const styles = useMarkdownPreviewStyles();
87 const markdown = useLiveThreadLinkLabels(props.markdown, props.environmentId);
88 const navigation = useNavigation();
89 const openPullRequest = useOpenPullRequest();
90 const openLink = (href: string) => {
91 const link = resolveMarkdownLinkPresentation(href);
92 if (link.kind !== "file") {
93 void openPullRequest(href);
94 return;
95 }
96 const path = resolveFileLinkPath(props.workspaceRoot, link.path);
97 if (path === null) return;
98 navigation.navigate("ThreadFile", {
99 environmentId: String(props.environmentId),
100 threadId: String(props.threadId),
101 path: fileRoutePathSegments(
102 resolveWorkspaceRelativeFilePath(props.workspaceRoot, path) ?? path,
103 ),
104 ...(link.line ? { line: String(link.line) } : {}),
105 });
106 };
107 return (
108 <SelectableMarkdownText
109 markdown={markdown}
110 textStyle={styles.nativeTextStyle}
111 writingDirection={markdownProseDirection(markdown)}
112 // Android's renderer aligns relative to each paragraph's direction.
113 textAlign={
114 Platform.OS === "android" ? undefined : props.setDirection === "rtl" ? "right" : "left"
115 }
116 onLinkPress={openLink}
117 preserveSoftBreaks={props.preserveSoftBreaks}
118 />
119 );
120}
121
122export function PendingUserInputCard(props: PendingUserInputCardProps) {
123 const questionCount = props.pendingUserInput.questions.length;
124 const canRespond = props.pendingUserInput.responseCapability !== "not_resumable";
125 const isResponding = props.respondingUserInputId === props.pendingUserInput.requestId;
126 const responseDisabled = !canRespond || isResponding;
127 const setDirection = markdownProseDirection(
128 props.pendingUserInput.questions
129 .flatMap((question) => [
130 question.header,
131 question.question,
132 ...question.options.flatMap((option) => [option.label, option.description ?? ""]),
133 ])
134 .join(" "),
135 );
136
137 const { cardCoverage, cardProgress, maxHeight } = props;
138 const barHeightRef = useRef(0);
139 const cardHeightRef = useRef(0);
140 const cardHeight = useSharedValue(0);
141 const notifyCoverage = () => {
142 // The card resizes with the keyboard every frame, so easing each sample would leave the
143 // feed inset and floating controls trailing it.
144 cardCoverage.set(Math.max(0, cardHeightRef.current - barHeightRef.current));
145 };
146 const handleBarLayout = (event: LayoutChangeEvent) => {
147 barHeightRef.current = event.nativeEvent.layout.height;
148 notifyCoverage();
149 };
150 const handleCardLayout = (event: LayoutChangeEvent) => {
151 cardHeightRef.current = event.nativeEvent.layout.height;
152 cardHeight.set(event.nativeEvent.layout.height);
153 notifyCoverage();
154 };
155 const cardAnimatedStyle = useAnimatedStyle(() => {
156 const progress = cardProgress.value;
157 const travel = Platform.OS === "android" ? cardCoverage.value : cardHeight.value;
158 return {
159 maxHeight: maxHeight.value,
160 opacity:
161 Platform.OS === "android"
162 ? Math.min(1, (progress * cardHeight.value) / Math.max(1, cardHeight.value - travel))
163 : 1,
164 transform: [{ translateY: (1 - progress) * travel }],
165 };
166 });
167 // Android hit-tests inside the parent; its height is also the space the card takes below the feed.
168 const androidBoundsStyle = useAnimatedStyle(() => ({
169 paddingTop: cardCoverage.value * cardProgress.value,
170 }));
171 const overlayHeightStyle = useAnimatedStyle(() => ({ height: maxHeight.value }));
172
173 const bar = (
174 <View
175 onLayout={handleBarLayout}
176 pointerEvents={props.collapsed ? "auto" : "none"}
177 accessibilityElementsHidden={!props.collapsed}
178 importantForAccessibility={props.collapsed ? "auto" : "no-hide-descendants"}
179 className="flex-row items-center gap-2 rounded-full border border-border bg-card-alt py-1.5 pl-4 pr-1.5"
180 >
181 <Pressable
182 accessibilityRole="button"
183 accessibilityLabel={`Expand user input, ${questionCount} question${
184 questionCount === 1 ? "" : "s"
185 }`}
186 onPress={props.onToggleCollapsed}
187 className="min-h-10 flex-1 flex-row items-center gap-2 active:opacity-70"
188 >
189 <Text className="font-t3-bold text-2xs uppercase tracking-[1.1px] text-foreground-secondary">
190 User input needed
191 </Text>
192 <Text className="font-sans text-xs text-foreground-muted">
193 {questionCount} question{questionCount === 1 ? "" : "s"}
194 </Text>
195 <View className="flex-1" />
196 <SymbolView
197 name="chevron.up"
198 size={12}
199 tintColorClassName={"accent-icon-subtle"}
200 type="monochrome"
201 />
202 </Pressable>
203 {props.onStopThread ? (
204 <ControlPill
205 accessibilityLabel="Stop"
206 icon="stop.fill"
207 variant="danger"
208 className="h-9 w-9"
209 disabled={!props.canOperateThread}
210 onPress={props.onStopThread}
211 />
212 ) : null}
213 </View>
214 );
215 const card = (
216 <Animated.View
217 onLayout={handleCardLayout}
218 pointerEvents={props.collapsed ? "none" : "auto"}
219 accessibilityElementsHidden={props.collapsed}
220 importantForAccessibility={props.collapsed ? "no-hide-descendants" : "auto"}
221 className="overflow-hidden gap-2.5 rounded-[20px] border border-border bg-card-alt p-4"
222 style={cardAnimatedStyle}
223 >
224 <Pressable
225 accessibilityRole="button"
226 accessibilityLabel="Collapse user input"
227 onPress={props.onToggleCollapsed}
228 className="flex-row items-start gap-2"
229 >
230 <View className="flex-1 gap-2.5">
231 <Text className="font-t3-bold text-2xs uppercase tracking-[1.1px] text-foreground-secondary">
232 User input needed
233 </Text>
234 <Text className="font-t3-bold text-lg text-foreground">Fill in the pending answers</Text>
235 </View>
236 <View className="h-8 w-8 items-center justify-center rounded-full bg-subtle-strong">
237 <SymbolView
238 name="chevron.down"
239 size={13}
240 tintColorClassName={"accent-icon-subtle"}
241 type="monochrome"
242 />
243 </View>
244 </Pressable>
245 <ScrollView
246 bounces={false}
247 className="min-h-0"
248 contentContainerClassName="gap-2.5 pb-1"
249 keyboardShouldPersistTaps="handled"
250 nestedScrollEnabled
251 showsVerticalScrollIndicator
252 style={{ flexShrink: 1 }}
253 >
254 {!canRespond ? (
255 <Text className="font-sans text-sm leading-5 text-foreground-muted">
256 The provider process for this request is no longer available. Interrupt or restart the
257 run to continue.
258 </Text>
259 ) : null}
260 {props.pendingUserInput.questions.map((question) => {
261 const draft = props.drafts[question.id];
262 return (
263 <View key={question.id} className="gap-2 pt-1">
264 <Text
265 className="font-t3-bold text-xs uppercase tracking-[1px] text-foreground-muted"
266 style={proseDirectionStyle(question.header, setDirection)}
267 >
268 {question.header}
269 </Text>
270 <QuestionText
271 markdown={question.question}
272 environmentId={props.environmentId}
273 threadId={props.threadId}
274 workspaceRoot={props.workspaceRoot}
275 setDirection={setDirection}
276 preserveSoftBreaks
277 />
278 <View className="gap-2">
279 {question.options.map((option) => {
280 const optionValue = option.value ?? option.label.trim();
281 const selected = isPendingUserInputOptionSelected(question, draft, optionValue);
282 const description =
283 option.description !== option.label ? option.description : undefined;
284 return (
285 <View key={optionValue} className="gap-1.5">
286 <Pressable
287 accessibilityRole={question.multiSelect ? "checkbox" : "radio"}
288 accessibilityState={{ checked: selected, disabled: responseDisabled }}
289 disabled={responseDisabled}
290 className={cn(
291 "min-h-12 w-full rounded-2xl border px-3.5 py-3",
292 selected ? "border-primary bg-primary/10" : "border-border bg-input",
293 )}
294 onPress={() =>
295 props.onSelectOption(
296 props.pendingUserInput.requestId,
297 question,
298 optionValue,
299 )
300 }
301 >
302 <View className="min-w-0 flex-1 gap-0.5">
303 <Text
304 className={cn(
305 "font-t3-bold text-sm",
306 selected ? "text-foreground" : "text-foreground-secondary",
307 )}
308 style={proseDirectionStyle(option.label, setDirection)}
309 >
310 {option.label}
311 </Text>
312 {description ? (
313 <Text
314 className="font-sans text-sm leading-5 text-foreground-muted"
315 style={proseDirectionStyle(description, setDirection)}
316 >
317 {description}
318 </Text>
319 ) : null}
320 </View>
321 </Pressable>
322 {option.preview ? (
323 <View className="px-3.5">
324 <QuestionText
325 markdown={option.preview}
326 environmentId={props.environmentId}
327 threadId={props.threadId}
328 workspaceRoot={props.workspaceRoot}
329 setDirection={setDirection}
330 preserveSoftBreaks
331 />
332 </View>
333 ) : null}
334 </View>
335 );
336 })}
337 </View>
338 {question.allowCustomAnswer !== false ? (
339 <QuestionAttachments
340 key={`${props.pendingUserInput.requestId}:${question.id}`}
341 requestId={props.pendingUserInput.requestId}
342 question={question}
343 questions={props.pendingUserInput.questions}
344 disabled={responseDisabled}
345 value={draft?.customAnswer ?? ""}
346 onChangeText={(value) =>
347 props.onChangeCustomAnswer(props.pendingUserInput.requestId, question.id, value)
348 }
349 onInputFocusChange={props.onInputFocusChange}
350 />
351 ) : null}
352 </View>
353 );
354 })}
355 </ScrollView>
356 <Button
357 label="Submit answers"
358 fullWidth
359 tone={props.answers ? "primary" : "secondary"}
360 disabled={!props.canOperateThread || responseDisabled || props.answers === null}
361 onPress={() => void props.onSubmit()}
362 />
363 {props.pendingUserInput.dismissible ? (
364 <Button
365 label="Dismiss without answering"
366 tone="text"
367 disabled={isResponding}
368 onPress={() => void props.onDismiss()}
369 />
370 ) : null}
371 {!props.canOperateThread ? (
372 <Text className="font-sans text-xs text-foreground-tertiary">
373 This connection cannot submit answers.
374 </Text>
375 ) : null}
376 </Animated.View>
377 );
378 return (
379 <Animated.View
380 className="relative"
381 style={Platform.OS === "android" ? androidBoundsStyle : undefined}
382 >
383 {bar}
384 <Animated.View
385 pointerEvents={props.collapsed ? "none" : "box-none"}
386 className="absolute inset-x-0 bottom-0 justify-end overflow-hidden"
387 style={overlayHeightStyle}
388 >
389 {card}
390 </Animated.View>
391 </Animated.View>
392 );
393}