import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react"; import { useTranslation } from "react-i18next"; import type { Task } from "@fusion/core"; import { getErrorMessage } from "@fusion/core"; import { startSubtaskBreakdown, retrySubtaskSession, connectSubtaskStream, createTasksFromBreakdown, fetchAiSession, fetchTaskWorkflow, parseConversationHistory, type SubtaskItem, type ConversationHistoryEntry, } from "../api"; import { saveSubtaskDescription, getSubtaskDescription, clearSubtaskDescription, } from "../hooks/modalPersistence"; import { CheckCircle, Loader2, ListTree, Plus, Trash2, X, GripVertical, ArrowUp, ArrowDown, Minimize2, RefreshCw } from "lucide-react"; import { ConversationHistory } from "./ConversationHistory"; import { useAiSessionSync } from "../hooks/useAiSessionSync"; import { useConfirm } from "../hooks/useConfirm"; import { useMobileKeyboard } from "../hooks/useMobileKeyboard"; import { useMobileScrollLock } from "../hooks/useMobileScrollLock"; import { useViewportMode } from "../hooks/useViewportMode"; const WARNING_ICON = "⚠️"; interface SubtaskBreakdownModalProps { isOpen: boolean; onClose: () => void; initialDescription: string; onTasksCreated: (tasks: Task[]) => void; parentTaskId?: string; projectId?: string; /** Active workflow lane selected when the breakdown modal was opened. */ workflowId?: string | null; resumeSessionId?: string; onOpenGroupModal?: (groupId: string) => void; } type ViewState = | { type: "initial" } | { type: "generating"; sessionId: string } | { type: "editing"; sessionId: string } | { type: "error"; sessionId: string; errorMessage: string } | { type: "creating"; sessionId: string }; function createEmptySubtask(index: number): SubtaskItem { return { id: `subtask-${index}`, title: "", description: "", suggestedSize: "M", dependsOn: [], }; } function hasDependencyCycle(subtasks: SubtaskItem[]): boolean { const graph = new Map(subtasks.map((item) => [item.id, item.dependsOn])); const visiting = new Set(); const visited = new Set(); const visit = (id: string): boolean => { if (visiting.has(id)) return true; if (visited.has(id)) return false; visiting.add(id); for (const dep of graph.get(id) ?? []) { if (graph.has(dep) && visit(dep)) return true; } visiting.delete(id); visited.add(id); return false; }; return subtasks.some((item) => visit(item.id)); } export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onTasksCreated, parentTaskId, projectId, workflowId, resumeSessionId, onOpenGroupModal }: SubtaskBreakdownModalProps) { const { t } = useTranslation("app"); const viewportMode = useViewportMode(); useMobileScrollLock(isOpen); const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } = useMobileKeyboard({ enabled: viewportMode === "mobile", }); const keyboardStyle: CSSProperties = keyboardOpen ? ({ "--keyboard-overlap": `${keyboardOverlap}px`, "--vv-offset-top": `${viewportOffsetTop}px`, ...(viewportHeight !== null ? { "--vv-height": `${viewportHeight}px` } : {}), } as CSSProperties) : {}; const [view, setView] = useState({ type: "initial" }); const [subtasks, setSubtasks] = useState([]); const [conversationHistory, setConversationHistory] = useState([]); const [thinkingOutput, setThinkingOutput] = useState(""); const [showThinking, setShowThinking] = useState(true); const [isReconnecting, setIsReconnecting] = useState(false); const [isRetrying, setIsRetrying] = useState(false); const [isStartingBreakdown, setIsStartingBreakdown] = useState(false); // Local description: synced from prop, can fall back to localStorage const [localDescription, setLocalDescription] = useState(initialDescription); const [error, setError] = useState(null); const [dirty, setDirty] = useState(false); const [branchMode, setBranchMode] = useState<"project-default" | "auto-new" | "existing" | "custom-new">("project-default"); const [branchName, setBranchName] = useState(""); const [baseBranch, setBaseBranch] = useState(""); const [branchAssignmentMode, setBranchAssignmentMode] = useState<"shared" | "per-task-derived">("shared"); // Drag-and-drop state const [draggingId, setDraggingId] = useState(null); const [dragOverId, setDragOverId] = useState(null); const [dragOverPosition, setDragOverPosition] = useState<'before' | 'after' | null>(null); const streamRef = useRef<{ close: () => void; isConnected: () => boolean } | null>(null); const titleRefs = useRef>([]); const autoStartedRef = useRef(false); const sessionId = view.type === "generating" || view.type === "editing" || view.type === "creating" || view.type === "error" ? view.sessionId : null; /* 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 breakdown. 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(); const isInvalid = useMemo(() => { if (subtasks.length === 0) return true; if (subtasks.some((subtask) => !subtask.title.trim())) return true; if ((branchMode === "existing" || branchMode === "custom-new") && !branchName.trim()) return true; return hasDependencyCycle(subtasks); }, [branchMode, branchName, subtasks]); const showSendToBackgroundButton = view.type === "generating" || view.type === "editing" || view.type === "error"; const { confirm } = useConfirm(); const resetState = useCallback(() => { // Save to localStorage before cleanup (preserve for re-entry) if (localDescription) { saveSubtaskDescription(localDescription, projectId); } streamRef.current?.close(); streamRef.current = null; setView({ type: "initial" }); setSubtasks([]); setConversationHistory([]); setThinkingOutput(""); setShowThinking(true); setIsReconnecting(false); setIsRetrying(false); setIsStartingBreakdown(false); setError(null); setDirty(false); setBranchMode("project-default"); setBranchName(""); setBaseBranch(""); setBranchAssignmentMode("shared"); autoStartedRef.current = false; }, [localDescription, projectId]); const keepSessionReachableInBackground = useCallback(() => { if (!sessionId) return; /** * FNXC:SubtaskBreakdown 2026-06-16-21:10: * Closing the modal is an exit affordance, not an explicit discard. Preserve running and review-ready subtask sessions in the background-session list so users can resume from BackgroundTasksIndicator instead of losing AI work. */ if (view.type !== "generating" && view.type !== "editing") return; const resumableStatus = view.type === "generating" ? "generating" : "awaiting_input"; broadcastUpdate({ sessionId, status: resumableStatus, needsInput: resumableStatus === "awaiting_input", type: "subtask", title: localDescription.trim() || undefined, projectId: projectId ?? null, }); }, [broadcastUpdate, localDescription, projectId, sessionId, view.type]); const handleSendToBackground = useCallback(() => { keepSessionReachableInBackground(); streamRef.current?.close(); streamRef.current = null; onClose(); }, [keepSessionReachableInBackground, onClose]); const handleClose = useCallback(async () => { const hasUnsavedChanges = dirty || view.type === "editing" || view.type === "creating"; if (hasUnsavedChanges) { const shouldClose = await confirm({ title: t("subtasks.keepSessionTitle", "Keep subtask session available?"), message: t("subtasks.keepSessionMessage", "Close this modal and keep the subtask session available from Background Tasks? Edited fields that have not been saved to the session may be reset when you resume."), }); if (!shouldClose) { return; } } keepSessionReachableInBackground(); streamRef.current?.close(); streamRef.current = null; resetState(); onClose(); }, [dirty, keepSessionReachableInBackground, onClose, resetState, view.type, confirm, t]); const connectToSubtaskStream = useCallback( (activeSessionId: string) => { streamRef.current?.close(); streamRef.current = connectSubtaskStream(activeSessionId, projectId, { onThinking: (data) => { setThinkingOutput((prev) => prev + data); broadcastUpdate({ sessionId: activeSessionId, status: "generating", needsInput: false, type: "subtask", title: localDescription.trim() || undefined, projectId: projectId ?? null, }); }, onSubtasks: (items) => { setIsReconnecting(false); setIsRetrying(false); clearSubtaskDescription(projectId); setSubtasks(items); setView({ type: "editing", sessionId: activeSessionId }); setDirty(false); broadcastUpdate({ sessionId: activeSessionId, status: "awaiting_input", needsInput: true, type: "subtask", title: localDescription.trim() || undefined, projectId: projectId ?? null, }); }, onError: (message) => { const errorMessage = message || t("subtasks.errorSessionFailed", "Session failed while contacting the AI."); setIsReconnecting(false); setIsRetrying(false); setError(null); setView({ type: "error", sessionId: activeSessionId, errorMessage }); broadcastUpdate({ sessionId: activeSessionId, status: "error", needsInput: false, type: "subtask", title: localDescription.trim() || undefined, projectId: projectId ?? null, }); broadcastCompleted({ sessionId: activeSessionId, status: "error" }); }, onComplete: () => { broadcastCompleted({ sessionId: activeSessionId, status: "complete" }); }, onConnectionStateChange: (state) => { setIsReconnecting(state === "reconnecting"); }, }); }, [ broadcastCompleted, broadcastUpdate, localDescription, projectId, ], ); const beginBreakdown = useCallback(async () => { if (!localDescription.trim()) return; setError(null); setConversationHistory([]); setThinkingOutput(""); setIsReconnecting(false); setIsStartingBreakdown(true); try { const { sessionId } = await startSubtaskBreakdown(localDescription.trim(), projectId); setView({ type: "generating", sessionId }); broadcastUpdate({ sessionId, status: "generating", needsInput: false, type: "subtask", title: localDescription.trim() || undefined, projectId: projectId ?? null, }); connectToSubtaskStream(sessionId); } catch (err) { setError(getErrorMessage(err) || t("subtasks.errorStartBreakdown", "Failed to start subtask breakdown")); setView({ type: "initial" }); } finally { setIsStartingBreakdown(false); } }, [broadcastUpdate, connectToSubtaskStream, localDescription, projectId, t]); useEffect(() => { if (!isOpen) { resetState(); return; } if (isOpen && initialDescription && !autoStartedRef.current) { setLocalDescription(initialDescription); autoStartedRef.current = true; void beginBreakdown(); } else if (isOpen && !initialDescription && !autoStartedRef.current) { // Check localStorage for persisted description when no prop provided const persisted = getSubtaskDescription(projectId); if (persisted) { setLocalDescription(persisted); } } }, [isOpen, initialDescription, beginBreakdown, resetState]); useEffect(() => { if (!isOpen || !resumeSessionId || view.type !== "initial") return; void (async () => { try { const session = await fetchAiSession(resumeSessionId); if (!session) return; const parsedHistory = parseConversationHistory(session.conversationHistory); setConversationHistory(parsedHistory); if (session.status === "generating" || session.status === "awaiting_input") { setThinkingOutput(session.thinkingOutput ?? ""); setView({ type: "generating", sessionId: resumeSessionId }); connectToSubtaskStream(resumeSessionId); } else if (session.status === "complete" && session.result) { clearSubtaskDescription(projectId); const items = JSON.parse(session.result) as SubtaskItem[]; setSubtasks(items); setView({ type: "editing", sessionId: resumeSessionId }); } else if (session.status === "error") { setError(null); setView({ type: "error", sessionId: resumeSessionId, errorMessage: session.error ?? t("subtasks.errorSessionEncountered", "Session encountered an error"), }); } } catch (err) { setError(getErrorMessage(err) || t("subtasks.errorResumeSession", "Failed to resume session")); } })(); }, [connectToSubtaskStream, isOpen, resumeSessionId, view.type, projectId]); useEffect(() => { return () => { streamRef.current?.close(); }; }, []); useEffect(() => { if (!isOpen) return; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { event.preventDefault(); void handleClose(); } }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); }, [isOpen, handleClose]); const updateSubtask = useCallback((id: string, patch: Partial) => { setSubtasks((current) => current.map((item) => item.id === id ? { ...item, ...patch } : item)); setDirty(true); }, []); const addSubtask = useCallback(() => { setSubtasks((current) => [...current, createEmptySubtask(current.length + 1)]); setDirty(true); }, []); const removeSubtask = useCallback((id: string) => { setSubtasks((current) => current .filter((item) => item.id !== id) .map((item) => ({ ...item, dependsOn: item.dependsOn.filter((dep) => dep !== id) }))); setDirty(true); }, []); // Drag-and-drop handlers const handleDragStart = useCallback((subtaskId: string) => (e: React.DragEvent) => { setDraggingId(subtaskId); e.dataTransfer.setData('text/plain', subtaskId); e.dataTransfer.effectAllowed = 'move'; }, []); const handleDragEnd = useCallback(() => { setDraggingId(null); setDragOverId(null); setDragOverPosition(null); }, []); const handleDragOver = useCallback((targetId: string) => (e: React.DragEvent) => { e.preventDefault(); if (targetId === draggingId) return; const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); const midY = rect.top + rect.height / 2; const position: 'before' | 'after' = e.clientY < midY ? 'before' : 'after'; setDragOverId(targetId); setDragOverPosition(position); }, [draggingId]); const handleDrop = useCallback((targetId: string) => (e: React.DragEvent) => { e.preventDefault(); const draggedId = e.dataTransfer.getData('text/plain'); if (!draggedId || draggedId === targetId) { setDraggingId(null); setDragOverId(null); setDragOverPosition(null); return; } setSubtasks((current) => { const fromIndex = current.findIndex((s) => s.id === draggedId); const toIndex = current.findIndex((s) => s.id === targetId); if (fromIndex === -1 || toIndex === -1) return current; const newSubtasks = [...current]; const [moved] = newSubtasks.splice(fromIndex, 1); let insertIndex = toIndex; if (dragOverPosition === 'after' && fromIndex < toIndex) insertIndex--; if (dragOverPosition === 'after') insertIndex++; newSubtasks.splice(insertIndex, 0, moved); return newSubtasks; }); setDirty(true); setDraggingId(null); setDragOverId(null); setDragOverPosition(null); }, [dragOverPosition]); const handleDragLeave = useCallback((e: React.DragEvent) => { const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); const x = e.clientX; const y = e.clientY; // Only clear if leaving the element entirely, not just moving between children if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) { setDragOverId(null); setDragOverPosition(null); } }, []); // Keyboard reordering handlers const moveSubtask = useCallback((fromIndex: number, toIndex: number) => { if (toIndex < 0 || toIndex >= subtasks.length) return; setSubtasks((current) => { const newSubtasks = [...current]; const [moved] = newSubtasks.splice(fromIndex, 1); newSubtasks.splice(toIndex, 0, moved); return newSubtasks; }); setDirty(true); }, [subtasks.length]); const moveFocusToNext = useCallback((index: number) => { titleRefs.current[index + 1]?.focus(); }, []); const handleCreateTasks = useCallback(async () => { if (!sessionId || isInvalid) return; setError(null); setView({ type: "creating", sessionId }); try { let effectiveWorkflowId = workflowId; if (parentTaskId) { try { effectiveWorkflowId = (await fetchTaskWorkflow(parentTaskId, projectId)).workflowId; } catch { effectiveWorkflowId = workflowId; } } const result = await createTasksFromBreakdown(sessionId, subtasks, parentTaskId, projectId, { branchSelection: { mode: branchMode, ...(branchMode === "existing" || branchMode === "custom-new" ? { branchName } : {}), ...(baseBranch.trim() ? { baseBranch: baseBranch.trim() } : {}), }, branchAssignment: { mode: branchAssignmentMode }, /* FNXC:WorkflowSelection 2026-06-20-16:48: Subtask Breakdown children inherit the parent task's workflow when available, otherwise they keep the workflow lane that opened this modal. */ ...(effectiveWorkflowId !== undefined ? { workflowId: effectiveWorkflowId } : {}), }); onTasksCreated(result.tasks); resetState(); onClose(); } catch (err) { setError(getErrorMessage(err) || t("subtasks.errorCreateTasks", "Failed to create tasks")); setView({ type: "editing", sessionId }); } }, [baseBranch, branchAssignmentMode, branchMode, branchName, isInvalid, onClose, onTasksCreated, parentTaskId, projectId, resetState, sessionId, subtasks, workflowId]); const handleRetry = useCallback(async () => { if (view.type !== "error") { return; } const retrySessionId = view.sessionId; setError(null); setIsRetrying(true); setThinkingOutput(""); setView({ type: "generating", sessionId: retrySessionId }); connectToSubtaskStream(retrySessionId); try { await retrySubtaskSession(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(t("subtasks.errorRefreshSession", "Failed to refresh subtask session.")); } setConversationHistory(parseConversationHistory(session.conversationHistory)); if (session.status === "generating" || session.status === "awaiting_input") { setThinkingOutput(session.thinkingOutput ?? ""); setView({ type: "generating", sessionId: session.id }); if (!streamRef.current?.isConnected()) { connectToSubtaskStream(session.id); } } else if (session.status === "complete") { if (!session.result) { throw new Error(t("subtasks.errorSessionNoResult", "Subtask session is complete but has no result.")); } clearSubtaskDescription(projectId); const items = JSON.parse(session.result) as SubtaskItem[]; setSubtasks(items); setView({ type: "editing", sessionId: session.id }); setDirty(false); } else if (session.status === "error") { setView({ type: "error", sessionId: session.id, errorMessage: session.error ?? t("subtasks.errorRetryFailed", "Retry failed. Please try again."), }); } setIsReconnecting(false); return; } catch (sessionRefreshError) { retryError = sessionRefreshError; } } streamRef.current?.close(); streamRef.current = null; setView({ type: "error", sessionId: retrySessionId, errorMessage: getErrorMessage(retryError) || t("subtasks.errorRetryFailed", "Retry failed. Please try again."), }); setIsReconnecting(false); } finally { setIsRetrying(false); } }, [connectToSubtaskStream, projectId, view]); if (!isOpen) return null; return (
event.target === event.currentTarget && void handleClose()} role="dialog" aria-modal="true">

