import "./PlanningModeModal.css"; 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, fetchAiSessions, deleteAiSession, parseConversationHistory, startPlanningBreakdown, createTasksFromPlanning, fetchModels, cancelPlanning, stopPlanningGeneration, updateGlobalSettings, type PlanningSession, type SubtaskItem, type ModelInfo, type ConversationHistoryEntry, type AiSessionSummary, } from "../api"; import { subscribeSse } from "../sse-bus"; import { useModalResizePersist } from "../hooks/useModalResizePersist"; import { savePlanningDescription, getPlanningDescription, clearPlanningDescription, } from "../hooks/modalPersistence"; import { Lightbulb, X, Loader2, CheckCircle, ArrowLeft, ArrowRight, Sparkles, ListTree, GripVertical, ArrowUp, ArrowDown, Plus, Trash2, RefreshCw, Lock, ChevronLeft, MessageSquarePlus, AlertCircle, Clock, HelpCircle, StopCircle } 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 hasLoadedPersistedRef = useRef(false); const [streamingOutput, setStreamingOutput] = useState(""); const [showThinking, setShowThinking] = useState(true); const [isReconnecting, setIsReconnecting] = useState(false); const [isRetrying, setIsRetrying] = useState(false); const [generationStartTime, setGenerationStartTime] = useState(null); const [elapsedSeconds, setElapsedSeconds] = useState(0); const textareaRef = useRef(null); const modalRef = 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); // Sidebar list state const [planningSessions, setPlanningSessions] = useState([]); const [sessionsLoading, setSessionsLoading] = useState(false); const [selectedSessionId, setSelectedSessionId] = useState(resumeSessionId ?? null); // Mobile: when the modal is narrow, only one pane is visible at a time. // `mobileShowDetail` toggles between list (false) and detail (true). const [mobileShowDetail, setMobileShowDetail] = useState(Boolean(resumeSessionId)); const [pendingDeleteId, setPendingDeleteId] = useState(null); // Track whether the mousedown that initiated a click came from inside the // modal. Resizing via the bottom-right grip can release the mouse outside // the modal element; without this guard, that release fires a click whose // target is the overlay and would dismiss the modal mid-resize. const overlayMouseDownOnSelfRef = useRef(false); const thinkingOutputRef = useRef(null); useModalResizePersist(modalRef, isOpen, "fusion:planning-modal-size"); // Keep the streaming AI thinking pane pinned to the bottom as new tokens // arrive. If the user has scrolled up to read earlier output, we leave the // scroll position alone — only auto-follow when they're already near the // tail. The 32px slack accounts for line-height jitter. useEffect(() => { const node = thinkingOutputRef.current; if (!node) return; const distanceFromBottom = node.scrollHeight - node.scrollTop - node.clientHeight; if (distanceFromBottom < 32) { node.scrollTop = node.scrollHeight; } }, [streamingOutput]); useEffect(() => { if (view.type !== "loading") { setGenerationStartTime(null); setElapsedSeconds(0); return; } const startedAt = Date.now(); setGenerationStartTime(startedAt); setElapsedSeconds(0); const timer = setInterval(() => { setElapsedSeconds(Math.max(0, Math.floor((Date.now() - startedAt) / 1000))); }, 1000); return () => clearInterval(timer); }, [view.type]); const resetDetailState = useCallback(() => { setInitialPlan(""); setView({ type: "initial" }); setError(null); setResponseHistory([]); setConversationHistory([]); setEditedSummary(null); setStreamingOutput(""); setIsReconnecting(false); setIsRetrying(false); setPlanningModelProvider(undefined); setPlanningModelId(undefined); currentSessionIdRef.current = null; setLockSessionId(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."; // A single transient stream error (e.g. tab was backgrounded long // enough for the SSE to time out) should not bounce the user to a // permanent error view. Refetch the session state — if the server // still has it in a recoverable state, silently reconnect; only // surface the error if the server actually persisted one. setIsReconnecting(true); (async () => { try { const session = await fetchAiSession(sessionId); if ( session && (session.status === "generating" || session.status === "awaiting_input") ) { connectToPlanningStream(sessionId); return; } } catch { // fall through to error view below } 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); setSelectedSessionId(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 && !hasLoadedPersistedRef.current && view.type === "initial" ) { // Restore the persisted description from localStorage on first open only. // Without the ref this effect re-fires on every keystroke (handleStart- // Planning depends on initialPlan), and each fire would clobber what // the user just typed back to the persisted value. hasLoadedPersistedRef.current = true; const persisted = getPlanningDescription(projectId); if (persisted) { setInitialPlan(persisted); } } }, [isOpen, initialPlanProp, view.type, handleStartPlanning, projectId]); // Load a specific persisted session into the right pane. const loadSession = useCallback( async (sessionId: string) => { streamConnectionRef.current?.close(); streamConnectionRef.current = null; setError(null); setStreamingOutput(""); setResponseHistory([]); setConversationHistory([]); setEditedSummary(null); setIsRetrying(false); setView({ type: "loading" }); try { const session = await fetchAiSession(sessionId); if (!session) { // The session was deleted (commonly: this tab just turned it into // tasks via Create Task / Create Tasks). Quietly fall back to the // new-session view rather than surfacing a scary error banner. setSelectedSessionId(null); setMobileShowDetail(false); setView({ type: "initial" }); return; } currentSessionIdRef.current = sessionId; setLockSessionId(sessionId); 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, currentQuestion: question, summary: null } }); if (session.thinkingOutput) setStreamingOutput(session.thinkingOutput); connectToPlanningStream(sessionId); } else if (session.status === "complete" && session.result) { clearPlanningDescription(projectId); const summary = JSON.parse(session.result); setView({ type: "summary", session: { sessionId, currentQuestion: null, summary }, summary }); setEditedSummary(summary); } else if (session.status === "generating") { setView({ type: "loading" }); if (session.thinkingOutput) setStreamingOutput(session.thinkingOutput); connectToPlanningStream(sessionId); } else if (session.status === "error") { setView({ type: "error", session: { sessionId, currentQuestion: null, summary: null }, errorMessage: session.error || "Session failed", }); } } catch { setError("Failed to load session"); setView({ type: "initial" }); } }, [connectToPlanningStream, projectId], ); // Resume the externally-requested session when the modal first opens. // (Selecting from the sidebar uses handleSelectSession instead.) useEffect(() => { if (!isOpen || !resumeSessionId) return; if (currentSessionIdRef.current === resumeSessionId) return; setSelectedSessionId(resumeSessionId); setMobileShowDetail(true); void loadSession(resumeSessionId); }, [isOpen, resumeSessionId, loadSession]); // Re-sync the selected session whenever the modal is reopened. Without this, // a session that progressed (or completed) on the server while the modal was // closed — or whose terminal SSE event we missed because the stream had been // torn down on close — keeps showing its stale view (e.g. stuck on "loading" // even though `awaiting_input` is already persisted). Hard reload used to be // the only fix; this effect makes close+reopen equivalent. useEffect(() => { if (!isOpen) return; if (!selectedSessionId) return; if (resumeSessionId && resumeSessionId === selectedSessionId) return; // resume effect handles this case if (streamConnectionRef.current?.isConnected()) return; void loadSession(selectedSessionId); // We intentionally do not depend on selectedSessionId or loadSession here: // handleSelectSession already drives loadSession when the user picks a // different row, and this effect only needs to fire on the open // transition. Listing them here would cause it to re-run mid-session. }, [isOpen]); // Load + maintain the planning sessions list (sidebar). const refreshSessionsList = useCallback(async () => { setSessionsLoading(true); try { const all = await fetchAiSessions(projectId); const planning = all .filter((s) => s.type === "planning") .sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt)); setPlanningSessions(planning); } catch { // Best-effort: list errors should not block the modal } finally { setSessionsLoading(false); } }, [projectId]); useEffect(() => { if (!isOpen) return; void refreshSessionsList(); }, [isOpen, refreshSessionsList]); // SSE subscription keeps the list live (mirrors useBackgroundSessions, but // unfiltered by status so completed/errored sessions stay visible). useEffect(() => { if (!isOpen) return; const params = projectId ? `?projectId=${encodeURIComponent(projectId)}` : ""; const handleUpdated = (e: MessageEvent) => { try { const updated = JSON.parse(e.data) as AiSessionSummary; if (updated.type !== "planning") return; setPlanningSessions((prev) => { const idx = prev.findIndex((s) => s.id === updated.id); const next = idx >= 0 ? [...prev.slice(0, idx), updated, ...prev.slice(idx + 1)] : [updated, ...prev]; return next.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt)); }); } catch { // ignore malformed payload } }; const handleDeleted = (e: MessageEvent) => { try { const id = JSON.parse(e.data) as string; setPlanningSessions((prev) => prev.filter((s) => s.id !== id)); } catch { // ignore malformed payload } }; return subscribeSse(`/api/events${params}`, { events: { "ai_session:updated": handleUpdated, "ai_session:deleted": handleDeleted, }, // Re-fetch on reconnect so terminal events that fired while the // channel was down don't leave stale rows in the sidebar. onReconnect: () => { void refreshSessionsList(); }, }); }, [isOpen, projectId, refreshSessionsList]); // Sidebar handlers const handleSelectSession = useCallback( (sessionId: string) => { if (selectedSessionId === sessionId) { setMobileShowDetail(true); return; } setSelectedSessionId(sessionId); setMobileShowDetail(true); void loadSession(sessionId); }, [loadSession, selectedSessionId], ); const handleNewSession = useCallback(() => { streamConnectionRef.current?.close(); streamConnectionRef.current = null; resetDetailState(); setSelectedSessionId(null); setMobileShowDetail(true); }, [resetDetailState]); const handleBackToList = useCallback(() => { setMobileShowDetail(false); }, []); const handleDeleteSession = useCallback( async (sessionId: string) => { const isActiveServerSession = (status: AiSessionSummary["status"]) => status === "generating" || status === "awaiting_input"; const target = planningSessions.find((s) => s.id === sessionId); // Cancel an in-flight server session before deleting so the engine stops // generating; for terminal sessions skip the cancel call. if (target && isActiveServerSession(target.status)) { try { await cancelPlanning(sessionId, projectId, sessionTabId); } catch { // best-effort } } try { await deleteAiSession(sessionId); } catch { // best-effort: SSE will reconcile if the delete actually succeeded } // Broadcast completion so sibling consumers (BackgroundTasksIndicator's // useBackgroundSessions hook, other tabs) prune this session from their // active lists. The server-side SSE delete event covers the in-flight // path, but the cross-tab broadcast is what keeps the footer pill in // lockstep when this modal initiates the delete. broadcastCompleted({ sessionId, status: "complete", timestamp: Date.now(), }); setPlanningSessions((prev) => prev.filter((s) => s.id !== sessionId)); if (selectedSessionId === sessionId) { streamConnectionRef.current?.close(); streamConnectionRef.current = null; resetDetailState(); setSelectedSessionId(null); setMobileShowDetail(false); } setPendingDeleteId(null); }, [broadcastCompleted, planningSessions, projectId, resetDetailState, selectedSessionId, sessionTabId], ); // Reset hasAutoStarted when modal closes useEffect(() => { if (!isOpen) { hasAutoStartedRef.current = false; hasLoadedPersistedRef.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]); // Close the modal without abandoning the active server session. Sessions // remain in the list and can be resumed later. Only an explicit Delete // (from the sidebar) cancels and removes a session. const handleClose = useCallback(() => { // Save the in-progress draft so the next open restores it. if (initialPlan && view.type === "initial") { savePlanningDescription(initialPlan, projectId); } streamConnectionRef.current?.close(); streamConnectionRef.current = null; setIsReconnecting(false); setIsRetrying(false); onClose(); }, [initialPlan, onClose, projectId, view.type]); // Handle escape key to close useEffect(() => { if (!isOpen) return; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { handleClose(); } }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); }, [isOpen, handleClose]); 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 handleStopGeneration = useCallback(async () => { const sessionId = currentSessionIdRef.current; if (!sessionId) { return; } try { await stopPlanningGeneration(sessionId, projectId, sessionTabId); } catch { // best-effort; server-side timeout/stop event may have already fired } streamConnectionRef.current?.close(); streamConnectionRef.current = null; setIsReconnecting(false); setIsRetrying(false); setView({ type: "error", session: { sessionId, currentQuestion: null, summary: null }, errorMessage: "Generation stopped by user. You can retry or start a new session.", }); setStreamingOutput(""); }, [projectId, sessionTabId]); 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 completedSessionId = view.session.sessionId; const task = await createTaskFromPlanning(completedSessionId, editedSummary ?? undefined, projectId); onTaskCreated(task); // The server cleans up the planning session after task creation. Drop // the local selection so a future reopen doesn't try to fetch a deleted // id (which would otherwise show "Session not found"). Also broadcast // completion so the footer's useBackgroundSessions prunes its count. setSelectedSessionId(null); setPlanningSessions((prev) => prev.filter((s) => s.id !== completedSessionId)); broadcastCompleted({ sessionId: completedSessionId, status: "complete", timestamp: Date.now(), }); handleClose(); } catch (err) { setError(getErrorMessage(err) || "Failed to create task"); setView({ type: "summary", session: view.session, summary: view.summary }); } }, [broadcastCompleted, editedSummary, view, projectId, onTaskCreated, handleClose]); 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 completedSessionId = view.sessionId; const result = await createTasksFromPlanning(completedSessionId, view.subtasks, projectId); onTasksCreated(result.tasks); // Server cleans up the planning session after task creation; mirror that // locally so reopen doesn't try to load a 404 and the footer count drops. setPlanningSessions((prev) => prev.filter((s) => s.id !== completedSessionId)); broadcastCompleted({ sessionId: completedSessionId, status: "complete", timestamp: Date.now(), }); // Reset and close setInitialPlan(""); setView({ type: "initial" }); setError(null); setResponseHistory([]); setConversationHistory([]); setEditedSummary(null); setStreamingOutput(""); setPlanningModelProvider(undefined); setPlanningModelId(undefined); currentSessionIdRef.current = null; setLockSessionId(null); setSelectedSessionId(null); onClose(); } catch (err) { setError(getErrorMessage(err) || "Failed to create tasks"); setView({ type: "breakdown", sessionId: view.sessionId, subtasks: view.subtasks, dirty: view.dirty }); } }, [broadcastCompleted, 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 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 (
{ overlayMouseDownOnSelfRef.current = e.target === e.currentTarget; }} onClick={(e) => { if (e.target === e.currentTarget && overlayMouseDownOnSelfRef.current) { handleClose(); } overlayMouseDownOnSelfRef.current = false; }} role="dialog" aria-modal="true" >
{mobileShowDetail && ( )}

Planning Mode

void handleDeleteSession(id)} onCancelDelete={() => setPendingDeleteId(null)} />
{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.