/** * The questionnaire surface. * * Transport-free by design: it takes a definition, the current draft and a set * of callbacks. That is what lets the same view serve the chat card, where the * draft is a live CRDT, and a plan widget, where it is a resolved record. * * Everyone edits one shared draft, so controls reflect other people's choices * as they arrive rather than tracking local state. */ import { useEffect, useId, useReducer, useRef, useState } from "react"; import { CheckIcon, ChevronIcon, DecisionIcon, PlusIcon, WarningIcon } from "@chopin/icons"; import { INPUT_EXPIRY_MS, MAX_LABEL, MAX_SHARED_OPTIONS } from "../limits"; import { answered } from "../draft"; import { InlineCode } from "./inline-code"; import { plainInlineList, plainInlineText } from "./inline-segments"; import { projectSuggestion, reduceSuggestionEditState } from "./project-suggestion"; import { cardRelation, RelationNote } from "./relation-note"; import { ResolvedActions } from "./resolved-actions"; import type { VisibleSuggestion } from "./project-suggestion"; import type { Relation } from "../relation"; import type { ReactNode } from "react"; import type { Question } from "@chopin/protocol"; import type { Draft, Drafts } from "../draft"; import type { Answer, Definition, Item } from "../schema"; export type Collaborator = { /** Stable per connection, so one person on two devices shows twice. */ client: string; handle: string; question?: string; }; export type AddOptionResult = { ok: true } | { ok: false; message: string }; export type QuestionStepRenderProps = { children: ReactNode; question: string; }; export type PreviousAnswer = { labels: string[]; by: string }; export type QuestionViewProps = { definition: Definition; drafts: Drafts; /** Absent once resolved: a decision is not re-opened, a new question is asked. */ onChange?: (question: string, change: Partial) => void; onSubmit?: (visibleSuggestion?: VisibleSuggestion) => void; suggested?: VisibleSuggestion; onDiscard?: () => void; onReopen?: () => void; previous?: PreviousAnswer | Record; refining?: boolean; showActions?: boolean; onCancel?: () => void; /** * Append an option for everyone. Absent where the viewer may not write; the * row is then shown disabled. Resolves once the server has made it durable. */ onAddOption?: (question: string, label: string) => Promise; disabled?: boolean; submitting?: boolean; status?: "open" | Question.Status | "discarded"; /** Shown instead of controls once the questionnaire has resolved. */ answers?: Answer[]; resolver?: string; onQuestionEnter?: (question: string) => void; onQuestionLeave?: (question: string) => void; /** Focus entered the open question (its id) or left it (undefined): presence for others. */ onQuestionFocus?: (question: string | undefined) => void; /** Goes to the prose the decision lives in. */ onQuestionSelect?: (question: string) => void; /** * How many passages each decision lives in, by question. * * Absent where nothing links the two — the chat card, or a decision still * waiting to be anchored. Without a destination the text stays inert prose * rather than advertising a jump that would do nothing. */ places?: Record; /** Whether each answered decision is linked, pending, deliberately empty or orphaned. */ relations?: Record; /** What a pending relationship is waiting on, when the host knows better than "Linking…". */ pendingRelation?: string; collaborators?: Collaborator[]; /** * Replaces the `@handle` pills for people on the current question. The view * cannot draw faces itself, so the host supplies them. */ renderPeople?: (people: Collaborator[]) => ReactNode; /** Validation or synchronisation problem, announced to assistive tech. */ error?: string; /** Host-owned presentation class for an error entering the view. */ errorClassName?: string; /** Host metadata, shown before an open question or below answered choices. */ aside?: ReactNode; /** Controls in the trailing header, beside question-specific presence. */ headerActions?: ReactNode; /** Lets a host retain bounded steps for presentation without owning question state. */ renderStep?: (props: QuestionStepRenderProps) => ReactNode; }; export function currentQuestion( definition: Definition, active: string | undefined, ): Item { return definition.questions.find(question => question.id === active) ?? definition.questions[0]!; } /** Find a previous decision without applying one question's answer to another. */ export function previousFor( previous: QuestionViewProps["previous"], question: string, ): PreviousAnswer | undefined { if (!previous) return undefined; if (Array.isArray((previous as PreviousAnswer).labels)) return previous as PreviousAnswer; return (previous as Record)[question]; } function Badges({ people }: { people: Collaborator[] }) { if (people.length === 0) return null; return ( {people.map(person => ( @{person.handle} ))} ); } function Presence( { people, render }: { people: Collaborator[]; render?: (people: Collaborator[]) => ReactNode }, ) { return render ? render(people) : ; } function DecisionHeading() { return (
); } function letter(index: number): string { return String.fromCharCode(65 + index); } /** A line-tall slot, so the tile centres on the label's first line, not the whole row. */ function Key({ children }: { children: ReactNode }) { return ( ); } function Choices( { question, draft, disabled, name, onChange, suggestedOptionId }: { question: Item; draft: Draft | undefined; disabled: boolean; name: string; onChange?: (change: Partial) => void; suggestedOptionId?: string; }, ) { let custom = draft?.mode === "custom"; return ( <> {question.options.map((option, index) => { let selected = question.multiple ? !!draft?.options[option.id] : draft?.choice === option.id; return ( ); })} ); } /** * An open draft that was already in free-text mode before options became * shared. Shown as the selected row it was; choosing any option leaves it, and * nothing new can enter this mode. */ function LegacyCustom( { question, draft, name }: { question: Item; draft: Draft; name: string }, ) { return ( ); } /** The existing option a typed label would repeat, matched like the server does. */ export function duplicateOf(question: Item, label: string) { let key = label.toLowerCase(); return question.options.find(option => option.label.trim().toLowerCase() === key); } /** * The last row: a prompt to add an option, which becomes the field for it. * * Enter adds it for everyone, Escape closes the field. While confirmation is * pending the field stays focusable so Escape still works; a rejection keeps * the text intact. */ function AddOption( { question, offset, disabled, onAdd, onFailed, onEdit, onCancelEdit, onCommitEdit }: { question: Item; /** Rows already shown below the options, such as a legacy custom answer. */ offset: number; disabled: boolean; onAdd?: (label: string) => Promise; onFailed: (message: string | undefined) => void; onEdit?: () => void; onCancelEdit?: () => void; onCommitEdit?: () => void; }, ) { let [text, setText] = useState(null); let [pending, setPending] = useState(null); let hintId = useId(); let input = useRef(null); let trigger = useRef(null); let focus = useRef<"field" | "trigger">(undefined); let edit = useRef(0); let letterIndex = question.options.length + offset; let duplicate = text ? duplicateOf(question, text.trim()) : undefined; useEffect(() => () => { edit.current++; }, []); useEffect(() => { let target = focus.current; focus.current = undefined; if (target === "field") input.current?.focus(); else if (target === "trigger") trigger.current?.focus(); }); useEffect(() => { let viewport = window.visualViewport; if (!viewport) return; let height = viewport.height; let reveal = () => { let previous = height; height = viewport.height; let control = input.current; if (height >= previous || document.activeElement !== control || !control) return; let bounds = control.getBoundingClientRect(); let top = viewport.offsetTop; let bottom = top + viewport.height; if (bounds.top >= top && bounds.bottom <= bottom) return; requestAnimationFrame(() => control.scrollIntoView({ block: "nearest" })); }; viewport.addEventListener("resize", reveal); return () => viewport.removeEventListener("resize", reveal); }, []); let add = async () => { let label = text?.trim(); if (!label || !onAdd || pending !== null || duplicateOf(question, label)) return; let submittedEdit = edit.current; setPending(label); onFailed(undefined); let result: AddOptionResult; try { result = await onAdd(label); } catch { result = { ok: false, message: "Could not add this option." }; } setPending(null); if (edit.current !== submittedEdit) return; let ownsFocus = document.activeElement === input.current; if (result.ok) { onCommitEdit?.(); setText(null); if (ownsFocus) focus.current = "trigger"; } else { onFailed(result.message); if (ownsFocus) focus.current = "field"; } }; if (text === null) { return ( ); } return (
{letter(letterIndex)} { // Only an empty field collapses by itself. Typed text is kept, because // adding an option is visible to everyone and should be deliberate. if (!text.trim() && pending === null) { edit.current++; onCancelEdit?.(); setText(null); } }} onChange={event => { onEdit?.(); setText(event.currentTarget.value); onFailed(undefined); }} onKeyDown={event => { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); edit.current++; onCancelEdit?.(); setText(null); onFailed(undefined); focus.current = "trigger"; } else if (event.key === "Enter" && !event.nativeEvent.isComposing) { event.preventDefault(); void add(); } }} placeholder="Add an option" readOnly={pending !== null} ref={input} value={text} /> {pending !== null && Adding option}

{duplicate ? `Already an option: ${duplicate.label}` : ""}

); } const LINK = "flex w-full cursor-pointer items-start justify-between gap-2 rounded-sm text-left"; /** * Something that may refer to somewhere else. * * Rendered as a button only when there is somewhere to go, so a real element * carries the affordance and the keyboard handling rather than text pretending * to be one. Unlinked, it takes no tab stop and offers no focus ring it could * never show. * * On resolved cards, only the heading is linked. Static answer rows stay * outside the button, leaving one destination per question. * * `label` is the plain-text reading of the children. It is composed into the * accessible name rather than replacing it: the decision is what the button is * for, and a name saying only where it goes would take it away from anybody who * cannot see it. */ function Related( { id, count, label, className, children, inline, onEnter, onLeave, onSelect }: { id: string | undefined; count: number; label: string; className: string; children: ReactNode; /** Sits inside a heading, so the unlinked form must be phrasing content. */ inline?: boolean; onEnter?: QuestionViewProps["onQuestionEnter"]; onLeave?: QuestionViewProps["onQuestionLeave"]; onSelect?: QuestionViewProps["onQuestionSelect"]; }, ) { if (!id || count === 0) { return inline ? {children} :
{children}
; } return ( ); } function Resolved( { answers, aside, definition, resolver, places, onQuestionEnter, onQuestionLeave, onQuestionSelect, }: { answers: Answer[]; aside?: ReactNode; definition: Definition; resolver?: string; places?: QuestionViewProps["places"]; onQuestionEnter?: QuestionViewProps["onQuestionEnter"]; onQuestionLeave?: QuestionViewProps["onQuestionLeave"]; onQuestionSelect?: QuestionViewProps["onQuestionSelect"]; }, ) { return (
{answers.map((answer, index) => { let question = definition.questions[index]; let id = question?.id; let options = question?.options ?? []; let chosenIds = answer.optionIds === undefined ? undefined : new Set(answer.optionIds); let chosenLabels = new Set(answer.choices ?? []); let selected = options.filter(option => chosenIds ? chosenIds.has(option.id) : chosenLabels.has(option.label) ); let matchedLabels = new Set(selected.map(option => option.label)); let unmatched = answer.custom ? [answer.custom] : (answer.choices ?? []).filter(label => !matchedLabels.has(label)); return (

{options.map((option, optionIndex) => { let isSelected = selected.includes(option); return (
{letter(optionIndex)} {isSelected && {"Selected: "}} {option.description && ( )}
); })} {unmatched.map((label, unmatchedIndex) => (
{letter(options.length + unmatchedIndex)} {"Selected: "}
))}
); })}
{aside ?
{aside}
: resolver &&

Answered by @{resolver}

}
); } /** A question nobody answered. There is nothing to show but who ended it. */ function Cancelled({ resolver }: { resolver?: string }) { return (

{resolver && resolver !== "system" ? `Cancelled by @${resolver}` : "Cancelled — the question was never answered."}

); } function Discarded({ definition, resolver }: { definition: Definition; resolver?: string }) { let questions = definition.questions.map(question => question.question).join(", "); let discarded = resolver && resolver !== "system" ? `Discarded by @${resolver}` : "Discarded"; return (

{discarded} {questions ? ` — ${questions}` : ""}

); } function Callout( { feedback, message, title }: { feedback?: string; message: string; title: string }, ) { return (

{title} {message}

); } const EXPIRED_NOTE = `Nobody answered within ${ INPUT_EXPIRY_MS / 60_000 } minutes. The Planner will use its best judgement for this decision.`; /** * Host input whose time ran out. Unlike a cancelled card it stays in the * document, so it keeps what was asked beside the note saying nobody answered. */ function Expired({ definition }: { definition: Definition }) { return (
{definition.questions.map(question => (

))}

{EXPIRED_NOTE}

); } export function QuestionView(props: QuestionViewProps) { let { definition, drafts, onChange, onSubmit, onCancel, onDiscard, onReopen, suggested, previous: priorAnswers, refining, showActions = false, onAddOption, disabled = false, submitting = false, status = "open", answers, resolver, collaborators = [], renderPeople, error, errorClassName, aside, headerActions, places, relations, pendingRelation, onQuestionEnter, onQuestionLeave, onQuestionFocus, onQuestionSelect, renderStep, } = props; let [suggestionEdits, dispatchSuggestionEdit] = useReducer(reduceSuggestionEditState, { answer: false, composer: false, suggestionPresent: false, suggestionGeneration: 0, }); useEffect(() => { // A missing suggestion starts a new lifecycle; revision changes stay in the same one. dispatchSuggestionEdit({ type: suggested ? "suggestion-visible" : "suggestion-cleared" }); }, [suggested]); let markHumanEdit = () => { if (suggested) dispatchSuggestionEdit({ type: "answer-edited" }); }; let markComposerEdit = () => { dispatchSuggestionEdit({ type: "composer-edited" }); }; let cancelComposerEdit = () => dispatchSuggestionEdit({ type: "composer-cancelled" }); let commitComposerEdit = () => dispatchSuggestionEdit({ type: "composer-committed" }); let base = useId(); let single = definition.questions.length === 1; let [selected, setActive] = useState(() => definition.questions[0]?.id); let current = currentQuestion(definition, selected); let active = current.id; let panelId = `${base}-panel-${active}`; if (active !== selected) setActive(active); // Discarding cannot be undone and the agent is waiting, so it takes a // second, deliberate click rather than a modal nobody reads. let [confirming, setConfirming] = useState(false); // Presence follows the current question and ends when it changes or the card goes. let focusing = useRef(onQuestionFocus); focusing.current = onQuestionFocus; useEffect(() => () => focusing.current?.(undefined), [active]); let [addError, setAddError] = useState(); let previous = useRef(null); let next = useRef(null); let primary = useRef(null); let refocus = useRef<"previous" | "next" | "primary">(undefined); // At either end the activated caret disables. Keep focus inside the stepper // so the new question is reached instead of dropping to the body. useEffect(() => { let target = refocus.current; refocus.current = undefined; if (target === "previous") previous.current?.focus(); else if (target === "next") next.current?.focus(); else if (target === "primary") primary.current?.focus(); }, [active]); let projection = single ? projectSuggestion( current, drafts[current.id], suggested, suggestionEdits.answer || suggestionEdits.composer, ) : { draft: drafts[current.id] }; if (status === "discarded") { return (
{single && } {aside}
); } // A cancelled or expired questionnaire has no answers, so it must be matched // on status alone — falling through would offer an editable form for a dead // question. if (status === "cancelled" || status === "expired") { return (
{single && } {aside} {status === "expired" ? : }
); } if (status !== "open") { let related = answers ? cardRelation(relations, definition.questions.map(question => question.id)) : undefined; let relationNote = related && ( ); return (
{answers ? ( ) : (

Saved decision

{aside &&
{aside}
}
)} {error && }
); } let multiple = !single; let index = definition.questions.findIndex(question => question.id === active); let total = definition.questions.length; let last = index === total - 1; // Nothing chosen, nothing to save or move on with. Read-only hosts have no // drafts to fill, so they keep free navigation. let ready = answered(current, projection.draft); let discard = onDiscard ?? onCancel; let previousAnswer = previousFor(priorAnswers, current.id); let step = (offset: number, from?: "primary") => { let question = definition.questions[index + offset]; if (!question) return; setActive(question.id); setAddError(undefined); let arrived = index + offset; if (from) refocus.current = "primary"; else if (arrived === 0) refocus.current = "next"; else if (arrived === total - 1) refocus.current = "previous"; }; let titleId = `${base}-title-${active}`; return (
{aside} {(() => { let panel = (
onQuestionEnter?.(current.id)} onMouseLeave={event => !event.currentTarget.contains(document.activeElement) && onQuestionLeave?.(current.id)} onFocusCapture={() => { onQuestionEnter?.(current.id); onQuestionFocus?.(current.id); }} onBlurCapture={event => { if (event.currentTarget.contains(event.relatedTarget)) { return; } onQuestionFocus?.(undefined); if (!event.currentTarget.matches(":hover")) { onQuestionLeave?.(current.id); } }} >

{current.multiple &&

Choose any

} {previousAnswer && (

Previously: {plainInlineList(previousAnswer.labels)} · @{previousAnswer.by}

)} {refining &&

Chopin is refining…

}
person.question === current.id )} render={renderPeople} /> {headerActions}
{current.header} { markHumanEdit(); onChange?.(current.id, change); }} suggestedOptionId={projection.suggestion?.optionId} /> {drafts[current.id]?.mode === "custom" && drafts[current.id]!.custom.trim() && ( )} {onAddOption && current.options.length < MAX_SHARED_OPTIONS && ( onAddOption(current.id, label)} onFailed={setAddError} onEdit={markComposerEdit} onCancelEdit={cancelComposerEdit} onCommitEdit={commitComposerEdit} /> )}
); return renderStep ? renderStep({ children: panel, question: current.id }) : panel; })()} {addError && } {error && ( )} {(onSubmit || discard || multiple || showActions) && ( )}
); }