import { useState, useCallback, useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; import type { PlanningQuestion, ThinkingLevel } from "@fusion/core"; import { getErrorMessage, THINKING_LEVELS } from "@fusion/core"; import { startMissionInterview, respondToMissionInterview, retryMissionInterviewSession, createMissionFromInterview, connectMissionInterviewStream, fetchAiSession, parseConversationHistory, fetchModels, updateGlobalSettings, type MissionPlanSummary, type ConversationHistoryEntry, type MissionPlanMilestone, type MissionPlanSlice, type MissionPlanFeature, type MissionWithHierarchy, type ModelInfo, type AiSessionDetail, } from "../api"; import { saveMissionGoal, getMissionGoal, clearMissionGoal, } from "../hooks/modalPersistence"; import { Target, X, Loader2, CheckCircle, ArrowLeft, ArrowRight, Sparkles, ChevronRight, ChevronDown, Layers, Package, Box, Plus, Trash2, RefreshCw, Minimize2, } from "lucide-react"; import { ConversationHistory } from "./ConversationHistory"; import { CustomModelDropdown } from "./CustomModelDropdown"; import { FloatingWindow } from "./FloatingWindow"; import { useAiSessionSync } from "../hooks/useAiSessionSync"; import { useMobileScrollLock } from "../hooks/useMobileScrollLock"; import "./MissionInterviewModal.css"; // 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), }; } const WARNING_ICON = "⚠️"; const MISSION_INTERVIEW_OTHER_RESPONSE_KEY = "_other"; const MISSION_INTERVIEW_OTHER_OPTION_ID = "__other__"; interface MissionInterviewModalProps { isOpen: boolean; onClose: () => void; onMissionCreated: (mission: MissionWithHierarchy) => void; projectId?: string; initialGoal?: string; resumeSessionId?: string; onSendToBackground?: () => void; showSendToBackgroundButton?: boolean; } 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, onSendToBackground, showSendToBackgroundButton = false, }: MissionInterviewModalProps) { const { t } = useTranslation("app"); 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 streamErrorRecoverySeqRef = useRef(0); const canSendToBackground = showSendToBackgroundButton && view.type !== "initial"; /* FNXC:PlanningMultiTab 2026-07-14-00:00: No tab lock: the persisted session row is the shared source of truth, so every tab may read and interact with this interview. The lock overlay, Take Control affordance, ownership broadcasts, heartbeat, and the "active in another tab" banner were removed; only session-status sync remains. */ const { broadcastUpdate, broadcastCompleted } = useAiSessionSync(); // Model selection state const [modelProvider, setModelProvider] = useState(undefined); const [modelId, setModelId] = useState(undefined); const [thinkingLevel, setThinkingLevel] = useState(""); 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, 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, 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, type: "mission_interview", title: missionGoal.trim() || undefined, projectId: projectId ?? null, }); }, onError: (message) => { const errorMessage = message || t("missions.interviewErrorDefault", "Session failed while contacting the AI."); if (currentSessionIdRef.current && currentSessionIdRef.current !== sessionId) { return; } const recoverySeq = streamErrorRecoverySeqRef.current + 1; streamErrorRecoverySeqRef.current = recoverySeq; /* FNXC:MissionInterview 2026-06-24-21:43: Mission interview SSE errors can be transient while the server-side AI session remains recoverable. Refetch persisted session state before showing the permanent Retry/Dismiss panel so issue #1745 cannot strand users on a literal Stream error. */ setIsReconnecting(true); (async () => { let terminalErrorMessage = errorMessage; const isCurrentRecovery = () => streamErrorRecoverySeqRef.current === recoverySeq && (!currentSessionIdRef.current || currentSessionIdRef.current === sessionId); const restoreHistoryFromSession = (session: AiSessionDetail) => { 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)), ), ); }; try { const session = await fetchAiSession(sessionId); if (!isCurrentRecovery()) return; if (session?.type === "mission_interview") { restoreHistoryFromSession(session); currentSessionIdRef.current = session.id; setHasProgress(true); if (session.status === "generating") { if (session.thinkingOutput) { setStreamingOutput(session.thinkingOutput); } connectToMissionInterviewStream(session.id); return; } if (session.status === "awaiting_input") { if (!session.currentQuestion) { throw new Error("Interview session is awaiting input but has no current question."); } const question = JSON.parse(session.currentQuestion) as PlanningQuestion; clearMissionGoal(projectId); setView({ type: "question", sessionId: session.id, question }); connectToMissionInterviewStream(session.id); return; } if (session.status === "complete") { if (!session.result) { throw new Error("Interview session is complete but has no result."); } const summary = JSON.parse(session.result) as MissionPlanSummary; clearMissionGoal(projectId); setEditedSummary(summary); setView({ type: "summary", sessionId: session.id, summary }); setStreamingOutput(""); setIsReconnecting(false); setIsRetrying(false); broadcastUpdate({ sessionId: session.id, status: "complete", needsInput: false, type: "mission_interview", title: missionGoal.trim() || undefined, projectId: projectId ?? null, }); broadcastCompleted({ sessionId: session.id, status: "complete" }); return; } if (session.status === "error") { terminalErrorMessage = session.error ?? t("missions.sessionEncounteredError", "The session encountered an error."); } } } catch { if (!isCurrentRecovery()) return; } if (!isCurrentRecovery()) return; setIsReconnecting(false); setIsRetrying(false); setError(null); setView({ type: "error", sessionId, errorMessage: terminalErrorMessage }); setStreamingOutput(""); setHasProgress(true); currentSessionIdRef.current = sessionId; broadcastUpdate({ sessionId, status: "error", needsInput: false, 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], ); 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 modelOverride = modelProvider && modelId ? { modelProvider, modelId, thinkingLevel: thinkingLevel || undefined } : (thinkingLevel ? { thinkingLevel } : undefined); const { sessionId } = await startMissionInterview( goal.trim(), projectId, modelOverride, ); currentSessionIdRef.current = sessionId; clearMissionGoal(projectId); connectToMissionInterviewStream(sessionId); setResponseHistory([]); } catch (err) { setIsReconnecting(false); setError(getErrorMessage(err) || "Failed to start interview session"); setView({ type: "initial" }); currentSessionIdRef.current = null; } }, [connectToMissionInterviewStream, missionGoal, modelProvider, modelId, thinkingLevel, projectId] ); // Focus textarea when opening useEffect(() => { if (isOpen && view.type === "initial") { textareaRef.current?.focus(); } }, [isOpen, view.type]); // Restore persisted goal from localStorage ONCE when the modal opens. // Must NOT depend on handleStartInterview or missionGoal — otherwise every // keystroke recreates handleStartInterview, re-triggers this effect, and // overwrites what the user just typed (cursor jumps to end, can't edit). useEffect(() => { if (isOpen && !initialGoalProp && !resumeSessionId && !hasAutoStartedRef.current && view.type === "initial") { const persisted = getMissionGoal(projectId); if (persisted) { setMissionGoal(persisted); } } }, [isOpen]); // 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); } }, [isOpen, initialGoalProp, view.type, handleStartInterview]); useEffect(() => { if (!isOpen) { hasAutoStartedRef.current = false; setIsReconnecting(false); setIsRetrying(false); } }, [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); try { const payload = session.inputPayload ? JSON.parse(session.inputPayload) : null; setThinkingLevel(THINKING_LEVELS.includes(payload?.thinkingLevel as ThinkingLevel) ? payload.thinkingLevel : ""); } catch { setThinkingLevel(""); } 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]); // Cleanup stream on unmount useEffect(() => { return () => { streamConnectionRef.current?.close(); streamConnectionRef.current = null; }; }, []); // 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 handleClose = useCallback(() => { if (missionGoal && view.type === "initial") { saveMissionGoal(missionGoal, projectId); } streamConnectionRef.current?.close(); streamConnectionRef.current = null; setIsReconnecting(false); setIsRetrying(false); setIsCreating(false); onClose(); }, [missionGoal, onClose, projectId, view.type]); const handleSendToBackground = useCallback(() => { streamConnectionRef.current?.close(); streamConnectionRef.current = null; setIsReconnecting(false); setIsRetrying(false); setIsCreating(false); if (onSendToBackground) { onSendToBackground(); return; } onClose(); }, [onClose, onSendToBackground]); // Escape key handler 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 { 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); 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, 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; await retryMissionInterviewSession(retrySessionId, projectId); } 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; 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, 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; 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; }; if (!isOpen) return null; return ( {/* FNXC:MissionInterviewModal 2026-06-24-00:00: The Plan Mission with AI workspace must be draggable and resizable on desktop by delegating geometry to FloatingWindow, while mobile keeps the existing full-screen/sheet-like mission interview flow. Keep one embedded mission header so close/send-to-background controls do not duplicate FloatingWindow chrome. */}

{t("missions.planTitle", "Plan Mission with AI")}

{canSendToBackground && ( )}
{error &&
{error}
} {isReconnecting &&
{t("missions.reconnecting", "Reconnecting…")}
} {view.type === "initial" && (

{t("missions.transformVision", "Transform your vision into a structured mission")}

{t("missions.describeGoal", "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.")}