import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { Task } from "@fusion/core"; import { startSubtaskBreakdown, connectSubtaskStream, createTasksFromBreakdown, cancelSubtaskBreakdown, type SubtaskItem, } from "../api"; import { CheckCircle, Loader2, ListTree, Plus, Trash2, X, GripVertical, ArrowUp, ArrowDown } from "lucide-react"; interface SubtaskBreakdownModalProps { isOpen: boolean; onClose: () => void; initialDescription: string; onTasksCreated: (tasks: Task[]) => void; parentTaskId?: string; } type ViewState = | { type: "initial" } | { type: "generating"; sessionId: string } | { type: "editing"; sessionId: 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 }: SubtaskBreakdownModalProps) { const [view, setView] = useState({ type: "initial" }); const [subtasks, setSubtasks] = useState([]); const [thinkingOutput, setThinkingOutput] = useState(""); const [showThinking, setShowThinking] = useState(true); const [error, setError] = useState(null); const [dirty, setDirty] = useState(false); // 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.sessionId : null; const isInvalid = useMemo(() => { if (subtasks.length === 0) return true; if (subtasks.some((subtask) => !subtask.title.trim())) return true; return hasDependencyCycle(subtasks); }, [subtasks]); const resetState = useCallback(() => { streamRef.current?.close(); streamRef.current = null; setView({ type: "initial" }); setSubtasks([]); setThinkingOutput(""); setShowThinking(true); setError(null); setDirty(false); autoStartedRef.current = false; }, []); const handleClose = useCallback(async () => { if ((dirty || view.type === "editing" || view.type === "creating") && !confirm("Close subtask breakdown? Unsaved changes will be lost.")) { return; } if (sessionId) { try { await cancelSubtaskBreakdown(sessionId); } catch { // ignore cancel errors } } resetState(); onClose(); }, [dirty, onClose, resetState, sessionId, view.type]); const beginBreakdown = useCallback(async () => { if (!initialDescription.trim()) return; setError(null); setThinkingOutput(""); try { const { sessionId } = await startSubtaskBreakdown(initialDescription.trim()); setView({ type: "generating", sessionId }); streamRef.current?.close(); streamRef.current = connectSubtaskStream(sessionId, { onThinking: (data) => setThinkingOutput((prev) => prev + data), onSubtasks: (items) => { setSubtasks(items); setView({ type: "editing", sessionId }); setDirty(false); }, onError: (message) => { setError(message); setView({ type: "initial" }); }, }); } catch (err: any) { setError(err.message || "Failed to start subtask breakdown"); setView({ type: "initial" }); } }, [initialDescription]); useEffect(() => { if (!isOpen) { resetState(); return; } if (isOpen && initialDescription && !autoStartedRef.current) { autoStartedRef.current = true; void beginBreakdown(); } }, [isOpen, initialDescription, beginBreakdown, resetState]); 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 { const result = await createTasksFromBreakdown(sessionId, subtasks, parentTaskId); onTasksCreated(result.tasks); resetState(); onClose(); } catch (err: any) { setError(err.message || "Failed to create tasks"); setView({ type: "editing", sessionId }); } }, [isInvalid, onClose, onTasksCreated, parentTaskId, resetState, sessionId, subtasks]); if (!isOpen) return null; return (
event.target === event.currentTarget && void handleClose()}>

Subtask Breakdown

{error &&
{error}
} {view.type === "initial" && (

Preparing to break this task into subtasks.

{initialDescription}
)} {view.type === "generating" && (

AI is generating subtasks...

{showThinking && thinkingOutput && (
{thinkingOutput}
)}
)} {(view.type === "editing" || view.type === "creating") && (

Review your subtasks

Edit titles, descriptions, sizes, and dependencies before creating all tasks at once.

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