"use client"; import { useEffect, useState, useCallback, useMemo } from "react"; import { DndContext, DragEndEvent, DragOverlay, DragStartEvent, PointerSensor, useSensor, useSensors, rectIntersection, pointerWithin, } from "@dnd-kit/core"; import { SortableContext, verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { useAppStore, Feature, FeatureImage } from "@/store/app-store"; import { getElectronAPI } from "@/lib/electron"; import { cn } from "@/lib/utils"; import { Card, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { CategoryAutocomplete } from "@/components/ui/category-autocomplete"; import { FeatureImageUpload } from "@/components/ui/feature-image-upload"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { KanbanColumn } from "./kanban-column"; import { KanbanCard } from "./kanban-card"; import { AutoModeLog } from "./auto-mode-log"; import { AgentOutputModal } from "./agent-output-modal"; import { Plus, RefreshCw, Play, StopCircle, Loader2, ChevronUp, ChevronDown, Users } from "lucide-react"; import { toast } from "sonner"; import { Slider } from "@/components/ui/slider"; import { useAutoMode } from "@/hooks/use-auto-mode"; type ColumnId = Feature["status"]; const COLUMNS: { id: ColumnId; title: string; color: string }[] = [ { id: "backlog", title: "Backlog", color: "bg-zinc-500" }, { id: "in_progress", title: "In Progress", color: "bg-yellow-500" }, { id: "verified", title: "Verified", color: "bg-green-500" }, ]; export function BoardView() { const { currentProject, features, setFeatures, addFeature, updateFeature, removeFeature, moveFeature, runningAutoTasks, maxConcurrency, setMaxConcurrency, } = useAppStore(); const [activeFeature, setActiveFeature] = useState(null); const [editingFeature, setEditingFeature] = useState(null); const [showAddDialog, setShowAddDialog] = useState(false); const [newFeature, setNewFeature] = useState({ category: "", description: "", steps: [""], images: [] as FeatureImage[], }); const [isLoading, setIsLoading] = useState(true); const [isMounted, setIsMounted] = useState(false); const [showActivityLog, setShowActivityLog] = useState(false); const [showOutputModal, setShowOutputModal] = useState(false); const [outputFeature, setOutputFeature] = useState(null); const [featuresWithContext, setFeaturesWithContext] = useState>(new Set()); // Make current project available globally for modal useEffect(() => { if (currentProject) { (window as any).__currentProject = currentProject; } return () => { (window as any).__currentProject = null; }; }, [currentProject]); // Auto mode hook const autoMode = useAutoMode(); // Prevent hydration issues useEffect(() => { setIsMounted(true); }, []); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8, }, }) ); // Get unique categories from existing features for autocomplete suggestions const categorySuggestions = useMemo(() => { const categories = features.map((f) => f.category).filter(Boolean); return [...new Set(categories)].sort(); }, [features]); // Custom collision detection that prioritizes columns over cards const collisionDetectionStrategy = useCallback((args: any) => { // First, check if pointer is within a column const pointerCollisions = pointerWithin(args); const columnCollisions = pointerCollisions.filter((collision: any) => COLUMNS.some((col) => col.id === collision.id) ); // If we found a column collision, use that if (columnCollisions.length > 0) { return columnCollisions; } // Otherwise, use rectangle intersection for cards return rectIntersection(args); }, []); // Load features from file const loadFeatures = useCallback(async () => { if (!currentProject) return; setIsLoading(true); try { const api = getElectronAPI(); const result = await api.readFile( `${currentProject.path}/.automaker/feature_list.json` ); if (result.success && result.content) { const parsed = JSON.parse(result.content); const featuresWithIds = parsed.map( (f: any, index: number) => ({ ...f, id: f.id || `feature-${index}-${Date.now()}`, status: f.status || "backlog", }) ); setFeatures(featuresWithIds); } } catch (error) { console.error("Failed to load features:", error); } finally { setIsLoading(false); } }, [currentProject, setFeatures]); // Auto-show activity log when auto mode starts useEffect(() => { if (autoMode.isRunning && !showActivityLog) { setShowActivityLog(true); } }, [autoMode.isRunning, showActivityLog]); // Listen for auto mode feature completion and reload features useEffect(() => { const api = getElectronAPI(); if (!api?.autoMode) return; const unsubscribe = api.autoMode.onEvent((event) => { if (event.type === "auto_mode_feature_complete") { // Reload features when a feature is completed console.log("[Board] Feature completed, reloading features..."); loadFeatures(); } }); return unsubscribe; }, [loadFeatures]); useEffect(() => { loadFeatures(); }, [loadFeatures]); // Sync running tasks from electron backend on mount useEffect(() => { const syncRunningTasks = async () => { try { const api = getElectronAPI(); if (!api?.autoMode?.status) return; const status = await api.autoMode.status(); if (status.success && status.runningFeatures) { console.log("[Board] Syncing running tasks from backend:", status.runningFeatures); // Clear existing running tasks and add the actual running ones const { clearRunningTasks, addRunningTask } = useAppStore.getState(); clearRunningTasks(); // Add each running feature to the store status.runningFeatures.forEach((featureId: string) => { addRunningTask(featureId); }); } } catch (error) { console.error("[Board] Failed to sync running tasks:", error); } }; syncRunningTasks(); }, []); // Check which features have context files useEffect(() => { const checkAllContexts = async () => { const inProgressFeatures = features.filter((f) => f.status === "in_progress"); const contextChecks = await Promise.all( inProgressFeatures.map(async (f) => ({ id: f.id, hasContext: await checkContextExists(f.id), })) ); const newSet = new Set(); contextChecks.forEach(({ id, hasContext }) => { if (hasContext) { newSet.add(id); } }); setFeaturesWithContext(newSet); }; if (features.length > 0 && !isLoading) { checkAllContexts(); } }, [features, isLoading]); // Save features to file const saveFeatures = useCallback(async () => { if (!currentProject) return; try { const api = getElectronAPI(); const toSave = features.map((f) => ({ id: f.id, category: f.category, description: f.description, steps: f.steps, status: f.status, })); await api.writeFile( `${currentProject.path}/.automaker/feature_list.json`, JSON.stringify(toSave, null, 2) ); } catch (error) { console.error("Failed to save features:", error); } }, [currentProject, features]); // Save when features change (after initial load is complete) useEffect(() => { if (!isLoading) { saveFeatures(); } }, [features, saveFeatures, isLoading]); const handleDragStart = (event: DragStartEvent) => { const { active } = event; const feature = features.find((f) => f.id === active.id); if (feature) { setActiveFeature(feature); } }; const handleDragEnd = async (event: DragEndEvent) => { const { active, over } = event; setActiveFeature(null); if (!over) return; const featureId = active.id as string; const overId = over.id as string; // Find the feature being dragged const draggedFeature = features.find((f) => f.id === featureId); if (!draggedFeature) return; // Only allow dragging from backlog if (draggedFeature.status !== "backlog") { console.log("[Board] Cannot drag feature that is already in progress or verified"); return; } let targetStatus: ColumnId | null = null; // Check if we dropped on a column const column = COLUMNS.find((c) => c.id === overId); if (column) { targetStatus = column.id; } else { // Dropped on another feature - find its column const overFeature = features.find((f) => f.id === overId); if (overFeature) { targetStatus = overFeature.status; } } if (!targetStatus) return; // Check concurrency limit before moving to in_progress if (targetStatus === "in_progress" && !autoMode.canStartNewTask) { console.log("[Board] Cannot start new task - at max concurrency limit"); toast.error("Concurrency limit reached", { description: `You can only have ${autoMode.maxConcurrency} task${autoMode.maxConcurrency > 1 ? "s" : ""} running at a time. Wait for a task to complete or increase the limit.`, }); return; } // Move the feature moveFeature(featureId, targetStatus); // If moved to in_progress, trigger the agent if (targetStatus === "in_progress") { console.log("[Board] Feature moved to in_progress, starting agent..."); await handleRunFeature(draggedFeature); } }; const handleAddFeature = () => { addFeature({ category: newFeature.category || "Uncategorized", description: newFeature.description, steps: newFeature.steps.filter((s) => s.trim()), status: "backlog", images: newFeature.images, }); setNewFeature({ category: "", description: "", steps: [""], images: [] }); setShowAddDialog(false); }; const handleUpdateFeature = () => { if (!editingFeature) return; updateFeature(editingFeature.id, { category: editingFeature.category, description: editingFeature.description, steps: editingFeature.steps, }); setEditingFeature(null); }; const handleDeleteFeature = (featureId: string) => { if (window.confirm("Are you sure you want to delete this feature?")) { removeFeature(featureId); } }; const handleRunFeature = async (feature: Feature) => { if (!currentProject) return; try { const api = getElectronAPI(); if (!api?.autoMode) { console.error("Auto mode API not available"); return; } // Call the API to run this specific feature by ID const result = await api.autoMode.runFeature( currentProject.path, feature.id ); if (result.success) { console.log("[Board] Feature run started successfully"); // The feature status will be updated by the auto mode service // and the UI will reload features when the agent completes (via event listener) } else { console.error("[Board] Failed to run feature:", result.error); // Reload to revert the UI status change await loadFeatures(); } } catch (error) { console.error("[Board] Error running feature:", error); // Reload to revert the UI status change await loadFeatures(); } }; const handleVerifyFeature = async (feature: Feature) => { if (!currentProject) return; console.log("[Board] Verifying feature:", { id: feature.id, description: feature.description }); try { const api = getElectronAPI(); if (!api?.autoMode) { console.error("Auto mode API not available"); return; } // Call the API to verify this specific feature by ID const result = await api.autoMode.verifyFeature( currentProject.path, feature.id ); if (result.success) { console.log("[Board] Feature verification started successfully"); // The feature status will be updated by the auto mode service // and the UI will reload features when verification completes } else { console.error("[Board] Failed to verify feature:", result.error); await loadFeatures(); } } catch (error) { console.error("[Board] Error verifying feature:", error); await loadFeatures(); } }; const handleResumeFeature = async (feature: Feature) => { if (!currentProject) return; console.log("[Board] Resuming feature:", { id: feature.id, description: feature.description }); try { const api = getElectronAPI(); if (!api?.autoMode) { console.error("Auto mode API not available"); return; } // Call the API to resume this specific feature by ID with context const result = await api.autoMode.resumeFeature( currentProject.path, feature.id ); if (result.success) { console.log("[Board] Feature resume started successfully"); // The feature status will be updated by the auto mode service // and the UI will reload features when resume completes } else { console.error("[Board] Failed to resume feature:", result.error); await loadFeatures(); } } catch (error) { console.error("[Board] Error resuming feature:", error); await loadFeatures(); } }; const checkContextExists = async (featureId: string): Promise => { if (!currentProject) return false; try { const api = getElectronAPI(); if (!api?.autoMode?.contextExists) { return false; } const result = await api.autoMode.contextExists( currentProject.path, featureId ); return result.success && result.exists === true; } catch (error) { console.error("[Board] Error checking context:", error); return false; } }; const getColumnFeatures = (columnId: ColumnId) => { return features.filter((f) => f.status === columnId); }; const handleViewOutput = (feature: Feature) => { setOutputFeature(feature); setShowOutputModal(true); }; const handleForceStopFeature = async (feature: Feature) => { try { await autoMode.stopFeature(feature.id); // Move the feature back to backlog status after stopping moveFeature(feature.id, "backlog"); toast.success("Agent stopped", { description: `Stopped working on: ${feature.description.slice(0, 50)}${feature.description.length > 50 ? "..." : ""}`, }); } catch (error) { console.error("[Board] Error stopping feature:", error); toast.error("Failed to stop agent", { description: error instanceof Error ? error.message : "An error occurred", }); } }; if (!currentProject) { return (

No project selected

); } if (isLoading) { return (
); } return (
{/* Header */}

Kanban Board

{currentProject.name}

{/* Concurrency Slider - only show after mount to prevent hydration issues */} {isMounted && (
setMaxConcurrency(value[0])} min={1} max={10} step={1} className="w-20" data-testid="concurrency-slider" /> {maxConcurrency}
)} {/* Auto Mode Toggle - only show after mount to prevent hydration issues */} {isMounted && ( <> {autoMode.isRunning ? ( ) : ( )} )} {isMounted && autoMode.isRunning && ( )}
{/* Main Content Area */}
{/* Kanban Columns */}
{COLUMNS.map((column) => { const columnFeatures = getColumnFeatures(column.id); return ( f.id)} strategy={verticalListSortingStrategy} > {columnFeatures.map((feature) => ( setEditingFeature(feature)} onDelete={() => handleDeleteFeature(feature.id)} onViewOutput={() => handleViewOutput(feature)} onVerify={() => handleVerifyFeature(feature)} onResume={() => handleResumeFeature(feature)} onForceStop={() => handleForceStopFeature(feature)} hasContext={featuresWithContext.has(feature.id)} isCurrentAutoTask={runningAutoTasks.includes(feature.id)} /> ))} ); })}
{activeFeature && ( {activeFeature.description} {activeFeature.category} )}
{/* Activity Log Panel */} {showActivityLog && (
setShowActivityLog(false)} />
)}
{/* Add Feature Dialog */} Add New Feature Create a new feature card for the Kanban board.
setNewFeature({ ...newFeature, category: value }) } suggestions={categorySuggestions} placeholder="e.g., Core, UI, API" data-testid="feature-category-input" />