From 84dde0f18ab25db6e2bb3865f7489b6d0621e9df Mon Sep 17 00:00:00 2001 From: Kiwi <214225921+sudorest@users.noreply.github.com> Date: Mon, 16 Mar 2026 18:04:51 +0000 Subject: [PATCH] feat: add bidirectional AI conversation, context persistence, and approve-with-notes Phase 1 - Approve with Comments: - Replace 'Annotations Won't Be Sent' warning with 'Approve with Notes' flow - Pass user annotations through as implementation notes on approve (Claude Code) - Add planApproveWithNotesFeedback() shared template Phase 2 - Context Persistence: - Track review sessions across deny/revise cycles with session IDs - Persist iterations to ~/.plannotator/sessions/ with Q&A history - Enhanced planDenyFeedback() includes previous iteration context - Add Review History sidebar tab showing iteration timeline - Plan metadata embedding (session ID, questions) via HTML comments Phase 3 - Bidirectional AI Conversation: - AI can embed clarification questions in plans via structured metadata - WebSocket support in plan server for real-time question updates - ClarificationPanel UI with 5 question types (pick_one, pick_many, confirm, ask_text, show_options) - Question answers flow back to AI through approve/deny feedback - CLARIFICATION_QUESTIONS_PROMPT teaches AI the question format Inspired by github.com/vtemian/octto branch-based Q&A patterns. --- apps/hook/server/index.ts | 89 ++- apps/opencode-plugin/index.ts | 91 ++- packages/editor/App.tsx | 172 ++++- packages/server/index.ts | 130 +++- packages/server/questions.ts | 148 +++++ packages/server/review-session.ts | 180 +++++ packages/shared/feedback-templates.ts | 46 +- packages/shared/prompts.ts | 86 +++ packages/shared/questions.ts | 254 +++++++ packages/ui/components/ClarificationPanel.tsx | 617 ++++++++++++++++++ .../ui/components/sidebar/ReviewHistory.tsx | 215 ++++++ .../components/sidebar/SidebarContainer.tsx | 41 ++ .../ui/components/sidebar/SidebarTabs.tsx | 31 + packages/ui/hooks/useSidebar.ts | 2 +- 14 files changed, 2053 insertions(+), 49 deletions(-) create mode 100644 packages/server/questions.ts create mode 100644 packages/server/review-session.ts create mode 100644 packages/shared/prompts.ts create mode 100644 packages/shared/questions.ts create mode 100644 packages/ui/components/ClarificationPanel.tsx create mode 100644 packages/ui/components/sidebar/ReviewHistory.tsx diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 5291c1c3..3bd62a38 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -49,7 +49,21 @@ import { resolveMarkdownFile } from "@plannotator/server/resolve-file"; import { registerSession, unregisterSession, listSessions } from "@plannotator/server/sessions"; import { openBrowser } from "@plannotator/server/browser"; import { detectProjectName } from "@plannotator/server/project"; -import { planDenyFeedback } from "@plannotator/shared/feedback-templates"; +import { planDenyFeedback, planApproveWithNotesFeedback } from "@plannotator/shared/feedback-templates"; +import { + extractSessionId, + extractQuestions, + stripPlanMetadata, + generateSessionId, + embedSessionId, +} from "@plannotator/shared/questions"; +import { + loadSession, + createSession, + recordIteration, + getPreviousIterations, + cleanupSessions, +} from "@plannotator/server/review-session"; import path from "path"; // Embed the built HTML at compile time @@ -303,20 +317,51 @@ if (args[0] === "sessions") { const planProject = (await detectProjectName()) ?? "_unknown"; - // Start the plan review server + // --- Session tracking for context persistence --- + // Extract or create a session ID so we can accumulate context across deny/revise cycles + let sessionId = extractSessionId(planContent); + const isReturningSession = !!sessionId; + + if (!sessionId) { + sessionId = generateSessionId(); + } + + // Extract any embedded clarification questions from the plan + const embeddedQuestions = extractQuestions(planContent); + + // Strip metadata from the plan so the user sees clean markdown + const cleanPlan = stripPlanMetadata(planContent); + + // Load previous iterations for context + const previousIterations = isReturningSession + ? getPreviousIterations(sessionId) + : []; + + // Ensure session exists on disk + if (!isReturningSession) { + createSession(sessionId, planProject, ""); + } + + // Periodically clean up old sessions (non-blocking) + cleanupSessions(); + + // Start the plan review server (use clean plan without metadata comments) const server = await startPlannotatorServer({ - plan: planContent, + plan: cleanPlan, origin: "claude-code", permissionMode, sharingEnabled, shareBaseUrl, pasteApiUrl, htmlContent: planHtmlContent, + sessionId, + previousIterations, + questions: embeddedQuestions, onReady: async (url, isRemote, port) => { handleServerReady(url, isRemote, port); if (isRemote && sharingEnabled) { - await writeRemoteShareLink(planContent, shareBaseUrl, "review the plan", "plan only").catch(() => {}); + await writeRemoteShareLink(cleanPlan, shareBaseUrl, "review the plan", "plan only").catch(() => {}); } }, }); @@ -340,6 +385,15 @@ if (args[0] === "sessions") { // Cleanup server.stop(); + // Record this iteration in the session store for context persistence + recordIteration(sessionId, { + plan: cleanPlan, + feedback: result.feedback || "", + questions: embeddedQuestions, + answers: result.answers || [], + decision: result.approved ? "approved" : "denied", + }); + // Output JSON for PermissionRequest hook decision control if (result.approved) { // Build updatedPermissions to preserve the current permission mode @@ -359,18 +413,43 @@ if (args[0] === "sessions") { decision: { behavior: "allow", ...(updatedPermissions.length > 0 && { updatedPermissions }), + // Pass through user annotations as implementation notes when approving with feedback + ...(result.feedback && { + message: planApproveWithNotesFeedback(result.feedback, { + clarificationQuestions: embeddedQuestions, + clarificationAnswers: result.answers || [], + }), + }), + // If no annotations but questions were answered, still include Q&A context + ...(!result.feedback && embeddedQuestions.length > 0 && result.answers?.length && { + message: planApproveWithNotesFeedback("", { + clarificationQuestions: embeddedQuestions, + clarificationAnswers: result.answers, + }), + }), }, }, }) ); } else { + // Include session ID in deny feedback so the AI embeds it in the next plan submission + const sessionHint = `\n\nIMPORTANT: Include this session marker at the top of your next plan so review context is preserved:\n`; + console.log( JSON.stringify({ hookSpecificOutput: { hookEventName: "PermissionRequest", decision: { behavior: "deny", - message: planDenyFeedback(result.feedback || "", "ExitPlanMode"), + message: planDenyFeedback( + (result.feedback || "") + sessionHint, + "ExitPlanMode", + { + previousIterations, + clarificationQuestions: embeddedQuestions, + clarificationAnswers: result.answers || [], + }, + ), }, }, }) diff --git a/apps/opencode-plugin/index.ts b/apps/opencode-plugin/index.ts index ab31af4c..37cace2f 100644 --- a/apps/opencode-plugin/index.ts +++ b/apps/opencode-plugin/index.ts @@ -29,7 +29,19 @@ import { import { getGitContext, runGitDiff } from "@plannotator/server/git"; import { writeRemoteShareLink } from "@plannotator/server/share-url"; import { resolveMarkdownFile } from "@plannotator/server/resolve-file"; -import { planDenyFeedback } from "@plannotator/shared/feedback-templates"; +import { planDenyFeedback, planApproveWithNotesFeedback } from "@plannotator/shared/feedback-templates"; +import { + extractSessionId, + extractQuestions, + stripPlanMetadata, + generateSessionId, +} from "@plannotator/shared/questions"; +import { + createSession, + recordIteration, + getPreviousIterations, + cleanupSessions, +} from "@plannotator/server/review-session"; // @ts-ignore - Bun import attribute for text import indexHtml from "./plannotator.html" with { type: "text" }; @@ -348,17 +360,44 @@ Do NOT proceed with implementation until your plan is approved. }, async execute(args, context) { + // --- Session tracking for context persistence --- + let sessionId = extractSessionId(args.plan); + const isReturningSession = !!sessionId; + + if (!sessionId) { + sessionId = generateSessionId(); + } + + // Extract embedded questions and strip metadata + const embeddedQuestions = extractQuestions(args.plan); + const cleanPlan = stripPlanMetadata(args.plan); + + // Load previous iterations for context + const previousIterations = isReturningSession + ? getPreviousIterations(sessionId) + : []; + + if (!isReturningSession) { + createSession(sessionId, process.cwd(), ""); + } + + // Periodically clean up old sessions (non-blocking) + cleanupSessions(); + const server = await startPlannotatorServer({ - plan: args.plan, + plan: cleanPlan, origin: "opencode", sharingEnabled: await getSharingEnabled(), shareBaseUrl: getShareBaseUrl(), htmlContent, opencodeClient: ctx.client, + sessionId, + previousIterations, + questions: embeddedQuestions, onReady: async (url, isRemote, port) => { handleServerReady(url, isRemote, port); if (isRemote && await getSharingEnabled()) { - await writeRemoteShareLink(args.plan, getShareBaseUrl(), "review the plan", "plan only").catch(() => {}); + await writeRemoteShareLink(cleanPlan, getShareBaseUrl(), "review the plan", "plan only").catch(() => {}); } }, }); @@ -386,6 +425,15 @@ Do NOT proceed with implementation until your plan is approved. await Bun.sleep(1500); server.stop(); + // Record this iteration in the session store for context persistence + recordIteration(sessionId, { + plan: cleanPlan, + feedback: result.feedback || "", + questions: embeddedQuestions, + answers: result.answers || [], + decision: result.approved ? "approved" : "denied", + }); + if (result.approved) { // Check agent switch setting (defaults to 'build' if not set) const shouldSwitchAgent = result.agentSwitch && result.agentSwitch !== 'disabled'; @@ -419,20 +467,20 @@ Do NOT proceed with implementation until your plan is approved. } } - // If user approved with annotations, include them as notes for implementation - if (result.feedback) { - return `Plan approved with notes! + // If user approved with annotations or answered questions, include context + const hasQA = embeddedQuestions.length > 0 && result.answers?.length; + if (result.feedback || hasQA) { + const approveMessage = planApproveWithNotesFeedback( + result.feedback || "", + { + clarificationQuestions: embeddedQuestions, + clarificationAnswers: result.answers || [], + }, + ); + return `${approveMessage} Plan Summary: ${args.summary} -${result.savedPath ? `Saved to: ${result.savedPath}` : ""} - -## Implementation Notes - -The user approved your plan but added the following notes to consider during implementation: - -${result.feedback} - -Proceed with implementation, incorporating these notes where applicable.`; +${result.savedPath ? `Saved to: ${result.savedPath}` : ""}`; } return `Plan approved! @@ -440,7 +488,18 @@ Proceed with implementation, incorporating these notes where applicable.`; Plan Summary: ${args.summary} ${result.savedPath ? `Saved to: ${result.savedPath}` : ""}`; } else { - return planDenyFeedback(result.feedback || "", "submit_plan"); + // Include session ID in deny feedback so the AI embeds it in the next plan submission + const sessionHint = `\n\nIMPORTANT: Include this session marker at the top of your next plan so review context is preserved:\n`; + + return planDenyFeedback( + (result.feedback || "") + sessionHint, + "submit_plan", + { + previousIterations, + clarificationQuestions: embeddedQuestions, + clarificationAnswers: result.answers || [], + }, + ); } }, }), diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index a0a9313b..cefe2de1 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -48,8 +48,10 @@ import { useEditorAnnotations } from '@plannotator/ui/hooks/useEditorAnnotations import { isVaultBrowserEnabled } from '@plannotator/ui/utils/obsidian'; import { SidebarTabs } from '@plannotator/ui/components/sidebar/SidebarTabs'; import { SidebarContainer } from '@plannotator/ui/components/sidebar/SidebarContainer'; +import { ClarificationPanel, ClarificationBadge, type ClarificationQuestion, type QuestionAnswer } from '@plannotator/ui/components/ClarificationPanel'; import { PlanDiffViewer } from '@plannotator/ui/components/plan-diff/PlanDiffViewer'; import type { PlanDiffMode } from '@plannotator/ui/components/plan-diff/PlanDiffModeSwitcher'; +import type { ReviewIterationDisplay } from '@plannotator/ui/components/sidebar/ReviewHistory'; import { DEMO_PLAN_CONTENT } from './demoPlan'; type NoteAutoSaveResults = { @@ -104,6 +106,15 @@ const App: React.FC = () => { const [planDiffMode, setPlanDiffMode] = useState('clean'); const [previousPlan, setPreviousPlan] = useState(null); const [versionInfo, setVersionInfo] = useState(null); + // Review session state (context persistence across iterations) + const [reviewSessionId, setReviewSessionId] = useState(null); + const [reviewIterations, setReviewIterations] = useState([]); + const [currentIteration, setCurrentIteration] = useState(1); + // Clarification questions state (bidirectional AI conversation) + const [clarificationQuestions, setClarificationQuestions] = useState([]); + const [clarificationAnswers, setClarificationAnswers] = useState([]); + const [showClarificationPanel, setShowClarificationPanel] = useState(false); + const wsRef = useRef(null); const viewerRef = useRef(null); const containerRef = useRef(null); @@ -369,6 +380,110 @@ const App: React.FC = () => { .finally(() => setIsLoading(false)); }, [isLoadingShared, isSharedSession]); + // Fetch review session data (context persistence across iterations) + useEffect(() => { + if (!isApiMode || isSharedSession) return; + + fetch('/api/session') + .then(res => { + if (!res.ok) return null; + return res.json(); + }) + .then((data: { sessionId?: string; previousIterations?: ReviewIterationDisplay[]; iterationCount?: number } | null) => { + if (!data) return; + if (data.sessionId) setReviewSessionId(data.sessionId); + if (data.previousIterations && data.previousIterations.length > 0) { + setReviewIterations(data.previousIterations); + setCurrentIteration(data.previousIterations.length + 1); + // Auto-open the history tab when there are previous iterations + sidebar.open('history'); + } + }) + .catch(() => { + // Session endpoint not available — no-op + }); + }, [isApiMode, isSharedSession]); + + // Fetch clarification questions and establish WebSocket for real-time updates + useEffect(() => { + if (!isApiMode || isSharedSession) return; + + // Fetch initial question state + fetch('/api/questions') + .then(res => { + if (!res.ok) return null; + return res.json(); + }) + .then((data: { questions?: ClarificationQuestion[]; answers?: QuestionAnswer[] } | null) => { + if (!data) return; + if (data.questions && data.questions.length > 0) { + setClarificationQuestions(data.questions); + setClarificationAnswers(data.answers || []); + setShowClarificationPanel(true); + } + }) + .catch(() => {}); + + // Establish WebSocket connection for real-time question updates + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${protocol}//${window.location.host}/ws`; + const ws = new WebSocket(wsUrl); + + ws.onmessage = (event) => { + try { + const msg = JSON.parse(event.data); + if (msg.type === 'questions:state' || msg.type === 'questions:update') { + if (msg.questions && msg.questions.length > 0) { + setClarificationQuestions(msg.questions); + setClarificationAnswers(msg.answers || []); + // Auto-open panel when new questions arrive + if (msg.type === 'questions:update') { + setShowClarificationPanel(true); + } + } + } + } catch { + // Ignore non-JSON messages + } + }; + + ws.onclose = () => { + // Auto-reconnect after 2 seconds + setTimeout(() => { + if (wsRef.current === ws) { + wsRef.current = null; + } + }, 2000); + }; + + wsRef.current = ws; + + return () => { + ws.close(); + wsRef.current = null; + }; + }, [isApiMode, isSharedSession]); + + // Submit clarification answer to server + const handleSubmitClarificationAnswer = useCallback(async (answer: QuestionAnswer) => { + setClarificationAnswers(prev => [...prev, answer]); + try { + const res = await fetch('/api/answers', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(answer), + }); + if (res.ok) { + const data = await res.json(); + if (data.answers) { + setClarificationAnswers(data.answers); + } + } + } catch { + // Answer already saved optimistically in state + } + }, []); + useEffect(() => { const { frontmatter: fm } = extractFrontmatter(markdown); setFrontmatter(fm); @@ -993,23 +1108,23 @@ const App: React.FC = () => { className={`px-2 py-1 md:px-2.5 rounded-md text-xs font-medium transition-all ${ isSubmitting ? 'opacity-50 cursor-not-allowed bg-muted text-muted-foreground' - : origin === 'claude-code' && annotations.length > 0 - ? 'bg-success/50 text-success-foreground/70 hover:bg-success hover:text-success-foreground' - : 'bg-success text-success-foreground hover:opacity-90' + : 'bg-success text-success-foreground hover:opacity-90' }`} > {isSubmitting ? '...' : 'OK'} - {isSubmitting ? 'Approving...' : 'Approve'} + {isSubmitting ? 'Approving...' : annotations.length > 0 ? 'Approve with Notes' : 'Approve'} - {origin === 'claude-code' && annotations.length > 0 && ( -
-
-
- {agentName} doesn't support feedback on approval. Your annotations won't be seen. -
- )}
} + {/* Clarification questions badge */} + {clarificationQuestions.length > 0 && ( + setShowClarificationPanel(true)} + /> + )} +
)} @@ -1189,6 +1304,8 @@ const App: React.FC = () => { onToggleTab={sidebar.toggleTab} hasDiff={planDiff.hasPreviousVersion} showVaultTab={showVaultTab} + showHistoryTab={reviewIterations.length > 0} + hasHistory={reviewIterations.length > 0} className="hidden lg:flex" /> )} @@ -1212,6 +1329,11 @@ const App: React.FC = () => { vaultBrowser={vaultBrowser} onVaultSelectFile={handleVaultFileSelect} onVaultFetchTree={handleVaultFetchTree} + showHistoryTab={reviewIterations.length > 0} + hasHistory={reviewIterations.length > 0} + reviewSessionId={reviewSessionId} + reviewIterations={reviewIterations} + currentIteration={currentIteration} versionInfo={versionInfo} versions={planDiff.versions} projectPlans={planDiff.projectPlans} @@ -1371,7 +1493,7 @@ const App: React.FC = () => { variant="info" /> - {/* Claude Code annotation warning dialog */} + {/* Claude Code approve with notes dialog */} setShowClaudeCodeWarning(false)} @@ -1379,22 +1501,16 @@ const App: React.FC = () => { setShowClaudeCodeWarning(false); handleApprove(); }} - title="Annotations Won't Be Sent" - message={<>{agentName} doesn't yet support feedback on approval. Your {annotations.length} annotation{annotations.length !== 1 ? 's' : ''} will be lost.} + title="Approve with Notes" + message={<>Your {annotations.length} annotation{annotations.length !== 1 ? 's' : ''} will be sent as implementation notes. {agentName} will proceed with the plan, incorporating your notes where applicable.} subMessage={ <> - To send feedback, use Send Feedback instead. -

- Want this feature? Upvote these issues: -
- #16001 - {' · '} - #15755 + To block the plan and require revisions, use Send Feedback instead. } - confirmText="Approve Anyway" + confirmText="Approve with Notes" cancelText="Cancel" - variant="warning" + variant="info" showCancel /> @@ -1440,6 +1556,16 @@ const App: React.FC = () => {
)} + {/* Clarification questions panel */} + setShowClarificationPanel(false)} + isSubmitting={isSubmitting} + /> + {/* Completion overlay - shown after approve/deny */} void; /** OpenCode client for querying available agents (OpenCode only) */ opencodeClient?: OpencodeClient; + /** Review session ID for context persistence across iterations */ + sessionId?: string; + /** Previous review iterations (loaded from session store) */ + previousIterations?: import("@plannotator/shared/questions").ReviewIteration[]; + /** AI clarification questions embedded in this plan submission */ + questions?: import("@plannotator/shared/questions").ClarificationQuestion[]; } export interface ServerResult { @@ -85,6 +99,8 @@ export interface ServerResult { savedPath?: string; agentSwitch?: string; permissionMode?: string; + /** Collected answers to AI clarification questions */ + answers?: QuestionAnswer[]; }>; /** Stop the server */ stop: () => void; @@ -135,6 +151,26 @@ export async function startPlannotatorServer( }; + // Question session (if the AI embedded questions in the plan) + const questionSession: QuestionSession | null = + options.questions && options.questions.length > 0 + ? createQuestionSession(options.questions) + : null; + + // WebSocket clients for real-time question/answer updates + const wsClients = new Set<{ send: (data: string) => void }>(); + + function broadcastWs(message: object): void { + const data = JSON.stringify(message); + for (const ws of wsClients) { + try { + ws.send(data); + } catch { + wsClients.delete(ws); + } + } + } + // Decision promise let resolveDecision: (result: { approved: boolean; @@ -142,6 +178,7 @@ export async function startPlannotatorServer( savedPath?: string; agentSwitch?: string; permissionMode?: string; + answers?: QuestionAnswer[]; }) => void; const decisionPromise = new Promise<{ approved: boolean; @@ -149,6 +186,7 @@ export async function startPlannotatorServer( savedPath?: string; agentSwitch?: string; permissionMode?: string; + answers?: QuestionAnswer[]; }>((resolve) => { resolveDecision = resolve; }); @@ -161,9 +199,38 @@ export async function startPlannotatorServer( server = Bun.serve({ port: configuredPort, - async fetch(req) { + // WebSocket handler for real-time question/answer updates + websocket: { + open(ws) { + wsClients.add(ws); + // Send current question state on connect + if (questionSession) { + ws.send( + JSON.stringify({ + type: "questions:state", + ...getSessionState(questionSession), + }) + ); + } + }, + message(_ws, _message) { + // Client messages not used currently — answers come via POST /api/answers + }, + close(ws) { + wsClients.delete(ws); + }, + }, + + async fetch(req, server) { const url = new URL(req.url); + // WebSocket upgrade + if (url.pathname === "/ws") { + const upgraded = server.upgrade(req); + if (upgraded) return undefined as unknown as Response; + return new Response("WebSocket upgrade failed", { status: 400 }); + } + // API: Get a specific plan version from history if (url.pathname === "/api/plan/version") { const vParam = url.searchParams.get("v"); @@ -313,6 +380,61 @@ export async function startPlannotatorServer( return Response.json({ ok: true, results }); } + // API: Get review session context (previous iterations, questions) + if (url.pathname === "/api/session") { + return Response.json({ + sessionId: options.sessionId || null, + previousIterations: options.previousIterations || [], + questions: options.questions || [], + answers: questionSession ? getAllAnswers(questionSession) : [], + iterationCount: (options.previousIterations?.length || 0) + 1, + }); + } + + // API: Submit an answer to a clarification question + if (url.pathname === "/api/answers" && req.method === "POST") { + if (!questionSession) { + return Response.json( + { error: "No active question session" }, + { status: 400 } + ); + } + + try { + const body = (await req.json()) as QuestionAnswer; + if (!body.questionId || !body.type) { + return Response.json( + { error: "Missing questionId or type" }, + { status: 400 } + ); + } + + submitAnswer(questionSession, body); + + // Broadcast the answer to all connected WebSocket clients + broadcastWs({ + type: "questions:answer", + answer: body, + ...getSessionState(questionSession), + }); + + return Response.json({ ok: true, ...getSessionState(questionSession) }); + } catch (err) { + return Response.json( + { error: err instanceof Error ? err.message : "Failed to submit answer" }, + { status: 500 } + ); + } + } + + // API: Get current question state + if (url.pathname === "/api/questions") { + if (!questionSession) { + return Response.json({ questions: [], answers: [], allAnswered: true }); + } + return Response.json(getSessionState(questionSession)); + } + // API: Approve plan if (url.pathname === "/api/approve" && req.method === "POST") { // Check for note integrations and optional feedback @@ -394,7 +516,8 @@ export async function startPlannotatorServer( // Use permission mode from client request if provided, otherwise fall back to hook input const effectivePermissionMode = requestedPermissionMode || permissionMode; - resolveDecision({ approved: true, feedback, savedPath, agentSwitch, permissionMode: effectivePermissionMode }); + const answers = questionSession ? getAllAnswers(questionSession) : undefined; + resolveDecision({ approved: true, feedback, savedPath, agentSwitch, permissionMode: effectivePermissionMode, answers }); return Response.json({ ok: true, savedPath }); } @@ -427,7 +550,8 @@ export async function startPlannotatorServer( } deleteDraft(draftKey); - resolveDecision({ approved: false, feedback, savedPath }); + const answers = questionSession ? getAllAnswers(questionSession) : undefined; + resolveDecision({ approved: false, feedback, savedPath, answers }); return Response.json({ ok: true, savedPath }); } diff --git a/packages/server/questions.ts b/packages/server/questions.ts new file mode 100644 index 00000000..4af412cf --- /dev/null +++ b/packages/server/questions.ts @@ -0,0 +1,148 @@ +import type { + ClarificationQuestion, + QuestionAnswer, +} from "@niceprompt/shared/questions"; + +/** + * Question session management for the Plannotator server. + * + * Manages in-memory state for clarification questions the AI has embedded + * in the plan. The UI fetches questions, the user answers them via the + * browser, and the hook/plugin collects the answers before making a decision. + * + * Inspired by Octto's waiter-based async answer collection pattern. + */ + +export interface QuestionSession { + questions: ClarificationQuestion[]; + answers: Map; + waiters: Array<{ + questionId: string; + resolve: (answer: QuestionAnswer) => void; + }>; + allAnsweredWaiters: Array<() => void>; +} + +export function createQuestionSession( + questions: ClarificationQuestion[] +): QuestionSession { + return { + questions, + answers: new Map(), + waiters: [], + allAnsweredWaiters: [], + }; +} + +/** + * Submit an answer to a question. Resolves any waiters for that question + * and checks if all questions are now answered. + */ +export function submitAnswer( + session: QuestionSession, + answer: QuestionAnswer +): void { + session.answers.set(answer.questionId, answer); + + // Resolve any waiters for this specific question + const pending = session.waiters.filter( + (w) => w.questionId === answer.questionId + ); + for (const waiter of pending) { + waiter.resolve(answer); + } + session.waiters = session.waiters.filter( + (w) => w.questionId !== answer.questionId + ); + + // Check if all questions are answered + if (areAllAnswered(session)) { + for (const resolve of session.allAnsweredWaiters) { + resolve(); + } + session.allAnsweredWaiters = []; + } +} + +/** + * Wait for a specific question to be answered. + */ +export function waitForAnswer( + session: QuestionSession, + questionId: string, + timeoutMs: number = 300_000 // 5 minutes +): Promise { + // Already answered? + const existing = session.answers.get(questionId); + if (existing) return Promise.resolve(existing); + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + session.waiters = session.waiters.filter( + (w) => w.questionId !== questionId + ); + reject(new Error(`Timeout waiting for answer to question ${questionId}`)); + }, timeoutMs); + + session.waiters.push({ + questionId, + resolve: (answer) => { + clearTimeout(timer); + resolve(answer); + }, + }); + }); +} + +/** + * Wait for all questions to be answered. + */ +export function waitForAllAnswers( + session: QuestionSession, + timeoutMs: number = 600_000 // 10 minutes +): Promise { + if (areAllAnswered(session)) { + return Promise.resolve(getAllAnswers(session)); + } + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + session.allAnsweredWaiters = []; + reject(new Error("Timeout waiting for all answers")); + }, timeoutMs); + + session.allAnsweredWaiters.push(() => { + clearTimeout(timer); + resolve(getAllAnswers(session)); + }); + }); +} + +/** + * Check if all questions have been answered. + */ +export function areAllAnswered(session: QuestionSession): boolean { + return session.questions.every((q) => session.answers.has(q.id)); +} + +/** + * Get all collected answers as an array. + */ +export function getAllAnswers(session: QuestionSession): QuestionAnswer[] { + return Array.from(session.answers.values()); +} + +/** + * Get the current state of the question session for the UI. + */ +export function getSessionState(session: QuestionSession): { + questions: ClarificationQuestion[]; + answers: QuestionAnswer[]; + allAnswered: boolean; +} { + return { + questions: session.questions, + answers: getAllAnswers(session), + allAnswered: areAllAnswered(session), + }; +} diff --git a/packages/server/review-session.ts b/packages/server/review-session.ts new file mode 100644 index 00000000..333e9706 --- /dev/null +++ b/packages/server/review-session.ts @@ -0,0 +1,180 @@ +/** + * Review Session Store + * + * Persists review sessions across deny/revise cycles so context is not lost. + * Sessions are stored in ~/.plannotator/sessions/{sessionId}.json. + * + * Each session tracks all iterations (plan text, annotations, Q&A, decisions) + * so the AI receives accumulated context when the user denies and revises. + */ + +import { homedir } from "os"; +import { join } from "path"; +import { + mkdirSync, + writeFileSync, + readFileSync, + existsSync, + readdirSync, + statSync, + unlinkSync, +} from "fs"; +import type { + ReviewSession, + ReviewIteration, + ClarificationQuestion, + QuestionAnswer, +} from "@plannotator/shared/questions"; + +// Re-export types for convenience +export type { ReviewSession, ReviewIteration }; + +const SESSIONS_DIR = join(homedir(), ".plannotator", "sessions"); + +/** Max age for session cleanup (7 days) */ +const MAX_SESSION_AGE_MS = 7 * 24 * 60 * 60 * 1000; + +/** + * Ensure the sessions directory exists. + */ +function ensureSessionsDir(): string { + mkdirSync(SESSIONS_DIR, { recursive: true }); + return SESSIONS_DIR; +} + +/** + * Get the file path for a session. + */ +function sessionPath(sessionId: string): string { + return join(ensureSessionsDir(), `${sessionId}.json`); +} + +/** + * Load a review session from disk. + * Returns null if the session doesn't exist or can't be read. + */ +export function loadSession(sessionId: string): ReviewSession | null { + const filePath = sessionPath(sessionId); + try { + if (!existsSync(filePath)) return null; + const raw = readFileSync(filePath, "utf-8"); + return JSON.parse(raw) as ReviewSession; + } catch { + return null; + } +} + +/** + * Save a review session to disk. + */ +export function saveSession(session: ReviewSession): void { + const filePath = sessionPath(session.sessionId); + ensureSessionsDir(); + writeFileSync(filePath, JSON.stringify(session, null, 2), "utf-8"); +} + +/** + * Create a new review session. + */ +export function createSession( + sessionId: string, + projectPath: string, + planSlug: string +): ReviewSession { + const now = new Date().toISOString(); + const session: ReviewSession = { + sessionId, + projectPath, + planSlug, + iterations: [], + createdAt: now, + updatedAt: now, + }; + saveSession(session); + return session; +} + +/** + * Record a completed review iteration in the session. + * + * This is called when the user approves or denies. It captures the full + * state of this review round: the plan text, the user's annotations, + * any Q&A that occurred, and the decision. + */ +export function recordIteration( + sessionId: string, + iteration: { + plan: string; + feedback: string; + questions: ClarificationQuestion[]; + answers: QuestionAnswer[]; + decision: "approved" | "denied"; + } +): ReviewSession { + let session = loadSession(sessionId); + + if (!session) { + // Session was lost or never created — create it now + session = createSession(sessionId, "_unknown", "_unknown"); + } + + const iterationRecord: ReviewIteration = { + iterationNumber: session.iterations.length + 1, + plan: iteration.plan, + feedback: iteration.feedback, + questions: iteration.questions, + answers: iteration.answers, + decision: iteration.decision, + timestamp: new Date().toISOString(), + }; + + session.iterations.push(iterationRecord); + session.updatedAt = new Date().toISOString(); + saveSession(session); + + return session; +} + +/** + * Get the previous iterations for a session (everything before the current round). + * Returns an empty array if no session exists or it's the first iteration. + */ +export function getPreviousIterations( + sessionId: string +): ReviewIteration[] { + const session = loadSession(sessionId); + if (!session) return []; + return session.iterations; +} + +/** + * Clean up old session files. + * Removes sessions older than MAX_SESSION_AGE_MS. + * Returns the number of sessions removed. + */ +export function cleanupSessions(): number { + const dir = ensureSessionsDir(); + const now = Date.now(); + let removed = 0; + + try { + const entries = readdirSync(dir); + for (const entry of entries) { + if (!entry.endsWith(".json")) continue; + const filePath = join(dir, entry); + try { + const stat = statSync(filePath); + if (now - stat.mtime.getTime() > MAX_SESSION_AGE_MS) { + unlinkSync(filePath); + removed++; + } + } catch { + // Skip files we can't stat + } + } + } catch { + // Directory doesn't exist or isn't readable + } + + return removed; +} diff --git a/packages/shared/feedback-templates.ts b/packages/shared/feedback-templates.ts index ae14a04f..62e86da0 100644 --- a/packages/shared/feedback-templates.ts +++ b/packages/shared/feedback-templates.ts @@ -5,8 +5,21 @@ * directive framing — Claude was ignoring softer phrasing. */ +import { + formatIterationContext, + formatQAContext, + type ReviewIteration, + type ClarificationQuestion, + type QuestionAnswer, +} from "./questions"; + export interface PlanDenyFeedbackOptions { planFilePath?: string; + /** Previous review iterations for context persistence */ + previousIterations?: ReviewIteration[]; + /** Clarification Q&A from the current iteration */ + clarificationQuestions?: ClarificationQuestion[]; + clarificationAnswers?: QuestionAnswer[]; } export const planDenyFeedback = ( @@ -18,5 +31,36 @@ export const planDenyFeedback = ( ? `- Read ${options.planFilePath} to see the current plan before editing it.\n` : ""; - return `YOUR PLAN WAS NOT APPROVED.\n\nYou MUST revise the plan to address ALL of the feedback below before calling ${toolName} again.\n\nRules:\n${planFileRule}- Use the Edit tool to make targeted changes to the plan — do not resubmit the same plan unchanged.\n- Do NOT change the plan title (first # heading) unless the user explicitly asks you to.\n\n${feedback || "Plan changes requested"}`; + // Build accumulated context from previous iterations + const iterationContext = options?.previousIterations?.length + ? `\n\n${formatIterationContext(options.previousIterations)}` + : ""; + + // Build Q&A context from current iteration + const qaContext = + options?.clarificationQuestions?.length + ? `\n\n${formatQAContext(options.clarificationQuestions, options.clarificationAnswers || [])}` + : ""; + + return `YOUR PLAN WAS NOT APPROVED.\n\nYou MUST revise the plan to address ALL of the feedback below before calling ${toolName} again.\n\nRules:\n${planFileRule}- Use the Edit tool to make targeted changes to the plan — do not resubmit the same plan unchanged.\n- Do NOT change the plan title (first # heading) unless the user explicitly asks you to.\n\n${feedback || "Plan changes requested"}${qaContext}${iterationContext}`; +}; + +/** + * Feedback template for "approve with notes" — the plan is approved and the + * agent should proceed with implementation, but the user attached annotations + * that should be considered (not blocking). + */ +export const planApproveWithNotesFeedback = ( + feedback: string, + options?: { + clarificationQuestions?: ClarificationQuestion[]; + clarificationAnswers?: QuestionAnswer[]; + }, +): string => { + const qaContext = + options?.clarificationQuestions?.length + ? `\n\n${formatQAContext(options.clarificationQuestions, options.clarificationAnswers || [])}` + : ""; + + return `Plan approved with notes!\n\n## Implementation Notes\n\nThe user approved your plan but added the following notes to consider during implementation:\n\n${feedback}${qaContext}\n\nProceed with implementation, incorporating these notes where applicable.`; }; diff --git a/packages/shared/prompts.ts b/packages/shared/prompts.ts new file mode 100644 index 00000000..e8d83ab0 --- /dev/null +++ b/packages/shared/prompts.ts @@ -0,0 +1,86 @@ +/** + * AI prompt fragments for teaching the AI how to use Plannotator's + * bidirectional conversation features. + * + * These are injected into system prompts by the hook and plugin to + * enable the AI to embed clarification questions in plans. + */ + +export const CLARIFICATION_QUESTIONS_PROMPT = ` +## Plannotator Clarification Questions + +When you need clarification from the user before finalizing a plan, you can embed interactive questions directly in the plan. The user will see these as clickable question cards in the Plannotator review UI. + +### How to Embed Questions + +Add a special HTML comment block anywhere in your plan (it will be invisible in the rendered markdown): + +\`\`\` + +\`\`\` + +### Available Question Types + +- **pick_one**: Radio buttons — user selects exactly one option +- **pick_many**: Checkboxes — user selects one or more options +- **confirm**: Yes/No buttons — for binary decisions +- **ask_text**: Text input — for open-ended answers +- **show_options**: Cards with pros/cons — for complex trade-off decisions + +### Question Format + +Each question object has: +- \`id\` (required): Unique identifier (e.g., "q1", "q2") +- \`type\` (required): One of the types above +- \`question\` (required): The question text +- \`context\` (optional): Why you're asking — helps the user understand the impact +- \`options\` (for pick_one, pick_many, show_options): Array of option objects with \`id\`, \`label\`, optional \`description\`, optional \`recommended: true\` +- \`placeholder\` (for ask_text): Placeholder text for the input +- \`required\` (optional): Whether the question must be answered (default: true) + +For **show_options** type, options can also include \`pros\` and \`cons\` arrays of strings. + +### Session Continuity + +When you receive feedback from a denied plan that includes a session ID like: +\`Include this in your next plan: \` + +You MUST include that session marker in your revised plan to maintain review context across iterations. + +### Best Practices + +1. Only ask questions when genuinely uncertain — don't ask about things you can reasonably decide +2. Keep questions focused and specific — avoid vague or overly broad questions +3. Provide context for each question so the user understands why it matters +4. Use pick_one/pick_many when you can enumerate the options — it's faster for the user +5. Use ask_text only when the answer space is truly open-ended +6. Group related questions logically — the user sees them all at once +7. Limit to 2-5 questions per plan — too many questions defeats the purpose +`.trim(); + +/** + * Short prompt fragment for session continuity. + * Used when a session ID needs to be carried forward. + */ +export function sessionContinuityPrompt(sessionId: string): string { + return `Include this session marker in your next plan to maintain review context: `; +} diff --git a/packages/shared/questions.ts b/packages/shared/questions.ts new file mode 100644 index 00000000..8a205f03 --- /dev/null +++ b/packages/shared/questions.ts @@ -0,0 +1,254 @@ +/** + * Shared types for AI ↔ Human clarification questions + * and review session persistence across iterations. + * + * Inspired by Octto's branch-based Q&A model, adapted for Plannotator's + * plan review workflow. + */ + +// --- Clarification Question Types --- + +export type QuestionType = + | "pick_one" + | "pick_many" + | "confirm" + | "ask_text" + | "show_options"; + +export interface QuestionOption { + id: string; + label: string; + description?: string; + recommended?: boolean; +} + +export interface ShowOption extends QuestionOption { + pros?: string[]; + cons?: string[]; +} + +/** + * A clarification question the AI pushes to the user during plan review. + * + * Questions can optionally be grouped by `branchId` (inspired by Octto's + * branch-based exploration model) to organize related questions together. + */ +export interface ClarificationQuestion { + id: string; + type: QuestionType; + question: string; + /** Why the AI is asking this — shown as context in the UI */ + context?: string; + /** For pick_one, pick_many */ + options?: QuestionOption[]; + /** For show_options — richer option cards with pros/cons */ + showOptions?: ShowOption[]; + /** For ask_text — input placeholder */ + placeholder?: string; + /** Whether the user must answer before proceeding */ + required?: boolean; + /** Optional grouping key (e.g., "cache-strategy", "auth-approach") */ + branchId?: string; + /** Human-friendly branch label */ + branchLabel?: string; +} + +export interface QuestionAnswer { + questionId: string; + type: QuestionType; + /** string for text/pick_one/confirm, string[] for pick_many */ + answer: string | string[]; + answeredAt: string; +} + +export interface ClarificationSession { + questions: ClarificationQuestion[]; + answers: QuestionAnswer[]; + status: "active" | "complete"; +} + +// --- Review Session Persistence --- + +/** + * A single review iteration — one deny/revise cycle. + * Captures everything that happened in that round. + */ +export interface ReviewIteration { + iterationNumber: number; + /** The plan text submitted in this iteration */ + plan: string; + /** User annotations/feedback for this iteration */ + feedback: string; + /** AI clarification questions asked in this iteration */ + questions: ClarificationQuestion[]; + /** User answers to clarification questions */ + answers: QuestionAnswer[]; + /** How the user decided */ + decision: "approved" | "denied"; + /** ISO timestamp */ + timestamp: string; +} + +/** + * A review session that persists across multiple deny/revise cycles. + * This is the key data structure for solving context loss. + */ +export interface ReviewSession { + sessionId: string; + projectPath: string; + planSlug: string; + iterations: ReviewIteration[]; + createdAt: string; + updatedAt: string; +} + +// --- Plan Metadata Parsing --- + +/** + * Regex to extract session ID from plan metadata comment. + * Format: + */ +const SESSION_METADATA_RE = + //; + +/** + * Regex to extract clarification questions from plan metadata comment. + * Format: + */ +const QUESTIONS_METADATA_RE = + //; + +/** + * Extract the session ID embedded in a plan's metadata comment. + * Returns null if no session metadata is found. + */ +export function extractSessionId(plan: string): string | null { + const match = plan.match(SESSION_METADATA_RE); + return match ? match[1] : null; +} + +/** + * Extract clarification questions embedded in a plan's metadata comment. + * Returns an empty array if no questions metadata is found or parsing fails. + */ +export function extractQuestions(plan: string): ClarificationQuestion[] { + const match = plan.match(QUESTIONS_METADATA_RE); + if (!match) return []; + + try { + const parsed = JSON.parse(match[1]); + if (!Array.isArray(parsed)) return []; + // Basic validation: each item must have id, type, and question + return parsed.filter( + (q: unknown) => + typeof q === "object" && + q !== null && + "id" in q && + "type" in q && + "question" in q + ) as ClarificationQuestion[]; + } catch { + return []; + } +} + +/** + * Strip all plannotator metadata comments from the plan markdown. + * This returns the "clean" plan the user sees in the UI. + */ +export function stripPlanMetadata(plan: string): string { + return plan + .replace(SESSION_METADATA_RE, "") + .replace(QUESTIONS_METADATA_RE, "") + .replace(/^\s*\n/, ""); // Remove leading blank line if metadata was at top +} + +/** + * Embed a session ID into a plan as a metadata comment. + * Prepends it as the first line (invisible in rendered markdown). + */ +export function embedSessionId(plan: string, sessionId: string): string { + // Remove existing session metadata first + const cleaned = plan.replace(SESSION_METADATA_RE, "").replace(/^\s*\n/, ""); + return `\n${cleaned}`; +} + +/** + * Generate a unique session ID. + * Format: ps_{timestamp}_{random} (ps = plannotator session) + */ +export function generateSessionId(): string { + const ts = Date.now().toString(36); + const rand = Math.random().toString(36).slice(2, 8); + return `ps_${ts}_${rand}`; +} + +/** + * Format Q&A context for inclusion in feedback templates. + * Produces a human-readable summary of questions asked and answers given. + */ +export function formatQAContext( + questions: ClarificationQuestion[], + answers: QuestionAnswer[] +): string { + if (questions.length === 0) return ""; + + const lines: string[] = ["## Clarification Q&A"]; + const answerMap = new Map(answers.map((a) => [a.questionId, a])); + + for (const q of questions) { + const a = answerMap.get(q.id); + lines.push(""); + lines.push(`**Q:** ${q.question}`); + + if (a) { + const answerText = Array.isArray(a.answer) + ? a.answer.join(", ") + : a.answer; + lines.push(`**A:** ${answerText}`); + } else { + lines.push(`**A:** _(not answered)_`); + } + } + + return lines.join("\n"); +} + +/** + * Format a summary of previous review iterations for feedback context. + * This is the key function that solves context loss across cycles. + */ +export function formatIterationContext( + iterations: ReviewIteration[] +): string { + if (iterations.length === 0) return ""; + + const lines: string[] = ["## Previous Review Iterations"]; + + for (const iter of iterations) { + lines.push(""); + lines.push( + `### Iteration ${iter.iterationNumber} (${iter.decision}, ${new Date(iter.timestamp).toLocaleString()})` + ); + + // Include feedback summary (truncated if very long) + if (iter.feedback) { + const feedbackPreview = + iter.feedback.length > 500 + ? iter.feedback.slice(0, 500) + "..." + : iter.feedback; + lines.push(""); + lines.push("**User Feedback:**"); + lines.push(feedbackPreview); + } + + // Include Q&A if any + const qaContext = formatQAContext(iter.questions, iter.answers); + if (qaContext) { + lines.push(""); + lines.push(qaContext); + } + } + + return lines.join("\n"); +} diff --git a/packages/ui/components/ClarificationPanel.tsx b/packages/ui/components/ClarificationPanel.tsx new file mode 100644 index 00000000..6c5c56d2 --- /dev/null +++ b/packages/ui/components/ClarificationPanel.tsx @@ -0,0 +1,617 @@ +/** + * ClarificationPanel — Floating panel for AI clarification questions + * + * When the AI embeds questions in a plan, this panel renders them as + * interactive question cards. Supports pick_one, pick_many, confirm, + * ask_text, and show_options question types. Inspired by Octto's + * branch-based Q&A model. + */ + +import React, { useState, useCallback } from "react"; + +/* ------------------------------------------------------------------ */ +/* Types (matching packages/shared/questions.ts) */ +/* ------------------------------------------------------------------ */ + +export type QuestionType = + | "pick_one" + | "pick_many" + | "confirm" + | "ask_text" + | "show_options"; + +export interface QuestionOption { + id: string; + label: string; + description?: string; + recommended?: boolean; +} + +export interface ShowOption { + id: string; + name: string; + description?: string; + pros?: string[]; + cons?: string[]; +} + +export interface ClarificationQuestion { + id: string; + type: QuestionType; + question: string; + context?: string; + options?: QuestionOption[]; + showOptions?: ShowOption[]; + placeholder?: string; + required?: boolean; + branchId?: string; +} + +export interface QuestionAnswer { + questionId: string; + type: QuestionType; + answer: string | string[]; + answeredAt: string; +} + +/* ------------------------------------------------------------------ */ +/* Props */ +/* ------------------------------------------------------------------ */ + +interface ClarificationPanelProps { + isOpen: boolean; + questions: ClarificationQuestion[]; + answers: QuestionAnswer[]; + onSubmitAnswer: (answer: QuestionAnswer) => void; + onClose: () => void; + isSubmitting?: boolean; +} + +/* ------------------------------------------------------------------ */ +/* Main Component */ +/* ------------------------------------------------------------------ */ + +export const ClarificationPanel: React.FC = ({ + isOpen, + questions, + answers, + onSubmitAnswer, + onClose, + isSubmitting, +}) => { + if (!isOpen || questions.length === 0) return null; + + const answeredIds = new Set(answers.map((a) => a.questionId)); + const unanswered = questions.filter((q) => !answeredIds.has(q.id)); + const answered = questions.filter((q) => answeredIds.has(q.id)); + const allAnswered = unanswered.length === 0; + + return ( +
+
+ {/* Header */} +
+ + + + + Clarification Questions + + + {answers.length}/{questions.length} answered + + +
+ + {/* Questions */} +
+ {/* Unanswered questions */} + {unanswered.map((q) => ( + + ))} + + {/* Answered questions (collapsed) */} + {answered.length > 0 && ( + <> + {unanswered.length > 0 && ( +
+
+ + Answered + +
+
+ )} + {answered.map((q) => { + const answer = answers.find((a) => a.questionId === q.id); + return ( + + ); + })} + + )} +
+ + {/* Footer */} + {allAnswered && ( +
+
+ + + + + All questions answered — continue reviewing the plan + + +
+
+ )} +
+
+ ); +}; + +/* ------------------------------------------------------------------ */ +/* QuestionCard — Single unanswered question */ +/* ------------------------------------------------------------------ */ + +const QuestionCard: React.FC<{ + question: ClarificationQuestion; + onSubmit: (answer: QuestionAnswer) => void; + disabled?: boolean; +}> = ({ question, onSubmit, disabled }) => { + const [selection, setSelection] = useState( + question.type === "pick_many" ? [] : "" + ); + const [error, setError] = useState(null); + + const handleSubmit = useCallback(() => { + // Validate + if (question.required !== false) { + if (question.type === "pick_many" && (selection as string[]).length === 0) { + setError("Please select at least one option"); + return; + } + if (question.type !== "pick_many" && !selection) { + setError("Please provide an answer"); + return; + } + } + setError(null); + onSubmit({ + questionId: question.id, + type: question.type, + answer: selection, + answeredAt: new Date().toISOString(), + }); + }, [question, selection, onSubmit]); + + return ( +
+ {/* Branch context */} + {question.branchId && ( +
+ {question.branchId} +
+ )} + + {/* Question text */} +
+ {question.question} +
+ + {/* Context/reasoning */} + {question.context && ( +
+ {question.context} +
+ )} + + {/* Answer input based on type */} +
+ {question.type === "pick_one" && question.options && ( + { setSelection(v); setError(null); }} + /> + )} + {question.type === "pick_many" && question.options && ( + { setSelection(v); setError(null); }} + /> + )} + {question.type === "confirm" && ( + { setSelection(v); setError(null); }} + /> + )} + {question.type === "ask_text" && ( + { setSelection(v); setError(null); }} + /> + )} + {question.type === "show_options" && question.showOptions && ( + { setSelection(v); setError(null); }} + /> + )} +
+ + {/* Error */} + {error && ( +
{error}
+ )} + + {/* Submit button */} +
+ +
+
+ ); +}; + +/* ------------------------------------------------------------------ */ +/* AnsweredCard — Collapsed answered question */ +/* ------------------------------------------------------------------ */ + +const AnsweredCard: React.FC<{ + question: ClarificationQuestion; + answer: QuestionAnswer; +}> = ({ question, answer }) => { + const displayAnswer = formatAnswer(question, answer); + + return ( +
+ + + +
+
+ {question.question} +
+
+ {displayAnswer} +
+
+
+ ); +}; + +/* ------------------------------------------------------------------ */ +/* Input Components */ +/* ------------------------------------------------------------------ */ + +const PickOneInput: React.FC<{ + options: QuestionOption[]; + value: string; + onChange: (value: string) => void; +}> = ({ options, value, onChange }) => ( +
+ {options.map((opt) => ( + + ))} +
+); + +const PickManyInput: React.FC<{ + options: QuestionOption[]; + value: string[]; + onChange: (value: string[]) => void; +}> = ({ options, value, onChange }) => ( +
+ {options.map((opt) => { + const checked = value.includes(opt.id); + return ( + + ); + })} +
+); + +const ConfirmInput: React.FC<{ + value: string; + onChange: (value: string) => void; +}> = ({ value, onChange }) => ( +
+ + +
+); + +const TextInput: React.FC<{ + placeholder?: string; + value: string; + onChange: (value: string) => void; +}> = ({ placeholder, value, onChange }) => ( +