import { useCallback, useState } from "react"; import { ArrowRightIcon, CheckIcon, ChevronRightIcon, } from "@heroicons/react/20/solid"; import { QuestionType } from "@qltysh/fabro-api-client"; import type { ApiQuestion, InterviewOption, } from "@qltysh/fabro-api-client"; import { useSubmitInterviewAnswer, type SubmitInterviewAnswerArg, } from "../lib/mutations"; import { ApiError } from "../lib/api-client"; import { displayLabel } from "./interview-label"; import { ReviewTargetQuestion, safeReviewTarget, } from "./review-target-question"; import { DockComposer, RunDockShell, DOCK_CHOICE_BUTTON, DOCK_CHOICE_BUTTON_SELECTED, DOCK_HEADER_BUTTON, } from "./run-dock"; import { Spinner } from "./state"; import { ErrorMessage, PRIMARY_BUTTON_CLASS, } from "./ui"; /** * Options stack into a list once a label is long enough that a row of pills * would wrap mid-sentence. */ const STACK_LABEL_LENGTH = 40; /** Shared by the plain question text and the review target rendering. */ const QUESTION_TEXT = "max-w-[78ch] text-base/6 font-medium text-pretty text-fg"; type SubmitInterviewAnswer = SubmitInterviewAnswerArg["answer"]; export interface InterviewDockProps { runId: string; questions: ApiQuestion[]; } export function InterviewDock({ runId, questions }: InterviewDockProps) { const [activeIndex, setActiveIndex] = useState(0); const safeIndex = activeIndex < questions.length ? activeIndex : 0; const question = questions[safeIndex]; if (!question) return null; const moreCount = questions.length - 1; return ( // Keyed by question id, so a new question always arrives expanded with an // empty composer. A collapsed panel can never silently block a run. setActiveIndex((index) => (index + 1) % questions.length) } /> ); } function InterviewQuestionDock({ runId, question, moreCount, onCycle, }: { runId: string; question: ApiQuestion; moreCount: number; onCycle: () => void; }) { const submitMutation = useSubmitInterviewAnswer(runId); const [error, setError] = useState(null); const [collapsed, setCollapsed] = useState(false); const submitting = submitMutation.isMutating; const reviewTarget = safeReviewTarget(question.review_target); const submit = useCallback( async (answer: SubmitInterviewAnswer) => { setError(null); try { await submitMutation.trigger({ questionId: question.id, answer }); return true; } catch (caught) { setError(interviewSubmitErrorMessage(caught)); return false; } }, [question.id, submitMutation], ); return ( 0 && ( ) } body={ <> {reviewTarget ? ( ) : (

{question.text}

)} {question.context_display && ( )} } actions={ <> {error && } } /> ); } /** * Context arrives collapsed. It repeats material the operator has usually * already read in the stage stream above, so it earns a line rather than a * standing panel. */ function ContextPanel({ text }: { text: string }) { return (
{text}
); } /** First line of the context, for the collapsed summary. */ export function contextPreview(text: string): string { let lineStart = 0; while (lineStart < text.length) { const newline = text.indexOf("\n", lineStart); const lineEnd = newline === -1 ? text.length : newline; const line = text.slice(lineStart, lineEnd).trim(); if (line) { return line.length > 60 ? `${line.slice(0, 60).trimEnd()}…` : line; } if (newline === -1) break; lineStart = newline + 1; } return ""; } function QuestionBody({ question, submitting, onSubmit, }: { question: ApiQuestion; submitting: boolean; onSubmit: (answer: SubmitInterviewAnswer) => Promise; }) { switch (question.question_type) { case QuestionType.YES_NO: return ; case QuestionType.CONFIRMATION: return ; case QuestionType.MULTI_SELECT: return ( ); case QuestionType.MULTIPLE_CHOICE: return ( ); case QuestionType.FREEFORM: return ( ); default: return null; } } function YesNoBody({ submitting, onSubmit, }: { submitting: boolean; onSubmit: (answer: SubmitInterviewAnswer) => Promise; }) { return (
{/* react-doctor-disable-next-line react-doctor/design-no-vague-button-label -- Yes/no interview answers conventionally use the literal answer as the visible button label. */}
); } function ConfirmationBody({ submitting, onSubmit, }: { submitting: boolean; onSubmit: (answer: SubmitInterviewAnswer) => Promise; }) { return (
); } /** * Long labels wrap badly as pills, so they become a stacked list instead. */ export function shouldStackOptions(options: InterviewOption[]): boolean { return options.some( (option) => option.label.length > STACK_LABEL_LENGTH || Boolean(option.description), ); } function optionListClass(stacked: boolean): string { return stacked ? "flex flex-col items-stretch gap-2" : "flex flex-wrap items-center gap-2"; } function ChoiceBody({ options, allowFreeform, submitting, onSubmit, }: { options: InterviewOption[]; allowFreeform: boolean; submitting: boolean; onSubmit: (answer: SubmitInterviewAnswer) => Promise; }) { const stacked = shouldStackOptions(options); return (
{options.length > 0 && (
{options.map((option) => ( ))}
)} {allowFreeform && ( 0 ? "Or write a custom response…" : "Write your response…" } /> )}
); } function MultiSelectBody({ options, submitting, onSubmit, }: { options: InterviewOption[]; submitting: boolean; onSubmit: (answer: SubmitInterviewAnswer) => Promise; }) { const [selected, setSelected] = useState>(new Set()); function toggle(key: string) { setSelected((current) => { const next = new Set(current); if (next.has(key)) next.delete(key); else next.add(key); return next; }); } const selectedKeys: string[] = []; for (const option of options) { if (selected.has(option.key)) selectedKeys.push(option.key); } const stacked = shouldStackOptions(options); return (
{options.map((option) => { const isSelected = selected.has(option.key); const base = isSelected ? DOCK_CHOICE_BUTTON_SELECTED : DOCK_CHOICE_BUTTON; return ( ); })}

{selectedKeys.length} selected

); } function FreeformAnswer({ submitting, onSubmit, placeholder, }: { submitting: boolean; onSubmit: (answer: SubmitInterviewAnswer) => Promise; placeholder: string; }) { return ( onSubmit({ kind: "text", text })} placeholder={placeholder} submitLabel="Send" submitting={submitting} ariaLabel="Interview answer" /> ); } function OptionLabel({ option }: { option: InterviewOption }) { return ( {displayLabel(option.label)} {option.description && ( {option.description} )} ); } function interviewSubmitErrorMessage(error: unknown): string { if (error instanceof ApiError) { return error.requestId ? `${error.message} Request ID: ${error.requestId}` : error.message; } return error instanceof Error ? error.message : "Couldn't submit your answer."; }