import { useState, useCallback, useEffect, useMemo, useRef } from "react"; import type { PlanningQuestion } from "@fusion/core"; import { getErrorMessage } from "@fusion/core"; import { startMissionInterview, respondToMissionInterview, retryMissionInterviewSession, cancelMissionInterview, createMissionFromInterview, connectMissionInterviewStream, fetchAiSession, parseConversationHistory, fetchModels, updateGlobalSettings, type MissionPlanSummary, type ConversationHistoryEntry, type MissionPlanMilestone, type MissionPlanSlice, type MissionPlanFeature, type MissionWithHierarchy, type ModelInfo, } from "../api"; import { saveMissionGoal, getMissionGoal, clearMissionGoal, } from "../hooks/modalPersistence"; import { Target, X, Loader2, CheckCircle, ArrowLeft, ArrowRight, Sparkles, ChevronRight, ChevronDown, Layers, Package, Box, Plus, Trash2, Minimize2, RefreshCw, Lock, } from "lucide-react"; import { ConversationHistory } from "./ConversationHistory"; import { CustomModelDropdown } from "./CustomModelDropdown"; import { useSessionLock } from "../hooks/useSessionLock"; import { useAiSessionSync } from "../hooks/useAiSessionSync"; import { useConfirm } from "../hooks/useConfirm"; import { useMobileScrollLock } from "../hooks/useMobileScrollLock"; import { getSessionTabId } from "../utils/getSessionTabId"; // Helper functions for model selection 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), }; } 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 } | { type: "error"; sessionId: string; errorMessage: string }; 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) { useMobileScrollLock(isOpen); const [missionGoal, setMissionGoal] = 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); const [hasProgress, setHasProgress] = useState(false); const hasAutoStartedRef = useRef(false); const [streamingOutput, setStreamingOutput] = useState(""); const [showThinking, setShowThinking] = useState(true); const [isReconnecting, setIsReconnecting] = useState(false); const [isRetrying, setIsRetrying] = useState(false); const [isCreating, setIsCreating] = useState(false); const textareaRef = useRef(null); const streamConnectionRef = useRef<{ close: () => void; isConnected: () => boolean } | null>(null); const currentSessionIdRef = useRef(null); const trackedLockSessionRef = 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 { confirm } = useConfirm(); // Model selection state const [modelProvider, setModelProvider] = useState(undefined); const [modelId, setModelId] = useState(undefined); const [loadedModels, setLoadedModels] = useState([]); const [modelsLoading, setModelsLoading] = useState(true); const [modelsError, setModelsError] = useState(null); const [favoriteProviders, setFavoriteProviders] = useState([]); const [favoriteModels, setFavoriteModels] = useState([]); const modelSelectionValue = getModelSelectionValue(modelProvider, modelId); // Load models on mount useEffect(() => { const load = async () => { try { setModelsLoading(true); const resp = await fetchModels(); setLoadedModels(resp.models); setFavoriteProviders(resp.favoriteProviders); setFavoriteModels(resp.favoriteModels); } catch (err) { setModelsError(getErrorMessage(err) || "Failed to load models"); } finally { setModelsLoading(false); } }; void load(); }, []); 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((modelIdToToggle: string) => { setFavoriteModels((prev) => { const currentFavorites = prev; const isFavorite = currentFavorites.includes(modelIdToToggle); const newFavorites = isFavorite ? currentFavorites.filter((item) => item !== modelIdToToggle) : [modelIdToToggle, ...currentFavorites]; updateGlobalSettings({ favoriteProviders, favoriteModels: newFavorites }).catch(() => { setFavoriteModels(currentFavorites); }); return newFavorites; }); }, [favoriteProviders]); const getModelBadgeLabel = useCallback( (provider?: string, mid?: string) => { if (!provider || !mid) return "Using default"; const matched = loadedModels.find((model) => model.provider === provider && model.id === mid); return matched ? `${matched.provider}/${matched.id}` : `${provider}/${mid}`; }, [loadedModels], ); const connectToMissionInterviewStream = useCallback( (sessionId: string) => { streamConnectionRef.current?.close(); const connection = connectMissionInterviewStream(sessionId, projectId, { onThinking: (data) => { setStreamingOutput((prev) => prev + data); broadcastUpdate({ sessionId, status: "generating", needsInput: false, owningTabId: sessionTabId, type: "mission_interview", title: missionGoal.trim() || undefined, projectId: projectId ?? null, }); }, onQuestion: (question) => { setIsReconnecting(false); setIsRetrying(false); clearMissionGoal(projectId); setView({ type: "question", sessionId, question }); setStreamingOutput(""); setHasProgress(true); broadcastUpdate({ sessionId, status: "awaiting_input", needsInput: true, owningTabId: sessionTabId, type: "mission_interview", title: missionGoal.trim() || undefined, projectId: projectId ?? null, }); }, onSummary: (summary) => { setIsReconnecting(false); setIsRetrying(false); clearMissionGoal(projectId); setView({ type: "summary", sessionId, summary }); setEditedSummary(summary); setStreamingOutput(""); setHasProgress(true); broadcastUpdate({ sessionId, status: "complete", needsInput: false, owningTabId: sessionTabId, type: "mission_interview", title: missionGoal.trim() || undefined, projectId: projectId ?? null, }); }, onError: (message) => { const errorMessage = message || "Session failed while contacting the AI."; setIsReconnecting(false); setIsRetrying(false); setError(null); setView({ type: "error", sessionId, errorMessage }); setStreamingOutput(""); setHasProgress(true); currentSessionIdRef.current = sessionId; broadcastUpdate({ sessionId, status: "error", needsInput: false, owningTabId: sessionTabId, type: "mission_interview", title: missionGoal.trim() || undefined, 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, missionGoal, projectId, sessionTabId], ); const handleStartInterview = useCallback( async (goalOverride?: string) => { const goal = goalOverride ?? missionGoal; if (!goal.trim()) return; setError(null); setStreamingOutput(""); setResponseHistory([]); setConversationHistory([]); setIsReconnecting(false); setView({ type: "loading" }); try { const { sessionId } = await startMissionInterview( goal.trim(), projectId, modelProvider && modelId ? { modelProvider, modelId } : undefined, ); currentSessionIdRef.current = sessionId; setLockSessionId(sessionId); clearMissionGoal(projectId); connectToMissionInterviewStream(sessionId); setResponseHistory([]); } catch (err) { setIsReconnecting(false); setError(getErrorMessage(err) || "Failed to start interview session"); setView({ type: "initial" }); currentSessionIdRef.current = null; setLockSessionId(null); } }, [connectToMissionInterviewStream, missionGoal, modelProvider, modelId, 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(projectId); if (persisted) { setMissionGoal(persisted); } } }, [isOpen, initialGoalProp, view.type, handleStartInterview]); useEffect(() => { if (!isOpen) { hasAutoStartedRef.current = false; setIsReconnecting(false); setIsRetrying(false); setLockSessionId(null); } }, [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; const parsedHistory = parseConversationHistory(session.conversationHistory); setConversationHistory(parsedHistory); setLockSessionId(session.id); 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) { try { clearMissionGoal(projectId); 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(projectId); 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" }); connectToMissionInterviewStream(session.id); } else if (session.status === "error") { currentSessionIdRef.current = session.id; setHasProgress(true); setError(null); setView({ type: "error", sessionId: session.id, errorMessage: session.error ?? "The session encountered an error.", }); } }).catch(() => { if (!cancelled) setError("Failed to resume session."); }); return () => { cancelled = true; }; }, [connectToMissionInterviewStream, isOpen, resumeSessionId, view.type, projectId]); // Broadcast ownership transitions between tabs. 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]); // Keep heartbeat alive while this tab owns an active mission interview session. 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 on unmount useEffect(() => { return () => { streamConnectionRef.current?.close(); streamConnectionRef.current = null; if (trackedLockSessionRef.current) { broadcastUnlock(trackedLockSessionRef.current, sessionTabId); trackedLockSessionRef.current = null; } }; }, [broadcastUnlock, sessionTabId]); // 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 handleSendToBackground = useCallback(() => { streamConnectionRef.current?.close(); streamConnectionRef.current = null; onClose(); }, [onClose]); const handleCancel = useCallback(async () => { // Save to localStorage BEFORE any cleanup if (missionGoal) { saveMissionGoal(missionGoal, projectId); } if (hasProgress) { const shouldClose = await confirm({ title: "Close Interview", message: "Are you sure you want to close? Your interview progress will be lost.", danger: true, }); if (!shouldClose) { return; } } streamConnectionRef.current?.close(); streamConnectionRef.current = null; if (view.type === "question" || view.type === "summary" || view.type === "error") { try { await cancelMissionInterview(view.sessionId, projectId, sessionTabId); } catch { // Ignore errors on cancel } } setMissionGoal(""); setView({ type: "initial" }); setError(null); setResponseHistory([]); setConversationHistory([]); setEditedSummary(null); setStreamingOutput(""); setIsReconnecting(false); setIsRetrying(false); setHasProgress(false); setIsCreating(false); setModelProvider(undefined); setModelId(undefined); currentSessionIdRef.current = null; setLockSessionId(null); onClose(); }, [missionGoal, hasProgress, view, onClose, projectId, sessionTabId, confirm]); // Escape key handler useEffect(() => { if (!isOpen) return; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { void handleCancel(); } }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); }, [isOpen, handleCancel]); const handleSubmitResponse = useCallback( async (responses: QuestionResponse) => { if (view.type !== "question") return; const { sessionId } = view; setError(null); setResponseHistory((prev) => [...prev, responses]); setConversationHistory((prev) => [ ...prev, { question: view.question, response: responses, }, ]); setView({ type: "loading" }); setStreamingOutput(""); try { connectToMissionInterviewStream(sessionId); await respondToMissionInterview(sessionId, responses, projectId, sessionTabId); setHasProgress(true); } catch (err) { streamConnectionRef.current?.close(); streamConnectionRef.current = null; setError(getErrorMessage(err) || "Failed to submit response"); setView({ type: "question", sessionId, question: view.question }); } }, [view, projectId, sessionTabId, connectToMissionInterviewStream] ); const handleRetryFromError = useCallback(async () => { if (view.type !== "error") { return; } const retrySessionId = view.sessionId; setError(null); setIsRetrying(true); setStreamingOutput(""); setView({ type: "loading" }); connectToMissionInterviewStream(retrySessionId); try { currentSessionIdRef.current = retrySessionId; setLockSessionId(retrySessionId); await retryMissionInterviewSession(retrySessionId, 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(retrySessionId); if (!session) { throw new Error("Failed to refresh interview session."); } 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)), ), ); currentSessionIdRef.current = session.id; setLockSessionId(session.id); setHasProgress(true); if (session.status === "generating") { setStreamingOutput(session.thinkingOutput ?? ""); setView({ type: "loading" }); if (!streamConnectionRef.current?.isConnected()) { connectToMissionInterviewStream(session.id); } } else if (session.status === "awaiting_input") { if (!session.currentQuestion) { throw new Error("Interview session is awaiting input but has no current question."); } clearMissionGoal(projectId); const question = JSON.parse(session.currentQuestion) as PlanningQuestion; setView({ type: "question", sessionId: session.id, question }); if (!streamConnectionRef.current?.isConnected()) { connectToMissionInterviewStream(session.id); } } else if (session.status === "complete") { if (!session.result) { throw new Error("Interview session is complete but has no result."); } clearMissionGoal(projectId); const summary = JSON.parse(session.result) as MissionPlanSummary; setEditedSummary(summary); setView({ type: "summary", sessionId: session.id, summary }); } else if (session.status === "error") { setView({ type: "error", sessionId: session.id, errorMessage: session.error ?? "Retry failed. Please try again.", }); } setIsReconnecting(false); return; } catch (sessionRefreshError) { retryError = sessionRefreshError; } } streamConnectionRef.current?.close(); streamConnectionRef.current = null; setView({ type: "error", sessionId: retrySessionId, errorMessage: getErrorMessage(retryError) || "Retry failed. Please try again.", }); setIsReconnecting(false); } finally { setIsRetrying(false); } }, [connectToMissionInterviewStream, projectId, sessionTabId, view]); 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(projectId); // Reset state without confirmation streamConnectionRef.current?.close(); streamConnectionRef.current = null; setMissionGoal(""); setView({ type: "initial" }); setError(null); setResponseHistory([]); setConversationHistory([]); setEditedSummary(null); setStreamingOutput(""); setIsReconnecting(false); setIsRetrying(false); setHasProgress(false); setIsCreating(false); currentSessionIdRef.current = null; setLockSessionId(null); onClose(); } catch (err) { setError(getErrorMessage(err) || "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; }; 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">

Plan Mission with AI

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