189 lines · 6.9 KB · TypeScript
RawDownload
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.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, 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>  );});