{t("subtasks.modalTitle", "Subtask Breakdown")}

{showSendToBackgroundButton && ( )}
{error &&
{error}
} {isReconnecting &&
{t("subtasks.reconnecting", "Reconnecting…")}
} {view.type === "initial" && (
{/* * FNXC:SubtaskBreakdown 2026-06-16-00:00: * Subtask generation must show live progress as soon as a run is requested, before the session id or streamed thinking exists, so users do not see a dead static preparation state. */}
)} {view.type === "generating" && (
{conversationHistory.length > 0 && ( <>
)}
)} {view.type === "error" && (
{conversationHistory.length > 0 && ( <>
)}
{WARNING_ICON}
{view.errorMessage}
)} {(view.type === "editing" || view.type === "creating") && (
{conversationHistory.length > 0 && ( <>
)}

{t("subtasks.reviewSubtasksHeading", "Review your subtasks")}

{t("subtasks.reviewSubtasksHint", "Edit titles, descriptions, sizes, and dependencies before creating all tasks at once.")}

{(branchMode === "existing" || branchMode === "custom-new") && (
setBranchName(event.target.value)} disabled={view.type === "creating"} />
)}
setBaseBranch(event.target.value)} disabled={view.type === "creating"} placeholder={t("subtasks.baseBranchPlaceholder", "main")} />
{branchAssignmentMode === "shared" && branchName.trim() && (

{t("subtasks.groupedOnSharedBranch", "Grouped on shared branch")} {branchName.trim()} {onOpenGroupModal && ( )}

)}
{subtasks.map((subtask, index) => { const isDragging = draggingId === subtask.id; const isDragOver = dragOverId === subtask.id; const dragClasses = [ 'task-detail-section', 'subtask-item', isDragging ? 'subtask-item-dragging' : '', isDragOver ? 'subtask-item-drop-target' : '', isDragOver && dragOverPosition === 'before' ? 'subtask-item-drop-before' : '', isDragOver && dragOverPosition === 'after' ? 'subtask-item-drop-after' : '', ].filter(Boolean).join(' '); return (
{subtask.id}
{ titleRefs.current[index] = element; }} value={subtask.title} onChange={(event) => updateSubtask(subtask.id, { title: event.target.value })} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); moveFocusToNext(index); } }} disabled={view.type === "creating"} />