"use client"; import { useEffect, useState, useCallback, useMemo, useRef } 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, FeatureImagePath, AgentModel, ThinkingLevel, AIProfile, } from "@/store/app-store"; import { getElectronAPI } from "@/lib/electron"; import { cn, modelSupportsThinking } 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 { DescriptionImageDropZone, FeatureImagePath as DescriptionImagePath, ImagePreviewMap, } from "@/components/ui/description-image-dropzone"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import { KanbanColumn } from "./kanban-column"; import { KanbanCard } from "./kanban-card"; import { AgentOutputModal } from "./agent-output-modal"; import { FeatureSuggestionsDialog } from "./feature-suggestions-dialog"; import { Plus, RefreshCw, Play, StopCircle, Loader2, Users, Trash2, FastForward, FlaskConical, CheckCircle2, MessageSquare, GitCommit, Brain, Zap, Settings2, Scale, Cpu, Rocket, Sparkles, UserCircle, Lightbulb, } from "lucide-react"; import { toast } from "sonner"; import { Slider } from "@/components/ui/slider"; import { Checkbox } from "@/components/ui/checkbox"; import { useAutoMode } from "@/hooks/use-auto-mode"; import { useKeyboardShortcuts, ACTION_SHORTCUTS, KeyboardShortcut, } from "@/hooks/use-keyboard-shortcuts"; import { useWindowState } from "@/hooks/use-window-state"; 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: "waiting_approval", title: "Waiting Approval", color: "bg-orange-500" }, { id: "verified", title: "Verified", color: "bg-green-500" }, ]; type ModelOption = { id: AgentModel; label: string; description: string; badge?: string; provider: "claude" | "codex"; }; const CLAUDE_MODELS: ModelOption[] = [ { id: "haiku", label: "Claude Haiku", description: "Fast and efficient for simple tasks.", badge: "Speed", provider: "claude", }, { id: "sonnet", label: "Claude Sonnet", description: "Balanced performance with strong reasoning.", badge: "Balanced", provider: "claude", }, { id: "opus", label: "Claude Opus", description: "Most capable model for complex work.", badge: "Premium", provider: "claude", }, ]; const CODEX_MODELS: ModelOption[] = [ { id: "gpt-5.1-codex-max", label: "GPT-5.1 Codex Max", description: "Flagship Codex model tuned for deep coding tasks.", badge: "Flagship", provider: "codex", }, { id: "gpt-5.1-codex", label: "GPT-5.1 Codex", description: "Strong coding performance with lower cost.", badge: "Standard", provider: "codex", }, { id: "gpt-5.1-codex-mini", label: "GPT-5.1 Codex Mini", description: "Fastest Codex option for lightweight edits.", badge: "Fast", provider: "codex", }, { id: "gpt-5.1", label: "GPT-5.1", description: "General-purpose reasoning with solid coding ability.", badge: "General", provider: "codex", }, ]; // Profile icon mapping const PROFILE_ICONS: Record> = { Brain, Zap, Scale, Cpu, Rocket, Sparkles, }; export function BoardView() { const { currentProject, features, setFeatures, addFeature, updateFeature, removeFeature, moveFeature, maxConcurrency, setMaxConcurrency, defaultSkipTests, useWorktrees, showProfilesOnly, aiProfiles, } = 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[], imagePaths: [] as DescriptionImagePath[], skipTests: false, model: "opus" as AgentModel, thinkingLevel: "none" as ThinkingLevel, }); const [isLoading, setIsLoading] = useState(true); const [isMounted, setIsMounted] = useState(false); const [showOutputModal, setShowOutputModal] = useState(false); const [outputFeature, setOutputFeature] = useState(null); const [featuresWithContext, setFeaturesWithContext] = useState>( new Set() ); const [showDeleteAllVerifiedDialog, setShowDeleteAllVerifiedDialog] = useState(false); const [persistedCategories, setPersistedCategories] = useState([]); const [showFollowUpDialog, setShowFollowUpDialog] = useState(false); const [followUpFeature, setFollowUpFeature] = useState(null); const [followUpPrompt, setFollowUpPrompt] = useState(""); const [followUpImagePaths, setFollowUpImagePaths] = useState< DescriptionImagePath[] >([]); // Preview maps to persist image previews across tab switches const [newFeaturePreviewMap, setNewFeaturePreviewMap] = useState( () => new Map() ); const [followUpPreviewMap, setFollowUpPreviewMap] = useState( () => new Map() ); // Local state to temporarily show advanced options when profiles-only mode is enabled const [showAdvancedOptions, setShowAdvancedOptions] = useState(false); const [showEditAdvancedOptions, setShowEditAdvancedOptions] = useState(false); const [showSuggestionsDialog, setShowSuggestionsDialog] = useState(false); const [suggestionsCount, setSuggestionsCount] = useState(0); const [featureSuggestions, setFeatureSuggestions] = useState< import("@/lib/electron").FeatureSuggestion[] >([]); const [isGeneratingSuggestions, setIsGeneratingSuggestions] = useState(false); // Make current project available globally for modal useEffect(() => { if (currentProject) { (window as any).__currentProject = currentProject; } return () => { (window as any).__currentProject = null; }; }, [currentProject]); // Listen for suggestions events to update count (persists even when dialog is closed) useEffect(() => { const api = getElectronAPI(); if (!api?.suggestions) return; const unsubscribe = api.suggestions.onEvent((event) => { if (event.type === "suggestions_complete" && event.suggestions) { setSuggestionsCount(event.suggestions.length); setFeatureSuggestions(event.suggestions); setIsGeneratingSuggestions(false); } else if (event.type === "suggestions_error") { setIsGeneratingSuggestions(false); } }); return () => { unsubscribe(); }; }, []); // Track previous project to detect switches const prevProjectPathRef = useRef(null); const isSwitchingProjectRef = useRef(false); // Auto mode hook const autoMode = useAutoMode(); // Get runningTasks from the hook (scoped to current project) const runningAutoTasks = autoMode.runningTasks; // Window state hook for compact dialog mode const { isMaximized } = useWindowState(); // Get in-progress features for keyboard shortcuts (memoized for shortcuts) const inProgressFeaturesForShortcuts = useMemo(() => { return features.filter((f) => { const isRunning = runningAutoTasks.includes(f.id); return isRunning || f.status === "in_progress"; }); }, [features, runningAutoTasks]); // Ref to hold the start next callback (to avoid dependency issues) const startNextFeaturesRef = useRef<() => void>(() => {}); // Keyboard shortcuts for this view const boardShortcuts: KeyboardShortcut[] = useMemo(() => { const shortcuts: KeyboardShortcut[] = [ { key: ACTION_SHORTCUTS.addFeature, action: () => setShowAddDialog(true), description: "Add new feature", }, { key: ACTION_SHORTCUTS.startNext, action: () => startNextFeaturesRef.current(), description: "Start next features from backlog", }, ]; // Add shortcuts for in-progress cards (1-9 and 0 for 10th) inProgressFeaturesForShortcuts.slice(0, 10).forEach((feature, index) => { // Keys 1-9 for first 9 cards, 0 for 10th card const key = index === 9 ? "0" : String(index + 1); shortcuts.push({ key, action: () => { setOutputFeature(feature); setShowOutputModal(true); }, description: `View output for in-progress card ${index + 1}`, }); }); return shortcuts; }, [inProgressFeaturesForShortcuts]); useKeyboardShortcuts(boardShortcuts); // Prevent hydration issues useEffect(() => { setIsMounted(true); }, []); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8, }, }) ); // Get unique categories from existing features AND persisted categories for autocomplete suggestions const categorySuggestions = useMemo(() => { const featureCategories = features.map((f) => f.category).filter(Boolean); // Merge feature categories with persisted categories const allCategories = [...featureCategories, ...persistedCategories]; return [...new Set(allCategories)].sort(); }, [features, persistedCategories]); // 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; const currentPath = currentProject.path; const previousPath = prevProjectPathRef.current; // If project switched, clear features first to prevent cross-contamination if (previousPath !== null && currentPath !== previousPath) { console.log( `[BoardView] Project switch detected: ${previousPath} -> ${currentPath}, clearing features` ); isSwitchingProjectRef.current = true; setFeatures([]); setPersistedCategories([]); // Also clear categories } // Update the ref to track current project prevProjectPathRef.current = currentPath; 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", startedAt: f.startedAt, // Preserve startedAt timestamp // Ensure model and thinkingLevel are set for backward compatibility model: f.model || "opus", thinkingLevel: f.thinkingLevel || "none", })); setFeatures(featuresWithIds); } } catch (error) { console.error("Failed to load features:", error); } finally { setIsLoading(false); isSwitchingProjectRef.current = false; } }, [currentProject, setFeatures]); // Load persisted categories from file const loadCategories = useCallback(async () => { if (!currentProject) return; try { const api = getElectronAPI(); const result = await api.readFile( `${currentProject.path}/.automaker/categories.json` ); if (result.success && result.content) { const parsed = JSON.parse(result.content); if (Array.isArray(parsed)) { setPersistedCategories(parsed); } } else { // File doesn't exist, ensure categories are cleared setPersistedCategories([]); } } catch (error) { console.error("Failed to load categories:", error); // If file doesn't exist, ensure categories are cleared setPersistedCategories([]); } }, [currentProject]); // Save a new category to the persisted categories file const saveCategory = useCallback( async (category: string) => { if (!currentProject || !category.trim()) return; try { const api = getElectronAPI(); // Read existing categories let categories: string[] = [...persistedCategories]; // Add new category if it doesn't exist if (!categories.includes(category)) { categories.push(category); categories.sort(); // Keep sorted // Write back to file await api.writeFile( `${currentProject.path}/.automaker/categories.json`, JSON.stringify(categories, null, 2) ); // Update state setPersistedCategories(categories); } } catch (error) { console.error("Failed to save category:", error); } }, [currentProject, persistedCategories] ); // Sync skipTests default when dialog opens useEffect(() => { if (showAddDialog) { setNewFeature((prev) => ({ ...prev, skipTests: defaultSkipTests, })); } }, [showAddDialog, defaultSkipTests]); // Listen for auto mode feature completion and errors to reload features useEffect(() => { const api = getElectronAPI(); if (!api?.autoMode || !currentProject) return; const { removeRunningTask } = useAppStore.getState(); const projectId = currentProject.id; const unsubscribe = api.autoMode.onEvent((event) => { // Use event's projectId if available, otherwise use current project const eventProjectId = event.projectId || projectId; if (event.type === "auto_mode_feature_complete") { // Reload features when a feature is completed console.log("[Board] Feature completed, reloading features..."); loadFeatures(); } else if (event.type === "auto_mode_error") { // Reload features when an error occurs (feature moved to waiting_approval) console.log( "[Board] Feature error, reloading features...", event.error ); // Remove from running tasks so it moves to the correct column if (event.featureId) { removeRunningTask(eventProjectId, event.featureId); } loadFeatures(); // Show error toast toast.error("Agent encountered an error", { description: event.error || "Check the logs for details", }); } }); return unsubscribe; }, [loadFeatures, currentProject]); useEffect(() => { loadFeatures(); }, [loadFeatures]); // Load persisted categories on mount useEffect(() => { loadCategories(); }, [loadCategories]); // Sync running tasks from electron backend on mount useEffect(() => { if (!currentProject) return; 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 for this project and add the actual running ones const { clearRunningTasks, addRunningTask } = useAppStore.getState(); const projectId = currentProject.id; clearRunningTasks(projectId); // Add each running feature to the store status.runningFeatures.forEach((featureId: string) => { addRunningTask(projectId, featureId); }); } } catch (error) { console.error("[Board] Failed to sync running tasks:", error); } }; syncRunningTasks(); }, [currentProject]); // Check which features have context files useEffect(() => { const checkAllContexts = async () => { // Check context for in_progress, waiting_approval, and verified features const featuresWithPotentialContext = features.filter( (f) => f.status === "in_progress" || f.status === "waiting_approval" || f.status === "verified" ); const contextChecks = await Promise.all( featuresWithPotentialContext.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, startedAt: f.startedAt, imagePaths: f.imagePaths, skipTests: f.skipTests, summary: f.summary, model: f.model, thinkingLevel: f.thinkingLevel, error: f.error, })); 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 && !isSwitchingProjectRef.current) { 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; // Check if this is a running task (non-skipTests, TDD) const isRunningTask = runningAutoTasks.includes(featureId); // Determine if dragging is allowed based on status and skipTests // - Backlog items can always be dragged // - skipTests (non-TDD) items can be dragged between in_progress and verified // - Non-skipTests (TDD) items that are in progress or verified cannot be dragged if (draggedFeature.status !== "backlog") { // Only allow dragging in_progress/verified if it's a skipTests feature and not currently running if (!draggedFeature.skipTests || isRunningTask) { console.log( "[Board] Cannot drag feature - TDD feature or currently running" ); 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; // Same column, nothing to do if (targetStatus === draggedFeature.status) return; // Check concurrency limit before moving to in_progress (only for backlog -> in_progress and if running agent) if ( targetStatus === "in_progress" && draggedFeature.status === "backlog" && !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; } // Handle different drag scenarios if (draggedFeature.status === "backlog") { // From backlog if (targetStatus === "in_progress") { // Update with startedAt timestamp updateFeature(featureId, { status: targetStatus, startedAt: new Date().toISOString(), }); console.log("[Board] Feature moved to in_progress, starting agent..."); await handleRunFeature(draggedFeature); } else { moveFeature(featureId, targetStatus); } } else if (draggedFeature.skipTests) { // skipTests feature being moved between in_progress and verified if ( targetStatus === "verified" && draggedFeature.status === "in_progress" ) { // Manual verify via drag moveFeature(featureId, "verified"); toast.success("Feature verified", { description: `Marked as verified: ${draggedFeature.description.slice( 0, 50 )}${draggedFeature.description.length > 50 ? "..." : ""}`, }); } else if ( targetStatus === "in_progress" && draggedFeature.status === "verified" ) { // Move back to in_progress updateFeature(featureId, { status: "in_progress", startedAt: new Date().toISOString(), }); toast.info("Feature moved back", { description: `Moved back to In Progress: ${draggedFeature.description.slice( 0, 50 )}${draggedFeature.description.length > 50 ? "..." : ""}`, }); } else if (targetStatus === "backlog") { // Allow moving skipTests cards back to backlog moveFeature(featureId, "backlog"); toast.info("Feature moved to backlog", { description: `Moved to Backlog: ${draggedFeature.description.slice( 0, 50 )}${draggedFeature.description.length > 50 ? "..." : ""}`, }); } } }; const handleAddFeature = () => { const category = newFeature.category || "Uncategorized"; const selectedModel = newFeature.model; const normalizedThinking = modelSupportsThinking(selectedModel) ? newFeature.thinkingLevel : "none"; addFeature({ category, description: newFeature.description, steps: newFeature.steps.filter((s) => s.trim()), status: "backlog", images: newFeature.images, imagePaths: newFeature.imagePaths, skipTests: newFeature.skipTests, model: selectedModel, thinkingLevel: normalizedThinking, }); // Persist the category saveCategory(category); setNewFeature({ category: "", description: "", steps: [""], images: [], imagePaths: [], skipTests: defaultSkipTests, model: "opus", thinkingLevel: "none", }); // Clear the preview map when the feature is added setNewFeaturePreviewMap(new Map()); setShowAddDialog(false); }; const handleUpdateFeature = () => { if (!editingFeature) return; const selectedModel = (editingFeature.model ?? "opus") as AgentModel; const normalizedThinking = modelSupportsThinking(selectedModel) ? editingFeature.thinkingLevel : "none"; updateFeature(editingFeature.id, { category: editingFeature.category, description: editingFeature.description, steps: editingFeature.steps, skipTests: editingFeature.skipTests, model: selectedModel, thinkingLevel: normalizedThinking, }); // Persist the category if it's new if (editingFeature.category) { saveCategory(editingFeature.category); } setEditingFeature(null); }; const handleDeleteFeature = async (featureId: string) => { const feature = features.find((f) => f.id === featureId); if (!feature) return; // Check if the feature is currently running const isRunning = runningAutoTasks.includes(featureId); // If the feature is running, stop the agent first if (isRunning) { try { await autoMode.stopFeature(featureId); toast.success("Agent stopped", { description: `Stopped and deleted: ${feature.description.slice( 0, 50 )}${feature.description.length > 50 ? "..." : ""}`, }); } catch (error) { console.error("[Board] Error stopping feature before delete:", error); toast.error("Failed to stop agent", { description: "The feature will still be deleted.", }); } } // Delete agent context file if it exists if (currentProject) { try { const api = getElectronAPI(); const contextPath = `${currentProject.path}/.automaker/agents-context/${featureId}.md`; await api.deleteFile(contextPath); console.log(`[Board] Deleted agent context for feature ${featureId}`); } catch (error) { // Context file might not exist, which is fine console.log( `[Board] Context file not found or already deleted for feature ${featureId}` ); } } // Delete attached images if they exist if (feature.imagePaths && feature.imagePaths.length > 0) { try { const api = getElectronAPI(); for (const imagePathObj of feature.imagePaths) { try { await api.deleteFile(imagePathObj.path); console.log(`[Board] Deleted image: ${imagePathObj.path}`); } catch (error) { console.error( `[Board] Failed to delete image ${imagePathObj.path}:`, error ); } } } catch (error) { console.error( `[Board] Error deleting images for feature ${featureId}:`, error ); } } // Remove the feature immediately without confirmation 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, useWorktrees ); 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(); } }; // Manual verification handler for skipTests features const handleManualVerify = (feature: Feature) => { console.log("[Board] Manually verifying feature:", { id: feature.id, description: feature.description, }); moveFeature(feature.id, "verified"); toast.success("Feature verified", { description: `Marked as verified: ${feature.description.slice(0, 50)}${ feature.description.length > 50 ? "..." : "" }`, }); }; // Move feature back to in_progress from verified (for skipTests features) const handleMoveBackToInProgress = (feature: Feature) => { console.log("[Board] Moving feature back to in_progress:", { id: feature.id, description: feature.description, }); updateFeature(feature.id, { status: "in_progress", startedAt: new Date().toISOString(), }); toast.info("Feature moved back", { description: `Moved back to In Progress: ${feature.description.slice( 0, 50 )}${feature.description.length > 50 ? "..." : ""}`, }); }; // Open follow-up dialog for waiting_approval features const handleOpenFollowUp = (feature: Feature) => { console.log("[Board] Opening follow-up dialog for feature:", { id: feature.id, description: feature.description, }); setFollowUpFeature(feature); setFollowUpPrompt(""); setFollowUpImagePaths([]); setShowFollowUpDialog(true); }; // Handle sending follow-up prompt const handleSendFollowUp = async () => { if (!currentProject || !followUpFeature || !followUpPrompt.trim()) return; // Save values before clearing state const featureId = followUpFeature.id; const featureDescription = followUpFeature.description; const prompt = followUpPrompt; const imagePaths = followUpImagePaths.map((img) => img.path); console.log("[Board] Sending follow-up prompt for feature:", { id: featureId, prompt: prompt, imagePaths: imagePaths, }); const api = getElectronAPI(); if (!api?.autoMode?.followUpFeature) { console.error("Follow-up feature API not available"); toast.error("Follow-up not available", { description: "This feature is not available in the current version.", }); return; } // Move feature back to in_progress before sending follow-up updateFeature(featureId, { status: "in_progress", startedAt: new Date().toISOString(), }); // Reset follow-up state immediately (close dialog, clear form) setShowFollowUpDialog(false); setFollowUpFeature(null); setFollowUpPrompt(""); setFollowUpImagePaths([]); setFollowUpPreviewMap(new Map()); // Show success toast immediately toast.success("Follow-up started", { description: `Continuing work on: ${featureDescription.slice(0, 50)}${ featureDescription.length > 50 ? "..." : "" }`, }); // Call the API in the background (don't await - let it run async) api.autoMode .followUpFeature(currentProject.path, featureId, prompt, imagePaths) .catch((error) => { console.error("[Board] Error sending follow-up:", error); toast.error("Failed to send follow-up", { description: error instanceof Error ? error.message : "An error occurred", }); // Reload features to revert status if there was an error loadFeatures(); }); }; // Handle commit-only for waiting_approval features (marks as verified and commits) const handleCommitFeature = async (feature: Feature) => { if (!currentProject) return; console.log("[Board] Committing feature:", { id: feature.id, description: feature.description, }); try { const api = getElectronAPI(); if (!api?.autoMode?.commitFeature) { console.error("Commit feature API not available"); toast.error("Commit not available", { description: "This feature is not available in the current version.", }); return; } // Call the API to commit this feature const result = await api.autoMode.commitFeature( currentProject.path, feature.id ); if (result.success) { console.log("[Board] Feature committed successfully"); // Move to verified status moveFeature(feature.id, "verified"); toast.success("Feature committed", { description: `Committed and verified: ${feature.description.slice( 0, 50 )}${feature.description.length > 50 ? "..." : ""}`, }); } else { console.error("[Board] Failed to commit feature:", result.error); toast.error("Failed to commit feature", { description: result.error || "An error occurred", }); await loadFeatures(); } } catch (error) { console.error("[Board] Error committing feature:", error); toast.error("Failed to commit feature", { description: error instanceof Error ? error.message : "An error occurred", }); await loadFeatures(); } }; // Move feature to waiting_approval (for skipTests features when agent completes) const handleMoveToWaitingApproval = (feature: Feature) => { console.log("[Board] Moving feature to waiting_approval:", { id: feature.id, description: feature.description, }); updateFeature(feature.id, { status: "waiting_approval" }); toast.info("Feature ready for review", { description: `Ready for approval: ${feature.description.slice(0, 50)}${ feature.description.length > 50 ? "..." : "" }`, }); }; // Revert feature changes by removing the worktree const handleRevertFeature = async (feature: Feature) => { if (!currentProject) return; console.log("[Board] Reverting feature:", { id: feature.id, description: feature.description, branchName: feature.branchName, }); try { const api = getElectronAPI(); if (!api?.worktree?.revertFeature) { console.error("Worktree API not available"); toast.error("Revert not available", { description: "This feature is not available in the current version.", }); return; } const result = await api.worktree.revertFeature( currentProject.path, feature.id ); if (result.success) { console.log("[Board] Feature reverted successfully"); // Reload features to update the UI await loadFeatures(); toast.success("Feature reverted", { description: `All changes discarded. Moved back to backlog: ${feature.description.slice( 0, 50 )}${feature.description.length > 50 ? "..." : ""}`, }); } else { console.error("[Board] Failed to revert feature:", result.error); toast.error("Failed to revert feature", { description: result.error || "An error occurred", }); } } catch (error) { console.error("[Board] Error reverting feature:", error); toast.error("Failed to revert feature", { description: error instanceof Error ? error.message : "An error occurred", }); } }; // Merge feature worktree changes back to main branch const handleMergeFeature = async (feature: Feature) => { if (!currentProject) return; console.log("[Board] Merging feature:", { id: feature.id, description: feature.description, branchName: feature.branchName, }); try { const api = getElectronAPI(); if (!api?.worktree?.mergeFeature) { console.error("Worktree API not available"); toast.error("Merge not available", { description: "This feature is not available in the current version.", }); return; } const result = await api.worktree.mergeFeature( currentProject.path, feature.id ); if (result.success) { console.log("[Board] Feature merged successfully"); // Reload features to update the UI await loadFeatures(); toast.success("Feature merged", { description: `Changes merged to main branch: ${feature.description.slice( 0, 50 )}${feature.description.length > 50 ? "..." : ""}`, }); } else { console.error("[Board] Failed to merge feature:", result.error); toast.error("Failed to merge feature", { description: result.error || "An error occurred", }); } } catch (error) { console.error("[Board] Error merging feature:", error); toast.error("Failed to merge feature", { description: error instanceof Error ? error.message : "An error occurred", }); } }; 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) => { // If feature has a running agent, always show it in "in_progress" const isRunning = runningAutoTasks.includes(f.id); if (isRunning) { return columnId === "in_progress"; } // Otherwise, use the feature's status return f.status === columnId; }); }; const handleViewOutput = (feature: Feature) => { setOutputFeature(feature); setShowOutputModal(true); }; // Handle number key press when output modal is open const handleOutputModalNumberKeyPress = useCallback( (key: string) => { // Convert key to index: 1-9 -> 0-8, 0 -> 9 const index = key === "0" ? 9 : parseInt(key, 10) - 1; // Get the feature at that index from in-progress features const targetFeature = inProgressFeaturesForShortcuts[index]; if (!targetFeature) { // No feature at this index, do nothing return; } // If pressing the same number key as the currently open feature, close the modal if (targetFeature.id === outputFeature?.id) { setShowOutputModal(false); } // If pressing a different number key, switch to that feature's output else { setOutputFeature(targetFeature); // Modal stays open, just showing different content } }, [inProgressFeaturesForShortcuts, outputFeature?.id] ); const handleForceStopFeature = async (feature: Feature) => { try { await autoMode.stopFeature(feature.id); // Determine where to move the feature after stopping: // - If it's a skipTests feature that was in waiting_approval (i.e., during commit operation), // move it back to waiting_approval so user can try commit again or do follow-up // - Otherwise, move to backlog const targetStatus = feature.skipTests && feature.status === "waiting_approval" ? "waiting_approval" : "backlog"; if (targetStatus !== feature.status) { moveFeature(feature.id, targetStatus); } toast.success("Agent stopped", { description: targetStatus === "waiting_approval" ? `Stopped commit - returned to waiting approval: ${feature.description.slice( 0, 50 )}${feature.description.length > 50 ? "..." : ""}` : `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", }); } }; // Start next features from backlog up to the concurrency limit const handleStartNextFeatures = useCallback(async () => { const backlogFeatures = features.filter((f) => f.status === "backlog"); const availableSlots = maxConcurrency - runningAutoTasks.length; if (availableSlots <= 0) { toast.error("Concurrency limit reached", { description: `You can only have ${maxConcurrency} task${ maxConcurrency > 1 ? "s" : "" } running at a time. Wait for a task to complete or increase the limit.`, }); return; } if (backlogFeatures.length === 0) { toast.info("No features in backlog", { description: "Add features to the backlog first.", }); return; } const featuresToStart = backlogFeatures.slice(0, availableSlots); for (const feature of featuresToStart) { // Update the feature status with startedAt timestamp updateFeature(feature.id, { status: "in_progress", startedAt: new Date().toISOString(), }); // Start the agent for this feature await handleRunFeature(feature); } toast.success( `Started ${featuresToStart.length} feature${ featuresToStart.length > 1 ? "s" : "" }`, { description: featuresToStart .map( (f) => f.description.slice(0, 30) + (f.description.length > 30 ? "..." : "") ) .join(", "), } ); }, [features, maxConcurrency, runningAutoTasks.length, updateFeature]); // Update ref when handleStartNextFeatures changes useEffect(() => { startNextFeaturesRef.current = handleStartNextFeatures; }, [handleStartNextFeatures]); const renderModelOptions = ( options: ModelOption[], selectedModel: AgentModel, onSelect: (model: AgentModel) => void, testIdPrefix = "model-select" ) => (
{options.map((option) => { const isSelected = selectedModel === option.id; const isCodex = option.provider === "codex"; // Shorter display names for compact view const shortName = option.label.replace("Claude ", "").replace("GPT-5.1 Codex ", "").replace("GPT-5.1 ", ""); return ( ); })}
); const newModelAllowsThinking = modelSupportsThinking(newFeature.model); const editModelAllowsThinking = modelSupportsThinking(editingFeature?.model); 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 ? ( ) : ( )} )}
{/* Main Content Area */}
{/* Kanban Columns */}
{COLUMNS.map((column) => { const columnFeatures = getColumnFeatures(column.id); return ( 0 ? ( ) : column.id === "backlog" ? (
{columnFeatures.length > 0 && ( )}
) : undefined } > f.id)} strategy={verticalListSortingStrategy} > {columnFeatures.map((feature, index) => { // Calculate shortcut key for in-progress cards (first 10 get 1-9, 0) let shortcutKey: string | undefined; if (column.id === "in_progress" && index < 10) { shortcutKey = index === 9 ? "0" : String(index + 1); } return ( setEditingFeature(feature)} onDelete={() => handleDeleteFeature(feature.id)} onViewOutput={() => handleViewOutput(feature)} onVerify={() => handleVerifyFeature(feature)} onResume={() => handleResumeFeature(feature)} onForceStop={() => handleForceStopFeature(feature)} onManualVerify={() => handleManualVerify(feature)} onMoveBackToInProgress={() => handleMoveBackToInProgress(feature) } onFollowUp={() => handleOpenFollowUp(feature)} onCommit={() => handleCommitFeature(feature)} onRevert={() => handleRevertFeature(feature)} onMerge={() => handleMergeFeature(feature)} hasContext={featuresWithContext.has(feature.id)} isCurrentAutoTask={runningAutoTasks.includes( feature.id )} shortcutKey={shortcutKey} /> ); })}
); })}
{activeFeature && ( {activeFeature.description} {activeFeature.category} )}
{/* Add Feature Dialog */} { setShowAddDialog(open); // Clear preview map and reset advanced options when dialog closes if (!open) { setNewFeaturePreviewMap(new Map()); setShowAdvancedOptions(false); } }}> { if ( (e.metaKey || e.ctrlKey) && e.key === "Enter" && newFeature.description ) { e.preventDefault(); handleAddFeature(); } }} > Add New Feature Create a new feature card for the Kanban board. Prompt Model Testing {/* Prompt Tab */}
setNewFeature({ ...newFeature, description: value }) } images={newFeature.imagePaths} onImagesChange={(images) => setNewFeature({ ...newFeature, imagePaths: images }) } placeholder="Describe the feature..." previewMap={newFeaturePreviewMap} onPreviewMapChange={setNewFeaturePreviewMap} />
setNewFeature({ ...newFeature, category: value }) } suggestions={categorySuggestions} placeholder="e.g., Core, UI, API" data-testid="feature-category-input" />
{/* Model Tab */} {/* Show Advanced Options Toggle - only when profiles-only mode is enabled */} {showProfilesOnly && (

Simple Mode Active

Only showing AI profiles. Advanced model tweaking is hidden.

)} {/* Quick Select Profile Section */} {aiProfiles.length > 0 && (
Presets
{aiProfiles.slice(0, 6).map((profile) => { const IconComponent = profile.icon ? PROFILE_ICONS[profile.icon] : Brain; const isCodex = profile.provider === "codex"; const isSelected = newFeature.model === profile.model && newFeature.thinkingLevel === profile.thinkingLevel; return ( ); })}

Or customize below. Manage profiles in{" "}

)} {/* Separator */} {aiProfiles.length > 0 && (!showProfilesOnly || showAdvancedOptions) &&
} {/* Claude Models Section - Hidden when showProfilesOnly is true and showAdvancedOptions is false */} {(!showProfilesOnly || showAdvancedOptions) && (
Native
{renderModelOptions( CLAUDE_MODELS, newFeature.model, (model) => setNewFeature({ ...newFeature, model, thinkingLevel: modelSupportsThinking(model) ? newFeature.thinkingLevel : "none", }) )} {/* Thinking Level - Only shown when Claude model is selected */} {newModelAllowsThinking && (
{(["none", "low", "medium", "high", "ultrathink"] as ThinkingLevel[]).map((level) => ( ))}

Higher levels give more time to reason through complex problems.

)}
)} {/* Separator */} {(!showProfilesOnly || showAdvancedOptions) &&
} {/* Codex Models Section - Hidden when showProfilesOnly is true and showAdvancedOptions is false */} {(!showProfilesOnly || showAdvancedOptions) && (
CLI
{renderModelOptions( CODEX_MODELS, newFeature.model, (model) => setNewFeature({ ...newFeature, model, thinkingLevel: "none", }) )}

Codex models do not support thinking levels.

)} {/* Testing Tab */}
setNewFeature({ ...newFeature, skipTests: checked === true }) } data-testid="skip-tests-checkbox" />

When enabled, this feature will require manual verification instead of automated TDD.

{/* Verification Steps - Only shown when skipTests is enabled */} {newFeature.skipTests && (

Add manual steps to verify this feature works correctly.

{newFeature.steps.map((step, index) => ( { const steps = [...newFeature.steps]; steps[index] = e.target.value; setNewFeature({ ...newFeature, steps }); }} data-testid={`feature-step-${index}-input`} /> ))}
)}
{/* Edit Feature Dialog */} { if (!open) { setEditingFeature(null); setShowEditAdvancedOptions(false); } }} > Edit Feature Modify the feature details. {editingFeature && ( Prompt Model Testing {/* Prompt Tab */}