mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-18 05:23:39 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agents ask structured questions in task threads and the composer. > - The shared form shows one question at a time. > - A single choice already completes an answer, but the form requires another click on Next. > - This pull request moves to the next question when the user selects one option. > - Multi-select and custom answers keep their Next step. The last page keeps explicit submission. ## Linked Issues or Issue Description **What existing behavior does this improve?** The paged question form in the task composer and task interaction cards. **Subsystem affected** ui/ — React board UI. **Current behavior** The user clicks a single choice, then clicks Next to reach the next question. **Proposed behavior** A single choice shows its checked state for 160 ms, then opens the next question with an 80 ms fade. Reduced-motion mode skips the animation. Multi-select stays on the current page until Next. Other stays open for typing. The last question waits for Submit answers. **Reason and benefit** Remove an extra click from each single-choice question while preserving explicit submission. **Breaking changes** Single-choice selection now changes the page. Answer payloads and APIs do not change. The user can return to earlier answers with the previous arrow. **Additional context** Related work: #12640 introduced the task workspace. No duplicate auto-advance change was found. ## What Changed - Confirm a single-choice answer with a brief radio animation and row highlight, then fade into the next page and focus the new question. - Use motion tokens for the 160 ms confirmation and 80 ms page fade. Honor reduced motion and cancel pending advances when the user changes direction or closes the form. - Lightly highlight every selected row with a foreground tint that remains visible against the composer in both themes, for single-select and multi-select answers. - Preserve multi-select, custom answers, back navigation, and final submission. - Ignore repeated number-key events and selection during an upload or submission. - Update composer and card tests. Add interactive and verified composer stories to the existing interaction Storybook group. - Document the Storybook scenario in the developer guide. ## Verification - Focused composer, card, and motion-catalog tests: 121 passed, including animation timing and cancellation. - `pnpm check:token-gates`: passed. - `pnpm build-storybook`: passed. Browser interaction story: passed with the selection animation enabled. - Selected-row refinement: 121 focused tests, token gates, and Storybook build passed. Browser inspection confirmed row highlighting for single-select and multiple selected checkboxes, plus light-theme contrast. - Manual browser check: select SQLite, select two features, click Next, select Now, then submit. The summary contains every answer. - `pnpm -r typecheck`: passed. - `pnpm build`: passed. - All 31 remote checks passed on the latest commit (`04a3f3983`), including typecheck, general and serialized tests, production build/native-runner verification, canary validation, and browser tests. Two optional Storybook deployment/visual jobs were skipped. Greptile reviewed the same commit at 5/5 with no actionable findings. The selected-row highlight refinement is included in that verification. - Animation refinement: UI typecheck, UI build, token gates, and 121 focused tests passed. - The broader UI run had 5,940 passing tests and three failures in unchanged Inbox/IssuesList tests. Both affected suites passed in isolation (73/73), including all three previously failing cases. - The broad local `pnpm test:run` was stopped after reproducing the native-runner failure below and after the full remote suite passed. It is not a clean local full-suite result. - Local runner limitation: `server/src/services/native-runtime/native-session-resume.test.ts` has one reproducible failure in unchanged code. The damaged-epoch recovery test expects `run.attach requires a settled Codex provider session`; the runner instead reports `semantic tool input content digest does not match its transmitted input` at line 1019. After building the missing fake provider with `pnpm --filter @paperclipai/paperclip-runner build:rust`, the isolated suite has 36 passing tests and this one failure. No server or runner files changed in this PR. ## Risks - Selecting a single choice changes the visible question after a brief checked-state confirmation. Back navigation preserves the choice so it can be edited. - The final page still requires Submit answers. Selecting Other still requires text and explicit progress. - No database, server, API, or dependency changes. ## Model Used - OpenAI GPT-6 in Codex, with reasoning, code editing, terminal tools, and browser verification. Exact serving revision and context window size are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [ ] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
732 lines
24 KiB
TypeScript
732 lines
24 KiB
TypeScript
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 { 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 { parseCssTimeMs } from "./motion-tokens";
|
|
import { matchSafeQuestionValidationPattern } from "./question-validation-pattern";
|
|
|
|
type Question = PaperclipQuestionSet["questions"][number];
|
|
type Answer = PaperclipQuestionResponse["answers"][string];
|
|
|
|
export interface QuestionFormProps {
|
|
id: string;
|
|
questionSet: PaperclipQuestionSet;
|
|
initialResponse?: PaperclipQuestionResponse | null;
|
|
implicitCustomAnswer?: boolean;
|
|
draftKey?: string;
|
|
disabled?: boolean;
|
|
imageUploadHandler?: (file: File) => Promise<string>;
|
|
mentions?: MentionOption[];
|
|
onSubmit: (response: PaperclipQuestionResponse) => void | Promise<void>;
|
|
/**
|
|
* 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<void>;
|
|
}
|
|
|
|
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,
|
|
confirming = false,
|
|
disabled,
|
|
onClick,
|
|
}: {
|
|
id: string;
|
|
label: string;
|
|
description?: string;
|
|
recommended?: boolean;
|
|
selected: boolean;
|
|
multiple: boolean;
|
|
confirming?: boolean;
|
|
disabled: boolean;
|
|
onClick: () => void;
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
id={id}
|
|
role={multiple ? "checkbox" : "radio"}
|
|
aria-checked={selected}
|
|
disabled={disabled}
|
|
className={cn(
|
|
"tc-question-option flex w-full items-start gap-2 rounded-md px-2.5 py-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-60",
|
|
selected
|
|
? "bg-foreground/5"
|
|
: recommended
|
|
? "bg-muted/50"
|
|
: "hover:bg-muted/40",
|
|
)}
|
|
data-selected={selected ? "true" : "false"}
|
|
data-recommended={recommended ? "true" : "false"}
|
|
onClick={onClick}
|
|
>
|
|
<span
|
|
aria-hidden
|
|
className={cn(
|
|
"mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center border",
|
|
multiple ? "rounded-sm" : "rounded-full",
|
|
confirming && "tc-question-choice-confirm",
|
|
selected
|
|
? "border-primary bg-primary text-primary-foreground"
|
|
: "border-muted-foreground/50",
|
|
)}
|
|
>
|
|
{selected ? (
|
|
multiple ? (
|
|
<Check className="h-3 w-3" />
|
|
) : (
|
|
<span className="h-1.5 w-1.5 rounded-full bg-current" />
|
|
)
|
|
) : null}
|
|
</span>
|
|
<span className="min-w-0 flex-1">
|
|
<span className="flex flex-wrap items-center gap-1.5 text-sm font-medium leading-5 text-foreground">
|
|
<span>{label}</span>
|
|
{recommended ? (
|
|
<span className="rounded-sm bg-background/70 px-1.5 py-0.5 text-(length:--text-micro) font-medium text-muted-foreground">
|
|
Recommended
|
|
</span>
|
|
) : null}
|
|
</span>
|
|
{description ? (
|
|
<span className="block text-xs leading-4 text-muted-foreground">
|
|
{description}
|
|
</span>
|
|
) : null}
|
|
</span>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
export function QuestionResponseSummary({
|
|
questionSet,
|
|
response,
|
|
}: {
|
|
questionSet: PaperclipQuestionSet;
|
|
response: PaperclipQuestionResponse;
|
|
}) {
|
|
return (
|
|
<dl className="grid gap-2 text-sm">
|
|
{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 (
|
|
<div key={question.id}>
|
|
<dt>
|
|
{question.header ? (
|
|
<span className="block text-xs font-medium text-muted-foreground">
|
|
{question.header}
|
|
</span>
|
|
) : null}
|
|
<span className="block text-sm text-foreground">
|
|
{question.prompt}
|
|
</span>
|
|
</dt>
|
|
<dd className="mt-0.5 text-foreground">
|
|
{values.length > 0 ? values.join(", ") : "No answer"}
|
|
</dd>
|
|
</div>
|
|
);
|
|
})}
|
|
</dl>
|
|
);
|
|
}
|
|
|
|
export function QuestionForm({
|
|
id,
|
|
questionSet,
|
|
initialResponse,
|
|
implicitCustomAnswer = false,
|
|
draftKey,
|
|
disabled = false,
|
|
imageUploadHandler,
|
|
mentions,
|
|
onSubmit,
|
|
onCancel,
|
|
}: QuestionFormProps) {
|
|
const takeoverActions = useTaskChatComposerTakeoverActions();
|
|
const initialDraft = draftKey
|
|
? loadStructuredDraft<{
|
|
page: number;
|
|
answers: Record<string, Answer>;
|
|
customActive: Record<string, boolean>;
|
|
}>(draftKey, {
|
|
page: 0,
|
|
answers: structuredClone(initialResponse?.answers ?? {}),
|
|
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<Record<string, Answer>>(
|
|
() =>
|
|
initialDraft?.answers ?? structuredClone(initialResponse?.answers ?? {}),
|
|
);
|
|
const [customActive, setCustomActive] = useState<Record<string, boolean>>(
|
|
() =>
|
|
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<string | null>(null);
|
|
const [pendingAdvance, setPendingAdvance] = useState<{
|
|
page: number;
|
|
questionId: string;
|
|
optionId: string;
|
|
} | null>(null);
|
|
const promptRef = useRef<HTMLParagraphElement>(null);
|
|
const previousPage = useRef(page);
|
|
|
|
useEffect(() => {
|
|
if (previousPage.current !== page) {
|
|
promptRef.current?.focus();
|
|
previousPage.current = page;
|
|
}
|
|
}, [page]);
|
|
|
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
|
|
|
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];
|
|
useEffect(() => {
|
|
if (!pendingAdvance) return;
|
|
if (
|
|
disabled ||
|
|
working ||
|
|
inputUploading ||
|
|
pendingAdvance.page !== page ||
|
|
pendingAdvance.questionId !== question?.id ||
|
|
page >= questionSet.questions.length - 1
|
|
) {
|
|
setPendingAdvance(null);
|
|
return;
|
|
}
|
|
const duration = getComputedStyle(document.documentElement)
|
|
.getPropertyValue("--motion-question-confirm")
|
|
.trim();
|
|
const durationMs = parseCssTimeMs(duration) || 0;
|
|
const advance = () => {
|
|
setPendingAdvance(null);
|
|
setPage(page + 1);
|
|
};
|
|
// With reduced motion (or without CSS), no visual hold is needed.
|
|
if (durationMs <= 0) {
|
|
advance();
|
|
return;
|
|
}
|
|
const timer = window.setTimeout(advance, durationMs);
|
|
return () => window.clearTimeout(timer);
|
|
}, [
|
|
pendingAdvance, page, question?.id, questionSet.questions.length,
|
|
disabled, working, inputUploading,
|
|
]);
|
|
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 (
|
|
<p className="text-sm text-muted-foreground">
|
|
No answerable questions were provided.
|
|
</p>
|
|
);
|
|
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 }));
|
|
// Briefly confirm the selected choice before moving on. The last page needs an
|
|
// explicit submit, and custom answers stay open for typing.
|
|
if (page < questionSet.questions.length - 1) {
|
|
setError(null);
|
|
setPendingAdvance({ page, questionId: question.id, optionId });
|
|
}
|
|
}
|
|
}
|
|
|
|
function toggleCustom() {
|
|
setPendingAdvance(null);
|
|
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<string, Answer> = 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(
|
|
`Question ${invalidIndex + 1} needs an answer before you can send.`,
|
|
);
|
|
return;
|
|
}
|
|
setWorking("submit");
|
|
setError(null);
|
|
try {
|
|
await onSubmit({
|
|
schema: "paperclip.question_response.v1",
|
|
answers: structuredClone(responseAnswers),
|
|
});
|
|
if (draftKey) clearDraft(draftKey);
|
|
} catch (cause) {
|
|
setError(
|
|
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(
|
|
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 ? (
|
|
<nav
|
|
className="flex shrink-0 items-center gap-1"
|
|
aria-label="Question pagination"
|
|
>
|
|
<Button
|
|
type="button"
|
|
size="icon-xs"
|
|
variant="ghost"
|
|
aria-label="Previous question"
|
|
disabled={disabled || working != null || page === 0}
|
|
onClick={() => setPage((current) => current - 1)}
|
|
>
|
|
<ChevronLeft aria-hidden />
|
|
</Button>
|
|
<span className="min-w-10 text-center tabular-nums">
|
|
{page + 1} of {questionSet.questions.length}
|
|
</span>
|
|
<Button
|
|
type="button"
|
|
size="icon-xs"
|
|
variant="ghost"
|
|
aria-label="Next question"
|
|
// The arrows browse; they do not validate. A send that finds an
|
|
// earlier answer missing returns to that question (see submit).
|
|
disabled={disabled || working != null || isLastPage}
|
|
onClick={() => setPage((current) => current + 1)}
|
|
>
|
|
<ChevronRight aria-hidden />
|
|
</Button>
|
|
</nav>
|
|
) : null;
|
|
|
|
function progressOrSubmit() {
|
|
if (!isLastPage) {
|
|
setPage((current) =>
|
|
Math.min(current + 1, questionSet.questions.length - 1),
|
|
);
|
|
return;
|
|
}
|
|
void submit();
|
|
}
|
|
return (
|
|
<div
|
|
key={question.id}
|
|
className="tc-question-page"
|
|
onKeyDown={(event) => {
|
|
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 ? (
|
|
<p className="mb-3 text-sm text-muted-foreground">
|
|
{questionSet.description}
|
|
</p>
|
|
) : null}
|
|
{question.answerMode === "text" ? (
|
|
<div className="mb-2 flex items-center gap-3 text-xs text-muted-foreground">
|
|
{question.answerMode === "text" ? <span>Write an answer</span> : null}
|
|
</div>
|
|
) : null}
|
|
{pagination ? (
|
|
takeoverActions ? (
|
|
<TaskChatComposerTakeoverControls>
|
|
{pagination}
|
|
</TaskChatComposerTakeoverControls>
|
|
) : (
|
|
<div className="mb-2 flex justify-end text-xs text-muted-foreground">
|
|
{pagination}
|
|
</div>
|
|
)
|
|
) : null}
|
|
<div>
|
|
{question.header ? (
|
|
<p className="mb-1 text-xs font-medium text-muted-foreground">
|
|
{question.header}
|
|
</p>
|
|
) : null}
|
|
<p
|
|
ref={promptRef}
|
|
tabIndex={-1}
|
|
id={`${id}-${question.id}-prompt`}
|
|
className="text-sm font-medium leading-5 text-foreground"
|
|
>
|
|
{question.prompt}
|
|
</p>
|
|
{question.helpText ? (
|
|
<p className="mt-1 text-xs leading-4 text-muted-foreground">
|
|
{question.helpText}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
{question.answerMode === "text" ? (
|
|
<div className="mt-3">
|
|
<TaskChatRichInput
|
|
ariaLabelledBy={`${id}-${question.id}-prompt`}
|
|
testId="question-text-answer-composer"
|
|
value={answer.text ?? ""}
|
|
disabled={disabled || working != null}
|
|
onChange={(value) => 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}`}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div
|
|
className="mt-3 grid gap-1.5"
|
|
role={multiple ? "group" : "radiogroup"}
|
|
aria-labelledby={`${id}-${question.id}-prompt`}
|
|
>
|
|
{(question.options?.length ?? 0) > 8 ? (
|
|
<label className="relative mb-1 block">
|
|
<Search
|
|
className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground"
|
|
aria-hidden
|
|
/>
|
|
<Input
|
|
value={filters[question.id] ?? ""}
|
|
onChange={(event) =>
|
|
setFilters((current) => ({
|
|
...current,
|
|
[question.id]: event.target.value,
|
|
}))
|
|
}
|
|
placeholder="Filter choices"
|
|
aria-label={`Filter choices for ${question.prompt}`}
|
|
className="pl-8"
|
|
/>
|
|
</label>
|
|
) : null}
|
|
{visibleOptions.map((option) => (
|
|
<SelectOption
|
|
key={option.id}
|
|
id={`${id}-${question.id}-${option.id}`}
|
|
label={option.label}
|
|
description={option.description}
|
|
recommended={option.recommended}
|
|
selected={selected.includes(option.id)}
|
|
confirming={pendingAdvance?.questionId === question.id && pendingAdvance.optionId === option.id}
|
|
multiple={multiple}
|
|
disabled={disabled || working != null}
|
|
onClick={() => toggleOption(option.id)}
|
|
/>
|
|
))}
|
|
{allowsCustom ? (
|
|
<div className="space-y-1.5">
|
|
<SelectOption
|
|
id={`${id}-${question.id}-custom`}
|
|
label={question.customAnswer?.label ?? "Other"}
|
|
selected={isCustomActive}
|
|
multiple={multiple}
|
|
disabled={disabled || working != null}
|
|
onClick={toggleCustom}
|
|
/>
|
|
{isCustomActive ? (
|
|
<TaskChatRichInput
|
|
ariaLabelledBy={`${id}-${question.id}-prompt`}
|
|
testId="question-other-answer-composer"
|
|
value={answer.customText ?? ""}
|
|
placeholder={
|
|
question.customAnswer?.placeholder ?? "Type your answer"
|
|
}
|
|
disabled={disabled || working != null}
|
|
onChange={(value) =>
|
|
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}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
{currentError && answerHasValue(answer) ? (
|
|
<p className="mt-2 text-xs text-destructive">{currentError}</p>
|
|
) : null}
|
|
<div aria-live="assertive">
|
|
{error ? (
|
|
<div className="mt-2 rounded-sm border border-destructive/60 bg-destructive/10 px-2.5 py-2 text-sm text-destructive">
|
|
{error}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
<div className="mt-3 flex flex-wrap items-center justify-end gap-2">
|
|
{cancelAction ? (
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="ghost"
|
|
disabled={busy}
|
|
onClick={cancelAction}
|
|
>
|
|
{working === "cancel" ? (
|
|
<Loader2 aria-hidden className="h-4 w-4 animate-spin" />
|
|
) : null}{" "}
|
|
Cancel
|
|
</Button>
|
|
) : null}
|
|
{!question.required ? (
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
disabled={busy}
|
|
onClick={skipQuestion}
|
|
>
|
|
Skip
|
|
</Button>
|
|
) : null}
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
disabled={busy || (isLastPage ? !allValid : currentError != null)}
|
|
onClick={progressOrSubmit}
|
|
>
|
|
{working === "submit" ? (
|
|
<Loader2 aria-hidden className="h-4 w-4 animate-spin" />
|
|
) : null}
|
|
{isLastPage ? (questionSet.submitLabel ?? "Submit answers") : "Next"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|