import { useState, useCallback, useEffect, useRef, useMemo } from "react"; import type { Task, PlanningQuestion, PlanningSummary } from "@fusion/core"; import { getErrorMessage } from "@fusion/core"; import { startPlanningStreaming, respondToPlanning, retryPlanningSession, createTaskFromPlanning, connectPlanningStream, fetchAiSession, parseConversationHistory, startPlanningBreakdown, createTasksFromPlanning, fetchModels, cancelPlanning, updateGlobalSettings, type PlanningSession, type SubtaskItem, type ModelInfo, type ConversationHistoryEntry, } from "../api"; import { savePlanningDescription, getPlanningDescription, clearPlanningDescription, } from "../hooks/modalPersistence"; import { Lightbulb, X, Loader2, CheckCircle, ArrowLeft, ArrowRight, Sparkles, ListTree, GripVertical, ArrowUp, ArrowDown, Plus, Trash2, Minimize2, RefreshCw, Lock } from "lucide-react"; import { CustomModelDropdown } from "./CustomModelDropdown"; import { ConversationHistory } from "./ConversationHistory"; import { useSessionLock } from "../hooks/useSessionLock"; import { useAiSessionSync } from "../hooks/useAiSessionSync"; import { getSessionTabId } from "../utils/getSessionTabId"; interface PlanningModeModalProps { isOpen: boolean; onClose: () => void; onTaskCreated: (task: Task) => void; onTasksCreated: (tasks: Task[]) => void; tasks: Task[]; initialPlan?: string; projectId?: string; /** When set, reconnect to a persisted background session instead of starting fresh */ resumeSessionId?: string; } interface QuestionResponse { [key: string]: unknown; } type ViewState = | { type: "initial" } | { type: "question"; session: PlanningSession } | { type: "summary"; session: PlanningSession; summary: PlanningSummary } | { type: "error"; session: PlanningSession; errorMessage: string } | { type: "breakdown"; sessionId: string; subtasks: SubtaskItem[]; dirty: boolean } | { type: "loading" } | { type: "creating" }; 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", ]; function getModelSelectionValue(provider?: string, modelId?: string): string { return provider && modelId ? `${provider}/${modelId}` : ""; } function parseModelSelection(value: string): { provider?: string; modelId?: string } { if (!value) { return { provider: undefined, modelId: undefined }; } const slashIndex = value.indexOf("/"); if (slashIndex === -1) { return { provider: undefined, modelId: undefined }; } return { provider: value.slice(0, slashIndex), modelId: value.slice(slashIndex + 1), }; } export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreated, tasks, initialPlan: initialPlanProp, projectId, resumeSessionId }: PlanningModeModalProps) { const [initialPlan, setInitialPlan] = useState(""); const [view, setView] = useState({ type: "initial" }); const [error, setError] = useState(null); const [responseHistory, setResponseHistory] = useState([]); const [conversationHistory, setConversationHistory] = 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 [isReconnecting, setIsReconnecting] = useState(false); const [isRetrying, setIsRetrying] = useState(false); const textareaRef = useRef(null); const streamConnectionRef = useRef<{ close: () => void; isConnected: () => boolean } | null>(null); const currentSessionIdRef = useRef(null); const [lockSessionId, setLockSessionId] = useState(resumeSessionId ?? null); const sessionTabId = useMemo(() => getSessionTabId(), []); const { isLockedByOther, takeControl, isLoading: isLockLoading, } = useSessionLock(isOpen ? lockSessionId : null); const { activeTabMap, broadcastUpdate, broadcastCompleted, broadcastLock, broadcastUnlock, broadcastHeartbeat, } = useAiSessionSync(); const [planningModelProvider, setPlanningModelProvider] = useState(undefined); const [planningModelId, setPlanningModelId] = useState(undefined); const [loadedModels, setLoadedModels] = useState([]); const [modelsLoading, setModelsLoading] = useState(false); const [modelsError, setModelsError] = useState(null); const [favoriteProviders, setFavoriteProviders] = useState([]); const [favoriteModels, setFavoriteModels] = useState([]); const trackedLockSessionRef = useRef(null); const planningSelectionValue = getModelSelectionValue(planningModelProvider, planningModelId); const getModelBadgeLabel = useCallback( (provider?: string, modelId?: string) => { if (!provider || !modelId) return "Using default"; const matched = loadedModels.find((model) => model.provider === provider && model.id === modelId); return matched ? `${matched.provider}/${matched.id}` : `${provider}/${modelId}`; }, [loadedModels], ); const loadModels = useCallback(async () => { setModelsLoading(true); setModelsError(null); try { const response = await fetchModels(); setLoadedModels(response.models); setFavoriteProviders(response.favoriteProviders); setFavoriteModels(response.favoriteModels); } catch (err) { setModelsError(getErrorMessage(err) || "Failed to load models"); } finally { setModelsLoading(false); } }, []); const handleToggleFavoriteProvider = useCallback((provider: string) => { setFavoriteProviders((prev) => { const currentFavorites = prev; const isFavorite = currentFavorites.includes(provider); const newFavorites = isFavorite ? currentFavorites.filter((item) => item !== provider) : [provider, ...currentFavorites]; updateGlobalSettings({ favoriteProviders: newFavorites, favoriteModels }).catch(() => { setFavoriteProviders(currentFavorites); }); return newFavorites; }); }, [favoriteModels]); const handleToggleFavoriteModel = useCallback((modelId: string) => { setFavoriteModels((prev) => { const currentFavorites = prev; const isFavorite = currentFavorites.includes(modelId); const newFavorites = isFavorite ? currentFavorites.filter((item) => item !== modelId) : [modelId, ...currentFavorites]; updateGlobalSettings({ favoriteProviders, favoriteModels: newFavorites }).catch(() => { setFavoriteModels(currentFavorites); }); return newFavorites; }); }, [favoriteProviders]); const connectToPlanningStream = useCallback( (sessionId: string) => { streamConnectionRef.current?.close(); const connection = connectPlanningStream(sessionId, projectId, { onThinking: (data) => { setStreamingOutput((prev) => prev + data); broadcastUpdate({ sessionId, status: "generating", needsInput: false, owningTabId: sessionTabId, type: "planning", title: initialPlan.trim() || "Planning session", projectId: projectId ?? null, }); }, onQuestion: (question) => { setIsReconnecting(false); setIsRetrying(false); clearPlanningDescription(projectId); setView({ type: "question", session: { sessionId, currentQuestion: question, summary: null }, }); setStreamingOutput(""); broadcastUpdate({ sessionId, status: "awaiting_input", needsInput: true, owningTabId: sessionTabId, type: "planning", title: initialPlan.trim() || "Planning session", projectId: projectId ?? null, }); }, onSummary: (summary) => { setIsReconnecting(false); setIsRetrying(false); clearPlanningDescription(projectId); setView({ type: "summary", session: { sessionId, currentQuestion: null, summary }, summary, }); setEditedSummary(summary); setStreamingOutput(""); broadcastUpdate({ sessionId, status: "complete", needsInput: false, owningTabId: sessionTabId, type: "planning", title: initialPlan.trim() || "Planning session", projectId: projectId ?? null, }); }, onError: (message) => { const errorMessage = message || "Session failed while contacting the AI."; setIsReconnecting(false); setIsRetrying(false); setError(null); setView((prev) => { if (prev.type === "question" || prev.type === "summary" || prev.type === "error") { return { type: "error", session: prev.session, errorMessage }; } return { type: "error", session: { sessionId, currentQuestion: null, summary: null }, errorMessage, }; }); setStreamingOutput(""); currentSessionIdRef.current = sessionId; broadcastUpdate({ sessionId, status: "error", needsInput: false, owningTabId: sessionTabId, type: "planning", title: initialPlan.trim() || "Planning session", projectId: projectId ?? null, }); broadcastCompleted({ sessionId, status: "error" }); }, onComplete: () => { setIsReconnecting(false); setIsRetrying(false); currentSessionIdRef.current = null; broadcastCompleted({ sessionId, status: "complete" }); }, onConnectionStateChange: (state) => { setIsReconnecting(state === "reconnecting"); }, }); streamConnectionRef.current = connection; }, [broadcastCompleted, broadcastUpdate, initialPlan, projectId, sessionTabId], ); const handleStartPlanning = useCallback(async (planOverride?: string) => { const plan = planOverride ?? initialPlan; if (!plan.trim()) return; setError(null); setStreamingOutput(""); setConversationHistory([]); setResponseHistory([]); setIsReconnecting(false); setView({ type: "loading" }); try { // Use streaming mode for real-time AI thinking display const modelOverride = planningModelProvider && planningModelId ? { planningModelProvider, planningModelId } : undefined; const { sessionId } = await startPlanningStreaming(plan.trim(), projectId, modelOverride); currentSessionIdRef.current = sessionId; setLockSessionId(sessionId); connectToPlanningStream(sessionId); setResponseHistory([]); } catch (err) { setIsReconnecting(false); setError(getErrorMessage(err) || "Failed to start planning session"); setView({ type: "initial" }); currentSessionIdRef.current = null; setLockSessionId(null); } }, [connectToPlanningStream, initialPlan, planningModelId, planningModelProvider, projectId]); // Focus textarea when opening useEffect(() => { if (isOpen && view.type === "initial") { textareaRef.current?.focus(); } }, [isOpen, view.type]); useEffect(() => { if (!isOpen) { return; } void loadModels(); }, [isOpen, loadModels]); // 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); } else if (isOpen && !initialPlanProp && !hasAutoStartedRef.current && view.type === "initial") { // Check localStorage for persisted description when no prop provided const persisted = getPlanningDescription(projectId); if (persisted) { setInitialPlan(persisted); } } }, [isOpen, initialPlanProp, view.type, handleStartPlanning]); // Resume a persisted background session useEffect(() => { if (!isOpen || !resumeSessionId || view.type !== "initial") return; let cancelled = false; (async () => { try { const session = await fetchAiSession(resumeSessionId); if (cancelled || !session) return; currentSessionIdRef.current = resumeSessionId; setLockSessionId(resumeSessionId); const parsedHistory = parseConversationHistory(session.conversationHistory); setConversationHistory(parsedHistory); setResponseHistory( parsedHistory .map((entry) => entry.response) .filter((response): response is QuestionResponse => Boolean(response && typeof response === "object" && !Array.isArray(response)), ), ); if (session.status === "awaiting_input" && session.currentQuestion) { clearPlanningDescription(projectId); const question = JSON.parse(session.currentQuestion); setView({ type: "question", session: { sessionId: resumeSessionId, currentQuestion: question, summary: null } }); if (session.thinkingOutput) setStreamingOutput(session.thinkingOutput); // Connect to stream for real-time updates (e.g., thinking output, next question) // The server will emit a catch-up question event if the client missed it connectToPlanningStream(resumeSessionId); } else if (session.status === "complete" && session.result) { clearPlanningDescription(projectId); const summary = JSON.parse(session.result); setView({ type: "summary", session: { sessionId: resumeSessionId, currentQuestion: null, summary }, summary }); setEditedSummary(summary); } else if (session.status === "generating") { setView({ type: "loading" }); if (session.thinkingOutput) setStreamingOutput(session.thinkingOutput); connectToPlanningStream(resumeSessionId); } else if (session.status === "error") { setError(null); setView({ type: "error", session: { sessionId: resumeSessionId, currentQuestion: null, summary: null }, errorMessage: session.error || "Session failed", }); } } catch { setError("Failed to resume session"); } })(); return () => { cancelled = true; }; }, [connectToPlanningStream, isOpen, resumeSessionId, view.type, projectId]); // Reset hasAutoStarted when modal closes useEffect(() => { if (!isOpen) { hasAutoStartedRef.current = false; setIsReconnecting(false); setIsRetrying(false); setLockSessionId(null); } }, [isOpen]); // Broadcast lock ownership transitions for cross-tab awareness. useEffect(() => { if (!isOpen) { if (trackedLockSessionRef.current) { broadcastUnlock(trackedLockSessionRef.current, sessionTabId); trackedLockSessionRef.current = null; } return; } if (lockSessionId && trackedLockSessionRef.current !== lockSessionId) { if (trackedLockSessionRef.current) { broadcastUnlock(trackedLockSessionRef.current, sessionTabId); } broadcastLock(lockSessionId, sessionTabId); trackedLockSessionRef.current = lockSessionId; return; } if (!lockSessionId && trackedLockSessionRef.current) { broadcastUnlock(trackedLockSessionRef.current, sessionTabId); trackedLockSessionRef.current = null; } }, [broadcastLock, broadcastUnlock, isOpen, lockSessionId, sessionTabId]); // Emit heartbeat while this tab actively owns the current session lock. useEffect(() => { if (!isOpen || !lockSessionId || trackedLockSessionRef.current !== lockSessionId) { return; } broadcastHeartbeat(sessionTabId); const timer = setInterval(() => { broadcastHeartbeat(sessionTabId); }, 30_000); return () => { clearInterval(timer); }; }, [broadcastHeartbeat, isOpen, lockSessionId, sessionTabId]); // Cleanup stream connection on unmount useEffect(() => { return () => { streamConnectionRef.current?.close(); streamConnectionRef.current = null; if (trackedLockSessionRef.current) { broadcastUnlock(trackedLockSessionRef.current, sessionTabId); trackedLockSessionRef.current = null; } }; }, [broadcastUnlock, sessionTabId]); // Handle browser unload while modal is open useEffect(() => { if (!isOpen) return; const handleBeforeUnload = () => { // Session is preserved server-side; just disconnect the local stream. streamConnectionRef.current?.close(); streamConnectionRef.current = null; }; window.addEventListener("beforeunload", handleBeforeUnload); return () => window.removeEventListener("beforeunload", handleBeforeUnload); }, [isOpen]); const handleSendToBackground = useCallback(() => { streamConnectionRef.current?.close(); streamConnectionRef.current = null; onClose(); }, [onClose]); const handleCancel = useCallback(() => { // Determine the active session ID to abandon let activeSessionId: string | null = null; if (view.type === "question" || view.type === "summary" || view.type === "error") { activeSessionId = view.session.sessionId; } else if (view.type === "breakdown") { activeSessionId = view.sessionId; } else if (view.type === "loading") { // During loading, the session ID is stored in the ref activeSessionId = currentSessionIdRef.current; } // Save to localStorage BEFORE any cleanup (preserve for re-entry) if (initialPlan) { savePlanningDescription(initialPlan, projectId); } // Always close the stream connection streamConnectionRef.current?.close(); streamConnectionRef.current = null; // Explicitly abandon the session on the server to prevent zombie sessions if (activeSessionId) { void cancelPlanning(activeSessionId, projectId, sessionTabId).catch(() => { // Best-effort: cancellation failures should not block UI reset }); } setInitialPlan(""); setView({ type: "initial" }); setError(null); setResponseHistory([]); setConversationHistory([]); setEditedSummary(null); setStreamingOutput(""); setIsReconnecting(false); setIsRetrying(false); setPlanningModelProvider(undefined); setPlanningModelId(undefined); currentSessionIdRef.current = null; setLockSessionId(null); onClose(); }, [initialPlan, onClose, projectId, sessionTabId, view]); // Handle escape key to close useEffect(() => { if (!isOpen) return; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { handleCancel(); } }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); }, [isOpen, handleCancel]); const handleSubmitResponse = useCallback( async (responses: QuestionResponse) => { if (view.type !== "question") return; const { session } = view; const sessionId = session.sessionId; const activeQuestion = session.currentQuestion; if (!activeQuestion) { setError("No active question in session"); return; } 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. setResponseHistory((prev) => [...prev, responses]); setConversationHistory((prev) => [ ...prev, { question: activeQuestion, response: responses, }, ]); 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, projectId, sessionTabId); // Events (question/summary) will arrive via the existing SSE stream } catch (err) { setError(getErrorMessage(err) || "Failed to submit response"); setView({ type: "question", session }); } }, [projectId, sessionTabId, view] ); const handleRetryFromError = useCallback(async () => { if (view.type !== "error") { return; } const retryTarget = view.session; setError(null); setIsRetrying(true); setStreamingOutput(""); setView({ type: "loading" }); connectToPlanningStream(retryTarget.sessionId); try { currentSessionIdRef.current = retryTarget.sessionId; setLockSessionId(retryTarget.sessionId); await retryPlanningSession(retryTarget.sessionId, projectId, sessionTabId); } catch (err) { let retryError: unknown = err; const retryErrorMessage = getErrorMessage(err) || ""; if (retryErrorMessage.includes("not in an error state")) { try { const session = await fetchAiSession(retryTarget.sessionId); if (!session) { throw new Error("Failed to refresh planning session."); } currentSessionIdRef.current = session.id; setLockSessionId(session.id); if (session.status === "generating") { setStreamingOutput(session.thinkingOutput ?? ""); setView({ type: "loading" }); } else if (session.status === "awaiting_input") { if (!session.currentQuestion) { throw new Error("Planning session is awaiting input but has no current question."); } const question = JSON.parse(session.currentQuestion) as PlanningQuestion; clearPlanningDescription(projectId); setView({ type: "question", session: { sessionId: session.id, currentQuestion: question, summary: null }, }); if (!streamConnectionRef.current?.isConnected()) { connectToPlanningStream(session.id); } } else if (session.status === "complete") { if (!session.result) { throw new Error("Planning session is complete but has no result."); } const summary = JSON.parse(session.result) as PlanningSummary; clearPlanningDescription(projectId); setView({ type: "summary", session: { sessionId: session.id, currentQuestion: null, summary }, summary, }); setEditedSummary(summary); } else if (session.status === "error") { setView({ type: "error", session: { sessionId: session.id, currentQuestion: null, summary: null }, errorMessage: session.error || "Retry failed. Please try again.", }); } setIsReconnecting(false); return; } catch (sessionRefreshError) { retryError = sessionRefreshError; } } streamConnectionRef.current?.close(); streamConnectionRef.current = null; setView({ type: "error", session: retryTarget, errorMessage: getErrorMessage(retryError) || "Retry failed. Please try again.", }); setIsReconnecting(false); } finally { setIsRetrying(false); } }, [connectToPlanningStream, projectId, sessionTabId, view]); const handleCreateTask = useCallback(async () => { if (view.type !== "summary") return; setError(null); setView({ type: "loading" }); try { const task = await createTaskFromPlanning(view.session.sessionId, editedSummary ?? undefined, projectId); onTaskCreated(task); handleCancel(); } catch (err) { setError(getErrorMessage(err) || "Failed to create task"); setView({ type: "summary", session: view.session, summary: view.summary }); } }, [editedSummary, view, projectId, onTaskCreated, handleCancel]); const handleStartBreakdown = useCallback(async () => { if (view.type !== "summary") return; setError(null); setView({ type: "loading" }); try { const result = await startPlanningBreakdown(view.session.sessionId, editedSummary ?? undefined, projectId); setLockSessionId(result.sessionId); setView({ type: "breakdown", sessionId: result.sessionId, subtasks: result.subtasks, dirty: false, }); } catch (err) { setError(getErrorMessage(err) || "Failed to start breakdown"); setView({ type: "summary", session: view.session, summary: view.summary }); } }, [editedSummary, view, projectId]); const handleCreateTasksFromBreakdown = useCallback(async () => { if (view.type !== "breakdown") return; setError(null); setView({ type: "creating" }); try { const result = await createTasksFromPlanning(view.sessionId, view.subtasks, projectId); onTasksCreated(result.tasks); // Reset and close setInitialPlan(""); setView({ type: "initial" }); setError(null); setResponseHistory([]); setConversationHistory([]); setEditedSummary(null); setStreamingOutput(""); setPlanningModelProvider(undefined); setPlanningModelId(undefined); currentSessionIdRef.current = null; setLockSessionId(null); onClose(); } catch (err) { setError(getErrorMessage(err) || "Failed to create tasks"); setView({ type: "breakdown", sessionId: view.sessionId, subtasks: view.subtasks, dirty: view.dirty }); } }, [view, onTasksCreated, onClose, projectId]); 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; }; const showSendToBackgroundButton = view.type === "loading" || view.type === "question" || view.type === "summary" || view.type === "error"; const activeLockInfo = lockSessionId ? activeTabMap.get(lockSessionId) : null; const activeRemoteTab = activeLockInfo && activeLockInfo.tabId !== sessionTabId; const activeInAnotherTab = Boolean(activeRemoteTab && !activeLockInfo.stale); const allowTakeover = isLockedByOther && (!activeRemoteTab || activeLockInfo.stale); if (!isOpen) return null; return (
e.target === e.currentTarget && handleCancel()} role="dialog" aria-modal="true">

Planning Mode

{showSendToBackgroundButton && ( )}
{error &&
{error}
} {isReconnecting &&
Reconnecting…
} {activeInAnotherTab && (
Session is active in another tab.
)} {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.