import { type ApprovalRequestId } from "@t3tools/contracts";import { memo, useEffect, useEffectEvent, useRef } from "react";import { type PendingUserInput } from "../../session-logic";import { derivePendingUserInputProgress, type PendingUserInputDraftAnswer,} from "../../pendingUserInput";import { CheckIcon } from "lucide-react";import { cn } from "~/lib/utils"; interface PendingUserInputPanelProps { pendingUserInputs: PendingUserInput[]; respondingRequestIds: ApprovalRequestId[]; answers: Record<string, PendingUserInputDraftAnswer>; 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 ( <ComposerPendingUserInputCard key={activePrompt.requestId} prompt={activePrompt} isResponding={respondingRequestIds.includes(activePrompt.requestId)} answers={answers} questionIndex={questionIndex} onToggleOption={onToggleOption} onAdvance={onAdvance} /> );}); const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard({ prompt, isResponding, answers, questionIndex, onToggleOption, onAdvance,}: { prompt: PendingUserInput; isResponding: boolean; answers: Record<string, PendingUserInputDraftAnswer>; questionIndex: number; onToggleOption: (questionId: string, optionLabel: string) => void; onAdvance: () => void;}) { const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex); const activeQuestion = progress.activeQuestion; const autoAdvanceTimerRef = useRef<number | null>(null); const onAdvanceRef = useRef(onAdvance); useEffect(() => { onAdvanceRef.current = onAdvance; }, [onAdvance]); // Clear auto-advance timer on unmount useEffect(() => { return () => { if (autoAdvanceTimerRef.current !== null) { window.clearTimeout(autoAdvanceTimerRef.current); } }; }, []); const handleOptionSelection = useEffectEvent((questionId: string, optionLabel: string) => { onToggleOption(questionId, optionLabel); if (activeQuestion?.multiSelect) { return; } if (autoAdvanceTimerRef.current !== null) { window.clearTimeout(autoAdvanceTimerRef.current); } autoAdvanceTimerRef.current = window.setTimeout(() => { autoAdvanceTimerRef.current = null; onAdvanceRef.current(); }, 200); }); // 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. useEffect(() => { if (!activeQuestion || isResponding) 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.isContentEditable) { 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, isResponding]); if (!activeQuestion) { return null; } return ( <div className="px-4 py-3 sm:px-5"> <div className="flex items-center gap-3"> <div className="flex items-center gap-2"> {prompt.questions.length > 1 ? ( <span className="flex h-5 items-center rounded-md bg-muted/60 px-1.5 text-[10px] font-medium tabular-nums text-muted-foreground/60"> {questionIndex + 1}/{prompt.questions.length} </span> ) : null} <span className="text-[11px] font-semibold tracking-widest text-muted-foreground/50 uppercase"> {activeQuestion.header} </span> </div> </div> <p className="mt-1.5 text-sm text-foreground/90">{activeQuestion.question}</p> {activeQuestion.multiSelect ? ( <p className="mt-1 text-xs text-muted-foreground/65">Select one or more options.</p> ) : null} <div className="mt-3 space-y-1"> {activeQuestion.options.map((option, index) => { const isSelected = progress.selectedOptionLabels.includes(option.label); const shortcutKey = index < 9 ? index + 1 : null; return ( <button key={`${activeQuestion.id}:${option.label}`} type="button" disabled={isResponding} onClick={() => handleOptionSelection(activeQuestion.id, option.label)} className={cn( "group flex w-full items-center gap-3 rounded-lg border px-3 py-2 text-left transition-all duration-150", isSelected ? "border-blue-500/40 bg-blue-500/8 text-foreground" : "border-transparent bg-muted/20 text-foreground/80 hover:bg-muted/40 hover:border-border/40", isResponding && "opacity-50 cursor-not-allowed", )} > {shortcutKey !== null ? ( <kbd className={cn( "flex size-5 shrink-0 items-center justify-center rounded text-[11px] font-medium tabular-nums transition-colors duration-150", isSelected ? "bg-blue-500/20 text-blue-400" : "bg-muted/40 text-muted-foreground/50 group-hover:bg-muted/60 group-hover:text-muted-foreground/70", )} > {shortcutKey} </kbd> ) : null} <div className="min-w-0 flex-1"> <span className="text-sm font-medium">{option.label}</span> {option.description && option.description !== option.label ? ( <span className="ml-2 text-xs text-muted-foreground/50"> {option.description} </span> ) : null} </div> {isSelected ? <CheckIcon className="size-3.5 shrink-0 text-blue-400" /> : null} </button> ); })} </div> </div> );});
import { type ApprovalRequestId } from "@t3tools/contracts";
import { memo, useEffect, useEffectEvent, useRef } from "react";
import { type PendingUserInput } from "../../session-logic";
import {
derivePendingUserInputProgress,
type PendingUserInputDraftAnswer,
} from "../../pendingUserInput";
import { CheckIcon } from "lucide-react";
import { cn } from "~/lib/utils";
interface PendingUserInputPanelProps {
pendingUserInputs: PendingUserInput[];
respondingRequestIds: ApprovalRequestId[];
answers: Record<string, PendingUserInputDraftAnswer>;
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 (
<ComposerPendingUserInputCard
key={activePrompt.requestId}
prompt={activePrompt}
isResponding={respondingRequestIds.includes(activePrompt.requestId)}
answers={answers}
questionIndex={questionIndex}
onToggleOption={onToggleOption}
onAdvance={onAdvance}
/>
);
});
const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard({
prompt,
isResponding,
answers,
questionIndex,
onToggleOption,
onAdvance,
}: {
prompt: PendingUserInput;
isResponding: boolean;
answers: Record<string, PendingUserInputDraftAnswer>;
questionIndex: number;
onToggleOption: (questionId: string, optionLabel: string) => void;
onAdvance: () => void;
}) {
const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex);
const activeQuestion = progress.activeQuestion;
const autoAdvanceTimerRef = useRef<number | null>(null);
const onAdvanceRef = useRef(onAdvance);
useEffect(() => {
onAdvanceRef.current = onAdvance;
}, [onAdvance]);
// Clear auto-advance timer on unmount
useEffect(() => {
return () => {
if (autoAdvanceTimerRef.current !== null) {
window.clearTimeout(autoAdvanceTimerRef.current);
}
};
}, []);
const handleOptionSelection = useEffectEvent((questionId: string, optionLabel: string) => {
onToggleOption(questionId, optionLabel);
if (activeQuestion?.multiSelect) {
return;
}
if (autoAdvanceTimerRef.current !== null) {
window.clearTimeout(autoAdvanceTimerRef.current);
}
autoAdvanceTimerRef.current = window.setTimeout(() => {
autoAdvanceTimerRef.current = null;
onAdvanceRef.current();
}, 200);
});
// 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.
useEffect(() => {
if (!activeQuestion || isResponding) 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.isContentEditable) {
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, isResponding]);
if (!activeQuestion) {
return null;
}
return (
<div className="px-4 py-3 sm:px-5">
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
{prompt.questions.length > 1 ? (
<span className="flex h-5 items-center rounded-md bg-muted/60 px-1.5 text-[10px] font-medium tabular-nums text-muted-foreground/60">
{questionIndex + 1}/{prompt.questions.length}
</span>
) : null}
<span className="text-[11px] font-semibold tracking-widest text-muted-foreground/50 uppercase">
{activeQuestion.header}
</span>
</div>
</div>
<p className="mt-1.5 text-sm text-foreground/90">{activeQuestion.question}</p>
{activeQuestion.multiSelect ? (
<p className="mt-1 text-xs text-muted-foreground/65">Select one or more options.</p>
) : null}
<div className="mt-3 space-y-1">
{activeQuestion.options.map((option, index) => {
const isSelected = progress.selectedOptionLabels.includes(option.label);
const shortcutKey = index < 9 ? index + 1 : null;
return (
<button
key={`${activeQuestion.id}:${option.label}`}
type="button"
disabled={isResponding}
onClick={() => handleOptionSelection(activeQuestion.id, option.label)}
className={cn(
"group flex w-full items-center gap-3 rounded-lg border px-3 py-2 text-left transition-all duration-150",
isSelected
? "border-blue-500/40 bg-blue-500/8 text-foreground"
: "border-transparent bg-muted/20 text-foreground/80 hover:bg-muted/40 hover:border-border/40",
isResponding && "opacity-50 cursor-not-allowed",
)}
>
{shortcutKey !== null ? (
<kbd
className={cn(
"flex size-5 shrink-0 items-center justify-center rounded text-[11px] font-medium tabular-nums transition-colors duration-150",
isSelected
? "bg-blue-500/20 text-blue-400"
: "bg-muted/40 text-muted-foreground/50 group-hover:bg-muted/60 group-hover:text-muted-foreground/70",
)}
>
{shortcutKey}
</kbd>
) : null}
<div className="min-w-0 flex-1">
<span className="text-sm font-medium">{option.label}</span>
{option.description && option.description !== option.label ? (
<span className="ml-2 text-xs text-muted-foreground/50">
{option.description}
</span>
) : null}
</div>
{isSelected ? <CheckIcon className="size-3.5 shrink-0 text-blue-400" /> : null}
</button>
);
})}
</div>
</div>
);
});