import { type ApprovalRequestId } from "@t3tools/contracts"; import { memo, useCallback, useEffect, useRef, useState } from "react"; import { type PendingUserInput } from "../../session-logic"; import { derivePendingUserInputProgress, type PendingUserInputDraftAnswer, } from "../../pendingUserInput"; import { CheckIcon } from "lucide-react"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; import { cn } from "~/lib/utils"; import { ComposerBanner } from "./ComposerBanner"; interface PendingUserInputPanelProps { pendingUserInputs: PendingUserInput[]; respondingRequestIds: ApprovalRequestId[]; answers: Record; questionIndex: number; onToggleOption: (questionId: string, optionValue: string) => void; onAdvance: () => void; onDismiss: (requestId: ApprovalRequestId) => void; } export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPanel({ pendingUserInputs, respondingRequestIds, answers, questionIndex, onToggleOption, onAdvance, onDismiss, }: PendingUserInputPanelProps) { if (pendingUserInputs.length === 0) return null; const activePrompt = pendingUserInputs[0]; if (!activePrompt) return null; return ( ); }); const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard({ prompt, isResponding, answers, questionIndex, onToggleOption, onAdvance, onDismiss, }: { prompt: PendingUserInput; isResponding: boolean; answers: Record; questionIndex: number; onToggleOption: (questionId: string, optionValue: string) => void; onAdvance: () => void; onDismiss: (requestId: ApprovalRequestId) => void; }) { const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex); const activeQuestion = progress.activeQuestion; const autoAdvanceTimerRef = useRef(null); const onAdvanceRef = useRef(onAdvance); const [optimisticSingleSelect, setOptimisticSingleSelect] = useState<{ questionId: string; optionValue: string; } | null>(null); // Collapsing hides everything but the header so a tall prompt stops covering // the thread the user is trying to read. Scoped to a single question: the card // is keyed by request id so the next prompt starts expanded, and storing the // collapsed question's id (rather than a bare flag) reopens the card when the // prompt advances to its next question, which can happen without a click — // sending from the composer advances the active question. const [collapsedQuestionId, setCollapsedQuestionId] = useState(null); const isCollapsed = collapsedQuestionId !== null && collapsedQuestionId === activeQuestion?.id; useEffect(() => { onAdvanceRef.current = onAdvance; }, [onAdvance]); useEffect(() => { if (!activeQuestion || activeQuestion.multiSelect || !optimisticSingleSelect) { return; } if (optimisticSingleSelect.questionId !== activeQuestion.id) { setOptimisticSingleSelect(null); return; } if ( progress.customAnswer.trim().length === 0 && progress.selectedOptionValues.includes(optimisticSingleSelect.optionValue) ) { setOptimisticSingleSelect(null); } }, [ activeQuestion, optimisticSingleSelect, progress.customAnswer, progress.selectedOptionValues, ]); // Clear auto-advance timer on unmount useEffect(() => { return () => { if (autoAdvanceTimerRef.current !== null) { window.clearTimeout(autoAdvanceTimerRef.current); } }; }, []); const handleOptionSelection = useCallback( (questionId: string, optionValue: string) => { if (activeQuestion?.multiSelect) { onToggleOption(questionId, optionValue); return; } setOptimisticSingleSelect({ questionId, optionValue }); onToggleOption(questionId, optionValue); if (autoAdvanceTimerRef.current !== null) { window.clearTimeout(autoAdvanceTimerRef.current); } autoAdvanceTimerRef.current = window.setTimeout(() => { autoAdvanceTimerRef.current = null; onAdvanceRef.current(); }, 200); }, [activeQuestion, onToggleOption], ); // Keyboard shortcut: number keys 1-9 select corresponding options when focus is // outside editable fields. Multi-select prompts toggle options in place; single- // select prompts keep the existing auto-advance behavior. Collapsed prompts opt // out, since the numbers they refer to are not on screen. useEffect(() => { if (!activeQuestion || isResponding || isCollapsed) return; const handler = (event: globalThis.KeyboardEvent) => { if (event.metaKey || event.ctrlKey || event.altKey) return; const target = event.target; if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) { return; } if ( target instanceof HTMLElement && target.closest('[contenteditable]:not([contenteditable="false"])') ) { return; } const digit = Number.parseInt(event.key, 10); if (Number.isNaN(digit) || digit < 1 || digit > 9) return; const optionIndex = digit - 1; if (optionIndex >= activeQuestion.options.length) return; const option = activeQuestion.options[optionIndex]; if (!option) return; event.preventDefault(); handleOptionSelection(activeQuestion.id, option.value ?? option.label); }; document.addEventListener("keydown", handler); return () => document.removeEventListener("keydown", handler); }, [activeQuestion, handleOptionSelection, isCollapsed, isResponding]); if (!activeQuestion) { return null; } const customAnswerActive = progress.customAnswer.trim().length > 0; return ( { setCollapsedQuestionId(open ? null : activeQuestion.id); }} > } />} title={ isCollapsed ? "Show the question and its options" : "Hide the question and its options" } data-pending-user-input-toggle={isCollapsed ? "collapsed" : "expanded"} > {activeQuestion.header} {isCollapsed ? ( {activeQuestion.question} ) : null} {prompt.questions.length > 1 ? ( {questionIndex + 1}/{prompt.questions.length} ) : null} {prompt.dismissible ? ( // Sits inside the trigger button, so stop the click from toggling // the disclosure. Dismiss closes the question without a reply. } aria-label="Dismiss question without answering" title="Dismiss question without answering" disabled={isResponding} data-pending-user-input-dismiss onClick={(event) => { event.preventDefault(); event.stopPropagation(); onDismiss(prompt.requestId); }} onKeyDown={(event) => { if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); event.stopPropagation(); onDismiss(prompt.requestId); }} /> ) : null}

{activeQuestion.question}

{activeQuestion.multiSelect ? (

Select one or more options.

) : null}
{activeQuestion.options.map((option, index) => { const optionValue = option.value ?? option.label; const isOptimisticallySelected = optimisticSingleSelect?.questionId === activeQuestion.id && optimisticSingleSelect.optionValue === optionValue; const isSelected = isOptimisticallySelected || (!customAnswerActive && progress.selectedOptionValues.includes(optionValue)); const shortcutKey = index < 9 ? index + 1 : null; const className = cn( "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-primary/25", isSelected ? "bg-muted/55 text-foreground" : "bg-transparent text-foreground/85 hover:bg-muted/30", isResponding && "opacity-50 cursor-not-allowed", !isResponding && "cursor-pointer", ); const content = ( <>
{option.label} {option.description && option.description !== option.label ? ( {option.description} ) : null}
{isSelected ? ( ) : shortcutKey !== null ? ( {shortcutKey} ) : null} ); return ( ); })}
); });