Open History to see the last commit
1import { type RuntimeRequestId, type ScopedThreadRef } from "@t3tools/contracts";
2import { markdownProseDirection, textDirection } from "~/textDirection";
3import { memo, use, useEffect, useEffectEvent, useRef, useState } from "react";
4import { BackgroundThreadContext } from "./backgroundThread";
5import { useStableCallback } from "~/hooks/useStableCallback";
6import { type PendingUserInput } from "../../session-logic";
7import type { PendingUserInputDraftAnswer } from "@t3tools/client-runtime/state/pending-user-input";
8import { derivePendingUserInputProgress } from "../../pendingUserInput";
9import { CheckboxMark } from "../ui/checkbox";
10import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible";
11import { Kbd } from "../ui/kbd";
12import { cn } from "~/lib/utils";
13import { isInSideChatPane } from "~/lib/sideChatPane";
14import { ComposerBanner } from "./ComposerBanner";
15import ChatMarkdown from "../ChatMarkdown";
16
17interface PendingUserInputPanelProps {
18 pendingUserInputs: PendingUserInput[];
19 cwd: string | undefined;
20 threadRef: ScopedThreadRef;
21 disabled?: boolean;
22 respondingRequestIds: RuntimeRequestId[];
23 answers: Record<string, PendingUserInputDraftAnswer>;
24 questionIndex: number;
25 onToggleOption: (questionId: string, optionValue: string) => void;
26 onAdvance: () => void;
27 onDismiss: (requestId: RuntimeRequestId) => void;
28}
29
30export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPanel({
31 pendingUserInputs,
32 cwd,
33 threadRef,
34 disabled = false,
35 respondingRequestIds,
36 answers,
37 questionIndex,
38 onToggleOption,
39 onAdvance,
40 onDismiss,
41}: PendingUserInputPanelProps) {
42 if (pendingUserInputs.length === 0) return null;
43 const activePrompt = pendingUserInputs[0];
44 if (!activePrompt) return null;
45
46 return (
47 <ComposerPendingUserInputCard
48 key={activePrompt.requestId}
49 prompt={activePrompt}
50 cwd={cwd}
51 threadRef={threadRef}
52 disabled={disabled}
53 isResponding={respondingRequestIds.includes(activePrompt.requestId)}
54 answers={answers}
55 questionIndex={questionIndex}
56 onToggleOption={onToggleOption}
57 onAdvance={onAdvance}
58 onDismiss={onDismiss}
59 />
60 );
61});
62
63const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard({
64 prompt,
65 cwd,
66 threadRef,
67 disabled,
68 isResponding,
69 answers,
70 questionIndex,
71 onToggleOption,
72 onAdvance,
73 onDismiss,
74}: {
75 prompt: PendingUserInput;
76 cwd: string | undefined;
77 threadRef: ScopedThreadRef;
78 disabled: boolean;
79 isResponding: boolean;
80 answers: Record<string, PendingUserInputDraftAnswer>;
81 questionIndex: number;
82 onToggleOption: (questionId: string, optionValue: string) => void;
83 onAdvance: () => void;
84 onDismiss: (requestId: RuntimeRequestId) => void;
85}) {
86 // Message-mode requests remain answerable after their provider turn ends.
87 const canRespond = prompt.responseCapability !== "not_resumable";
88 const responseDisabled = disabled || isResponding || !canRespond;
89 const autoAdvanceTimerRef = useRef<number | null>(null);
90 const advance = useStableCallback(onAdvance);
91 const [optimisticSingleSelect, setOptimisticSingleSelect] = useState<{
92 questionId: string;
93 optionValue: string;
94 } | null>(null);
95 const [collapsedQuestionId, setCollapsedQuestionId] = useState<string | null>(null);
96 // Keep question identity stable for its shortcut listener.
97 const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex);
98 const activeQuestion = progress.activeQuestion;
99 const customAnswerActive = progress.customAnswer.trim().length > 0;
100 // Read before later hooks to preserve the question's compiler cache.
101 const draftSelectedOptions = activeQuestion?.options.map(
102 (option) =>
103 !customAnswerActive && progress.selectedOptionValues.includes(option.value ?? option.label),
104 );
105 const isCollapsed = collapsedQuestionId !== null && collapsedQuestionId === activeQuestion?.id;
106
107 if (
108 optimisticSingleSelect &&
109 activeQuestion &&
110 !activeQuestion.multiSelect &&
111 (optimisticSingleSelect.questionId !== activeQuestion.id ||
112 (!customAnswerActive &&
113 progress.selectedOptionValues.includes(optimisticSingleSelect.optionValue)))
114 ) {
115 setOptimisticSingleSelect(null);
116 }
117
118 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- clears the auto-advance timer when the question changes, input is disabled, or the panel unmounts
119 useEffect(() => {
120 const clearAutoAdvance = () => {
121 if (autoAdvanceTimerRef.current !== null) {
122 window.clearTimeout(autoAdvanceTimerRef.current);
123 autoAdvanceTimerRef.current = null;
124 }
125 };
126 if (disabled || activeQuestion?.id === undefined) clearAutoAdvance();
127 return clearAutoAdvance;
128 }, [activeQuestion?.id, disabled]);
129
130 const handleOptionSelection = (questionId: string, optionValue: string) => {
131 if (disabled || isResponding) return;
132 if (activeQuestion?.multiSelect) {
133 onToggleOption(questionId, optionValue);
134 return;
135 }
136 setOptimisticSingleSelect({ questionId, optionValue });
137 onToggleOption(questionId, optionValue);
138 if (autoAdvanceTimerRef.current !== null) {
139 window.clearTimeout(autoAdvanceTimerRef.current);
140 }
141 autoAdvanceTimerRef.current = window.setTimeout(() => {
142 autoAdvanceTimerRef.current = null;
143 advance();
144 }, 200);
145 };
146
147 const selectOptionFromShortcut = useEffectEvent(handleOptionSelection);
148 const backgroundThread = use(BackgroundThreadContext);
149
150 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes a document keydown listener for digit shortcuts while a question is answerable
151 useEffect(() => {
152 if (!activeQuestion || responseDisabled || isCollapsed || backgroundThread) return;
153 const handler = (event: globalThis.KeyboardEvent) => {
154 if (event.metaKey || event.ctrlKey || event.altKey) return;
155 const target = event.target;
156 if (isInSideChatPane(target)) return;
157 if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) {
158 return;
159 }
160 if (
161 target instanceof HTMLElement &&
162 target.closest('[contenteditable]:not([contenteditable="false"])')
163 ) {
164 return;
165 }
166 const digit = Number.parseInt(event.key, 10);
167 if (Number.isNaN(digit) || digit < 1 || digit > 9) return;
168 const optionIndex = digit - 1;
169 if (optionIndex >= activeQuestion.options.length) return;
170 const option = activeQuestion.options[optionIndex];
171 if (!option) return;
172 event.preventDefault();
173 selectOptionFromShortcut(activeQuestion.id, option.value ?? option.label);
174 };
175 document.addEventListener("keydown", handler);
176 return () => document.removeEventListener("keydown", handler);
177 }, [activeQuestion, backgroundThread, isCollapsed, responseDisabled]);
178
179 if (!activeQuestion) {
180 return null;
181 }
182
183 const promptDirection = markdownProseDirection(
184 prompt.questions
185 .flatMap((question) => [
186 question.header,
187 question.question,
188 ...question.options.flatMap((option) => [option.label, option.description ?? ""]),
189 ])
190 .join(" "),
191 );
192 const questionTextAlign = promptDirection === "rtl" ? "text-right" : "text-left";
193
194 return (
195 <Collapsible
196 dir={promptDirection}
197 open={!isCollapsed}
198 onOpenChange={(open) => {
199 setCollapsedQuestionId(open ? null : activeQuestion.id);
200 }}
201 >
202 <CollapsibleTrigger
203 render={<ComposerBanner.Row render={<button type="button" />} />}
204 title={
205 isCollapsed ? "Show the question and its options" : "Hide the question and its options"
206 }
207 data-pending-user-input-toggle={isCollapsed ? "collapsed" : "expanded"}
208 className="px-4 py-1.5"
209 >
210 <ComposerBanner.Content>
211 <span
212 className={cn("min-w-0 truncate text-muted-foreground", isCollapsed && "max-w-1/2")}
213 dir={textDirection(activeQuestion.header)}
214 >
215 {activeQuestion.header}
216 </span>
217 {isCollapsed ? (
218 <>
219 <ComposerBanner.Separator />
220 <span
221 className={cn("min-w-0 flex-1 truncate text-secondary-label", questionTextAlign)}
222 dir={markdownProseDirection(activeQuestion.question)}
223 >
224 {activeQuestion.question}
225 </span>
226 </>
227 ) : null}
228 </ComposerBanner.Content>
229 <ComposerBanner.Actions className="gap-0">
230 {prompt.questions.length > 1 ? (
231 <ComposerBanner.Count>
232 {questionIndex + 1}/{prompt.questions.length}
233 </ComposerBanner.Count>
234 ) : null}
235 <ComposerBanner.ToggleIcon expanded={!isCollapsed} />
236 {prompt.dismissible ? (
237 <ComposerBanner.Dismiss
238 render={<span role="button" tabIndex={0} />}
239 aria-label="Dismiss question without answering"
240 title="Dismiss question without answering"
241 disabled={isResponding}
242 data-pending-user-input-dismiss
243 onClick={(event) => {
244 event.preventDefault();
245 event.stopPropagation();
246 onDismiss(prompt.requestId);
247 }}
248 onKeyDown={(event) => {
249 if (event.key !== "Enter" && event.key !== " ") return;
250 event.preventDefault();
251 event.stopPropagation();
252 onDismiss(prompt.requestId);
253 }}
254 />
255 ) : null}
256 </ComposerBanner.Actions>
257 </CollapsibleTrigger>
258 <CollapsiblePanel>
259 <ComposerBanner.Scroll>
260 <ComposerBanner.Body className="ps-6 pe-4 pb-1 wrap-anywhere sm:ps-5.5">
261 <ChatMarkdown
262 text={activeQuestion.question}
263 cwd={cwd}
264 threadRef={threadRef}
265 lineBreaks
266 className={cn("pt-0.5 text-foreground/85", questionTextAlign)}
267 />
268 {/* Rows span the header row's width so the radios align with the question text and the shortcut column sits under the dismiss button. */}
269 <div className="-ms-2 mt-1.5 sm:-ms-1.5">
270 {activeQuestion.options.map((option, index) => {
271 const optionValue = option.value ?? option.label;
272 const isOptimisticallySelected =
273 optimisticSingleSelect?.questionId === activeQuestion.id &&
274 optimisticSingleSelect.optionValue === optionValue;
275 const isSelected =
276 isOptimisticallySelected || draftSelectedOptions?.[index] === true;
277 const className = cn(
278 questionTextAlign,
279 "group flex w-full items-start gap-2.5 rounded-md bg-transparent py-1.5 ps-2 sm:ps-1.5 outline-none transition-colors duration-150 focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-primary/25",
280 isSelected ? "text-foreground" : "text-foreground/70 hover:bg-muted/30",
281 (disabled || isResponding) && "opacity-50 cursor-not-allowed",
282 !disabled && !isResponding && "cursor-pointer",
283 );
284 // The h-5 slots match the label's line box, so the indicator and shortcut stay on its first line.
285 const content = (
286 <>
287 <span className="pointer-events-none flex h-5 shrink-0 items-center">
288 {activeQuestion.multiSelect ? (
289 <CheckboxMark checked={isSelected} variant="contrast" />
290 ) : (
291 <OptionRadio checked={isSelected} />
292 )}
293 </span>
294 <div className="min-w-0 flex-1 flex flex-col gap-0.5">
295 <span className="text-sm font-medium" dir={textDirection(option.label)}>
296 {option.label}
297 </span>
298 {option.description && option.description !== option.label ? (
299 <span
300 className="text-secondary-label text-2xs"
301 dir={textDirection(option.description)}
302 >
303 {option.description}
304 </span>
305 ) : null}
306 </div>
307 {index < 9 ? (
308 <span className="flex h-5 w-7 shrink-0 items-center justify-center sm:w-6">
309 <Kbd>{index + 1}</Kbd>
310 </span>
311 ) : null}
312 </>
313 );
314 // The preview sits outside the button: it can hold links, and a click on a single-select option advances.
315 return (
316 <div key={`${activeQuestion.id}:${optionValue}`}>
317 <button
318 type="button"
319 aria-pressed={isSelected}
320 disabled={disabled || isResponding}
321 onClick={() => {
322 handleOptionSelection(activeQuestion.id, optionValue);
323 }}
324 className={className}
325 >
326 {content}
327 </button>
328 {option.preview ? (
329 <ChatMarkdown
330 text={option.preview}
331 cwd={cwd}
332 threadRef={threadRef}
333 lineBreaks
334 className="ps-9 pe-2 pb-1.5 text-xs text-foreground/70 sm:ps-8"
335 />
336 ) : null}
337 </div>
338 );
339 })}
340 </div>
341 </ComposerBanner.Body>
342 </ComposerBanner.Scroll>
343 </CollapsiblePanel>
344 </Collapsible>
345 );
346});
347
348// Drawn with ui/radio-group's look instead of using its Radio: Radio only checks inside a
349// RadioGroup, whose arrow keys select options and would trip the single-select auto-advance.
350function OptionRadio({ checked }: { checked: boolean }) {
351 return (
352 <span
353 aria-hidden
354 className={cn(
355 "relative inline-flex size-4.5 shrink-0 items-center justify-center rounded-full border border-foreground/55 bg-background not-dark:bg-clip-padding sm:size-4 dark:bg-input/32",
356 checked ? "shadow-none" : "shadow-xs/5",
357 )}
358 >
359 {checked ? (
360 <span className="-inset-px absolute flex items-center justify-center rounded-full bg-primary before:size-2 before:rounded-full before:bg-primary-foreground sm:before:size-1.5" />
361 ) : null}
362 </span>
363 );
364}