import { IconCheck, IconChevronRight, IconUpload, IconX, } from "@tabler/icons-react"; import { keepPreviousData, useQuery, useQueryClient, } from "@tanstack/react-query"; import { useCallback, useEffect, useMemo, useState } from "react"; import { sendToAgentChat } from "./agent-chat.js"; import { agentNativePath } from "./api-path.js"; import { setClientAppState } from "./application-state.js"; import { useChangeVersions } from "./use-change-version.js"; import { cn } from "./utils.js"; export type GuidedQuestionType = | "text-options" | "color-options" | "slider" | "file" | "freeform"; export interface GuidedQuestionOption { label: string; value: string; color?: string; icon?: string; description?: string; recommended?: boolean; /** Optional preview content (mockup, code snippet, or short comparison) * shown beneath the option to help the user compare choices. Mirrors the * `preview` field of Claude Code's AskUserQuestion options. */ preview?: string; } export interface GuidedQuestion { id: string; type: GuidedQuestionType; header?: string; question: string; description?: string; options?: GuidedQuestionOption[]; choices?: GuidedQuestionOption[]; multiSelect?: boolean; min?: number; max?: number; step?: number; required?: boolean; placeholder?: string; allowOther?: boolean; includeExplore?: boolean; includeDecide?: boolean; /** Submit immediately when a single-select option is clicked. */ submitOnSelect?: boolean; } export type GuidedQuestionAnswers = Record; export interface GuidedQuestionPayload { questions: GuidedQuestion[]; title?: string; description?: string; skipLabel?: string; submitLabel?: string; submitMessage?: string; skipMessage?: string; /** * @internal Set by {@link askUserQuestion} for client-initiated questions. * When present, `useGuidedQuestionFlow` resolves the matching in-memory * promise with the answer instead of forwarding it to the agent chat. */ clientResolveId?: string; } const OTHER_OPTION_PREFIX = "__other__:"; const EXPLORE_OPTION: GuidedQuestionOption = { label: "Explore a few options", value: "__explore__", description: "Show me a few distinct directions before committing.", }; const DECIDE_OPTION: GuidedQuestionOption = { label: "Decide for me", value: "__decide__", description: "Use your judgment and keep moving.", }; function isFileLike(value: unknown): value is { name: string } { return ( typeof value === "object" && value !== null && "name" in value && typeof (value as { name?: unknown }).name === "string" ); } export function isOtherGuidedAnswer(value: unknown): value is string { return typeof value === "string" && value.startsWith(OTHER_OPTION_PREFIX); } export function getOtherGuidedAnswerText(value: unknown): string { return isOtherGuidedAnswer(value) ? value.slice(OTHER_OPTION_PREFIX.length) : ""; } export function makeOtherGuidedAnswer(text = ""): string { return `${OTHER_OPTION_PREFIX}${text}`; } export function hasGuidedAnswer(value: unknown): boolean { if (value == null || value === "") return false; if (Array.isArray(value)) return value.some(hasGuidedAnswer); if (isOtherGuidedAnswer(value)) { return getOtherGuidedAnswerText(value).trim().length > 0; } return true; } export function formatGuidedAnswerValue(value: unknown): unknown { if (Array.isArray(value)) { return value.map(formatGuidedAnswerValue).filter(hasGuidedAnswer); } if (isOtherGuidedAnswer(value)) { const text = getOtherGuidedAnswerText(value).trim(); return text ? `Other: ${text}` : ""; } if (isFileLike(value)) return value.name; return value; } export function normalizeGuidedAnswers( answers: GuidedQuestionAnswers, ): GuidedQuestionAnswers { return Object.fromEntries( Object.entries(answers).map(([id, value]) => [ id, formatGuidedAnswerValue(value), ]), ); } export function formatGuidedAnswersForAgent( answers: GuidedQuestionAnswers, ): string { return Object.entries(normalizeGuidedAnswers(answers)) .filter(([, value]) => hasGuidedAnswer(value)) .map(([id, value]) => { if (Array.isArray(value)) return `${id}: ${value.join(", ")}`; return `${id}: ${String(value)}`; }) .join("\n"); } function defaultGuidedSubmitContext(formattedAnswers: string): string { return [ "The user answered the guided questions.", "Use the selected option values below as authoritative. If an answer includes exact ids, file names, or action instructions, follow those exact details instead of inferring them.", "", "Answers:", formattedAnswers, ].join("\n"); } /** A single option for {@link askUserQuestion}. Mirrors the agent `ask-question` * tool and Claude Code's AskUserQuestion option shape. */ export interface AskUserQuestionOption { /** Display text the user picks (1-5 words). */ label: string; /** Value reported back. Defaults to `label` when omitted. */ value?: string; /** Short explanation of the trade-off. */ description?: string; /** Optional preview (mockup, code snippet, short comparison) shown under the option. */ preview?: string; /** Mark the most likely option so the UI highlights it. */ recommended?: boolean; } /** Input for {@link askUserQuestion}. */ export interface AskUserQuestionInput { /** The complete question. Clear, specific, ends with a question mark. */ question: string; /** Optional very short chip/heading (≈12 chars), e.g. "Date range". */ header?: string; /** 2-4 distinct options (mutually exclusive unless `allowMultiple`). */ options: AskUserQuestionOption[]; /** Allow a free-text "Other" answer. Default `true`. */ allowFreeText?: boolean; /** Allow selecting more than one option (multi-select). Default `false`. */ allowMultiple?: boolean; /** Application-state key the agent panel polls. Default `"guided-questions"`. */ stateKey?: string; } const GUIDED_QUESTIONS_STATE_KEY = "guided-questions"; /** The user's answer to an {@link askUserQuestion}: the selected option * value(s), the free-text "Other" string, or `null` if the user skipped. */ export type AskUserQuestionResult = string | string[] | null; // In-memory resolver registry shared between `askUserQuestion` (which registers // a resolver and writes the question to application state) and // `useGuidedQuestionFlow` (which renders the question and, on submit/skip, // resolves the matching promise). Same module → the map is shared. type AskQuestionResolver = (answer: AskUserQuestionResult) => void; const clientQuestionResolvers = new Map(); let askQuestionCounter = 0; function nextClientResolveId(): string { askQuestionCounter += 1; const rand = typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(36).slice(2)}`; return `askq-${askQuestionCounter}-${rand}`; } /** Resolve a pending client-initiated question by id. Returns false when no * resolver is registered (e.g. an agent-initiated question, or a reload). */ function resolveClientQuestion( id: string, answer: AskUserQuestionResult, ): boolean { const resolver = clientQuestionResolvers.get(id); if (!resolver) return false; clientQuestionResolvers.delete(id); resolver(answer); return true; } /** Pull the answer for a single guided question out of the answers map, * normalizing "Other" free-text and multi-select arrays. */ function extractSingleAnswer( answers: GuidedQuestionAnswers, questionId: string, ): AskUserQuestionResult { const raw = answers[questionId]; if (!hasGuidedAnswer(raw)) return null; if (Array.isArray(raw)) { const values = raw .map((v) => isOtherGuidedAnswer(v) ? getOtherGuidedAnswerText(v).trim() : String(v), ) .filter((s) => s.length > 0); return values.length ? values : null; } if (isOtherGuidedAnswer(raw)) { const text = getOtherGuidedAnswerText(raw).trim(); return text.length ? text : null; } return String(raw); } /** * Ask the user a multiple-choice question from app code and render it inline in * the agent panel — the client-side twin of the agent's `ask-question` tool. * * The question is written to application state (`"guided-questions"` by * default), where the mounted `GuidedQuestionFlow` (driven by * {@link useGuidedQuestionFlow}) renders it, and the agent panel is revealed so * it's visible. **Resolves with the user's answer** — the selected option * value (or `value[]` when `allowMultiple`), the free-text "Other" string, or * `null` if they skip — so the caller can branch on it (e.g. build the right * generate prompt before kicking off agent work): * * ```ts * const length = await askUserQuestion({ * question: "How long should this deck be?", * header: "Deck length", * options: [{ label: "Short", recommended: true }, { label: "Long" }], * }); * if (length) sendToAgentChat({ message: `Make a ${length} deck`, submit: true }); * ``` * * Requires the agent panel (the mounted `GuidedQuestionFlow`) to exist, which * it does in every template. The returned promise stays pending until the user * answers or skips. */ export async function askUserQuestion( input: AskUserQuestionInput, ): Promise { const question = String(input?.question ?? "").trim(); if (!question) { throw new TypeError("askUserQuestion: `question` is required."); } const header = typeof input.header === "string" && input.header.trim() ? input.header.trim() : undefined; const allowMultiple = input.allowMultiple === true; const allowFreeText = input.allowFreeText !== false; const options: GuidedQuestionOption[] = ( Array.isArray(input.options) ? input.options : [] ) .map((raw): GuidedQuestionOption | null => { const label = typeof raw?.label === "string" && raw.label.trim() ? raw.label.trim() : typeof raw?.value === "string" ? String(raw.value).trim() : ""; if (!label) return null; const value = typeof raw?.value === "string" && raw.value.trim() ? raw.value.trim() : label; const option: GuidedQuestionOption = { label, value }; if (typeof raw.description === "string" && raw.description.trim()) { option.description = raw.description.trim(); } if (typeof raw.preview === "string" && raw.preview.trim()) { option.preview = raw.preview; } if (raw.recommended === true) option.recommended = true; return option; }) .filter((opt): opt is GuidedQuestionOption => opt !== null); if (options.length === 0) { throw new TypeError( "askUserQuestion: `options` must contain at least one option with a label.", ); } const resolveId = nextClientResolveId(); const payload: GuidedQuestionPayload = { clientResolveId: resolveId, questions: [ { id: "q1", type: "text-options", question, ...(header ? { header } : {}), required: !allowFreeText, multiSelect: allowMultiple, allowOther: allowFreeText, includeExplore: false, includeDecide: false, options, }, ], }; const answerPromise = new Promise((resolve) => { clientQuestionResolvers.set(resolveId, resolve); }); // Reveal the agent panel so the inline question is visible even if collapsed. if (typeof window !== "undefined") { try { window.dispatchEvent(new CustomEvent("agent-panel:open")); } catch { // best-effort — the question still renders if the panel is already open } } try { await setClientAppState( input.stateKey ?? GUIDED_QUESTIONS_STATE_KEY, payload, ); } catch (err) { clientQuestionResolvers.delete(resolveId); throw err; } return answerPromise; } function optionKey(option: GuidedQuestionOption): string { return `${option.value.toLowerCase()}::${option.label.toLowerCase()}`; } /** Stable content hash so poll refreshes do not reset in-progress answers. */ export function guidedQuestionsFingerprint( questions: GuidedQuestion[], ): string { return JSON.stringify( questions.map((question) => ({ id: question.id, type: question.type, header: question.header ?? null, question: question.question, description: question.description ?? null, multiSelect: question.multiSelect ?? false, required: question.required ?? false, allowOther: question.allowOther ?? null, includeExplore: question.includeExplore ?? null, includeDecide: question.includeDecide ?? null, submitOnSelect: question.submitOnSelect ?? false, min: question.min ?? null, max: question.max ?? null, step: question.step ?? null, placeholder: question.placeholder ?? null, options: (question.options ?? question.choices ?? []).map((option) => ({ label: option.label, value: option.value, color: option.color ?? null, description: option.description ?? null, recommended: option.recommended ?? false, })), })), ); } function withDefaultOptions(question: GuidedQuestion): GuidedQuestionOption[] { const base = question.options ?? question.choices ?? []; const seen = new Set(base.map(optionKey)); const result = [...base]; const maybePush = (option: GuidedQuestionOption, enabled: boolean) => { if (!enabled) return; const key = optionKey(option); const label = option.label.toLowerCase(); const value = option.value.toLowerCase(); const duplicate = result.some( (existing) => optionKey(existing) === key || existing.label.toLowerCase() === label || existing.value.toLowerCase() === value, ); if (duplicate || seen.has(key)) return; seen.add(key); result.push(option); }; maybePush(EXPLORE_OPTION, question.includeExplore !== false); maybePush(DECIDE_OPTION, question.includeDecide !== false); return result; } export interface GuidedQuestionFlowProps { questions: GuidedQuestion[]; onSubmit: (answers: GuidedQuestionAnswers) => void; onSkip: () => void; title?: string; description?: string; skipLabel?: string; submitLabel?: string; className?: string; } export function GuidedQuestionFlow({ questions, onSubmit, onSkip, title = "Before I generate", description = "Use Other for custom details, or let the agent decide.", skipLabel = "Skip", submitLabel = "Continue", className, }: GuidedQuestionFlowProps) { const [answers, setAnswers] = useState({}); const questionsFingerprint = useMemo( () => guidedQuestionsFingerprint(questions), [questions], ); useEffect(() => { setAnswers({}); }, [questionsFingerprint]); const setAnswer = useCallback((id: string, value: unknown) => { setAnswers((prev) => ({ ...prev, [id]: value })); }, []); const submitAnswers = useCallback( (nextAnswers: GuidedQuestionAnswers = answers) => onSubmit(normalizeGuidedAnswers(nextAnswers)), [answers, onSubmit], ); const allRequiredAnswered = questions .filter((question) => question.required) .every((question) => hasGuidedAnswer(answers[question.id])); return (

{title}

{description && (

{description}

)}
{questions.map((question, index) => ( setAnswer(question.id, value)} onSubmitAnswer={(value) => submitAnswers({ ...answers, [question.id]: value }) } /> ))}
{questions.map((question, index) => ( ))}
); } function QuestionCard({ index, question, value, onChange, onSubmitAnswer, }: { index: number; question: GuidedQuestion; value: unknown; onChange: (value: unknown) => void; onSubmitAnswer: (value: unknown) => void; }) { return (
{index + 1}
{question.header && (

{question.header}

)}

{question.question} {question.required && ( * )}

{question.description && (

{question.description}

)}
{question.type === "text-options" && ( )} {question.type === "color-options" && ( )} {question.type === "slider" && ( )} {question.type === "file" && ( )} {question.type === "freeform" && (