import { useState, useCallback, useEffect, useRef } from "react"; import type { PlanningQuestion } from "@fusion/core"; import { startMissionInterview, respondToMissionInterview, cancelMissionInterview, createMissionFromInterview, connectMissionInterviewStream, fetchAiSession, type MissionPlanSummary, type MissionPlanMilestone, type MissionPlanSlice, type MissionPlanFeature, type MissionWithHierarchy, } from "../api"; import { saveMissionGoal, getMissionGoal, clearMissionGoal, } from "../hooks/modalPersistence"; import { Target, X, Loader2, CheckCircle, ArrowLeft, ArrowRight, Sparkles, ChevronRight, ChevronDown, Layers, Package, Box, Plus, Trash2, } from "lucide-react"; interface MissionInterviewModalProps { isOpen: boolean; onClose: () => void; onMissionCreated: (mission: MissionWithHierarchy) => void; projectId?: string; initialGoal?: string; resumeSessionId?: string; } interface QuestionResponse { [key: string]: unknown; } type ViewState = | { type: "initial" } | { type: "loading" } | { type: "question"; sessionId: string; question: PlanningQuestion } | { type: "summary"; sessionId: string; summary: MissionPlanSummary }; const EXAMPLE_MISSIONS = [ "Build a real-time collaborative document editor", "Create a customer onboarding flow with email verification", "Add a reporting dashboard with charts and CSV export", "Implement a plugin system with marketplace", ]; export function MissionInterviewModal({ isOpen, onClose, onMissionCreated, projectId, initialGoal: initialGoalProp, resumeSessionId, }: MissionInterviewModalProps) { const [missionGoal, setMissionGoal] = useState(""); const [view, setView] = useState({ type: "initial" }); const [error, setError] = useState(null); const [responseHistory, setResponseHistory] = useState([]); const [editedSummary, setEditedSummary] = useState(null); const [hasProgress, setHasProgress] = useState(false); const hasAutoStartedRef = useRef(false); const [streamingOutput, setStreamingOutput] = useState(""); const [showThinking, setShowThinking] = useState(true); const [isCreating, setIsCreating] = useState(false); const textareaRef = useRef(null); const streamConnectionRef = useRef<{ close: () => void; isConnected: () => boolean } | null>(null); const currentSessionIdRef = useRef(null); const handleStartInterview = useCallback( async (goalOverride?: string) => { const goal = goalOverride ?? missionGoal; if (!goal.trim()) return; setError(null); setStreamingOutput(""); setView({ type: "loading" }); try { const { sessionId } = await startMissionInterview(goal.trim(), projectId); currentSessionIdRef.current = sessionId; clearMissionGoal(); const connection = connectMissionInterviewStream(sessionId, projectId, { onThinking: (data) => { setStreamingOutput((prev) => prev + data); }, onQuestion: (question) => { clearMissionGoal(); setView({ type: "question", sessionId, question }); setStreamingOutput(""); setHasProgress(true); }, onSummary: (summary) => { clearMissionGoal(); setView({ type: "summary", sessionId, summary }); setEditedSummary(summary); setStreamingOutput(""); setHasProgress(true); }, onError: (message) => { setError(message); setView({ type: "initial" }); setStreamingOutput(""); currentSessionIdRef.current = null; }, onComplete: () => { currentSessionIdRef.current = null; }, }); streamConnectionRef.current = connection; setResponseHistory([]); } catch (err: any) { setError(err.message || "Failed to start interview session"); setView({ type: "initial" }); currentSessionIdRef.current = null; } }, [missionGoal, projectId] ); // Focus textarea when opening useEffect(() => { if (isOpen && view.type === "initial") { textareaRef.current?.focus(); } }, [isOpen, view.type]); // Auto-start when initialGoal prop is provided useEffect(() => { if (isOpen && initialGoalProp && !hasAutoStartedRef.current && view.type === "initial") { setMissionGoal(initialGoalProp); const timer = setTimeout(() => { hasAutoStartedRef.current = true; handleStartInterview(initialGoalProp); }, 0); return () => clearTimeout(timer); } else if (isOpen && !initialGoalProp && !hasAutoStartedRef.current && view.type === "initial") { // Check localStorage for persisted goal when no prop provided const persisted = getMissionGoal(); if (persisted) { setMissionGoal(persisted); } } }, [isOpen, initialGoalProp, view.type, handleStartInterview]); useEffect(() => { if (!isOpen) { hasAutoStartedRef.current = false; } }, [isOpen]); // Reconnect to a persisted session when resumeSessionId is provided useEffect(() => { if (!isOpen || !resumeSessionId || view.type !== "initial") return; let cancelled = false; fetchAiSession(resumeSessionId).then((session) => { if (cancelled || !session) return; if (session.status === "awaiting_input" && session.currentQuestion) { try { clearMissionGoal(); const question = JSON.parse(session.currentQuestion) as import("@fusion/core").PlanningQuestion; currentSessionIdRef.current = session.id; setHasProgress(true); setView({ type: "question", sessionId: session.id, question }); } catch { setError("Failed to restore session question."); } } else if (session.status === "complete" && session.result) { try { clearMissionGoal(); const summary = JSON.parse(session.result) as MissionPlanSummary; currentSessionIdRef.current = session.id; setHasProgress(true); setEditedSummary(summary); setView({ type: "summary", sessionId: session.id, summary }); } catch { setError("Failed to restore session result."); } } else if (session.status === "generating") { currentSessionIdRef.current = session.id; setHasProgress(true); if (session.thinkingOutput) { setStreamingOutput(session.thinkingOutput); } setView({ type: "loading" }); const connection = connectMissionInterviewStream(session.id, projectId, { onThinking: (data) => { setStreamingOutput((prev) => prev + data); }, onQuestion: (question) => { clearMissionGoal(); setView({ type: "question", sessionId: session.id, question }); setStreamingOutput(""); }, onSummary: (summary) => { clearMissionGoal(); setView({ type: "summary", sessionId: session.id, summary }); setEditedSummary(summary); setStreamingOutput(""); }, onError: (message) => { setError(message); setView({ type: "initial" }); setStreamingOutput(""); currentSessionIdRef.current = null; }, onComplete: () => { currentSessionIdRef.current = null; }, }); streamConnectionRef.current = connection; } else if (session.status === "error") { setError(session.error ?? "The session encountered an error."); } }).catch(() => { if (!cancelled) setError("Failed to resume session."); }); return () => { cancelled = true; }; }, [isOpen, resumeSessionId, view.type, projectId]); // Cleanup stream on unmount useEffect(() => { return () => { streamConnectionRef.current?.close(); streamConnectionRef.current = null; }; }, []); // Unload protection useEffect(() => { if (!isOpen) return; const handleBeforeUnload = (e: BeforeUnloadEvent) => { if (view.type === "question" || view.type === "summary") { e.preventDefault(); e.returnValue = ""; } streamConnectionRef.current?.close(); }; window.addEventListener("beforeunload", handleBeforeUnload); return () => window.removeEventListener("beforeunload", handleBeforeUnload); }, [isOpen, view]); const handleCancel = useCallback(async () => { // Save to localStorage BEFORE any cleanup if (missionGoal) { saveMissionGoal(missionGoal); } if (hasProgress) { if (!confirm("Are you sure you want to close? Your interview progress will be lost.")) { return; } } streamConnectionRef.current?.close(); streamConnectionRef.current = null; if (view.type === "question" || view.type === "summary") { try { await cancelMissionInterview(view.sessionId, projectId); } catch { // Ignore errors on cancel } } setMissionGoal(""); setView({ type: "initial" }); setError(null); setResponseHistory([]); setEditedSummary(null); setStreamingOutput(""); setHasProgress(false); setIsCreating(false); currentSessionIdRef.current = null; onClose(); }, [missionGoal, hasProgress, view, onClose, projectId]); // Escape key handler useEffect(() => { if (!isOpen) return; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { if (hasProgress) { if (confirm("Are you sure you want to close? Your interview progress will be lost.")) { handleCancel(); } } else { handleCancel(); } } }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); }, [isOpen, hasProgress, handleCancel]); const handleSubmitResponse = useCallback( async (responses: QuestionResponse) => { if (view.type !== "question") return; const { sessionId } = view; setError(null); setView({ type: "loading" }); setStreamingOutput(""); try { await respondToMissionInterview(sessionId, responses, projectId); setResponseHistory((prev) => [...prev, responses]); setHasProgress(true); } catch (err: any) { setError(err.message || "Failed to submit response"); setView({ type: "question", sessionId, question: view.question }); } }, [view, projectId] ); const handleApprovePlan = useCallback(async () => { if (view.type !== "summary") return; setError(null); setIsCreating(true); try { const mission = await createMissionFromInterview(view.sessionId, editedSummary || undefined, projectId); onMissionCreated(mission); clearMissionGoal(); // Reset state without confirmation streamConnectionRef.current?.close(); streamConnectionRef.current = null; setMissionGoal(""); setView({ type: "initial" }); setError(null); setResponseHistory([]); setEditedSummary(null); setStreamingOutput(""); setHasProgress(false); setIsCreating(false); currentSessionIdRef.current = null; onClose(); } catch (err: any) { setError(err.message || "Failed to create mission"); setIsCreating(false); } }, [view, editedSummary, onMissionCreated, onClose, projectId]); const getProgress = () => { if (view.type === "question") { return Math.min(responseHistory.length + 1, 6); } return 6; }; if (!isOpen) return null; return (
e.target === e.currentTarget && handleCancel()}>

Plan Mission with AI

{error &&
{error}
} {view.type === "initial" && (

Transform your vision into a structured mission

Describe what you want to build. The AI will interview you to understand scope, constraints, and requirements, then produce a structured plan with milestones, slices, and features.