import { useState, useCallback, useEffect, useRef } from "react";
import { Plus, Pencil, Trash2, Check, X, GripVertical, Sparkles, Download, Copy, Loader, ArrowLeft, ChevronUp } from "lucide-react";
import "./RoadmapsView.css";
import type { ToastType } from "../hooks/useToast";
import { useRoadmaps, type FeatureSuggestion, type MilestoneSuggestion, type SuggestionDraftPatch } from "../hooks/useRoadmaps";
import { useViewportMode } from "../hooks/useViewportMode";
import { useConfirm } from "../hooks/useConfirm";
import type {
Roadmap,
RoadmapMilestone,
RoadmapFeature,
RoadmapCreateInput,
RoadmapUpdateInput,
RoadmapMilestoneCreateInput,
RoadmapMilestoneUpdateInput,
RoadmapFeatureCreateInput,
RoadmapFeatureUpdateInput,
RoadmapMissionPlanningHandoff,
RoadmapFeatureTaskPlanningHandoff,
} from "@fusion/core";
export interface RoadmapsViewProps {
projectId?: string;
addToast: (message: string, type?: ToastType) => void;
}
// ── Drag State Types ────────────────────────────────────────────────
interface MilestoneDragState {
draggingId: string | null;
dropTargetId: string | null;
dropPosition: "before" | "after" | null;
}
interface FeatureDragState {
draggingId: string | null;
draggingMilestoneId: string | null;
dropTargetMilestoneId: string | null;
dropTargetIndex: number | null;
dropPosition: "before" | "after" | null;
}
// ── Inline Edit State Types ─────────────────────────────────────────
interface InlineEditState {
roadmapId: string | null;
field: "title" | "description" | null;
value: string;
}
interface MilestoneInlineEditState {
milestoneId: string | null;
field: "title" | "description" | null;
value: string;
}
interface FeatureInlineEditState {
featureId: string | null;
field: "title" | "description" | null;
value: string;
}
// ── Create Form State ───────────────────────────────────────────────
interface CreateFormState {
type: "roadmap" | "milestone" | "feature" | null;
parentId?: string;
title: string;
description: string;
}
// ── Handoff Modal Types ─────────────────────────────────────────────
interface HandoffModalProps {
isOpen: boolean;
onClose: () => void;
roadmapId: string;
roadmapTitle: string;
handoffPayload: { mission: RoadmapMissionPlanningHandoff; features: RoadmapFeatureTaskPlanningHandoff[] } | null;
isLoading: boolean;
error: Error | null;
onFetchHandoff: () => void;
onCopyToClipboard: () => void;
}
// ── Handoff Modal Component ─────────────────────────────────────────
function HandoffModal({
isOpen,
onClose,
roadmapTitle,
handoffPayload,
isLoading,
error,
onFetchHandoff,
onCopyToClipboard,
}: HandoffModalProps) {
if (!isOpen) return null;
return (
e.stopPropagation()} role="dialog" aria-modal="true" aria-labelledby="handoff-modal-title">
Export Roadmap: {roadmapTitle}
Export roadmap data for use in mission and task planning flows.
This is a read-only export — no missions or tasks will be created.
{error && (
Error loading handoff data: {error.message}
)}
{!handoffPayload && !isLoading && (
Load Handoff Data
)}
{isLoading && (
)}
{handoffPayload && (
<>
Mission Planning Handoff
{JSON.stringify(handoffPayload.mission, null, 2)}
Feature Task Planning Handoffs ({handoffPayload.features.length})
{JSON.stringify(handoffPayload.features, null, 2)}
>
)}
{handoffPayload && (
Copy to Clipboard
)}
Close
);
}
// ── Roadmap Item ─────────────────────────────────────────────────────
function RoadmapItem({
roadmap,
isSelected,
onSelect,
onEdit,
onDelete,
onExport,
}: {
roadmap: Roadmap;
isSelected: boolean;
onSelect: () => void;
onEdit: () => void;
onDelete: () => void;
onExport: () => void;
}) {
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
onSelect();
}
};
const handleEditClick = (e: React.MouseEvent) => {
e.stopPropagation();
onEdit();
};
const handleDeleteClick = (e: React.MouseEvent) => {
e.stopPropagation();
onDelete();
};
const handleExportClick = (e: React.MouseEvent) => {
e.stopPropagation();
onExport();
};
return (
{roadmap.title}
{roadmap.description && (
{roadmap.description}
)}
);
}
// ── Mobile Roadmap List ──────────────────────────────────────────────
function MobileRoadmapList({
roadmaps,
selectedRoadmapId,
onSelect,
onCreate,
onEdit,
onDelete,
onExport,
showCreateForm,
onCancelCreate,
onSaveCreate,
}: {
roadmaps: Roadmap[];
selectedRoadmapId: string | null;
onSelect: (id: string) => void;
onCreate: () => void;
onEdit: (roadmap: Roadmap) => void;
onDelete: (roadmapId: string) => void;
onExport: (roadmap: Roadmap) => void;
showCreateForm: boolean;
onCancelCreate: () => void;
onSaveCreate: (input: RoadmapCreateInput) => void;
}) {
return (
Roadmaps
{!showCreateForm && (
)}
{showCreateForm && (
)}
{roadmaps.length === 0 && !showCreateForm ? (
No roadmaps yet.
Create Roadmap
) : (
{roadmaps.map((roadmap) => (
onSelect(roadmap.id)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter") {
onSelect(roadmap.id);
}
}}
data-testid={`mobile-roadmap-item-${roadmap.id}`}
>
{roadmap.title}
{roadmap.description && (
{roadmap.description}
)}
{
e.stopPropagation();
onExport(roadmap);
}}
title="Export roadmap"
aria-label="Export roadmap"
data-testid={`mobile-roadmap-export-${roadmap.id}`}
>
{
e.stopPropagation();
onEdit(roadmap);
}}
title="Edit roadmap"
aria-label="Edit roadmap"
data-testid={`mobile-roadmap-edit-${roadmap.id}`}
>
{
e.stopPropagation();
onDelete(roadmap.id);
}}
title="Delete roadmap"
aria-label="Delete roadmap"
data-testid={`mobile-roadmap-delete-${roadmap.id}`}
>
))}
)}
);
}
// ── Mobile Roadmap Header (shown when roadmap is selected) ────────────
function MobileRoadmapHeader({
roadmapTitle,
onBack,
onEdit,
onDelete,
onCreate,
}: {
roadmapTitle: string;
onBack: () => void;
onEdit: () => void;
onDelete: () => void;
onCreate: () => void;
}) {
return (
);
}
// ── Milestone Card ───────────────────────────────────────────────────
function MilestoneCard({
milestone,
features,
onEditMilestone,
onDeleteMilestone,
onAddFeature,
onEditFeature,
onDeleteFeature,
milestoneEdit,
onMilestoneEditChange,
onMilestoneEditFieldChange,
onCancelMilestoneEdit,
onSaveMilestoneEdit,
featureEdit,
onFeatureEditChange,
onStartFeatureEdit: _onStartFeatureEdit,
onCancelFeatureEdit,
onSaveFeatureEdit,
projectId: _projectId,
addToast: _addToast,
// Milestone drag-and-drop props
isMilestoneDragging,
isMilestoneDropTarget,
milestoneDropPosition,
onMilestoneDragStart,
onMilestoneDragEnd,
onMilestoneDragOver,
onMilestoneDrop,
onMilestoneDragLeave,
// Feature drag-and-drop props
isFeatureDragging,
isFeatureDropTarget,
featureDropIndex,
onFeatureDragStart,
onFeatureDragEnd,
onFeatureDragOver,
onFeatureDrop,
onFeatureDragLeave,
onFeatureDropOnMilestone,
// Feature suggestion props
featureSuggestions,
isGeneratingFeatureSuggestions,
onGenerateFeatureSuggestions,
onAcceptFeatureSuggestion,
onAcceptAllFeatureSuggestions,
onUpdateFeatureSuggestionDraft,
onClearFeatureSuggestions,
}: {
milestone: RoadmapMilestone;
features: RoadmapFeature[];
onEditMilestone: () => void;
onDeleteMilestone: () => void;
onAddFeature: () => void;
onEditFeature: (featureId: string) => void;
onDeleteFeature: (featureId: string) => void;
milestoneEdit: MilestoneInlineEditState | null;
onMilestoneEditChange: (value: string) => void;
onMilestoneEditFieldChange: (field: "title" | "description") => void;
onCancelMilestoneEdit: () => void;
onSaveMilestoneEdit: (updates: RoadmapMilestoneUpdateInput) => void;
featureEdit: FeatureInlineEditState | null;
onFeatureEditChange: (value: string) => void;
onStartFeatureEdit: (featureId: string, currentTitle: string, currentDescription?: string) => void;
onCancelFeatureEdit: () => void;
onSaveFeatureEdit: (updates: RoadmapFeatureUpdateInput) => void;
projectId?: string;
addToast: (message: string, type?: ToastType) => void;
// Milestone drag-and-drop props
isMilestoneDragging: boolean;
isMilestoneDropTarget: boolean;
milestoneDropPosition: "before" | "after" | null;
onMilestoneDragStart: (milestoneId: string) => void;
onMilestoneDragEnd: () => void;
onMilestoneDragOver: (milestoneId: string) => void;
onMilestoneDrop: (milestoneId: string) => void;
onMilestoneDragLeave: (e: React.DragEvent) => void;
// Feature drag-and-drop props
isFeatureDragging: (featureId: string) => boolean;
isFeatureDropTarget: boolean;
featureDropIndex: number | null;
onFeatureDragStart: (featureId: string, milestoneId: string) => void;
onFeatureDragEnd: () => void;
onFeatureDragOver: (featureId: string, position: "before" | "after") => void;
onFeatureDrop: (featureId: string, targetIndex: number) => void;
onFeatureDragLeave: (e: React.DragEvent) => void;
onFeatureDropOnMilestone: () => void;
// Feature suggestion props
featureSuggestions?: FeatureSuggestion[];
isGeneratingFeatureSuggestions?: boolean;
onGenerateFeatureSuggestions?: () => void;
onUpdateFeatureSuggestionDraft?: (milestoneId: string, draftId: string, patch: SuggestionDraftPatch) => void;
onAcceptFeatureSuggestion?: (milestoneId: string, draftId: string) => void;
onAcceptAllFeatureSuggestions?: () => void;
onClearFeatureSuggestions?: () => void;
}) {
const isEditingMilestone = milestoneEdit?.milestoneId === milestone.id;
const handleMilestoneTitleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
if (milestoneEdit) {
onSaveMilestoneEdit({ title: milestoneEdit.value });
}
} else if (e.key === "Escape") {
onCancelMilestoneEdit();
}
};
const handleMilestoneDescKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
onCancelMilestoneEdit();
}
};
// Build class names for drag states
const milestoneClasses = [
"roadmaps-view__milestone",
isMilestoneDragging ? "roadmaps-view__milestone--dragging" : "",
isMilestoneDropTarget ? "roadmaps-view__milestone--drop-target" : "",
isMilestoneDropTarget && milestoneDropPosition === "before" ? "roadmaps-view__milestone--drop-before" : "",
isMilestoneDropTarget && milestoneDropPosition === "after" ? "roadmaps-view__milestone--drop-after" : "",
].filter(Boolean).join(" ");
// Build class names for feature list drop state
const featureListClasses = [
"roadmaps-view__feature-list",
isFeatureDropTarget ? "roadmaps-view__feature-list--drop-target" : "",
].filter(Boolean).join(" ");
return (
{
if (!isEditingMilestone) {
onMilestoneDragStart(milestone.id);
e.dataTransfer.setData("text/plain", `milestone:${milestone.id}`);
e.dataTransfer.effectAllowed = "move";
}
}}
onDragEnd={onMilestoneDragEnd}
onDragOver={(e) => {
// Only prevent default for milestone drops, not feature drops
if (e.dataTransfer.types.includes("text/plain")) {
const data = e.dataTransfer.types.includes("text/plain");
if (data) {
// This is a milestone drag
e.preventDefault();
e.dataTransfer.dropEffect = "move";
onMilestoneDragOver(milestone.id);
}
}
}}
onDrop={(e) => {
e.preventDefault();
// Check if this is a feature drop or milestone drop
const data = e.dataTransfer.getData("text/plain");
if (data?.startsWith("feature:")) {
// Feature drop - handled by child element
} else {
onMilestoneDrop(milestone.id);
}
}}
onDragLeave={onMilestoneDragLeave}
data-testid={`milestone-card-${milestone.id}`}
>
{isEditingMilestone ? (
) : (
<>
{milestone.description && (
{milestone.description}
)}
>
)}
Add Feature
{
// Generate feature suggestions for this milestone
onGenerateFeatureSuggestions?.();
}}
disabled={isGeneratingFeatureSuggestions ?? false}
title="Generate feature suggestions with AI"
aria-label="Generate feature suggestions"
data-testid={`generate-features-${milestone.id}`}
>
{isGeneratingFeatureSuggestions ? "Generating..." : "AI Suggestions"}
{
e.preventDefault();
e.dataTransfer.dropEffect = "move";
// Check if this is a feature being dragged
const data = e.dataTransfer.getData("text/plain");
if (data?.startsWith("feature:")) {
onFeatureDropOnMilestone();
}
}}
onDrop={(e) => {
e.preventDefault();
const data = e.dataTransfer.getData("text/plain");
if (data?.startsWith("feature:")) {
// Drop on empty area of feature list - append to end
onFeatureDrop(data.split(":")[1], features.length);
}
}}
onDragLeave={onFeatureDragLeave}
>
{features.length === 0 ? (
No features yet.
) : (
features.map((feature, index) => {
const isEditingFeature = featureEdit?.featureId === feature.id;
const isFeatureDraggingThis = isFeatureDragging(feature.id);
const handleFeatureTitleKeyDown = (e: React.KeyboardEvent
) => {
if (e.key === "Enter") {
e.preventDefault();
if (featureEdit) {
onSaveFeatureEdit({ title: featureEdit.value });
}
} else if (e.key === "Escape") {
onCancelFeatureEdit();
}
};
// Build class names for feature drag states
const featureClasses = [
"roadmaps-view__feature-item",
isFeatureDraggingThis ? "roadmaps-view__feature-item--dragging" : "",
isFeatureDropTarget && featureDropIndex === index ? "roadmaps-view__feature-item--drop-before" : "",
isFeatureDropTarget && featureDropIndex === index + 1 ? "roadmaps-view__feature-item--drop-after" : "",
].filter(Boolean).join(" ");
return (
{
if (!isEditingFeature) {
onFeatureDragStart(feature.id, milestone.id);
e.dataTransfer.setData("text/plain", `feature:${feature.id}`);
e.dataTransfer.effectAllowed = "move";
}
}}
onDragEnd={onFeatureDragEnd}
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = "move";
const data = e.dataTransfer.getData("text/plain");
if (data?.startsWith("feature:")) {
// Calculate position (before or after)
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const midY = rect.top + rect.height / 2;
const position: "before" | "after" = e.clientY < midY ? "before" : "after";
onFeatureDragOver(feature.id, position);
}
}}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
const data = e.dataTransfer.getData("text/plain");
if (data?.startsWith("feature:")) {
const draggedFeatureId = data.split(":")[1];
// Calculate target index
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const midY = rect.top + rect.height / 2;
const position: "before" | "after" = e.clientY < midY ? "before" : "after";
let targetIndex = index;
if (position === "after") {
targetIndex = index + 1;
}
onFeatureDrop(draggedFeatureId, targetIndex);
}
}}
onDragLeave={onFeatureDragLeave}
data-testid={`feature-item-${feature.id}`}
>
{isEditingFeature ? (
) : (
<>
{feature.title}
{feature.description && (
{feature.description}
)}
onEditFeature(feature.id)}
title="Edit feature"
aria-label="Edit feature"
data-testid={`feature-edit-${feature.id}`}
>
onDeleteFeature(feature.id)}
title="Delete feature"
aria-label="Delete feature"
data-testid={`feature-delete-${feature.id}`}
>
>
)}
);
})
)}
{/* Feature Suggestions Section */}
{featureSuggestions && featureSuggestions.length > 0 && (
AI Feature Suggestions
onAcceptAllFeatureSuggestions?.()}
title="Accept all suggestions"
aria-label="Accept all"
data-testid={`accept-all-features-${milestone.id}`}
>
Accept All
onClearFeatureSuggestions?.()}
title="Clear suggestions"
aria-label="Clear"
data-testid={`clear-features-${milestone.id}`}
>
Clear
{featureSuggestions.map((suggestion) => (
onUpdateFeatureSuggestionDraft?.(milestone.id, suggestion.id, patch)}
onAccept={() => {
onAcceptFeatureSuggestion?.(milestone.id, suggestion.id);
}}
testIdPrefix={`feature-suggestion-${milestone.id}`}
/>
))}
)}
);
}
// ── Feature Suggestion Card ───────────────────────────────────────────
interface FeatureSuggestionCardProps {
suggestion: FeatureSuggestion;
onUpdateDraft: (patch: SuggestionDraftPatch) => void;
onAccept: () => void;
testIdPrefix: string;
}
function FeatureSuggestionCard({
suggestion,
onUpdateDraft,
onAccept,
testIdPrefix,
}: FeatureSuggestionCardProps) {
const [isEditing, setIsEditing] = useState(false);
const [editTitle, setEditTitle] = useState(suggestion.title);
const [editDescription, setEditDescription] = useState(suggestion.description || "");
const handleStartEdit = () => {
setEditTitle(suggestion.title);
setEditDescription(suggestion.description || "");
setIsEditing(true);
};
const handleSaveEdit = () => {
onUpdateDraft({
title: editTitle.trim(),
description: editDescription.trim() || undefined,
});
setIsEditing(false);
};
const handleCancelEdit = () => {
setEditTitle(suggestion.title);
setEditDescription(suggestion.description || "");
setIsEditing(false);
};
const handleAccept = () => {
if (!suggestion.title.trim()) {
return; // Don't accept empty titles
}
onAccept();
};
const isValid = suggestion.title.trim().length > 0;
if (isEditing) {
return (
);
}
return (
{suggestion.title}
{suggestion.description && (
{suggestion.description}
)}
);
}
// ── Milestone Suggestion Card ────────────────────────────────────────
interface MilestoneSuggestionCardProps {
suggestion: MilestoneSuggestion;
onUpdateDraft: (patch: SuggestionDraftPatch) => void;
onAccept: () => void;
testIdPrefix: string;
}
function MilestoneSuggestionCard({
suggestion,
onUpdateDraft,
onAccept,
testIdPrefix,
}: MilestoneSuggestionCardProps) {
const [isEditing, setIsEditing] = useState(false);
const [editTitle, setEditTitle] = useState(suggestion.title);
const [editDescription, setEditDescription] = useState(suggestion.description || "");
const handleStartEdit = () => {
setEditTitle(suggestion.title);
setEditDescription(suggestion.description || "");
setIsEditing(true);
};
const handleSaveEdit = () => {
onUpdateDraft({
title: editTitle.trim(),
description: editDescription.trim() || undefined,
});
setIsEditing(false);
};
const handleCancelEdit = () => {
setEditTitle(suggestion.title);
setEditDescription(suggestion.description || "");
setIsEditing(false);
};
const handleAccept = () => {
if (!suggestion.title.trim()) {
return; // Don't accept empty titles
}
onAccept();
};
const isValid = suggestion.title.trim().length > 0;
if (isEditing) {
return (
);
}
return (
{suggestion.title}
{suggestion.description && (
{suggestion.description}
)}
);
}
// ── Create Form ───────────────────────────────────────────────────────
function CreateRoadmapForm({
onSave,
onCancel,
}: {
onSave: (input: RoadmapCreateInput) => void;
onCancel: () => void;
}) {
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim()) return;
onSave({ title: title.trim(), description: description.trim() || undefined });
};
return (
);
}
function CreateMilestoneForm({
onSave,
onCancel,
}: {
onSave: (input: RoadmapMilestoneCreateInput) => void;
onCancel: () => void;
}) {
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim()) return;
onSave({ title: title.trim(), description: description.trim() || undefined });
};
return (
);
}
function CreateFeatureForm({
onSave,
onCancel,
}: {
onSave: (input: RoadmapFeatureCreateInput) => void;
onCancel: () => void;
}) {
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim()) return;
onSave({ title: title.trim(), description: description.trim() || undefined });
};
return (
);
}
// ── Main Component ────────────────────────────────────────────────────
export function RoadmapsView({ projectId, addToast }: RoadmapsViewProps) {
const { confirm } = useConfirm();
const isMobile = useViewportMode() === "mobile";
const {
roadmaps,
selectedRoadmapId,
selectedRoadmap,
milestones,
featuresByMilestoneId,
loading,
error,
createRoadmap,
updateRoadmap,
deleteRoadmap,
selectRoadmap,
createMilestone,
updateMilestone,
deleteMilestone,
createFeature,
updateFeature,
deleteFeature,
reorderMilestones,
reorderFeatures,
moveFeature,
milestoneSuggestions,
isGeneratingSuggestions,
generateMilestoneSuggestions,
updateMilestoneSuggestionDraft,
acceptMilestoneSuggestion,
acceptAllMilestoneSuggestions,
clearMilestoneSuggestions,
featureSuggestionsByMilestoneId,
isGeneratingFeatureSuggestions,
generateFeatureSuggestions,
updateFeatureSuggestionDraft,
acceptFeatureSuggestion,
acceptAllFeatureSuggestions,
clearFeatureSuggestions,
handoffPayload,
isFetchingHandoff,
handoffError,
fetchHandoff,
clearHandoff,
} = useRoadmaps({ projectId });
// Handoff modal state
const [handoffModalOpen, setHandoffModalOpen] = useState(false);
const [handoffRoadmapId, setHandoffRoadmapId] = useState(null);
const [handoffRoadmapTitle, setHandoffRoadmapTitle] = useState("");
// Goal prompt state for milestone suggestion generation
const [goalPrompt, setGoalPrompt] = useState("");
// Mobile suggestion panel collapse state
const [showSuggestionPanel, setShowSuggestionPanel] = useState(false);
// Reset suggestion panel when roadmap changes on mobile
const prevRoadmapIdRef = useRef(null);
useEffect(() => {
if (prevRoadmapIdRef.current !== null && prevRoadmapIdRef.current !== selectedRoadmapId) {
setShowSuggestionPanel(false);
}
prevRoadmapIdRef.current = selectedRoadmapId;
}, [selectedRoadmapId]);
// Inline edit states
const [roadmapEdit, setRoadmapEdit] = useState({
roadmapId: null,
field: null,
value: "",
});
const [milestoneEdit, setMilestoneEdit] = useState({
milestoneId: null,
field: null,
value: "",
});
const [featureEdit, setFeatureEdit] = useState({
featureId: null,
field: null,
value: "",
});
// Create form state
const [createForm, setCreateForm] = useState({
type: null,
parentId: undefined,
title: "",
description: "",
});
// Mobile roadmap list create form state
const [mobileShowCreateForm, setMobileShowCreateForm] = useState(false);
// Milestone drag-and-drop state
const [milestoneDrag, setMilestoneDrag] = useState({
draggingId: null,
dropTargetId: null,
dropPosition: null,
});
// Milestone drag handlers
const handleMilestoneDragStart = useCallback((milestoneId: string) => {
setMilestoneDrag((prev) => ({
...prev,
draggingId: milestoneId,
}));
}, []);
const handleMilestoneDragEnd = useCallback(() => {
setMilestoneDrag({
draggingId: null,
dropTargetId: null,
dropPosition: null,
});
}, []);
const handleMilestoneDragOver = useCallback((targetMilestoneId: string) => {
setMilestoneDrag((prev) => {
// Don't update if dragging over self
if (prev.draggingId === targetMilestoneId) {
return prev;
}
// Calculate drop position based on mouse position relative to target
// The position will be computed based on where the drop will happen
// For now, we just track the target
return {
...prev,
dropTargetId: targetMilestoneId,
dropPosition: null, // Will be set in handleMilestoneDrop
};
});
}, []);
// Feature drag-and-drop state
const [featureDrag, setFeatureDrag] = useState({
draggingId: null,
draggingMilestoneId: null,
dropTargetMilestoneId: null,
dropTargetIndex: null,
dropPosition: null,
});
// Feature drag handlers
const handleFeatureDragStart = useCallback((featureId: string, milestoneId: string) => {
setFeatureDrag((prev) => ({
...prev,
draggingId: featureId,
draggingMilestoneId: milestoneId,
}));
}, []);
const handleFeatureDragEnd = useCallback(() => {
setFeatureDrag({
draggingId: null,
draggingMilestoneId: null,
dropTargetMilestoneId: null,
dropTargetIndex: null,
dropPosition: null,
});
}, []);
const handleFeatureDragOver = useCallback((targetFeatureId: string, position: "before" | "after") => {
setFeatureDrag((prev) => {
// Don't update if dragging over self
if (prev.draggingId === targetFeatureId) {
return prev;
}
// Find the target feature's index in its milestone
const targetFeatures = featuresByMilestoneId[prev.draggingMilestoneId || ""] || [];
const targetIndex = targetFeatures.findIndex((f) => f.id === targetFeatureId);
let dropTargetIndex: number;
if (position === "before") {
dropTargetIndex = targetIndex;
} else {
dropTargetIndex = targetIndex + 1;
}
return {
...prev,
dropTargetMilestoneId: prev.draggingMilestoneId,
dropTargetIndex,
dropPosition: position,
};
});
}, [featuresByMilestoneId]);
const handleFeatureDropOnMilestone = useCallback(() => {
setFeatureDrag((prev) => ({
...prev,
dropTargetMilestoneId: prev.draggingMilestoneId,
// Append to end of feature list
dropTargetIndex: (featuresByMilestoneId[prev.draggingMilestoneId || ""] || []).length,
}));
}, [featuresByMilestoneId]);
const handleFeatureDrop = useCallback(async (featureId: string, targetIndex: number) => {
const { draggingMilestoneId, dropTargetMilestoneId } = featureDrag;
if (!draggingMilestoneId) {
handleFeatureDragEnd();
return;
}
// Determine the target milestone - use the drop target if available, otherwise the dragging milestone
const targetMilestoneId = dropTargetMilestoneId || draggingMilestoneId;
// Get the source features
const sourceFeatures = featuresByMilestoneId[draggingMilestoneId] || [];
// Find the feature being dragged
const featureBeingDragged = sourceFeatures.find((f) => f.id === featureId);
if (!featureBeingDragged) {
handleFeatureDragEnd();
return;
}
// Check if this is a cross-milestone move
const isCrossMilestone = draggingMilestoneId !== targetMilestoneId;
if (isCrossMilestone) {
// No-op check: if moving to same position in same milestone (shouldn't happen but safety check)
if (draggingMilestoneId === targetMilestoneId) {
handleFeatureDragEnd();
return;
}
// Perform the move
try {
await moveFeature(featureId, targetMilestoneId, targetIndex, {
onError: (err) => {
addToast(`Failed to move feature: ${err.message}`, "error");
},
});
} catch {
// Error handled in callback
}
} else {
// Same-milestone reorder
const targetFeatures = [...sourceFeatures];
const fromIndex = targetFeatures.findIndex((f) => f.id === featureId);
// Remove from current position and insert at target
targetFeatures.splice(fromIndex, 1);
targetFeatures.splice(targetIndex, 0, featureBeingDragged);
// Compute new order of feature IDs
const orderedIds = targetFeatures.map((f) => f.id);
// No-op check: if order is unchanged
const currentIds = sourceFeatures.map((f) => f.id);
if (orderedIds.join(",") === currentIds.join(",")) {
handleFeatureDragEnd();
return;
}
// Perform the reorder
try {
await reorderFeatures(draggingMilestoneId, orderedIds, {
onError: (err) => {
addToast(`Failed to reorder features: ${err.message}`, "error");
},
});
} catch {
// Error handled in callback
}
}
handleFeatureDragEnd();
}, [featureDrag, featuresByMilestoneId, reorderFeatures, moveFeature, addToast, handleFeatureDragEnd]);
const handleFeatureDragLeave = useCallback((e: React.DragEvent) => {
// Only clear if leaving the element entirely
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const x = e.clientX;
const y = e.clientY;
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
setFeatureDrag((prev) => ({
...prev,
dropTargetMilestoneId: null,
dropTargetIndex: null,
dropPosition: null,
}));
}
}, []);
// Check if a feature is being dragged
const isFeatureDragging = useCallback((featureId: string) => {
return featureDrag.draggingId === featureId;
}, [featureDrag.draggingId]);
const handleMilestoneDrop = useCallback(async (targetMilestoneId: string) => {
const { draggingId } = milestoneDrag;
if (!draggingId || draggingId === targetMilestoneId) {
handleMilestoneDragEnd();
return;
}
// Compute the new order
const currentOrder = milestones.map((m) => m.id);
const fromIndex = currentOrder.indexOf(draggingId);
const toIndex = currentOrder.indexOf(targetMilestoneId);
if (fromIndex === -1 || toIndex === -1) {
handleMilestoneDragEnd();
return;
}
// Compute the new order based on drop position
// The drop indicator shows where the item will be inserted
const newOrder = [...currentOrder];
newOrder.splice(fromIndex, 1);
newOrder.splice(toIndex, 0, draggingId);
// No-op check: if the order is unchanged
if (newOrder.join(",") === currentOrder.join(",")) {
handleMilestoneDragEnd();
return;
}
// Perform the reorder
try {
await reorderMilestones(selectedRoadmapId!, newOrder, {
onError: (err) => {
addToast(`Failed to reorder milestones: ${err.message}`, "error");
},
});
} catch {
// Error handled in callback
}
handleMilestoneDragEnd();
}, [milestoneDrag, milestones, selectedRoadmapId, reorderMilestones, addToast, handleMilestoneDragEnd]);
const handleMilestoneDragLeave = useCallback((e: React.DragEvent) => {
// Only clear if leaving the element entirely
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const x = e.clientX;
const y = e.clientY;
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
setMilestoneDrag((prev) => ({
...prev,
dropTargetId: null,
dropPosition: null,
}));
}
}, []);
// Roadmap handlers
const handleStartRoadmapEdit = useCallback((roadmap: Roadmap) => {
selectRoadmap(roadmap.id);
setRoadmapEdit({
roadmapId: roadmap.id,
field: "title",
value: roadmap.title,
});
}, [selectRoadmap]);
const handleCancelRoadmapEdit = useCallback(() => {
setRoadmapEdit({ roadmapId: null, field: null, value: "" });
}, []);
const handleSaveRoadmapEdit = useCallback(
async (updates: RoadmapUpdateInput) => {
if (!roadmapEdit.roadmapId) return;
try {
await updateRoadmap(roadmapEdit.roadmapId, updates, {
onError: (err) => addToast(err.message, "error"),
});
handleCancelRoadmapEdit();
} catch {
// Error handled in callback
}
},
[roadmapEdit.roadmapId, updateRoadmap, handleCancelRoadmapEdit, addToast]
);
const handleDeleteRoadmap = useCallback(
async (roadmapId: string) => {
const shouldDelete = await confirm({
title: "Delete Roadmap",
message: "Delete this roadmap? This cannot be undone.",
danger: true,
});
if (!shouldDelete) return;
try {
await deleteRoadmap(roadmapId, {
onError: (err) => addToast(err.message, "error"),
});
addToast("Roadmap deleted", "success");
} catch {
// Error handled in callback
}
},
[deleteRoadmap, addToast, confirm]
);
// Handoff handlers
const handleOpenHandoffModal = useCallback((roadmapId: string, roadmapTitle: string) => {
setHandoffRoadmapId(roadmapId);
setHandoffRoadmapTitle(roadmapTitle);
setHandoffModalOpen(true);
// Clear any previous handoff data
clearHandoff();
}, [clearHandoff]);
const handleCloseHandoffModal = useCallback(() => {
setHandoffModalOpen(false);
setHandoffRoadmapId(null);
setHandoffRoadmapTitle("");
clearHandoff();
}, [clearHandoff]);
const handleFetchHandoff = useCallback(() => {
if (handoffRoadmapId) {
fetchHandoff(handoffRoadmapId, {
onError: (err) => addToast(`Failed to load handoff: ${err.message}`, "error"),
});
}
}, [handoffRoadmapId, fetchHandoff, addToast]);
const handleCopyHandoffToClipboard = useCallback(() => {
if (handoffPayload) {
const data = JSON.stringify(handoffPayload, null, 2);
navigator.clipboard.writeText(data).then(() => {
addToast("Handoff data copied to clipboard", "success");
}).catch(() => {
addToast("Failed to copy to clipboard", "error");
});
}
}, [handoffPayload, addToast]);
const handleCreateRoadmap = useCallback(
async (input: RoadmapCreateInput) => {
try {
await createRoadmap(input, {
onError: (err) => addToast(err.message, "error"),
});
setCreateForm({ type: null, parentId: undefined, title: "", description: "" });
addToast("Roadmap created", "success");
} catch {
// Error handled in callback
}
},
[createRoadmap, addToast]
);
// Milestone handlers
const handleStartMilestoneEdit = useCallback((milestone: RoadmapMilestone) => {
setMilestoneEdit({
milestoneId: milestone.id,
field: "title",
value: milestone.title,
});
}, []);
const handleMilestoneEditChange = useCallback((value: string) => {
setMilestoneEdit((previous) => ({ ...previous, value }));
}, []);
const handleMilestoneEditFieldChange = useCallback((field: "title" | "description") => {
setMilestoneEdit((previous) => ({ ...previous, field }));
}, []);
const handleCancelMilestoneEdit = useCallback(() => {
setMilestoneEdit({ milestoneId: null, field: null, value: "" });
}, []);
const handleSaveMilestoneEdit = useCallback(
async (updates: RoadmapMilestoneUpdateInput) => {
if (!milestoneEdit.milestoneId) return;
try {
await updateMilestone(milestoneEdit.milestoneId, updates, {
onError: (err) => addToast(err.message, "error"),
});
handleCancelMilestoneEdit();
} catch {
// Error handled in callback
}
},
[milestoneEdit.milestoneId, updateMilestone, handleCancelMilestoneEdit, addToast]
);
const handleDeleteMilestone = useCallback(
async (milestoneId: string) => {
const shouldDelete = await confirm({
title: "Delete Milestone",
message: "Delete this milestone and all its features?",
danger: true,
});
if (!shouldDelete) return;
try {
await deleteMilestone(milestoneId, {
onError: (err) => addToast(err.message, "error"),
});
addToast("Milestone deleted", "success");
} catch {
// Error handled in callback
}
},
[deleteMilestone, addToast, confirm]
);
const handleCreateMilestone = useCallback(
async (input: RoadmapMilestoneCreateInput) => {
try {
await createMilestone(input, {
onError: (err) => addToast(err.message, "error"),
});
setCreateForm({ type: null, parentId: undefined, title: "", description: "" });
addToast("Milestone created", "success");
} catch {
// Error handled in callback
}
},
[createMilestone, addToast]
);
// Feature handlers
const handleStartFeatureEdit = useCallback(
(featureId: string, currentTitle: string, _currentDescription?: string) => {
setFeatureEdit({
featureId,
field: "title",
value: currentTitle,
});
},
[]
);
const handleFeatureEditChange = useCallback((value: string) => {
setFeatureEdit((previous) => ({ ...previous, value }));
}, []);
const handleCancelFeatureEdit = useCallback(() => {
setFeatureEdit({ featureId: null, field: null, value: "" });
}, []);
const handleSaveFeatureEdit = useCallback(
async (updates: RoadmapFeatureUpdateInput) => {
if (!featureEdit.featureId) return;
try {
await updateFeature(featureEdit.featureId, updates, {
onError: (err) => addToast(err.message, "error"),
});
handleCancelFeatureEdit();
} catch {
// Error handled in callback
}
},
[featureEdit.featureId, updateFeature, handleCancelFeatureEdit, addToast]
);
const handleDeleteFeature = useCallback(
async (featureId: string) => {
const shouldDelete = await confirm({
title: "Delete Feature",
message: "Delete this feature?",
danger: true,
});
if (!shouldDelete) return;
try {
await deleteFeature(featureId, {
onError: (err) => addToast(err.message, "error"),
});
addToast("Feature deleted", "success");
} catch {
// Error handled in callback
}
},
[deleteFeature, addToast, confirm]
);
// Milestone suggestion handlers
const handleGenerateSuggestions = useCallback(
async () => {
if (!goalPrompt.trim()) return;
try {
await generateMilestoneSuggestions(goalPrompt, 5, {
onError: (err) => addToast(err.message, "error"),
});
} catch {
// Error handled in callback
}
},
[goalPrompt, generateMilestoneSuggestions, addToast]
);
const handleAcceptSuggestion = useCallback(
async (draftId: string) => {
try {
await acceptMilestoneSuggestion(draftId, {
onError: (err) => addToast(err.message, "error"),
});
addToast("Milestone added", "success");
} catch {
// Error handled in callback
}
},
[acceptMilestoneSuggestion, addToast]
);
const handleAcceptAllSuggestions = useCallback(
async () => {
try {
await acceptAllMilestoneSuggestions({
onError: (err) => addToast(err.message, "error"),
});
addToast(`${milestoneSuggestions.length} milestones added`, "success");
setGoalPrompt("");
} catch {
// Error handled in callback
}
},
[acceptAllMilestoneSuggestions, milestoneSuggestions.length, addToast]
);
const handleClearSuggestions = useCallback(() => {
clearMilestoneSuggestions();
setGoalPrompt("");
}, [clearMilestoneSuggestions]);
// Feature suggestion handlers
const handleGenerateFeatureSuggestions = useCallback(
async (milestoneId: string) => {
try {
await generateFeatureSuggestions(milestoneId, { count: 5 }, {
onError: (err) => addToast(err.message, "error"),
});
} catch {
// Error handled in callback
}
},
[generateFeatureSuggestions, addToast]
);
const handleAcceptFeatureSuggestion = useCallback(
async (milestoneId: string, draftId: string) => {
try {
await acceptFeatureSuggestion(milestoneId, draftId, {
onError: (err) => addToast(err.message, "error"),
});
addToast("Feature added", "success");
} catch {
// Error handled in callback
}
},
[acceptFeatureSuggestion, addToast]
);
const handleUpdateFeatureSuggestionDraft = useCallback(
(milestoneId: string, draftId: string, patch: SuggestionDraftPatch) => {
updateFeatureSuggestionDraft(milestoneId, draftId, patch);
},
[updateFeatureSuggestionDraft]
);
const handleAcceptAllFeatureSuggestions = useCallback(
async (milestoneId: string) => {
const suggestions = featureSuggestionsByMilestoneId[milestoneId] || [];
try {
await acceptAllFeatureSuggestions(milestoneId, {
onError: (err) => addToast(err.message, "error"),
});
addToast(`${suggestions.length} features added`, "success");
} catch {
// Error handled in callback
}
},
[acceptAllFeatureSuggestions, featureSuggestionsByMilestoneId, addToast]
);
const handleClearFeatureSuggestions = useCallback(
(milestoneId: string) => {
clearFeatureSuggestions(milestoneId);
},
[clearFeatureSuggestions]
);
const handleCreateFeature = useCallback(
async (milestoneId: string, input: RoadmapFeatureCreateInput) => {
try {
await createFeature(milestoneId, input, {
onError: (err) => addToast(err.message, "error"),
});
setCreateForm({ type: null, parentId: undefined, title: "", description: "" });
addToast("Feature created", "success");
} catch {
// Error handled in callback
}
},
[createFeature, addToast]
);
// Get the currently selected roadmap ID (handles both desktop and mobile)
const effectiveSelectedRoadmapId = selectedRoadmapId;
if (loading && roadmaps.length === 0) {
return (
);
}
if (error && roadmaps.length === 0) {
return (
Failed to load roadmaps
{error.message}
);
}
return (
{/* Mobile Roadmap List (shown when mobile and no roadmap selected) */}
{isMobile && !effectiveSelectedRoadmapId && (
selectRoadmap(id)}
onCreate={() => setMobileShowCreateForm(true)}
onEdit={handleStartRoadmapEdit}
onDelete={handleDeleteRoadmap}
onExport={(roadmap) => handleOpenHandoffModal(roadmap.id, roadmap.title)}
showCreateForm={mobileShowCreateForm}
onCancelCreate={() => setMobileShowCreateForm(false)}
onSaveCreate={async (input) => {
await handleCreateRoadmap(input);
setMobileShowCreateForm(false);
}}
/>
)}
{/* Desktop sidebar (hidden on mobile) */}
{!isMobile && (
Roadmaps
setCreateForm({ type: "roadmap", title: "", description: "" })}
title="Create roadmap"
aria-label="Create roadmap"
data-testid="create-roadmap-btn"
>
{createForm.type === "roadmap" && (
setCreateForm({ type: null, parentId: undefined, title: "", description: "" })}
/>
)}
{roadmaps.length === 0 ? (
No roadmaps yet. Click + to create one.
) : (
roadmaps.map((roadmap) => (
selectRoadmap(roadmap.id)}
onEdit={() => handleStartRoadmapEdit(roadmap)}
onDelete={() => handleDeleteRoadmap(roadmap.id)}
onExport={() => handleOpenHandoffModal(roadmap.id, roadmap.title)}
/>
))
)}
)}
{/* Main content */}
{/* Mobile header when roadmap is selected */}
{isMobile && effectiveSelectedRoadmapId && (
selectRoadmap(null)}
onEdit={() => {
if (selectedRoadmap) handleStartRoadmapEdit(selectedRoadmap);
}}
onDelete={() => handleDeleteRoadmap(effectiveSelectedRoadmapId)}
onCreate={() => setMobileShowCreateForm(true)}
/>
)}
{!effectiveSelectedRoadmapId ? (
Select a roadmap from the sidebar to view its milestones.
) : (
<>
{/* Roadmap header */}
{roadmapEdit.roadmapId === effectiveSelectedRoadmapId ? (
) : (
<>
{selectedRoadmap?.title || "Untitled Roadmap"}
{
if (selectedRoadmap) handleStartRoadmapEdit(selectedRoadmap);
}}
title="Edit roadmap"
aria-label="Edit roadmap"
data-testid="edit-roadmap-btn"
>
handleDeleteRoadmap(effectiveSelectedRoadmapId)}
title="Delete roadmap"
aria-label="Delete roadmap"
data-testid="delete-roadmap-btn"
>
{selectedRoadmap?.description && (
{selectedRoadmap.description}
)}
>
)}
{/* Milestone Suggestions Section */}
{isMobile ? (
showSuggestionPanel ? (
Generate Milestone Ideas
setShowSuggestionPanel(false)}
aria-label="Collapse suggestion panel"
data-testid="collapse-suggestion-panel-btn"
>
setGoalPrompt(e.target.value)}
placeholder="Describe your roadmap goal (e.g., 'Build a user authentication system with OAuth, profiles, and admin dashboard')"
rows={2}
disabled={isGeneratingSuggestions || !selectedRoadmapId}
data-testid="goal-prompt-input"
autoFocus
/>
{isGeneratingSuggestions ? "Generating..." : "Generate Milestones"}
{milestoneSuggestions.length > 0 && (
<>
Accept All ({milestoneSuggestions.length})
>
)}
{/* Suggestion Cards */}
{milestoneSuggestions.length > 0 && (
{milestoneSuggestions.map((suggestion) => (
updateMilestoneSuggestionDraft(suggestion.id, patch)}
onAccept={() => handleAcceptSuggestion(suggestion.id)}
testIdPrefix="suggestion"
/>
))}
)}
) : (
setShowSuggestionPanel(true)}
disabled={!selectedRoadmapId}
data-testid="expand-suggestion-panel-btn"
>
Generate Milestone Ideas
)
) : (
Generate Milestone Ideas
setGoalPrompt(e.target.value)}
placeholder="Describe your roadmap goal (e.g., 'Build a user authentication system with OAuth, profiles, and admin dashboard')"
rows={2}
disabled={isGeneratingSuggestions || !selectedRoadmapId}
data-testid="goal-prompt-input"
/>
{isGeneratingSuggestions ? "Generating..." : "Generate Milestones"}
{milestoneSuggestions.length > 0 && (
<>
Accept All ({milestoneSuggestions.length})
>
)}
{/* Suggestion Cards */}
{milestoneSuggestions.length > 0 && (
{milestoneSuggestions.map((suggestion) => (
updateMilestoneSuggestionDraft(suggestion.id, patch)}
onAccept={() => handleAcceptSuggestion(suggestion.id)}
testIdPrefix="suggestion"
/>
))}
)}
)}
{/* Milestone lanes */}
{createForm.type === "milestone" && (
setCreateForm({ type: null, parentId: undefined, title: "", description: "" })}
/>
)}
{milestones.length === 0 && createForm.type !== "milestone" ? (
This roadmap has no milestones.
setCreateForm({ type: "milestone", title: "", description: "" })}
data-testid="add-milestone-btn-empty"
>
Add Milestone
) : (
<>
{createForm.type !== "milestone" && (
setCreateForm({ type: "milestone", title: "", description: "" })}
data-testid="add-milestone-btn"
>
Add Milestone
)}
{milestones.map((milestone) => (
handleStartMilestoneEdit(milestone)}
onDeleteMilestone={() => handleDeleteMilestone(milestone.id)}
onAddFeature={() => setCreateForm({ type: "feature", parentId: milestone.id, title: "", description: "" })}
onEditFeature={(featureId) => {
const feature = featuresByMilestoneId[milestone.id]?.find((f) => f.id === featureId);
if (feature) {
handleStartFeatureEdit(featureId, feature.title, feature.description);
}
}}
onDeleteFeature={handleDeleteFeature}
milestoneEdit={milestoneEdit}
onMilestoneEditChange={handleMilestoneEditChange}
onMilestoneEditFieldChange={handleMilestoneEditFieldChange}
onCancelMilestoneEdit={handleCancelMilestoneEdit}
onSaveMilestoneEdit={handleSaveMilestoneEdit}
featureEdit={featureEdit}
onFeatureEditChange={handleFeatureEditChange}
onStartFeatureEdit={handleStartFeatureEdit}
onCancelFeatureEdit={handleCancelFeatureEdit}
onSaveFeatureEdit={handleSaveFeatureEdit}
projectId={projectId}
addToast={addToast}
// Milestone drag-and-drop props
isMilestoneDragging={milestoneDrag.draggingId === milestone.id}
isMilestoneDropTarget={milestoneDrag.dropTargetId === milestone.id}
milestoneDropPosition={milestoneDrag.dropTargetId === milestone.id ? milestoneDrag.dropPosition : null}
onMilestoneDragStart={handleMilestoneDragStart}
onMilestoneDragEnd={handleMilestoneDragEnd}
onMilestoneDragOver={handleMilestoneDragOver}
onMilestoneDrop={handleMilestoneDrop}
onMilestoneDragLeave={handleMilestoneDragLeave}
// Feature drag-and-drop props
isFeatureDragging={isFeatureDragging}
isFeatureDropTarget={featureDrag.dropTargetMilestoneId === milestone.id}
featureDropIndex={featureDrag.dropTargetMilestoneId === milestone.id ? featureDrag.dropTargetIndex : null}
onFeatureDragStart={handleFeatureDragStart}
onFeatureDragEnd={handleFeatureDragEnd}
onFeatureDragOver={handleFeatureDragOver}
onFeatureDrop={handleFeatureDrop}
onFeatureDragLeave={handleFeatureDragLeave}
onFeatureDropOnMilestone={handleFeatureDropOnMilestone}
// Feature suggestion props
featureSuggestions={featureSuggestionsByMilestoneId[milestone.id]}
isGeneratingFeatureSuggestions={isGeneratingFeatureSuggestions(milestone.id)}
onGenerateFeatureSuggestions={() => handleGenerateFeatureSuggestions(milestone.id)}
onAcceptFeatureSuggestion={(index) => handleAcceptFeatureSuggestion(milestone.id, index)}
onAcceptAllFeatureSuggestions={() => handleAcceptAllFeatureSuggestions(milestone.id)}
onUpdateFeatureSuggestionDraft={(milestoneId, draftId, patch) => handleUpdateFeatureSuggestionDraft(milestoneId, draftId, patch)}
onClearFeatureSuggestions={() => handleClearFeatureSuggestions(milestone.id)}
/>
))}
>
)}
>
)}
{/* Feature create form overlay */}
{createForm.type === "feature" && createForm.parentId && (
handleCreateFeature(createForm.parentId!, input)}
onCancel={() => setCreateForm({ type: null, parentId: undefined, title: "", description: "" })}
/>
)}
{/* Handoff export modal */}
);
}