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, ChevronDownIcon } from "lucide-react"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; import { cn } from "~/lib/utils"; interface PendingUserInputPanelProps { pendingUserInputs: PendingUserInput[]; respondingRequestIds: ApprovalRequestId[]; answers: Record; questionIndex: number; onToggleOption: (questionId: string, optionLabel: string) => void; onAdvance: () => void; } export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPanel({ pendingUserInputs, respondingRequestIds, answers, questionIndex, onToggleOption, onAdvance, }: 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, }: { prompt: PendingUserInput; isResponding: boolean; answers: Record; questionIndex: number; onToggleOption: (questionId: string, optionLabel: string) => void; onAdvance: () => 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; optionLabel: 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.selectedOptionLabels.includes(optimisticSingleSelect.optionLabel) ) { setOptimisticSingleSelect(null); } }, [ activeQuestion, optimisticSingleSelect, progress.customAnswer, progress.selectedOptionLabels, ]); // Clear auto-advance timer on unmount useEffect(() => { return () => { if (autoAdvanceTimerRef.current !== null) { window.clearTimeout(autoAdvanceTimerRef.current); } }; }, []); const handleOptionSelection = useCallback( (questionId: string, optionLabel: string) => { if (activeQuestion?.multiSelect) { onToggleOption(questionId, optionLabel); return; } setOptimisticSingleSelect({ questionId, optionLabel }); onToggleOption(questionId, optionLabel); 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.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); }} > {/* The trigger's wrapper is inset less than the card's text column, and the trigger pays the difference back as padding: the hover background and focus ring bleed 10px past that column on both sides, while the header label and the chevron still line up with the left and right edges of the question text below. The negative block margin keeps the taller hit area from pushing the panel down. */}
{activeQuestion.header} {prompt.questions.length > 1 ? ( {questionIndex + 1}/{prompt.questions.length} ) : null} {/* Collapsed, the header is otherwise just a section label and a counter, so the question itself is echoed here as a one-line reminder of what is being asked. */} {isCollapsed ? ( {activeQuestion.question} ) : null} {/* The chevron points at the body: down while it is open below the header, up while it is collapsed into it. */}
{/* The panel carries the horizontal padding itself: it clips its content while the height animates, so the option buttons have to sit inside that padding or their focus rings get shaved off at the edges. */}

{activeQuestion.question}

{activeQuestion.multiSelect ? (

Select one or more options.

) : null}
{activeQuestion.options.map((option, index) => { const isOptimisticallySelected = optimisticSingleSelect?.questionId === activeQuestion.id && optimisticSingleSelect.optionLabel === option.label; const isSelected = isOptimisticallySelected || (!customAnswerActive && progress.selectedOptionLabels.includes(option.label)); 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 ( ); })}
); });