import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Check, ChevronDown, Copy, Edit3, MessageSquare, Plus, TestTube2, Trash2, X, } from 'lucide-react'; import type { GoalSetupBundle, GoalSetupFactResult, GoalSetupFactsBundle, GoalSetupInterviewBundle, GoalSetupQuestion, GoalSetupQuestionAnswer, } from '@plannotator/core/goal-setup'; import { ConfirmDialog } from '../ConfirmDialog'; import { CommentPopover } from '../CommentPopover'; import { Button } from '../core/button'; import { Textarea } from '../core/textarea'; import { copyTextToClipboard } from '../../utils/clipboard'; interface GoalSetupSurfaceProps { bundle: GoalSetupBundle; maxWidth?: number | null; onSubmitted?: () => void; onActionStateChange?: (state: GoalSetupActionState) => void; } type SubmitState = 'idle' | 'submitting' | 'submitted' | 'error'; export interface GoalSetupSurfaceHandle { submit: () => void; } export interface GoalSetupActionState { canSubmit: boolean; isSubmitting: boolean; submitted: boolean; submitLabel: string; } function cx(...classes: Array): string { return classes.filter(Boolean).join(' '); } async function submitGoalSetup(payload: unknown): Promise { const response = await fetch('/api/goal-setup/submit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!response.ok) { let message = 'Submission failed'; try { const body = await response.json(); if (typeof body.error === 'string') message = body.error; } catch { // Keep the generic message. } throw new Error(message); } } async function copyGoalSetupText(text: string): Promise { let writeError: unknown; try { const clipboardWrite = navigator.clipboard?.writeText(text); if (clipboardWrite) { await clipboardWrite; return; } } catch (err) { writeError = err; } // Clipboard API absent or rejected: try the legacy copy-event fallback. if (await copyTextToClipboard(text)) return; // All strategies failed. When the API existed but rejected, surface its // real error; use the generic message only when the API was absent. if (writeError instanceof Error) throw writeError; throw new Error('Clipboard is unavailable in this browser'); } function useGoalSetupCopy(onError: (message: string) => void) { const [copied, setCopied] = useState(null); useEffect(() => { if (!copied) return; const timeout = window.setTimeout(() => setCopied(null), 1400); return () => window.clearTimeout(timeout); }, [copied]); const copy = useCallback( async (id: string, text: string) => { try { await copyGoalSetupText(text); onError(''); setCopied(id); } catch (err) { onError(err instanceof Error ? err.message : 'Copy failed'); } }, [onError] ); return { copied, copy }; } export const GoalSetupSurface = React.forwardRef(({ bundle, maxWidth = 880, onSubmitted, onActionStateChange, }, ref) => (
{bundle.stage === 'interview' ? ( ) : ( )}
)); GoalSetupSurface.displayName = 'GoalSetupSurface'; const InterviewSurface = React.forwardRef void; onActionStateChange?: (state: GoalSetupActionState) => void; }>(({ bundle, onSubmitted, onActionStateChange }, ref) => { const [answers, setAnswers] = useState>(() => Object.fromEntries( bundle.questions.map((question) => [ question.id, { questionId: question.id, selectedOptionIds: [], customAnswer: '', note: '', answer: '', completed: false, }, ]) ) ); const [activeQuestionId, setActiveQuestionId] = useState( () => bundle.questions[0]?.id ?? null ); const [skippedIds, setSkippedIds] = useState>(new Set()); const [noteOpenForId, setNoteOpenForId] = useState(null); const [recommendationActionForId, setRecommendationActionForId] = useState<{ id: string; nonce: number; } | null>(null); const questionRefs = useRef(new Map()); const didAutoFocus = useRef(false); const [submitState, setSubmitState] = useState('idle'); const [error, setError] = useState(''); const { copied, copy } = useGoalSetupCopy(setError); const focusQuestionControl = useCallback( (questionId: string, options?: { scroll?: boolean }) => { requestAnimationFrame(() => { const row = questionRefs.current.get(questionId); if (!row) return; if (options?.scroll) row.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); const focusTarget = row.querySelector('.goal-answer-focus'); if (focusTarget) requestAnimationFrame(() => focusTarget.focus()); }); }, [] ); useEffect(() => { if (didAutoFocus.current || !activeQuestionId) return; didAutoFocus.current = true; focusQuestionControl(activeQuestionId); }, [activeQuestionId, focusQuestionControl]); const answerList = useMemo( () => bundle.questions.map((question) => { const answer = answers[question.id]; const completed = hasAnswer(question, answer); return { ...answer, answer: buildAnswerText(question, answer), completed, skipped: skippedIds.has(question.id) && !completed, }; }), [answers, bundle.questions, skippedIds] ); const completedCount = answerList.filter((answer) => answer.completed).length; const [showIncompleteWarning, setShowIncompleteWarning] = useState(false); const copyInterviewJson = useCallback(() => { copy('interview-json', JSON.stringify(buildInterviewCopyPayload(bundle, answerList), null, 2)); }, [answerList, bundle, copy]); const copyInterviewMarkdown = useCallback(() => { copy('interview-markdown', formatInterviewMarkdown(bundle, answerList)); }, [answerList, bundle, copy]); const incompleteQuestions = useMemo( () => bundle.questions.filter( (q) => q.required !== false && !hasAnswer(q, answers[q.id]) && !skippedIds.has(q.id) ), [bundle.questions, answers, skippedIds] ); const skippedQuestions = useMemo( () => bundle.questions.filter((q) => skippedIds.has(q.id) && !hasAnswer(q, answers[q.id])), [bundle.questions, answers, skippedIds] ); const updateAnswer = useCallback( (questionId: string, patch: Partial) => { const answerFieldsChanged = Object.prototype.hasOwnProperty.call(patch, 'answer') || Object.prototype.hasOwnProperty.call(patch, 'customAnswer') || Object.prototype.hasOwnProperty.call(patch, 'selectedOptionIds'); setAnswers((current) => ({ ...current, [questionId]: { ...current[questionId], ...patch, }, })); setSkippedIds((current) => { if (!answerFieldsChanged || !current.has(questionId)) return current; const next = new Set(current); next.delete(questionId); return next; }); }, [] ); const doSubmit = async () => { if (submitState === 'submitting') return; setShowIncompleteWarning(false); setSubmitState('submitting'); setError(''); try { await submitGoalSetup({ stage: 'interview', title: bundle.title, goalSlug: bundle.goalSlug, answers: answerList, }); setSubmitState('submitted'); onSubmitted?.(); } catch (err) { setError(err instanceof Error ? err.message : 'Submission failed'); setSubmitState('error'); } }; const handleSubmit = () => { if (submitState === 'submitting') return; if (incompleteQuestions.length > 0 || skippedQuestions.length > 0) { setShowIncompleteWarning(true); return; } doSubmit(); }; React.useImperativeHandle(ref, () => ({ submit: handleSubmit }), [handleSubmit]); useEffect(() => { onActionStateChange?.({ canSubmit: true, isSubmitting: submitState === 'submitting', submitted: submitState === 'submitted', submitLabel: 'Submit Answers', }); }, [onActionStateChange, submitState]); const advance = useCallback( (delta: 1 | -1) => { const ids = bundle.questions.map((q) => q.id); if (ids.length === 0) return; const currentIndex = activeQuestionId ? ids.indexOf(activeQuestionId) : -1; const nextIndex = currentIndex === -1 ? delta === 1 ? 0 : ids.length - 1 : Math.max(0, Math.min(ids.length - 1, currentIndex + delta)); const nextId = ids[nextIndex]; setActiveQuestionId(nextId); focusQuestionControl(nextId, { scroll: true }); }, [bundle.questions, activeQuestionId, focusQuestionControl] ); const skipQuestion = useCallback( (questionId: string) => { setSkippedIds((current) => { const next = new Set(current); next.add(questionId); return next; }); advance(1); }, [advance] ); useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { // Ctrl+letter shortcuts — work from anywhere, including inside text fields if (event.ctrlKey && !event.metaKey && !event.altKey && activeQuestionId) { const key = event.key.toLowerCase(); if (key === 'u') { event.preventDefault(); setRecommendationActionForId({ id: activeQuestionId, nonce: Date.now() }); return; } if (key === 'k') { event.preventDefault(); skipQuestion(activeQuestionId); return; } if (key === 'j') { event.preventDefault(); setNoteOpenForId(activeQuestionId); requestAnimationFrame(() => { const row = questionRefs.current.get(activeQuestionId); const noteInput = row?.querySelector('.goal-note-textarea'); if (noteInput) requestAnimationFrame(() => noteInput.focus()); }); return; } } // Escape — blur current input so shortcuts work again if (event.key === 'Escape') { const el = event.target; if (el instanceof HTMLElement && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA')) { event.preventDefault(); el.blur(); } return; } // Tab / Shift+Tab — advance between questions if (event.key === 'Tab') { const target = event.target; if (!(target instanceof HTMLElement)) return; if (!target.closest('.goal-row')) return; event.preventDefault(); advance(event.shiftKey ? -1 : 1); return; } // Number keys — toggle options on the active question if (!activeQuestionId) return; const el = event.target; if (el instanceof HTMLElement && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable)) return; const activeQuestion = bundle.questions.find((q) => q.id === activeQuestionId); if (!activeQuestion?.options?.length) return; const mode = activeQuestion.answerMode || 'text'; const isMulti = mode === 'multi' || mode === 'multi-custom'; // "/" — focus the custom input for the active question if (event.key === '/' && (mode === 'custom' || mode === 'single-custom' || mode === 'multi-custom')) { event.preventDefault(); const row = questionRefs.current.get(activeQuestionId); const customInput = row?.querySelector('.goal-custom-input'); customInput?.focus(); return; } const digit = parseInt(event.key, 10); if (isNaN(digit) || digit < 1 || digit > activeQuestion.options.length) return; event.preventDefault(); const option = activeQuestion.options[digit - 1]; const current = answers[activeQuestionId]; if (isMulti) { updateAnswer(activeQuestionId, { selectedOptionIds: current.selectedOptionIds.includes(option.id) ? current.selectedOptionIds.filter((id) => id !== option.id) : [...current.selectedOptionIds, option.id], }); } else { updateAnswer(activeQuestionId, { selectedOptionIds: current.selectedOptionIds.includes(option.id) ? [] : [option.id], customAnswer: '', }); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [advance, activeQuestionId, bundle.questions, answers, updateAnswer, skipQuestion]); return (
setShowIncompleteWarning(false)} onConfirm={doSubmit} variant="warning" title="Submit with incomplete answers?" message={ <> {skippedQuestions.length > 0 && ( {skippedQuestions.length} skipped question{skippedQuestions.length !== 1 ? 's' : ''} will be sent as skipped, including any notes. )} {incompleteQuestions.length > 0 && ( {incompleteQuestions.length} required question{incompleteQuestions.length !== 1 ? 's' : ''} {incompleteQuestions.length !== 1 ? 'are' : 'is'} still unanswered. )} The agent will work with whatever you've provided. } confirmText="Submit anyway" cancelText="Go back" showCancel />
Goal interview

{bundle.title || 'Answer the setup questions'}

{completedCount}/{bundle.questions.length} answered
{error && (
{error}
)} {bundle.questions.map((question) => { const answer = answers[question.id]; const isActive = activeQuestionId === question.id; const complete = hasAnswer(question, answer); const skipped = skippedIds.has(question.id) && !complete; const noteSummary = answer.note?.trim(); const summary = skipped ? noteSummary ? `Skipped · ${noteSummary}` : 'Skipped' : complete ? buildAnswerText(question, answer).replace(/\s*\n+\s*/g, ' · ').trim() : ''; return (
{ if (node) questionRefs.current.set(question.id, node); else questionRefs.current.delete(question.id); }} className={cx( 'goal-row', isActive && 'active', complete && 'answered', skipped && 'skipped' )} >
updateAnswer(question.id, patch)} noteOpen={noteOpenForId === question.id} onNoteOpenChange={(open) => setNoteOpenForId(open ? question.id : null) } onSkip={() => skipQuestion(question.id)} recommendationCommand={ recommendationActionForId?.id === question.id ? recommendationActionForId : null } />
); })}
); }); InterviewSurface.displayName = 'InterviewSurface'; const QuestionAnswerControls: React.FC<{ question: GoalSetupQuestion; answer: GoalSetupQuestionAnswer; onChange: (patch: Partial) => void; noteOpen: boolean; onNoteOpenChange: (open: boolean) => void; onSkip: () => void; recommendationCommand: { nonce: number } | null; }> = ({ question, answer, onChange, noteOpen, onNoteOpenChange, onSkip, recommendationCommand }) => { const mode = question.answerMode || 'text'; const options = question.options || []; const supportsOptions = mode === 'single' || mode === 'multi' || mode === 'single-custom' || mode === 'multi-custom'; const supportsCustom = mode === 'custom' || mode === 'single-custom' || mode === 'multi-custom'; const supportsText = mode === 'text'; const isMulti = mode === 'multi' || mode === 'multi-custom'; const textValue = supportsText ? answer.answer : answer.customAnswer; const showTextArea = supportsText || mode === 'custom'; const showCustomOption = supportsOptions && supportsCustom; const textareaRef = useRef(null); const hasAnyAnswer = hasAnswer(question, answer); const canUseRecommendation = showTextArea || supportsCustom || Boolean(question.recommendedOptionIds?.length); useEffect(() => { if (answer.note && !noteOpen) { onNoteOpenChange(true); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [answer.note]); const toggleOption = (optionId: string) => { if (isMulti) { onChange({ selectedOptionIds: answer.selectedOptionIds.includes(optionId) ? answer.selectedOptionIds.filter((id) => id !== optionId) : [...answer.selectedOptionIds, optionId], }); return; } onChange({ selectedOptionIds: answer.selectedOptionIds.includes(optionId) ? [] : [optionId], customAnswer: '', }); }; const updateTextValue = (value: string) => { supportsText ? onChange({ answer: value }) : onChange({ customAnswer: value }); }; const updateCustomOption = (value: string) => { onChange({ customAnswer: value, ...(mode === 'single-custom' && value.trim() ? { selectedOptionIds: [] } : {}), }); }; const applyRecommendation = () => { const patch: Partial = {}; const hasRecommendedOptions = Boolean(question.recommendedOptionIds?.length && supportsOptions); if (question.recommendedOptionIds?.length && supportsOptions) { patch.selectedOptionIds = isMulti ? question.recommendedOptionIds : [question.recommendedOptionIds[0]]; if (!isMulti) patch.customAnswer = ''; } if (question.recommendedAnswer) { if (supportsText) { patch.answer = question.recommendedAnswer; } else if (mode === 'custom') { patch.customAnswer = question.recommendedAnswer; } else if (supportsCustom && !hasRecommendedOptions) { patch.customAnswer = question.recommendedAnswer; if (mode === 'single-custom') patch.selectedOptionIds = []; } } if (Object.keys(patch).length === 0) return; onChange(patch); if (showTextArea) requestAnimationFrame(() => textareaRef.current?.focus()); }; useEffect(() => { if (!recommendationCommand) return; applyRecommendation(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [recommendationCommand?.nonce]); return (
{question.description && (

{question.description}

)} {(question.recommendedAnswer || question.recommendedOptionIds?.length) && !hasAnyAnswer && (

{question.recommendedAnswer || question.recommendedOptionIds!.map((id) => question.options?.find((o) => o.id === id)?.label ?? id ).join(', ')}

{canUseRecommendation && ( )}
)} {supportsOptions && options.length > 0 && (
{options.map((option, optionIndex) => { const selected = answer.selectedOptionIds.includes(option.id); return ( ); })} {showCustomOption && ( )}
)} {showTextArea && (