1import { type RuntimeRequestId, type ScopedThreadRef } from "@t3tools/contracts";
2import { memo, useCallback, useEffect, useRef, useState } from "react";
3import { type PendingUserInput } from "../../session-logic";
4import {
5 derivePendingUserInputProgress,
6 type PendingUserInputDraftAnswer,
7} from "../../pendingUserInput";
8import { CheckIcon } from "lucide-react";
9import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible";
10import { cn } from "~/lib/utils";
11import { ComposerBanner } from "./ComposerBanner";
12import ChatMarkdown from "../ChatMarkdown";
13
14interface PendingUserInputPanelProps {
15 pendingUserInputs: PendingUserInput[];
16 /** Resolves file links in the question text. */
17 cwd: string | undefined;
18 threadRef: ScopedThreadRef;
19 disabled?: boolean;
20 respondingRequestIds: RuntimeRequestId[];
21 answers: Record<string, PendingUserInputDraftAnswer>;
22 questionIndex: number;
23 onToggleOption: (questionId: string, optionValue: string) => void;
24 onAdvance: () => void;
25 onDismiss: (requestId: RuntimeRequestId) => void;
26}
27
28export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPanel({
29 pendingUserInputs,
30 cwd,
31 threadRef,
32 disabled = false,
33 respondingRequestIds,
34 answers,
35 questionIndex,
36 onToggleOption,
37 onAdvance,
38 onDismiss,
39}: PendingUserInputPanelProps) {
40 if (pendingUserInputs.length === 0) return null;
41 const activePrompt = pendingUserInputs[0];
42 if (!activePrompt) return null;
43
44 return (
45 <ComposerPendingUserInputCard
46 key={activePrompt.requestId}
47 prompt={activePrompt}
48 cwd={cwd}
49 threadRef={threadRef}
50 disabled={disabled}
51 isResponding={respondingRequestIds.includes(activePrompt.requestId)}
52 answers={answers}
53 questionIndex={questionIndex}
54 onToggleOption={onToggleOption}
55 onAdvance={onAdvance}
56 onDismiss={onDismiss}
57 />
58 );
59});
60
61const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard({
62 prompt,
63 cwd,
64 threadRef,
65 disabled,
66 isResponding,
67 answers,
68 questionIndex,
69 onToggleOption,
70 onAdvance,
71 onDismiss,
72}: {
73 prompt: PendingUserInput;
74 cwd: string | undefined;
75 threadRef: ScopedThreadRef;
76 disabled: boolean;
77 isResponding: boolean;
78 answers: Record<string, PendingUserInputDraftAnswer>;
79 questionIndex: number;
80 onToggleOption: (questionId: string, optionValue: string) => void;
81 onAdvance: () => void;
82 onDismiss: (requestId: RuntimeRequestId) => void;
83}) {
84 // Message-mode requests remain answerable after their provider turn ends.
85 const canRespond = prompt.responseCapability !== "not_resumable";
86 const responseDisabled = disabled || isResponding || !canRespond;
87 const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex);
88 const activeQuestion = progress.activeQuestion;
89 const autoAdvanceTimerRef = useRef<number | null>(null);
90 const onAdvanceRef = useRef(onAdvance);
91 const [optimisticSingleSelect, setOptimisticSingleSelect] = useState<{
92 questionId: string;
93 optionValue: string;
94 } | null>(null);
95 // Collapsing hides everything but the header so a tall prompt stops covering
96 // the thread the user is trying to read. Scoped to a single question: the card
97 // is keyed by request id so the next prompt starts expanded, and storing the
98 // collapsed question's id (rather than a bare flag) reopens the card when the
99 // prompt advances to its next question, which can happen without a click —
100 // sending from the composer advances the active question.
101 const [collapsedQuestionId, setCollapsedQuestionId] = useState<string | null>(null);
102 const isCollapsed = collapsedQuestionId !== null && collapsedQuestionId === activeQuestion?.id;
103
104 useEffect(() => {
105 onAdvanceRef.current = onAdvance;
106 }, [onAdvance]);
107
108 useEffect(() => {
109 if (!activeQuestion || activeQuestion.multiSelect || !optimisticSingleSelect) {
110 return;
111 }
112 if (optimisticSingleSelect.questionId !== activeQuestion.id) {
113 setOptimisticSingleSelect(null);
114 return;
115 }
116 if (
117 progress.customAnswer.trim().length === 0 &&
118 progress.selectedOptionValues.includes(optimisticSingleSelect.optionValue)
119 ) {
120 setOptimisticSingleSelect(null);
121 }
122 }, [
123 activeQuestion,
124 optimisticSingleSelect,
125 progress.customAnswer,
126 progress.selectedOptionValues,
127 ]);
128
129 // A delayed answer only advances its own question, while permission remains granted.
130 useEffect(() => {
131 const clearAutoAdvance = () => {
132 if (autoAdvanceTimerRef.current !== null) {
133 window.clearTimeout(autoAdvanceTimerRef.current);
134 autoAdvanceTimerRef.current = null;
135 }
136 };
137 if (disabled || activeQuestion?.id === undefined) clearAutoAdvance();
138 return clearAutoAdvance;
139 }, [activeQuestion?.id, disabled]);
140
141 const handleOptionSelection = useCallback(
142 (questionId: string, optionValue: string) => {
143 if (disabled || isResponding) return;
144 if (activeQuestion?.multiSelect) {
145 onToggleOption(questionId, optionValue);
146 return;
147 }
148 setOptimisticSingleSelect({ questionId, optionValue });
149 onToggleOption(questionId, optionValue);
150 if (autoAdvanceTimerRef.current !== null) {
151 window.clearTimeout(autoAdvanceTimerRef.current);
152 }
153 autoAdvanceTimerRef.current = window.setTimeout(() => {
154 autoAdvanceTimerRef.current = null;
155 onAdvanceRef.current();
156 }, 200);
157 },
158 [activeQuestion, disabled, isResponding, onToggleOption],
159 );
160
161 // Keyboard shortcut: number keys 1-9 select corresponding options when focus is
162 // outside editable fields. Multi-select prompts toggle options in place; single-
163 // select prompts keep the existing auto-advance behavior. Collapsed prompts opt
164 // out, since the numbers they refer to are not on screen.
165 useEffect(() => {
166 if (!activeQuestion || responseDisabled || isCollapsed) return;
167 const handler = (event: globalThis.KeyboardEvent) => {
168 if (event.metaKey || event.ctrlKey || event.altKey) return;
169 const target = event.target;
170 if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) {
171 return;
172 }
173 if (
174 target instanceof HTMLElement &&
175 target.closest('[contenteditable]:not([contenteditable="false"])')
176 ) {
177 return;
178 }
179 const digit = Number.parseInt(event.key, 10);
180 if (Number.isNaN(digit) || digit < 1 || digit > 9) return;
181 const optionIndex = digit - 1;
182 if (optionIndex >= activeQuestion.options.length) return;
183 const option = activeQuestion.options[optionIndex];
184 if (!option) return;
185 event.preventDefault();
186 handleOptionSelection(activeQuestion.id, option.value ?? option.label);
187 };
188 document.addEventListener("keydown", handler);
189 return () => document.removeEventListener("keydown", handler);
190 }, [activeQuestion, handleOptionSelection, isCollapsed, responseDisabled]);
191
192 if (!activeQuestion) {
193 return null;
194 }
195
196 const customAnswerActive = progress.customAnswer.trim().length > 0;
197
198 return (
199 <Collapsible
200 open={!isCollapsed}
201 onOpenChange={(open) => {
202 setCollapsedQuestionId(open ? null : activeQuestion.id);
203 }}
204 >
205 <CollapsibleTrigger
206 render={<ComposerBanner.Row render={<button type="button" />} />}
207 title={
208 isCollapsed ? "Show the question and its options" : "Hide the question and its options"
209 }
210 data-pending-user-input-toggle={isCollapsed ? "collapsed" : "expanded"}
211 >
212 <ComposerBanner.Icon />
213 <ComposerBanner.Content>
214 <span className="shrink-0 font-medium text-muted-foreground">
215 {activeQuestion.header}
216 </span>
217 {isCollapsed ? (
218 <span className="min-w-0 flex-1 truncate text-secondary-label">
219 {activeQuestion.question}
220 </span>
221 ) : null}
222 </ComposerBanner.Content>
223 <ComposerBanner.Actions>
224 {prompt.questions.length > 1 ? (
225 <span className="text-3xs font-medium text-muted-foreground tabular-nums">
226 {questionIndex + 1}/{prompt.questions.length}
227 </span>
228 ) : null}
229 <ComposerBanner.ToggleIcon expanded={!isCollapsed} />
230 {prompt.dismissible ? (
231 // Sits inside the trigger button, so stop the click from toggling
232 // the disclosure. Dismiss closes the question without a reply.
233 <ComposerBanner.Dismiss
234 render={<span role="button" tabIndex={0} />}
235 aria-label="Dismiss question without answering"
236 title="Dismiss question without answering"
237 disabled={isResponding}
238 data-pending-user-input-dismiss
239 onClick={(event) => {
240 event.preventDefault();
241 event.stopPropagation();
242 onDismiss(prompt.requestId);
243 }}
244 onKeyDown={(event) => {
245 if (event.key !== "Enter" && event.key !== " ") return;
246 event.preventDefault();
247 event.stopPropagation();
248 onDismiss(prompt.requestId);
249 }}
250 />
251 ) : null}
252 </ComposerBanner.Actions>
253 </CollapsibleTrigger>
254 <CollapsiblePanel>
255 <ComposerBanner.Scroll>
256 <ComposerBanner.Body className="pe-1 pb-1 wrap-anywhere">
257 <ChatMarkdown
258 text={activeQuestion.question}
259 cwd={cwd}
260 threadRef={threadRef}
261 className="text-foreground/85"
262 />
263 {activeQuestion.multiSelect ? (
264 <p className="mt-1 text-secondary-label text-xs">Select one or more options.</p>
265 ) : null}
266 <div className="mt-2 space-y-0.5">
267 {activeQuestion.options.map((option, index) => {
268 const optionValue = option.value ?? option.label;
269 const isOptimisticallySelected =
270 optimisticSingleSelect?.questionId === activeQuestion.id &&
271 optimisticSingleSelect.optionValue === optionValue;
272 const isSelected =
273 isOptimisticallySelected ||
274 (!customAnswerActive && progress.selectedOptionValues.includes(optionValue));
275 const shortcutKey = index < 9 ? index + 1 : null;
276 const className = cn(
277 "group flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left outline-none transition-colors duration-150 focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-primary/25",
278 isSelected
279 ? "bg-muted/55 text-foreground"
280 : "bg-transparent text-foreground/85 hover:bg-muted/30",
281 (disabled || isResponding) && "opacity-50 cursor-not-allowed",
282 !disabled && !isResponding && "cursor-pointer",
283 );
284 const content = (
285 <>
286 <div className="min-w-0 flex-1 flex flex-col gap-0.5">
287 <span className="text-sm font-medium">{option.label}</span>
288 {option.description && option.description !== option.label ? (
289 <span className="text-secondary-label text-2xs">{option.description}</span>
290 ) : null}
291 </div>
292 {isSelected ? (
293 <CheckIcon className="size-3.5 shrink-0 text-primary" />
294 ) : shortcutKey !== null ? (
295 <kbd
296 className={cn(
297 "flex size-5 shrink-0 items-center justify-center text-3xs font-medium text-muted-foreground tabular-nums",
298 )}
299 >
300 {shortcutKey}
301 </kbd>
302 ) : null}
303 </>
304 );
305 return (
306 <button
307 key={`${activeQuestion.id}:${optionValue}`}
308 type="button"
309 disabled={disabled || isResponding}
310 onClick={() => {
311 handleOptionSelection(activeQuestion.id, optionValue);
312 }}
313 className={className}
314 >
315 {content}
316 </button>
317 );
318 })}
319 </div>
320 </ComposerBanner.Body>
321 </ComposerBanner.Scroll>
322 </CollapsiblePanel>
323 </Collapsible>
324 );
325});