import { useState, useCallback, useEffect, useRef } from "react"; import type { Task, PlanningQuestion, PlanningSummary } from "@kb/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); // 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); // 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); hasAutoStartedRef.current = true; // Use a small timeout to allow state update to propagate before starting const timer = setTimeout(() => { handleStartPlanningWithPlan(initialPlanProp); }, 0); return () => clearTimeout(timer); } }, [isOpen, initialPlanProp, view.type]); // 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]); // Handle escape key to close useEffect(() => { if (!isOpen) return; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { if (view.type === "question" || view.type === "summary") { 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, view]); const handleStartPlanning = useCallback(async () => { if (!initialPlan.trim()) return; setError(null); setStreamingOutput(""); setView({ type: "loading" }); try { // Use streaming mode for real-time AI thinking display const { sessionId } = await startPlanningStreaming(initialPlan.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(""); }, onSummary: (summary) => { setView({ type: "summary", session: { sessionId, currentQuestion: null, summary }, summary, }); setEditedSummary(summary); setStreamingOutput(""); }, 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]); // Helper for auto-start with a specific plan (from prop) const handleStartPlanningWithPlan = useCallback(async (plan: string) => { if (!plan.trim()) return; setError(null); setStreamingOutput(""); setView({ type: "loading" }); try { const { sessionId } = await startPlanningStreaming(plan.trim()); currentSessionIdRef.current = sessionId; const connection = connectPlanningStream(sessionId, { onThinking: (data) => { setStreamingOutput((prev) => prev + data); }, onQuestion: (question) => { setView({ type: "question", session: { sessionId, currentQuestion: question, summary: null }, }); setStreamingOutput(""); }, onSummary: (summary) => { setView({ type: "summary", session: { sessionId, currentQuestion: null, summary }, summary, }); setEditedSummary(summary); setStreamingOutput(""); }, 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; } }, []); const handleSubmitResponse = useCallback( async (responses: QuestionResponse) => { if (view.type !== "question") return; const { session } = view; const sessionId = session.sessionId; setError(null); setStreamingOutput(""); setView({ type: "loading" }); try { // Close previous connection if any streamConnectionRef.current?.close(); // Submit response - this will trigger the AI to process and stream const updatedSession = await respondToPlanning(sessionId, responses); setResponseHistory((prev) => [...prev, responses]); // If we got an immediate response (non-streaming mode), use it if (updatedSession.summary) { setView({ type: "summary", session: updatedSession, summary: updatedSession.summary }); setEditedSummary(updatedSession.summary); return; } if (updatedSession.currentQuestion) { setView({ type: "question", session: updatedSession }); return; } // Otherwise, set up streaming for the next question const connection = connectPlanningStream(sessionId, { onThinking: (data) => { setStreamingOutput((prev) => prev + data); }, onQuestion: (question) => { setView({ type: "question", session: { sessionId, currentQuestion: question, summary: null }, }); setStreamingOutput(""); }, onSummary: (summary) => { setView({ type: "summary", session: { sessionId, currentQuestion: null, summary }, summary, }); setEditedSummary(summary); setStreamingOutput(""); }, onError: (message) => { setError(message); setView({ type: "question", session }); setStreamingOutput(""); }, }); streamConnectionRef.current = connection; } catch (err: any) { setError(err.message || "Failed to submit response"); setView({ type: "question", session }); } }, [view] ); const handleCancel = useCallback(async () => { // 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(""); currentSessionIdRef.current = null; onClose(); }, [view, onClose]); 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.