294 lines · 11.1 KB · TypeScript
RawDownload
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<string, PendingUserInputDraftAnswer>;  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 (    <ComposerPendingUserInputCard      key={activePrompt.requestId}      prompt={activePrompt}      isResponding={respondingRequestIds.includes(activePrompt.requestId)}      answers={answers}      questionIndex={questionIndex}      onToggleOption={onToggleOption}      onAdvance={onAdvance}      onDismiss={onDismiss}    />  );}); const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard({  prompt,  isResponding,  answers,  questionIndex,  onToggleOption,  onAdvance,  onDismiss,}: {  prompt: PendingUserInput;  isResponding: boolean;  answers: Record<string, PendingUserInputDraftAnswer>;  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<number | null>(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<string | null>(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 (    <Collapsible      open={!isCollapsed}      onOpenChange={(open) => {        setCollapsedQuestionId(open ? null : activeQuestion.id);      }}    >      <CollapsibleTrigger        render={<ComposerBanner.Row render={<button type="button" />} />}        title={          isCollapsed ? "Show the question and its options" : "Hide the question and its options"        }        data-pending-user-input-toggle={isCollapsed ? "collapsed" : "expanded"}      >        <ComposerBanner.Icon />        <ComposerBanner.Content>          <span className="shrink-0 font-medium text-muted-foreground">            {activeQuestion.header}          </span>          {isCollapsed ? (            <span className="min-w-0 flex-1 truncate text-secondary-label">              {activeQuestion.question}            </span>          ) : null}        </ComposerBanner.Content>        <ComposerBanner.Actions>          {prompt.questions.length > 1 ? (            <span className="text-[10px] font-medium text-muted-foreground tabular-nums">              {questionIndex + 1}/{prompt.questions.length}            </span>          ) : null}          <ComposerBanner.ToggleIcon expanded={!isCollapsed} />          {prompt.dismissible ? (            // Sits inside the trigger button, so stop the click from toggling            // the disclosure. Dismiss closes the question without a reply.            <ComposerBanner.Dismiss              render={<span role="button" tabIndex={0} />}              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}        </ComposerBanner.Actions>      </CollapsibleTrigger>      <CollapsiblePanel>        <ComposerBanner.Body className="pe-1 pb-1">          <p className="text-sm text-foreground/85">{activeQuestion.question}</p>          {activeQuestion.multiSelect ? (            <p className="mt-1 text-secondary-label text-xs">Select one or more options.</p>          ) : null}          <div className="mt-2 space-y-0.5">            {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 = (                <>                  <div className="min-w-0 flex-1 flex flex-col gap-0.5">                    <span className="text-sm font-medium">{option.label}</span>                    {option.description && option.description !== option.label ? (                      <span className="text-secondary-label text-[11px]">{option.description}</span>                    ) : null}                  </div>                  {isSelected ? (                    <CheckIcon className="size-3.5 shrink-0 text-primary" />                  ) : shortcutKey !== null ? (                    <kbd                      className={cn(                        "flex size-5 shrink-0 items-center justify-center text-[10px] font-medium text-muted-foreground tabular-nums",                      )}                    >                      {shortcutKey}                    </kbd>                  ) : null}                </>              );              return (                <button                  key={`${activeQuestion.id}:${optionValue}`}                  type="button"                  disabled={isResponding}                  onClick={() => {                    handleOptionSelection(activeQuestion.id, optionValue);                  }}                  className={className}                >                  {content}                </button>              );            })}          </div>        </ComposerBanner.Body>      </CollapsiblePanel>    </Collapsible>  );});