import { useEffect, useMemo, useRef, useState } from "react"; import { Check, ChevronLeft, ChevronRight, Loader2, Search, } from "lucide-react"; import type { PaperclipQuestionResponse, PaperclipQuestionSet, } from "@paperclipai/adapter-utils"; import type { MentionOption } from "@/components/MarkdownEditor"; import { MarkdownBody } from "@/components/MarkdownBody"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { clearDraft, loadStructuredDraft, saveStructuredDraft, } from "@/lib/composer-draft"; import { cn } from "@/lib/utils"; import { TaskChatComposerTakeoverControls, useTaskChatComposerTakeoverActions, } from "./TaskChatComposerTakeoverContext"; import { TaskChatRichInput } from "./TaskChatRichInput"; import { matchSafeQuestionValidationPattern } from "./question-validation-pattern"; type Question = PaperclipQuestionSet["questions"][number]; type Answer = PaperclipQuestionResponse["answers"][string]; /** * A form-level message, tagged with whether answering a question resolves it. * * Only a missing-answer complaint is something a selection can settle. A send * that failed is not: the answers are still unsent, so the message has to * outlive the next click rather than disappear the moment the reader touches * an option. */ type FormError = { message: string; fromMissingAnswer?: boolean }; export interface QuestionFormProps { id: string; questionSet: PaperclipQuestionSet; initialResponse?: PaperclipQuestionResponse | null; implicitCustomAnswer?: boolean; draftKey?: string; disabled?: boolean; imageUploadHandler?: (file: File) => Promise; mentions?: MentionOption[]; onSubmit: (response: PaperclipQuestionResponse) => void | Promise; /** * Resolves the request itself (a timeline card cancelling the interaction). * Inside the composer takeover the form falls back to dismissing the * takeover, which returns the plain composer without touching the request. */ onCancel?: () => void | Promise; } function answerHasValue(answer: Answer | undefined): boolean { return Boolean( answer?.text?.trim() || answer?.customText?.trim() || answer?.selectedOptionIds?.length, ); } function answerError( question: Question, answer: Answer | undefined, ): string | null { if (question.required && !answerHasValue(answer)) return "This question is required."; if ( question.answerMode !== "text" && answer?.customText !== undefined && !answer.customText.trim() ) { return "Enter a custom answer."; } const value = question.answerMode === "text" ? answer?.text : answer?.customText; if (value == null || value.length === 0) return null; const validation = question.textValidation; if (validation?.minLength != null && value.length < validation.minLength) return `Enter at least ${validation.minLength} characters.`; if (validation?.maxLength != null && value.length > validation.maxLength) return `Enter no more than ${validation.maxLength} characters.`; if (validation?.pattern) { const result = matchSafeQuestionValidationPattern(validation.pattern, value); if (result === "unsupported") return "This question has an unsupported validation pattern."; if (result === "no_match") return "Use the requested format."; } if ( validation?.inputType === "number" || validation?.inputType === "integer" ) { const numeric = Number(value); if ( !Number.isFinite(numeric) || (validation.inputType === "integer" && !Number.isInteger(numeric)) ) { return `Enter a valid ${validation.inputType}.`; } if (validation.minimum != null && numeric < validation.minimum) return `Enter a value of at least ${validation.minimum}.`; if (validation.maximum != null && numeric > validation.maximum) return `Enter a value no greater than ${validation.maximum}.`; } return null; } function SelectOption({ id, label, description, recommended, selected, multiple, disabled, onClick, }: { id: string; label: string; description?: string; recommended?: boolean; selected: boolean; multiple: boolean; disabled: boolean; onClick: () => void; }) { return ( ); } export function QuestionResponseSummary({ questionSet, response, }: { questionSet: PaperclipQuestionSet; response: PaperclipQuestionResponse; }) { return (
{questionSet.questions.map((question) => { const answer = response.answers[question.id]; const selectedLabels = (answer?.selectedOptionIds ?? []).map( (optionId) => question.options?.find((option) => option.id === optionId)?.label ?? optionId, ); const values = [ ...selectedLabels, answer?.text, answer?.customText, ].filter((value): value is string => Boolean(value)); return (
{question.header ? ( {question.header} ) : null} {question.prompt}
{values.length > 0 ? values.join(", ") : "No answer"}
); })}
); } export function QuestionForm(props: QuestionFormProps) { // Takeovers can reuse this component for another pending request. Remount // before draft persistence runs so one request cannot overwrite another. return ; } function QuestionFormForRequest({ id, questionSet, initialResponse, implicitCustomAnswer = false, draftKey, disabled = false, imageUploadHandler, mentions, onSubmit, onCancel, }: QuestionFormProps) { const takeoverActions = useTaskChatComposerTakeoverActions(); // A provider default is an editable draft only. An existing response or // saved draft, including an explicitly cleared value, always wins. State // initialization runs once so request refreshes cannot replace user edits. const startingAnswers = initialResponse?.answers ?? Object.fromEntries( questionSet.questions .filter((question) => question.answerMode === "text" && question.initialText !== undefined) .map((question) => [question.id, { text: question.initialText! }]), ); const initialDraft = draftKey ? loadStructuredDraft<{ page: number; answers: Record; customActive: Record; }>(draftKey, { page: 0, answers: structuredClone(startingAnswers), customActive: Object.fromEntries( Object.entries(initialResponse?.answers ?? {}) .filter(([, answer]) => Boolean(answer.customText)) .map(([questionId]) => [questionId, true]), ), }) : null; const [page, setPage] = useState(initialDraft?.page ?? 0); const [answers, setAnswers] = useState>( () => initialDraft?.answers ?? structuredClone(startingAnswers), ); const [customActive, setCustomActive] = useState>( () => initialDraft?.customActive ?? Object.fromEntries( Object.entries(initialResponse?.answers ?? {}) .filter(([, answer]) => Boolean(answer.customText)) .map(([questionId]) => [questionId, true]), ), ); const [working, setWorking] = useState<"submit" | "cancel" | null>(null); const [inputUploading, setInputUploading] = useState(false); const [error, setError] = useState(null); const promptRef = useRef(null); const previousPage = useRef(page); useEffect(() => { if (previousPage.current !== page) { promptRef.current?.focus(); previousPage.current = page; } }, [page]); const [filters, setFilters] = useState>({}); useEffect(() => { setPage((current) => Math.min(current, Math.max(questionSet.questions.length - 1, 0)), ); }, [questionSet.questions.length]); useEffect(() => { if (draftKey) saveStructuredDraft(draftKey, { page, answers, customActive }); }, [answers, customActive, draftKey, page]); const question = questionSet.questions[page]; const validationErrors = useMemo( () => Object.fromEntries( questionSet.questions.map((candidate) => [ candidate.id, answerError(candidate, answers[candidate.id]), ]), ), [answers, questionSet.questions], ); const allValid = Object.values(validationErrors).every( (value) => value == null, ); if (!question) return (

No answerable questions were provided.

); const answer = answers[question.id] ?? {}; const selected = answer.selectedOptionIds ?? []; const multiple = question.answerMode === "multi_select"; const allowsCustom = question.answerMode !== "text" && (question.customAnswer?.enabled === true || implicitCustomAnswer); const isCustomActive = customActive[question.id] === true; const optionFilter = filters[question.id]?.trim().toLowerCase() ?? ""; const visibleOptions = (question.options ?? []).filter( (option) => !optionFilter || option.label.toLowerCase().includes(optionFilter) || option.description?.toLowerCase().includes(optionFilter), ); function updateAnswer(next: Answer) { setAnswers((current) => ({ ...current, [question.id]: next })); } function toggleOption(optionId: string) { if (disabled || working || inputUploading) return; const optionIds = multiple ? selected.includes(optionId) ? selected.filter((candidate) => candidate !== optionId) : [...selected, optionId] : [optionId]; const nextAnswer = { ...answer, selectedOptionIds: optionIds, ...(!multiple ? { customText: undefined } : {}), }; setAnswers({ ...answers, [question.id]: nextAnswer }); if (!multiple) { setCustomActive((current) => ({ ...current, [question.id]: false })); // Picking an option answers the question; it does not navigate. Moving on // stays an explicit act — Next, the pagination arrows, or Submit — so a // misclick never costs the reader the page they were still reading. // // Clear only the complaint this selection actually answers. A failed send // has to survive it, or the last page quietly loses the one sign that the // answers never left. setError((current) => (current?.fromMissingAnswer ? null : current)); } } function toggleCustom() { const active = !isCustomActive; setCustomActive((current) => ({ ...current, [question.id]: active })); updateAnswer({ ...answer, ...(!multiple && active ? { selectedOptionIds: [] } : {}), ...(active ? { customText: answer.customText ?? "" } : { customText: undefined }), }); } async function submit(responseAnswers: Record = answers) { if (disabled || working || inputUploading) return; const invalidIndex = questionSet.questions.findIndex( (candidate) => answerError(candidate, responseAnswers[candidate.id]) != null, ); if (invalidIndex >= 0) { // A required answer is missing: the pagination arrows browse without // validating, and a restored draft can land past it. Go back to that // question and say so rather than dropping the send. setPage(invalidIndex); setError({ message: `Question ${invalidIndex + 1} needs an answer before you can send.`, fromMissingAnswer: true, }); return; } setWorking("submit"); setError(null); try { await onSubmit({ schema: "paperclip.question_response.v1", answers: structuredClone(responseAnswers), }); if (draftKey) clearDraft(draftKey); } catch (cause) { setError({ message: cause instanceof Error ? cause.message : "The answers could not be submitted.", }); } finally { setWorking(null); } } async function cancel() { if (!onCancel || disabled || working || inputUploading) return; setWorking("cancel"); setError(null); try { await onCancel(); if (draftKey) clearDraft(draftKey); } catch (cause) { setError({ message: cause instanceof Error ? cause.message : "The questions could not be cancelled.", }); } finally { setWorking(null); } } const currentError = validationErrors[question.id]; const isLastPage = page === questionSet.questions.length - 1; const busy = disabled || working != null || inputUploading; // Cancel resolves the request when the host owns that; otherwise it just // closes the composer takeover so the user can type freely. const cancelAction = onCancel ? () => void cancel() : takeoverActions?.dismiss; /** Leaves the current question unanswered and moves on (or sends). */ function skipQuestion() { if (busy) return; const { [question.id]: _skipped, ...rest } = answers; setAnswers(rest); setCustomActive((current) => ({ ...current, [question.id]: false })); if (isLastPage) void submit(rest); else setPage(page + 1); } const pagination = questionSet.questions.length > 1 ? ( ) : null; function progressOrSubmit() { if (!isLastPage) { setPage((current) => Math.min(current + 1, questionSet.questions.length - 1), ); return; } void submit(); } return (
{ if ( disabled || working || event.repeat || question.answerMode === "text" || event.metaKey || event.ctrlKey || event.altKey ) return; const target = event.target as HTMLElement; if (target.matches("input, textarea, [contenteditable='true']")) return; const optionIndex = Number.parseInt(event.key, 10) - 1; const option = visibleOptions[optionIndex]; if (!option || optionIndex < 0 || optionIndex > 8) return; event.preventDefault(); toggleOption(option.id); }} > {questionSet.description ? (
{questionSet.description}
) : null} {question.answerMode === "text" ? (
{question.answerMode === "text" ? Write an answer : null}
) : null} {pagination ? ( takeoverActions ? ( {pagination} ) : (
{pagination}
) ) : null}
{question.header ? (

{question.header}

) : null}

{question.prompt}

{question.helpText ? (

{question.helpText}

) : null}
{question.answerMode === "text" ? (
updateAnswer({ text: value })} placeholder="Write your answer" imageUploadHandler={imageUploadHandler} mentions={mentions} autoFocus onUploadingChange={setInputUploading} onSubmit={() => { if (!inputUploading && currentError == null) progressOrSubmit(); }} attachAriaLabel={`Attach image to answer for ${question.prompt}`} />
) : (
{(question.options?.length ?? 0) > 8 ? ( ) : null} {visibleOptions.map((option) => ( toggleOption(option.id)} /> ))} {allowsCustom ? (
{isCustomActive ? ( updateAnswer({ ...answer, customText: value }) } autoFocus imageUploadHandler={imageUploadHandler} mentions={mentions} onUploadingChange={setInputUploading} onSubmit={() => { if (!inputUploading && currentError == null) progressOrSubmit(); }} attachAriaLabel={`Attach image to other answer for ${question.prompt}`} /> ) : null}
) : null}
)} {currentError && answerHasValue(answer) ? (

{currentError}

) : null}
{error ? (
{error.message}
) : null}
{cancelAction ? ( ) : null} {!question.required ? ( ) : null}
); }