/** * 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, 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 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 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?: () => void; 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; /** 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; 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; /** Rendered beside the heading; hosts use it for counts and provenance. */ aside?: 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]!; } 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 }: { question: Item; draft: Draft | undefined; disabled: boolean; name: string; onChange?: (change: Partial) => void; }, ) { 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 last row: a prompt to add an option, which becomes the field for it. * * Enter adds it for everyone, Escape cancels. The new row is shown straight * away, dimmed, until the server confirms it; a rejection reopens the field * with the text intact. */ function AddOption( { question, offset, disabled, onAdd, onFailed }: { 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; }, ) { let [text, setText] = useState(null); let [pending, setPending] = useState(null); let input = useRef(null); let trigger = useRef(null); let focus = useRef<"field" | "trigger">(undefined); let letterIndex = question.options.length + offset; 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) return; 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 (result.ok) { setText(null); focus.current = "trigger"; } else { onFailed(result.message); focus.current = "field"; } }; // Until the server confirms, the new row stands where the field was. If the // broadcast beat the acknowledgement, the real row is already listed above. if (pending !== null) { let known = question.options.some(option => option.label.trim().toLowerCase() === pending.toLowerCase() ); if (known) return null; return (
{letter(letterIndex)} {pending} Adding option
); } 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()) setText(null); }} onChange={event => { setText(event.currentTarget.value); onFailed(undefined); }} onKeyDown={event => { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); setText(null); onFailed(undefined); focus.current = "trigger"; } else if (event.key === "Enter" && !event.nativeEvent.isComposing) { event.preventDefault(); void add(); } }} placeholder="Add an option" ref={input} value={text} />
); } 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. * * Wraps whatever it is given rather than being a paragraph itself, because on a * resolved card what points into the plan is the question *and* its answer * together. Those used to be two adjacent, identically-labelled buttons — one * for what the question was about, one for what the answer produced — which the * agent anchored to overlapping prose, so the two led to the same block. * * `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, definition, resolver, places, onQuestionEnter, onQuestionLeave, onQuestionSelect, }: { answers: Answer[]; definition: Definition; resolver?: string; places?: QuestionViewProps["places"]; onQuestionEnter?: QuestionViewProps["onQuestionEnter"]; onQuestionLeave?: QuestionViewProps["onQuestionLeave"]; onQuestionSelect?: QuestionViewProps["onQuestionSelect"]; }, ) { return (
{answers.map((answer, index) => { let id = definition.questions[index]?.id; let chosen = answer.custom ?? (answer.choices ?? []).join(", "); return ( { /* The question and what was chosen are one decision, so they are one target: two stacked buttons led to the same prose. */ }

{answer.question}

{chosen}

); })} {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 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 => (

{question.question}

))}

{EXPIRED_NOTE}

); } export function QuestionView(props: QuestionViewProps) { let { definition, drafts, onChange, onSubmit, onCancel, onAddOption, disabled = false, submitting = false, status = "open", answers, resolver, collaborators = [], renderPeople, error, errorClassName, aside, places, onQuestionEnter, onQuestionLeave, onQuestionFocus, onQuestionSelect, renderStep, } = props; 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]); // 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" && answers) { return (
{single && } {aside}
); } 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, drafts[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.question}

{current.multiple &&

Choose any

}
person.question === current.id )} render={renderPeople} />
{current.header} onChange?.(current.id, change)} /> {drafts[current.id]?.mode === "custom" && drafts[current.id]!.custom.trim() && ( )} {current.options.length < MAX_SHARED_OPTIONS && ( onAddOption(current.id, label) : undefined} onFailed={setAddError} /> )}
); return renderStep ? renderStep({ children: panel, question: current.id }) : panel; })()} {addError && } {error && ( )} {(onSubmit || onCancel || multiple) && ( )}
); }