import "./PlanningModeModal.css"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import { useState, useCallback, useEffect, useRef, useMemo, type MouseEvent } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import type { Task, PlanningQuestion, PlanningSummary, TaskPriority } from "@fusion/core"; import { DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, getErrorMessage } from "@fusion/core"; import { startPlanningStreaming, createPlanningDraft, respondToPlanning, rewindPlanningSession, retryPlanningSession, createTaskFromPlanning, connectPlanningStream, fetchAiSession, fetchAiSessions, deleteAiSession, archiveAiSession, unarchiveAiSession, parseConversationHistory, startPlanningBreakdown, createTasksFromPlanning, fetchModels, cancelPlanning, stopPlanningGeneration, updatePlanningSessionDraft, summarizePlanningDraftTitle, updateGlobalSettings, type PlanningSession, type SubtaskItem, type PlanningSubtaskDraft, type ModelInfo, type ConversationHistoryEntry, type AiSessionSummary, } from "../api"; import { subscribeSse } from "../sse-bus"; import { useModalResizePersist } from "../hooks/useModalResizePersist"; import { useEmbeddedPresentation, type ModalPresentation } from "../hooks/useEmbeddedPresentation"; import { savePlanningDescription, getPlanningDescription, clearPlanningDescription, } from "../hooks/modalPersistence"; import { getRelativeTimeBucket } from "../utils/relativeTimeAgo"; import { Lightbulb, X, Loader2, CheckCircle, ArrowLeft, ArrowRight, Sparkles, ListTree, GripVertical, ArrowUp, ArrowDown, Plus, Trash2, RefreshCw, Lock, ChevronLeft, MessageSquarePlus, AlertCircle, Clock, HelpCircle, StopCircle, Archive, ArchiveRestore } from "lucide-react"; import { CustomModelDropdown } from "./CustomModelDropdown"; import { ConversationHistory } from "./ConversationHistory"; import { OnboardingDisclosure } from "./OnboardingDisclosure"; import { useSessionLock } from "../hooks/useSessionLock"; import { useAiSessionSync } from "../hooks/useAiSessionSync"; import { useViewportMode } from "../hooks/useViewportMode"; import { useMobileKeyboard } from "../hooks/useMobileKeyboard"; import { useNavigationHistoryContext } from "../hooks/useNavigationHistory"; import { useMobileScrollLock } from "../hooks/useMobileScrollLock"; import { useAutosizeTextarea } from "../hooks/useAutosizeTextarea"; import { useToast } from "../hooks/useToast"; import { getSessionTabId } from "../utils/getSessionTabId"; const WARNING_ICON = "⚠️"; /* FNXC:Planning 2026-06-23-02:00: The embedded Planning sidebar is resizable exactly like Missions (MissionManager's MISSION_SIDEBAR_* constants). Default 300px matches Missions' default (calc(--space-lg 16px * 18.75)); min/max/storage mirror Missions so the two views resize identically and persist independently. */ const PLANNING_SIDEBAR_DEFAULT_WIDTH = 300; const PLANNING_SIDEBAR_MIN_WIDTH = 220; const PLANNING_SIDEBAR_MAX_WIDTH = 560; const PLANNING_SIDEBAR_STORAGE_KEY = "fusion:planning-sidebar-width"; interface PlanningModeModalProps { isOpen: boolean; onClose: () => void; onTaskCreated: (task: Task) => void; onTasksCreated: (tasks: Task[]) => void; tasks: Task[]; initialPlan?: string; projectId?: string; /** Active workflow lane selected when Planning Mode was opened. */ workflowId?: string | null; /** When set, reconnect to a persisted background session instead of starting fresh */ resumeSessionId?: string; /** Render without the full-screen modal chrome when Planning Mode is mounted as a top-level app view. */ presentation?: ModalPresentation; } 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; originalSubtasks: SubtaskItem[]; subtasks: SubtaskItem[]; dirty: boolean } | { type: "loading" }; function getExamplePlans(t: TFunction<"app">): string[] { return [ t("planning.examplePlan1", "Build a user authentication system with login and signup"), t("planning.examplePlan2", "Add dark mode support to the dashboard"), t("planning.examplePlan3", "Create an API endpoint for exporting tasks as CSV"), t("planning.examplePlan4", "Refactor the task card component for better performance"), ]; } function normalizeTaskPriority(priority?: TaskPriority): TaskPriority { if (priority && (TASK_PRIORITIES as readonly string[]).includes(priority)) { return priority; } return DEFAULT_TASK_PRIORITY; } function normalizeStringArray(value: unknown): string[] { if (!Array.isArray(value)) { return []; } const seen = new Set(); const normalized: string[] = []; for (const item of value) { if (typeof item !== "string") { continue; } const trimmed = item.trim(); if (!trimmed || seen.has(trimmed)) { continue; } seen.add(trimmed); normalized.push(trimmed); } return normalized; } /* FNXC:PlanningNormalization 2026-06-25-00:00: Planning Mode treats AI and persisted session payloads as untrusted runtime data. Missing summary arrays, question options, and subtask dependency arrays normalize before render/action state so #1743 cannot crash React with undefined `.map`. */ function normalizePlanningSummary(summary: PlanningSummary): PlanningSummary { const raw = summary as PlanningSummary & Record; const title = typeof raw.title === "string" && raw.title.trim().length > 0 ? raw.title.trim() : "Untitled planning task"; const description = typeof raw.description === "string" && raw.description.trim().length > 0 ? raw.description.trim() : title; return { ...summary, title, description, suggestedSize: raw.suggestedSize === "S" || raw.suggestedSize === "M" || raw.suggestedSize === "L" ? raw.suggestedSize : "M", priority: normalizeTaskPriority(summary.priority), suggestedDependencies: normalizeStringArray(raw.suggestedDependencies), keyDeliverables: normalizeStringArray(raw.keyDeliverables), }; } const PLANNING_OTHER_RESPONSE_KEY = "_other"; const PLANNING_OTHER_OPTION_ID = "__other__"; function normalizeQuestionOptions(question: PlanningQuestion): PlanningQuestion { if (question.type !== "single_select" && question.type !== "multi_select") { return question; } const options = Array.isArray(question.options) ? question.options .filter((option): option is { id: string; label: string; description?: string } => Boolean( option && typeof option === "object" && typeof option.id === "string" && option.id.trim().length > 0 && typeof option.label === "string" && option.label.trim().length > 0 && (option.description === undefined || typeof option.description === "string"), ), ) .map((option) => ({ ...option, id: option.id.trim(), label: option.label.trim(), ...(option.description ? { description: option.description.trim() } : {}), })) : []; return { ...question, options }; } function normalizeSubtaskItem(subtask: SubtaskItem): SubtaskItem { const raw = subtask as SubtaskItem & Record; return { ...subtask, title: typeof raw.title === "string" ? raw.title : "", description: typeof raw.description === "string" ? raw.description : "", suggestedSize: raw.suggestedSize === "S" || raw.suggestedSize === "M" || raw.suggestedSize === "L" ? raw.suggestedSize : "M", priority: normalizeTaskPriority(subtask.priority), dependsOn: normalizeStringArray(raw.dependsOn), }; } function areStringArraysEqual(left: string[], right: string[]): boolean { return left.length === right.length && left.every((value, index) => value === right[index]); } function parseSessionUpdatedAt(updatedAt: string): number { const parsed = Date.parse(updatedAt); return Number.isNaN(parsed) ? Number.NEGATIVE_INFINITY : parsed; } export function dedupeSessionsById(sessions: AiSessionSummary[]): AiSessionSummary[] { const byId = new Map(); sessions.forEach((session, index) => { const updatedAtMs = parseSessionUpdatedAt(session.updatedAt); const existing = byId.get(session.id); if (!existing) { byId.set(session.id, { session, updatedAtMs, firstSeen: index }); return; } if (updatedAtMs > existing.updatedAtMs) { byId.set(session.id, { session, updatedAtMs, firstSeen: existing.firstSeen, }); } }); return [...byId.values()] .sort((left, right) => right.updatedAtMs - left.updatedAtMs || left.firstSeen - right.firstSeen) .map(({ session }) => session); } function buildCompactPlanningSubtaskDrafts( originalSubtasks: SubtaskItem[], editedSubtasks: SubtaskItem[], ): PlanningSubtaskDraft[] { const originalById = new Map(originalSubtasks.map((subtask) => [subtask.id, subtask])); return editedSubtasks.map((subtask) => { const original = originalById.get(subtask.id); const normalizedPriority = normalizeTaskPriority(subtask.priority); const draft: PlanningSubtaskDraft = { id: subtask.id }; if (!original || subtask.title !== original.title) { draft.title = subtask.title; } if (!original || subtask.description !== original.description) { draft.description = subtask.description; } if (!original || subtask.suggestedSize !== original.suggestedSize) { draft.suggestedSize = subtask.suggestedSize; } if (!original || normalizedPriority !== normalizeTaskPriority(original.priority)) { draft.priority = normalizedPriority; } if (!original || !areStringArraysEqual(subtask.dependsOn, original.dependsOn)) { draft.dependsOn = subtask.dependsOn; } return draft; }); } 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, workflowId, resumeSessionId, presentation = "modal" }: PlanningModeModalProps) { const { t } = useTranslation("app"); // FNXC:EmbeddedPresentation 2026-06-22-12:00: shared hook supplies isEmbedded (DOM branching) plus the modal-only gates. // Note: the Escape handler intentionally does NOT gate on embedded here — embedded planning preserves its historical // Escape-to-close behavior (the back-stack/onClose path), so escapeEnabled is deliberately not wired below. const { isEmbedded, scrollLockEnabled, resizePersistEnabled } = useEmbeddedPresentation(presentation); 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); const [branchMode, setBranchMode] = useState<"project-default" | "auto-new" | "existing" | "custom-new">("project-default"); const [branchName, setBranchName] = useState(""); const [baseBranch, setBaseBranch] = useState(""); // 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 [isCreatingTask, setIsCreatingTask] = useState(false); const [isStartingBreakdown, setIsStartingBreakdown] = useState(false); const [isCreatingFromBreakdown, setIsCreatingFromBreakdown] = useState(false); const [generationStartTime, setGenerationStartTime] = useState(null); const [elapsedSeconds, setElapsedSeconds] = useState(0); const textareaRef = useRef(null); // Align long-form planning composers with FN-5146's 640px chat convention so // multi-paragraph drafts stay visible; SummaryView keeps a larger expanded // cap so the two-tier collapsed/expanded editing UX remains intact. const { ref: initialPlanAutosizeRef } = useAutosizeTextarea({ value: initialPlan, minHeight: 120, maxHeight: 640, }); const setInitialPlanTextareaRef = useCallback((node: HTMLTextAreaElement | null) => { textareaRef.current = node; initialPlanAutosizeRef(node); }, [initialPlanAutosizeRef]); const modalRef = useRef(null); const streamConnectionRef = useRef<{ close: () => void; isConnected: () => boolean } | null>(null); const currentSessionIdRef = useRef(null); const draftSessionIdRef = useRef(null); const draftDebounceRef = useRef | null>(null); // Tracks resumeSessionId values the user has explicitly dismissed (via "New // Session"). Without this, the resume effect re-fires on every callback // identity change (e.g. typing into the textarea recreates loadSession) and // yanks the user back into the previous session's question view. const dismissedResumeRef = 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 [planningDepth, setPlanningDepth] = useState<"small" | "medium" | "large">("medium"); const [customQuestionCount, setCustomQuestionCount] = useState(""); const [loadedModels, setLoadedModels] = useState([]); const [modelsLoading, setModelsLoading] = useState(false); const [modelsError, setModelsError] = useState(null); const [favoriteProviders, setFavoriteProviders] = useState([]); const [favoriteModels, setFavoriteModels] = useState([]); const [resolvedPlanningModel, setResolvedPlanningModel] = useState<{ provider?: string; modelId?: string; }>({}); 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); const [showArchived, setShowArchived] = useState(false); // 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); // Mirrors `streamingOutput` state for reading inside callbacks without // stale closure issues (e.g. capturing reasoning before onQuestion clears it). const streamingOutputRef = useRef("" ); const draftSyncTimerRef = useRef | null>(null); const lastSyncedDraftRef = useRef<{ sessionId: string; initialPlan: string; modelProvider?: string; modelId?: string; } | null>(null); useModalResizePersist(modalRef, isOpen && resizePersistEnabled, "fusion:planning-modal-size"); const viewportMode = useViewportMode(); const isMobile = viewportMode === "mobile"; const { addToast } = useToast(); const { pushNav } = useNavigationHistoryContext(); /* FNXC:Planning 2026-06-23-02:00: Resizable Planning sidebar — pointer-drag + arrow-key resize with localStorage persistence, mirroring MissionManager.handleSidebarResizeStart/handleSidebarResizeKeyDown. Width is clamped to PLANNING_SIDEBAR_MIN/MAX and applied as an inline width on the sidebar