import { useState, useCallback, useEffect, useRef } from "react"; import type { Task, PlanningQuestion, PlanningSummary } from "@fusion/core"; import { startPlanning, startPlanningStreaming, respondToPlanning, cancelPlanning, createTaskFromPlanning, connectPlanningStream, type PlanningSession, } from "../api"; import { Lightbulb, X, Loader2, CheckCircle, ArrowLeft, ArrowRight, Sparkles } from "lucide-react"; interface PlanningModeModalProps { isOpen: boolean; onClose: () => void; onTaskCreated: (task: Task) => void; tasks: Task[]; initialPlan?: string; } interface QuestionResponse { [key: string]: unknown; } type ViewState = | { type: "initial" } | { type: "question"; session: PlanningSession } | { type: "summary"; session: PlanningSession; summary: PlanningSummary } | { type: "loading" }; const EXAMPLE_PLANS = [ "Build a user authentication system with login and signup", "Add dark mode support to the dashboard", "Create an API endpoint for exporting tasks as CSV", "Refactor the task card component for better performance", ]; export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initialPlan: initialPlanProp }: PlanningModeModalProps) { const [initialPlan, setInitialPlan] = 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); // Use ref instead of state for hasAutoStarted to handle React StrictMode double-render. // In StrictMode, components render twice but state persists across renders, // which would skip auto-start on the second (committed) render. Refs are // re-initialized on each render, ensuring the auto-start effect runs correctly. const hasAutoStartedRef = useRef(false); const [streamingOutput, setStreamingOutput] = useState(""); const [showThinking, setShowThinking] = useState(true); const textareaRef = useRef(null); const streamConnectionRef = useRef<{ close: () => void; isConnected: () => boolean } | null>(null); const currentSessionIdRef = useRef(null); const handleStartPlanning = useCallback(async (planOverride?: string) => { const plan = planOverride ?? initialPlan; if (!plan.trim()) return; setError(null); setStreamingOutput(""); setView({ type: "loading" }); try { // Use streaming mode for real-time AI thinking display const { sessionId } = await startPlanningStreaming(plan.trim()); currentSessionIdRef.current = sessionId; // Connect to SSE stream const connection = connectPlanningStream(sessionId, { onThinking: (data) => { setStreamingOutput((prev) => prev + data); }, onQuestion: (question) => { setView({ type: "question", session: { sessionId, currentQuestion: question, summary: null }, }); setStreamingOutput(""); setHasProgress(true); }, onSummary: (summary) => { setView({ type: "summary", session: { sessionId, currentQuestion: null, summary }, 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 planning session"); setView({ type: "initial" }); currentSessionIdRef.current = null; } }, [initialPlan]); // Focus textarea when opening useEffect(() => { if (isOpen && view.type === "initial") { textareaRef.current?.focus(); } }, [isOpen, view.type]); // Auto-start planning when initialPlan prop is provided useEffect(() => { if (isOpen && initialPlanProp && !hasAutoStartedRef.current && view.type === "initial") { setInitialPlan(initialPlanProp); // Use a small timeout to allow state update to propagate before starting const timer = setTimeout(() => { // Only mark as auto-started when we actually start planning hasAutoStartedRef.current = true; handleStartPlanning(initialPlanProp); }, 0); return () => clearTimeout(timer); } }, [isOpen, initialPlanProp, view.type, handleStartPlanning]); // Reset hasAutoStarted when modal closes useEffect(() => { if (!isOpen) { hasAutoStartedRef.current = false; } }, [isOpen]); // Cleanup stream connection on unmount useEffect(() => { return () => { streamConnectionRef.current?.close(); streamConnectionRef.current = null; }; }, []); // Handle browser unload during active session useEffect(() => { if (!isOpen) return; const handleBeforeUnload = (e: BeforeUnloadEvent) => { if (view.type === "question" || view.type === "summary") { e.preventDefault(); e.returnValue = ""; } // Close stream connection streamConnectionRef.current?.close(); }; window.addEventListener("beforeunload", handleBeforeUnload); return () => window.removeEventListener("beforeunload", handleBeforeUnload); }, [isOpen, view]); const handleCancel = useCallback(async () => { // Show confirmation if user has made progress if (hasProgress) { if (!confirm("Are you sure you want to close? Your planning progress will be lost.")) { return; } } // Always close the stream connection streamConnectionRef.current?.close(); streamConnectionRef.current = null; if (view.type === "question" || view.type === "summary") { try { await cancelPlanning(view.session.sessionId); } catch { // Ignore errors on cancel } } setInitialPlan(""); setView({ type: "initial" }); setError(null); setResponseHistory([]); setEditedSummary(null); setStreamingOutput(""); setHasProgress(false); currentSessionIdRef.current = null; onClose(); }, [hasProgress, view, onClose]); // Handle escape key to close useEffect(() => { if (!isOpen) return; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { if (hasProgress) { if (confirm("Are you sure you want to close? Your planning 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 { session } = view; const sessionId = session.sessionId; setError(null); // Keep the existing SSE connection alive - do NOT close it! // The connection established in handleStartPlanning will continue // to receive events (thinking, question, summary) throughout the session. // This prevents the race condition where events are missed because // the frontend disconnects and reconnects after the API call. setView({ type: "loading" }); setStreamingOutput(""); // Clear old thinking output when entering loading state try { // Submit response - AI will broadcast events via the already-connected stream await respondToPlanning(sessionId, responses); setResponseHistory((prev) => [...prev, responses]); setHasProgress(true); // Events (question/summary) will arrive via the existing SSE stream } catch (err: any) { setError(err.message || "Failed to submit response"); setView({ type: "question", session }); } }, [view] ); const handleCreateTask = useCallback(async () => { if (view.type !== "summary") return; setError(null); setView({ type: "loading" }); try { const task = await createTaskFromPlanning(view.session.sessionId); onTaskCreated(task); handleCancel(); } catch (err: any) { setError(err.message || "Failed to create task"); setView({ type: "summary", session: view.session, summary: view.summary }); } }, [view, onTaskCreated, handleCancel]); const handleBack = useCallback(() => { if (view.type === "question" && responseHistory.length > 0) { // Remove last response and go back const previousResponses = responseHistory.slice(0, -1); setResponseHistory(previousResponses); // Note: We don't actually have a way to go back in the backend, // so we just reset to the question from the initial session setView({ type: "question", session: view.session }); } }, [view, responseHistory]); const getProgress = () => { if (view.type === "question") { return Math.min(responseHistory.length + 1, 3); } return 3; }; if (!isOpen) return null; return (
e.target === e.currentTarget && handleCancel()}>

Planning Mode

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

Transform your idea into a detailed task

Describe what you want to build in plain language. The AI will ask clarifying questions and help you structure a well-defined task.