import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react"; import type { Task } from "@fusion/core"; import { getErrorMessage } from "@fusion/core"; import { startSubtaskBreakdown, retrySubtaskSession, connectSubtaskStream, createTasksFromBreakdown, cancelSubtaskBreakdown, fetchAiSession, 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, Lock } from "lucide-react"; import { ConversationHistory } from "./ConversationHistory"; import { useSessionLock } from "../hooks/useSessionLock"; 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"; import { getSessionTabId } from "../utils/getSessionTabId"; interface SubtaskBreakdownModalProps { isOpen: boolean; onClose: () => void; initialDescription: string; onTasksCreated: (tasks: Task[]) => void; parentTaskId?: string; projectId?: string; 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, resumeSessionId, onOpenGroupModal }: SubtaskBreakdownModalProps) { 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); // 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 trackedLockSessionRef = useRef(null); const sessionId = view.type === "generating" || view.type === "editing" || view.type === "creating" || view.type === "error" ? view.sessionId : null; const sessionTabId = useMemo(() => getSessionTabId(), []); const { isLockedByOther, takeControl, isLoading: isLockLoading, } = useSessionLock(isOpen ? sessionId : null); const { activeTabMap, broadcastUpdate, broadcastCompleted, broadcastLock, broadcastUnlock, broadcastHeartbeat, } = 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 activeLockInfo = sessionId ? activeTabMap.get(sessionId) : null; const { confirm } = useConfirm(); const activeRemoteTab = activeLockInfo && activeLockInfo.tabId !== sessionTabId; const activeInAnotherTab = Boolean(activeRemoteTab && !activeLockInfo.stale); const allowTakeover = isLockedByOther && (!activeRemoteTab || activeLockInfo.stale); 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); setError(null); setDirty(false); setBranchMode("project-default"); setBranchName(""); setBaseBranch(""); setBranchAssignmentMode("shared"); autoStartedRef.current = false; }, [localDescription, projectId]); const handleSendToBackground = useCallback(() => { streamRef.current?.close(); streamRef.current = null; onClose(); }, [onClose]); const handleClose = useCallback(async () => { const hasUnsavedChanges = dirty || view.type === "editing" || view.type === "creating"; if (hasUnsavedChanges) { const shouldClose = await confirm({ title: "Discard Changes", message: "Close subtask breakdown? Unsaved changes will be lost.", danger: true, }); if (!shouldClose) { return; } } if (sessionId) { try { await cancelSubtaskBreakdown(sessionId, projectId, sessionTabId); } catch { // ignore cancel errors } } resetState(); onClose(); }, [dirty, onClose, resetState, sessionId, sessionTabId, view.type, projectId, confirm]); 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, owningTabId: sessionTabId, 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, owningTabId: sessionTabId, type: "subtask", title: localDescription.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: activeSessionId, errorMessage }); broadcastUpdate({ sessionId: activeSessionId, status: "error", needsInput: false, owningTabId: sessionTabId, 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, sessionTabId, ], ); const beginBreakdown = useCallback(async () => { if (!localDescription.trim()) return; setError(null); setConversationHistory([]); setThinkingOutput(""); setIsReconnecting(false); try { const { sessionId } = await startSubtaskBreakdown(localDescription.trim(), projectId); setView({ type: "generating", sessionId }); connectToSubtaskStream(sessionId); } catch (err) { setError(getErrorMessage(err) || "Failed to start subtask breakdown"); setView({ type: "initial" }); } }, [connectToSubtaskStream, localDescription, projectId]); 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 ?? "Session encountered an error", }); } } catch (err) { setError(getErrorMessage(err) || "Failed to resume session"); } })(); }, [connectToSubtaskStream, isOpen, resumeSessionId, view.type, projectId]); // Broadcast lock ownership transitions across tabs. useEffect(() => { if (!isOpen) { if (trackedLockSessionRef.current) { broadcastUnlock(trackedLockSessionRef.current, sessionTabId); trackedLockSessionRef.current = null; } return; } if (sessionId && trackedLockSessionRef.current !== sessionId) { if (trackedLockSessionRef.current) { broadcastUnlock(trackedLockSessionRef.current, sessionTabId); } broadcastLock(sessionId, sessionTabId); trackedLockSessionRef.current = sessionId; return; } if (!sessionId && trackedLockSessionRef.current) { broadcastUnlock(trackedLockSessionRef.current, sessionTabId); trackedLockSessionRef.current = null; } }, [broadcastLock, broadcastUnlock, isOpen, sessionId, sessionTabId]); // Keep ownership heartbeat alive while this tab is interacting with the session. useEffect(() => { if (!isOpen || !sessionId || trackedLockSessionRef.current !== sessionId) { return; } broadcastHeartbeat(sessionTabId); const timer = setInterval(() => { broadcastHeartbeat(sessionTabId); }, 30_000); return () => { clearInterval(timer); }; }, [broadcastHeartbeat, isOpen, sessionId, sessionTabId]); useEffect(() => { return () => { streamRef.current?.close(); if (trackedLockSessionRef.current) { broadcastUnlock(trackedLockSessionRef.current, sessionTabId); trackedLockSessionRef.current = null; } }; }, [broadcastUnlock, sessionTabId]); 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 { 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 }, }); onTasksCreated(result.tasks); resetState(); onClose(); } catch (err) { setError(getErrorMessage(err) || "Failed to create tasks"); setView({ type: "editing", sessionId }); } }, [baseBranch, branchAssignmentMode, branchMode, branchName, isInvalid, onClose, onTasksCreated, parentTaskId, projectId, resetState, sessionId, subtasks]); 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, 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 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("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 ?? "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) || "Retry failed. Please try again.", }); setIsReconnecting(false); } finally { setIsRetrying(false); } }, [connectToSubtaskStream, projectId, sessionTabId, view]); if (!isOpen) return null; return (
event.target === event.currentTarget && void handleClose()} role="dialog" aria-modal="true">

Subtask Breakdown

{showSendToBackgroundButton && ( )}
{error &&
{error}
} {isReconnecting &&
Reconnecting…
} {activeInAnotherTab && (
Session is active in another tab.
)} {view.type === "initial" && (

Preparing to break this task into subtasks.

{localDescription}
)} {view.type === "generating" && (
{conversationHistory.length > 0 && ( <>
)}

AI is generating subtasks...

{showThinking && thinkingOutput && (
{thinkingOutput}
)}
)} {view.type === "error" && (
{conversationHistory.length > 0 && ( <>
)}
⚠️
{view.errorMessage}
)} {(view.type === "editing" || view.type === "creating") && (
{conversationHistory.length > 0 && ( <>
)}

Review your subtasks

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="main" />
{branchAssignmentMode === "shared" && branchName.trim() && (

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"} />