import { IconCheck, IconChevronDown, IconClipboardText, IconPencil, IconPlus, IconSend, IconTrash, } from "@tabler/icons-react"; import { useEffect, useState } from "react"; import { writeClipboardText } from "../../clipboard.js"; import { cn } from "../../utils.js"; import { defineBlock } from "../types.js"; import type { BlockReadProps, BlockEditProps, BlockRenderContext, NestedBlock, } from "../types.js"; import { questionFormSchema, questionFormMdx, visualQuestionsSchema, visualQuestionsMdx, type QuestionFormData, type QuestionFormOption, type QuestionFormQuestion, type QuestionMode, type VisualQuestionsData, } from "./question-form.config.js"; /** * Shared `question-form` and `visual-questions` blocks. A respondent-facing * intake form: single/multi/freeform questions, recommended options, optional * write-in answers, and optional inline wireframe/diagram previews per option. * Lives in core so any app can register it (it originated in the plan template). * * The block stays app-agnostic: * - It is shadcn-free. The "Send to agent" affordance uses `ctx.renderEditSurface` * (the app-provided popover primitive); when no surface is wired it falls back * to a plain button that submits directly. * - Submission routes through `ctx.onQuestionFormSubmit` so each app wires its own * destination (plan posts the summary into the side agent). The readable summary * string is built generically here from questions + collected answers. * - Per-option `wireframe`/`diagram` previews render through `ctx.renderBlock` * (the same nested-block seam tabs/columns use), so core never imports an app's * wireframe or diagram renderer. * - Colors map to shadcn theme tokens (`text-muted-foreground`, `border-border`, * `bg-background`, `bg-card`, `primary`). The root section carries BOTH the * app-neutral `an-questions-block` class and the legacy `plan-questions-block` * class so plan renders byte-identically while other apps get the theme treatment. */ /** * `ctx.onQuestionFormSubmit` is the documented submit hook. It is read off the * render context as an optional extra so a host that has not yet added it to its * provider degrades to a no-op (the button disables) rather than throwing. */ type QuestionFormSubmitCtx = BlockRenderContext & { onQuestionFormSubmit?: (summary: string) => void; }; /** * Reviewer answers are transient and never persisted on block data — they live * in local component state keyed by question id. `freeform` → a string; * `single`/`multi` → selected option ids (with an optional write-in `text`). */ type QuestionAnswer = { text?: string; selected?: string[] }; type QuestionAnswers = Record; type QuestionFormHandoffMode = "copy" | "submit"; type QuestionFormHandoff = { mode: QuestionFormHandoffMode; answered: number; total: number; }; function isAnswered( question: QuestionFormQuestion, answer?: QuestionAnswer, ): boolean { if (question.mode === "freeform") return Boolean(answer?.text?.trim()); return Boolean(answer?.selected?.length || answer?.text?.trim()); } /** * Build a readable, agent-ready summary string from the questions + collected * answers. Generic replacement for the plan-specific `summarizeQuestionForm`. */ function summarizeAnswers( blockId: string | undefined, blockTitle: string | undefined, questions: QuestionFormQuestion[], answers: QuestionAnswers, ): string { const lines = [ "Use these question answers to revise the plan:", blockId ? `Question block: ${blockId}` : "", blockTitle ? `Section: ${blockTitle}` : "", "", ].filter((line) => line !== ""); for (const question of questions) { const answer = answers[question.id]; const selectedLabels = question.options ?.filter((option) => answer?.selected?.includes(option.id)) .map((option) => option.label) ?? []; const other = answer?.text?.trim(); const value = question.mode === "freeform" ? other : [...selectedLabels, ...(other ? [`Other: ${other}`] : [])].join(", "); lines.push(`- ${question.title}: ${value || "No answer yet"}`); } return lines.join("\n"); } /** Render an inline preview (wireframe or diagram) through the app's dispatcher. */ function OptionVisual({ type, data, blockId, ctx, }: { type: "wireframe" | "diagram"; data: unknown; blockId: string; ctx: BlockRenderContext; }) { if (!data || !ctx.renderBlock) return null; const block: NestedBlock = { id: `${blockId}-${type}`, type, data, }; return ( <>{ctx.renderBlock({ block, editing: false, compactVisuals: true })} ); } function QuestionView({ question, index, answer, blockId, ctx, onAnswer, }: { question: QuestionFormQuestion; index: number; answer?: QuestionAnswer; blockId: string; ctx: BlockRenderContext; onAnswer: (answer: QuestionAnswer) => void; }) { const selected = answer?.selected ?? []; const hasVisualOptions = Boolean( question.options?.some((option) => option.wireframe || option.diagram), ); return (
{index + 1}

{question.title}

{question.subtitle && (

{question.subtitle}

)} {question.mode === "freeform" ? (