import { type RuntimeRequestId, type ScopedThreadRef } from "@t3tools/contracts"; import { memo, use, useEffect, useEffectEvent, useRef, useState } from "react"; import { BackgroundThreadContext } from "./backgroundThread"; import { useStableCallback } from "~/hooks/useStableCallback"; import { type PendingUserInput } from "../../session-logic"; import type { PendingUserInputDraftAnswer } from "@t3tools/client-runtime/state/pending-user-input"; import { derivePendingUserInputProgress } from "../../pendingUserInput"; import { CheckboxMark } from "../ui/checkbox"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; import { Kbd } from "../ui/kbd"; import { cn } from "~/lib/utils"; import { isInSideChatPane } from "~/lib/sideChatPane"; import { ComposerBanner } from "./ComposerBanner"; import ChatMarkdown from "../ChatMarkdown"; interface PendingUserInputPanelProps { pendingUserInputs: PendingUserInput[]; cwd: string | undefined; threadRef: ScopedThreadRef; disabled?: boolean; respondingRequestIds: RuntimeRequestId[]; answers: Record; questionIndex: number; onToggleOption: (questionId: string, optionValue: string) => void; onAdvance: () => void; onDismiss: (requestId: RuntimeRequestId) => void; } export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPanel({ pendingUserInputs, cwd, threadRef, disabled = false, 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, cwd, threadRef, disabled, isResponding, answers, questionIndex, onToggleOption, onAdvance, onDismiss, }: { prompt: PendingUserInput; cwd: string | undefined; threadRef: ScopedThreadRef; disabled: boolean; isResponding: boolean; answers: Record; questionIndex: number; onToggleOption: (questionId: string, optionValue: string) => void; onAdvance: () => void; onDismiss: (requestId: RuntimeRequestId) => void; }) { // Message-mode requests remain answerable after their provider turn ends. const canRespond = prompt.responseCapability !== "not_resumable"; const responseDisabled = disabled || isResponding || !canRespond; const autoAdvanceTimerRef = useRef(null); const advance = useStableCallback(onAdvance); const [optimisticSingleSelect, setOptimisticSingleSelect] = useState<{ questionId: string; optionValue: string; } | null>(null); const [collapsedQuestionId, setCollapsedQuestionId] = useState(null); // Keep question identity stable for its shortcut listener. const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex); const activeQuestion = progress.activeQuestion; const customAnswerActive = progress.customAnswer.trim().length > 0; // Read before later hooks to preserve the question's compiler cache. const draftSelectedOptions = activeQuestion?.options.map( (option) => !customAnswerActive && progress.selectedOptionValues.includes(option.value ?? option.label), ); const isCollapsed = collapsedQuestionId !== null && collapsedQuestionId === activeQuestion?.id; if ( optimisticSingleSelect && activeQuestion && !activeQuestion.multiSelect && (optimisticSingleSelect.questionId !== activeQuestion.id || (!customAnswerActive && progress.selectedOptionValues.includes(optimisticSingleSelect.optionValue))) ) { setOptimisticSingleSelect(null); } // 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 useEffect(() => { const clearAutoAdvance = () => { if (autoAdvanceTimerRef.current !== null) { window.clearTimeout(autoAdvanceTimerRef.current); autoAdvanceTimerRef.current = null; } }; if (disabled || activeQuestion?.id === undefined) clearAutoAdvance(); return clearAutoAdvance; }, [activeQuestion?.id, disabled]); const handleOptionSelection = (questionId: string, optionValue: string) => { if (disabled || isResponding) return; 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; advance(); }, 200); }; const selectOptionFromShortcut = useEffectEvent(handleOptionSelection); const backgroundThread = use(BackgroundThreadContext); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes a document keydown listener for digit shortcuts while a question is answerable useEffect(() => { if (!activeQuestion || responseDisabled || isCollapsed || backgroundThread) return; const handler = (event: globalThis.KeyboardEvent) => { if (event.metaKey || event.ctrlKey || event.altKey) return; const target = event.target; if (isInSideChatPane(target)) return; 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(); selectOptionFromShortcut(activeQuestion.id, option.value ?? option.label); }; document.addEventListener("keydown", handler); return () => document.removeEventListener("keydown", handler); }, [activeQuestion, backgroundThread, isCollapsed, responseDisabled]); if (!activeQuestion) { return null; } 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"} className="px-4 pt-1.5" > {activeQuestion.header} {isCollapsed ? ( <> {activeQuestion.question} ) : null} {prompt.questions.length > 1 ? ( {questionIndex + 1}/{prompt.questions.length} ) : null} {prompt.dismissible ? ( } 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} {/* Rows span the header row's width so the radios align with the question text and the shortcut column sits under the dismiss button. */}
{activeQuestion.options.map((option, index) => { const optionValue = option.value ?? option.label; const isOptimisticallySelected = optimisticSingleSelect?.questionId === activeQuestion.id && optimisticSingleSelect.optionValue === optionValue; const isSelected = isOptimisticallySelected || draftSelectedOptions?.[index] === true; const className = cn( "group flex w-full items-start gap-2.5 rounded-md bg-transparent py-1.5 ps-2 text-left sm:ps-1.5 outline-none transition-colors duration-150 focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-primary/25", isSelected ? "text-foreground" : "text-foreground/70 hover:bg-muted/30", (disabled || isResponding) && "opacity-50 cursor-not-allowed", !disabled && !isResponding && "cursor-pointer", ); // The h-5 slots match the label's line box, so the indicator and shortcut stay on its first line. const content = ( <> {activeQuestion.multiSelect ? ( ) : ( )}
{option.label} {option.description && option.description !== option.label ? ( {option.description} ) : null}
{index < 9 ? ( {index + 1} ) : null} ); return ( ); })}
); }); // Drawn with ui/radio-group's look instead of using its Radio: Radio only checks inside a // RadioGroup, whose arrow keys select options and would trip the single-select auto-advance. function OptionRadio({ checked }: { checked: boolean }) { return ( {checked ? ( ) : null} ); }