"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, defaultBackgroundSettings, } from "@/store/app-store"; import { getElectronAPI } from "@/lib/electron"; import { cn, modelSupportsThinking } from "@/lib/utils"; import type { SpecRegenerationEvent } from "@/types/electron"; import { Card, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { HotkeyButton } from "@/components/ui/hotkey-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 { BoardBackgroundModal } from "@/components/dialogs/board-background-modal"; import { Plus, RefreshCw, Play, StopCircle, Loader2, Users, Trash2, FastForward, FlaskConical, CheckCircle2, MessageSquare, GitCommit, Brain, Zap, Settings2, Scale, Cpu, Rocket, Sparkles, UserCircle, Lightbulb, Search, X, Minimize2, Square, Maximize2, Shuffle, ImageIcon, Archive, ArchiveRestore, } from "lucide-react"; import { toast } from "sonner"; import { Slider } from "@/components/ui/slider"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; import { Checkbox } from "@/components/ui/checkbox"; import { useAutoMode } from "@/hooks/use-auto-mode"; import { useKeyboardShortcuts, useKeyboardShortcutsConfig, KeyboardShortcut, } from "@/hooks/use-keyboard-shortcuts"; import { useWindowState } from "@/hooks/use-window-state"; type ColumnId = Feature["status"]; const COLUMNS: { id: ColumnId; title: string; colorClass: string }[] = [ { id: "backlog", title: "Backlog", colorClass: "bg-[var(--status-backlog)]" }, { id: "in_progress", title: "In Progress", colorClass: "bg-[var(--status-in-progress)]", }, { id: "waiting_approval", title: "Waiting Approval", colorClass: "bg-[var(--status-waiting)]", }, { id: "verified", title: "Verified", colorClass: "bg-[var(--status-success)]", }, ]; type ModelOption = { id: AgentModel; label: string; description: string; badge?: string; provider: "claude"; }; 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", }, ]; // Profile icon mapping const PROFILE_ICONS: Record< string, React.ComponentType<{ className?: string }> > = { Brain, Zap, Scale, Cpu, Rocket, Sparkles, }; export function BoardView() { const { currentProject, features, setFeatures, addFeature, updateFeature, removeFeature, moveFeature, maxConcurrency, setMaxConcurrency, defaultSkipTests, useWorktrees, showProfilesOnly, aiProfiles, kanbanCardDetailLevel, setKanbanCardDetailLevel, boardBackgroundByProject, specCreatingForProject, setSpecCreatingForProject, } = useAppStore(); const shortcuts = useKeyboardShortcutsConfig(); 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 [showBoardBackgroundModal, setShowBoardBackgroundModal] = useState(false); const [showCompletedModal, setShowCompletedModal] = useState(false); const [deleteCompletedFeature, setDeleteCompletedFeature] = useState(null); 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() ); const [editFeaturePreviewMap, setEditFeaturePreviewMap] = 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); // Search filter for Kanban cards const [searchQuery, setSearchQuery] = useState(""); // Validation state for add feature form const [descriptionError, setDescriptionError] = useState(false); // Derive spec creation state from store - check if current project is the one being created const isCreatingSpec = specCreatingForProject === currentProject?.path; const creatingSpecProjectPath = specCreatingForProject; // 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(); }; }, []); // Subscribe to spec regeneration events to clear creating state on completion useEffect(() => { const api = getElectronAPI(); if (!api.specRegeneration) return; const unsubscribe = api.specRegeneration.onEvent((event) => { console.log( "[BoardView] Spec regeneration event:", event.type, "for project:", event.projectPath ); // Only handle completion/error events for the project being created // The creating state is set by sidebar when user initiates the action if (event.projectPath !== specCreatingForProject) { return; } if (event.type === "spec_regeneration_complete") { setSpecCreatingForProject(null); } else if (event.type === "spec_regeneration_error") { setSpecCreatingForProject(null); } }); return () => { unsubscribe(); }; }, [specCreatingForProject, setSpecCreatingForProject]); // Track previous project to detect switches const prevProjectPathRef = useRef(null); const isSwitchingProjectRef = useRef(false); // Track if this is the initial load (to avoid showing loading spinner on subsequent reloads) const isInitialLoadRef = useRef(true); // 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>(() => {}); // Ref for search input to enable keyboard shortcut focus const searchInputRef = useRef(null); // Keyboard shortcuts for this view const boardShortcuts: KeyboardShortcut[] = useMemo(() => { const shortcutsList: KeyboardShortcut[] = [ { key: shortcuts.addFeature, action: () => setShowAddDialog(true), description: "Add new feature", }, { key: shortcuts.startNext, action: () => startNextFeaturesRef.current(), description: "Start next features from backlog", }, { key: "/", action: () => searchInputRef.current?.focus(), description: "Focus search input", }, ]; // 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); shortcutsList.push({ key, action: () => { setOutputFeature(feature); setShowOutputModal(true); }, description: `View output for in-progress card ${index + 1}`, }); }); return shortcutsList; }, [inProgressFeaturesForShortcuts, shortcuts]); 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 using features API // IMPORTANT: Do NOT add 'features' to dependency array - it would cause infinite reload loop const loadFeatures = useCallback(async () => { if (!currentProject) return; const currentPath = currentProject.path; const previousPath = prevProjectPathRef.current; const isProjectSwitch = previousPath !== null && currentPath !== previousPath; // Get cached features from store (without adding to dependencies) const cachedFeatures = useAppStore.getState().features; // If project switched, mark it but don't clear features yet // We'll clear after successful API load to prevent data loss if (isProjectSwitch) { console.log( `[BoardView] Project switch detected: ${previousPath} -> ${currentPath}` ); isSwitchingProjectRef.current = true; isInitialLoadRef.current = true; } // Update the ref to track current project prevProjectPathRef.current = currentPath; // Only show loading spinner on initial load to prevent board flash during reloads if (isInitialLoadRef.current) { setIsLoading(true); } try { const api = getElectronAPI(); if (!api.features) { console.error("[BoardView] Features API not available"); // Keep cached features if API is unavailable return; } const result = await api.features.getAll(currentProject.path); if (result.success && result.features) { const featuresWithIds = result.features.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", }) ); // Successfully loaded features - now safe to set them setFeatures(featuresWithIds); // Only clear categories on project switch AFTER successful load if (isProjectSwitch) { setPersistedCategories([]); } } else if (!result.success && result.error) { console.error("[BoardView] API returned error:", result.error); // If it's a new project or the error indicates no features found, // that's expected - start with empty array if (isProjectSwitch) { setFeatures([]); setPersistedCategories([]); } // Otherwise keep cached features } } catch (error) { console.error("Failed to load features:", error); // On error, keep existing cached features for the current project // Only clear on project switch if we have no features from server if (isProjectSwitch && cachedFeatures.length === 0) { setFeatures([]); setPersistedCategories([]); } } finally { setIsLoading(false); isInitialLoadRef.current = false; isSwitchingProjectRef.current = false; } }, [currentProject, setFeatures]); // Subscribe to spec regeneration complete events to refresh kanban board useEffect(() => { const api = getElectronAPI(); if (!api.specRegeneration) return; const unsubscribe = api.specRegeneration.onEvent((event) => { // Refresh the kanban board when spec regeneration completes for the current project if ( event.type === "spec_regeneration_complete" && currentProject && event.projectPath === currentProject.path ) { console.log( "[BoardView] Spec regeneration complete, refreshing features" ); loadFeatures(); } }); return () => { unsubscribe(); }; }, [currentProject, loadFeatures]); // 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 projectPath or projectId if available, otherwise use current project // Board view only reacts to events for the currently selected project const eventProjectId = ("projectId" in event && 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(); // Play ding sound when feature is done (unless muted) const { muteDoneSound } = useAppStore.getState(); if (!muteDoneSound) { const audio = new Audio("/sounds/ding.mp3"); audio .play() .catch((err) => console.warn("Could not play ding sound:", err)); } } 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(); // Check for authentication errors and show a more helpful message const isAuthError = event.errorType === "authentication" || (event.error && (event.error.includes("Authentication failed") || event.error.includes("Invalid API key"))); if (isAuthError) { toast.error("Authentication Failed", { description: "Your API key is invalid or expired. Please check Settings or run 'claude login' in terminal.", duration: 10000, }); } else { 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(currentProject.path); if (status.success) { const projectId = currentProject.id; const { clearRunningTasks, addRunningTask, setAutoModeRunning } = useAppStore.getState(); // Sync running features if available if (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 clearRunningTasks(projectId); // Add each running feature to the store status.runningFeatures.forEach((featureId: string) => { addRunningTask(projectId, featureId); }); } // Sync auto mode running state (backend returns autoLoopRunning, mock returns isRunning) const isAutoModeRunning = status.autoLoopRunning ?? status.isRunning ?? false; console.log( "[Board] Syncing auto mode running state:", isAutoModeRunning ); setAutoModeRunning(projectId, isAutoModeRunning); } } 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]); // Persist feature update to API (replaces saveFeatures) const persistFeatureUpdate = useCallback( async (featureId: string, updates: Partial) => { if (!currentProject) return; try { const api = getElectronAPI(); if (!api.features) { console.error("[BoardView] Features API not available"); return; } const result = await api.features.update( currentProject.path, featureId, updates ); if (result.success && result.feature) { updateFeature(result.feature.id, result.feature); } } catch (error) { console.error("Failed to persist feature update:", error); } }, [currentProject, updateFeature] ); // Persist feature creation to API const persistFeatureCreate = useCallback( async (feature: Feature) => { if (!currentProject) return; try { const api = getElectronAPI(); if (!api.features) { console.error("[BoardView] Features API not available"); return; } const result = await api.features.create(currentProject.path, feature); if (result.success && result.feature) { updateFeature(result.feature.id, result.feature); } } catch (error) { console.error("Failed to persist feature creation:", error); } }, [currentProject, updateFeature] ); // Persist feature deletion to API const persistFeatureDelete = useCallback( async (featureId: string) => { if (!currentProject) return; try { const api = getElectronAPI(); if (!api.features) { console.error("[BoardView] Features API not available"); return; } await api.features.delete(currentProject.path, featureId); } catch (error) { console.error("Failed to persist feature deletion:", error); } }, [currentProject] ); 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 // - waiting_approval items can always be dragged (to allow manual verification via drag) // - verified items can always be dragged (to allow moving back to waiting_approval) // - skipTests (non-TDD) items can be dragged between in_progress and verified // - Non-skipTests (TDD) items that are in progress cannot be dragged (they are running) if ( draggedFeature.status !== "backlog" && draggedFeature.status !== "waiting_approval" && draggedFeature.status !== "verified" ) { // Only allow dragging in_progress 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; // Handle different drag scenarios if (draggedFeature.status === "backlog") { // From backlog if (targetStatus === "in_progress") { // Use helper function to handle concurrency check and start implementation await handleStartImplementation(draggedFeature); } else { moveFeature(featureId, targetStatus); persistFeatureUpdate(featureId, { status: targetStatus }); } } else if (draggedFeature.status === "waiting_approval") { // waiting_approval features can be dragged to verified for manual verification // NOTE: This check must come BEFORE skipTests check because waiting_approval // features often have skipTests=true, and we want status-based handling first if (targetStatus === "verified") { moveFeature(featureId, "verified"); // Clear justFinishedAt timestamp when manually verifying via drag persistFeatureUpdate(featureId, { status: "verified", justFinishedAt: undefined, }); toast.success("Feature verified", { description: `Manually verified: ${draggedFeature.description.slice( 0, 50 )}${draggedFeature.description.length > 50 ? "..." : ""}`, }); } else if (targetStatus === "backlog") { // Allow moving waiting_approval cards back to backlog moveFeature(featureId, "backlog"); // Clear justFinishedAt timestamp when moving back to backlog persistFeatureUpdate(featureId, { status: "backlog", justFinishedAt: undefined, }); toast.info("Feature moved to backlog", { description: `Moved to Backlog: ${draggedFeature.description.slice( 0, 50 )}${draggedFeature.description.length > 50 ? "..." : ""}`, }); } } 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"); persistFeatureUpdate(featureId, { status: "verified" }); toast.success("Feature verified", { description: `Marked as verified: ${draggedFeature.description.slice( 0, 50 )}${draggedFeature.description.length > 50 ? "..." : ""}`, }); } else if ( targetStatus === "waiting_approval" && draggedFeature.status === "verified" ) { // Move verified feature back to waiting_approval moveFeature(featureId, "waiting_approval"); persistFeatureUpdate(featureId, { status: "waiting_approval" }); toast.info("Feature moved back", { description: `Moved back to Waiting Approval: ${draggedFeature.description.slice( 0, 50 )}${draggedFeature.description.length > 50 ? "..." : ""}`, }); } else if (targetStatus === "backlog") { // Allow moving skipTests cards back to backlog moveFeature(featureId, "backlog"); persistFeatureUpdate(featureId, { status: "backlog" }); toast.info("Feature moved to backlog", { description: `Moved to Backlog: ${draggedFeature.description.slice( 0, 50 )}${draggedFeature.description.length > 50 ? "..." : ""}`, }); } } else if (draggedFeature.status === "verified") { // Handle verified TDD (non-skipTests) features being moved back if (targetStatus === "waiting_approval") { // Move verified feature back to waiting_approval moveFeature(featureId, "waiting_approval"); persistFeatureUpdate(featureId, { status: "waiting_approval" }); toast.info("Feature moved back", { description: `Moved back to Waiting Approval: ${draggedFeature.description.slice( 0, 50 )}${draggedFeature.description.length > 50 ? "..." : ""}`, }); } else if (targetStatus === "backlog") { // Allow moving verified cards back to backlog moveFeature(featureId, "backlog"); persistFeatureUpdate(featureId, { status: "backlog" }); toast.info("Feature moved to backlog", { description: `Moved to Backlog: ${draggedFeature.description.slice( 0, 50 )}${draggedFeature.description.length > 50 ? "..." : ""}`, }); } } }; const handleAddFeature = () => { // Validate description is required if (!newFeature.description.trim()) { setDescriptionError(true); return; } const category = newFeature.category || "Uncategorized"; const selectedModel = newFeature.model; const normalizedThinking = modelSupportsThinking(selectedModel) ? newFeature.thinkingLevel : "none"; const newFeatureData = { category, description: newFeature.description, steps: newFeature.steps.filter((s) => s.trim()), status: "backlog" as const, images: newFeature.images, imagePaths: newFeature.imagePaths, skipTests: newFeature.skipTests, model: selectedModel, thinkingLevel: normalizedThinking, }; const createdFeature = addFeature(newFeatureData); persistFeatureCreate(createdFeature); // 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"; const updates = { category: editingFeature.category, description: editingFeature.description, steps: editingFeature.steps, skipTests: editingFeature.skipTests, model: selectedModel, thinkingLevel: normalizedThinking, imagePaths: editingFeature.imagePaths, }; updateFeature(editingFeature.id, updates); persistFeatureUpdate(editingFeature.id, updates); // Clear the preview map after saving setEditFeaturePreviewMap(new Map()); // 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.", }); } } // Note: Agent context file will be deleted automatically when feature folder is deleted // via persistFeatureDelete, so no manual deletion needed if (currentProject) { try { // Feature folder deletion handles agent-output.md automatically console.log( `[Board] Feature ${featureId} will be deleted (including agent context)` ); } 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); persistFeatureDelete(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(); } }; // Helper function to start implementing a feature (from backlog to in_progress) const handleStartImplementation = async (feature: Feature) => { if (!autoMode.canStartNewTask) { 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 false; } const updates = { status: "in_progress" as const, startedAt: new Date().toISOString(), }; updateFeature(feature.id, updates); persistFeatureUpdate(feature.id, updates); console.log("[Board] Feature moved to in_progress, starting agent..."); await handleRunFeature(feature); return true; }; 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"); // Clear justFinishedAt timestamp when manually verifying persistFeatureUpdate(feature.id, { status: "verified", justFinishedAt: undefined, }); 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, }); const updates = { status: "in_progress" as const, startedAt: new Date().toISOString(), }; updateFeature(feature.id, updates); persistFeatureUpdate(feature.id, updates); 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 // Clear justFinishedAt timestamp since user is now interacting with it const updates = { status: "in_progress" as const, startedAt: new Date().toISOString(), justFinishedAt: undefined, }; updateFeature(featureId, updates); persistFeatureUpdate(featureId, updates); // 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"); persistFeatureUpdate(feature.id, { status: "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, }); const updates = { status: "waiting_approval" as const }; updateFeature(feature.id, updates); persistFeatureUpdate(feature.id, updates); 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", }); } }; // Complete a verified feature (move to completed/archived) const handleCompleteFeature = (feature: Feature) => { console.log("[Board] Completing feature:", { id: feature.id, description: feature.description, }); const updates = { status: "completed" as const, }; updateFeature(feature.id, updates); persistFeatureUpdate(feature.id, updates); toast.success("Feature completed", { description: `Archived: ${feature.description.slice(0, 50)}${ feature.description.length > 50 ? "..." : "" }`, }); }; // Unarchive a completed feature (move back to verified) const handleUnarchiveFeature = (feature: Feature) => { console.log("[Board] Unarchiving feature:", { id: feature.id, description: feature.description, }); const updates = { status: "verified" as const, }; updateFeature(feature.id, updates); persistFeatureUpdate(feature.id, updates); toast.success("Feature restored", { description: `Moved back to verified: ${feature.description.slice( 0, 50 )}${feature.description.length > 50 ? "..." : ""}`, }); }; 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; } }; // Memoize completed features for the archive modal const completedFeatures = useMemo(() => { return features.filter((f) => f.status === "completed"); }, [features]); // Memoize column features to prevent unnecessary re-renders const columnFeaturesMap = useMemo(() => { const map: Record = { backlog: [], in_progress: [], waiting_approval: [], verified: [], completed: [], // Completed features are shown in the archive modal, not as a column }; // Filter features by search query (case-insensitive) const normalizedQuery = searchQuery.toLowerCase().trim(); const filteredFeatures = normalizedQuery ? features.filter( (f) => f.description.toLowerCase().includes(normalizedQuery) || f.category?.toLowerCase().includes(normalizedQuery) ) : features; filteredFeatures.forEach((f) => { // If feature has a running agent, always show it in "in_progress" const isRunning = runningAutoTasks.includes(f.id); if (isRunning) { map.in_progress.push(f); } else { // Otherwise, use the feature's status (fallback to backlog for unknown statuses) const status = f.status as ColumnId; if (map[status]) { map[status].push(f); } else { // Unknown status, default to backlog map.backlog.push(f); } } }); // Sort backlog by priority: 1 (high) -> 2 (medium) -> 3 (low) -> no priority map.backlog.sort((a, b) => { const aPriority = a.priority ?? 999; // Features without priority go last const bPriority = b.priority ?? 999; return aPriority - bPriority; }); return map; }, [features, runningAutoTasks, searchQuery]); const getColumnFeatures = useCallback( (columnId: ColumnId) => { return columnFeaturesMap[columnId]; }, [columnFeaturesMap] ); 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); persistFeatureUpdate(feature.id, { status: 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, 1); for (const feature of featuresToStart) { // Update the feature status with startedAt timestamp const updates = { status: "in_progress" as const, startedAt: new Date().toISOString(), }; updateFeature(feature.id, updates); persistFeatureUpdate(feature.id, updates); // 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; // Shorter display names for compact view const shortName = option.label.replace("Claude ", ""); 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 ? ( ) : ( )} )} setShowAddDialog(true)} hotkey={shortcuts.addFeature} hotkeyActive={false} data-testid="add-feature-button" > Add Feature
{/* Main Content Area */}
{/* Search Bar Row */}
setSearchQuery(e.target.value)} className="pl-9 pr-12 border-border" data-testid="kanban-search-input" /> {searchQuery ? ( ) : ( / )}
{/* Spec Creation Loading Badge */} {isCreatingSpec && currentProject?.path === creatingSpecProjectPath && (
Creating spec
)}
{/* Board Background & Detail Level Controls */} {isMounted && (
{/* Board Background Button */}

Board Background Settings

{/* Completed/Archived Features Button */}

Completed Features ({completedFeatures.length})

{/* Kanban Card Detail Level Toggle */}

Minimal - Title & category only

Standard - Steps & progress

Detailed - Model, tools & tasks

)}
{/* Kanban Columns */} {(() => { // Get background settings for current project const backgroundSettings = (currentProject && boardBackgroundByProject[currentProject.path]) || defaultBackgroundSettings; // Build background image style if image exists const backgroundImageStyle = backgroundSettings.imagePath ? { backgroundImage: `url(${ process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008" }/api/fs/image?path=${encodeURIComponent( backgroundSettings.imagePath )}&projectPath=${encodeURIComponent( currentProject?.path || "" )}${ backgroundSettings.imageVersion ? `&v=${backgroundSettings.imageVersion}` : "" })`, backgroundSize: "cover", backgroundPosition: "center", backgroundRepeat: "no-repeat", } : {}; return (
{COLUMNS.map((column) => { const columnFeatures = getColumnFeatures(column.id); return ( 0 ? ( ) : column.id === "backlog" ? (
{columnFeatures.length > 0 && ( Make )}
) : 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)} onComplete={() => handleCompleteFeature(feature) } onImplement={() => handleStartImplementation(feature) } hasContext={featuresWithContext.has(feature.id)} isCurrentAutoTask={runningAutoTasks.includes( feature.id )} shortcutKey={shortcutKey} opacity={backgroundSettings.cardOpacity} glassmorphism={ backgroundSettings.cardGlassmorphism } cardBorderEnabled={ backgroundSettings.cardBorderEnabled } cardBorderOpacity={ backgroundSettings.cardBorderOpacity } /> ); })}
); })}
{activeFeature && ( {activeFeature.description} {activeFeature.category} )}
); })()}
{/* Board Background Modal */} {/* Completed Features Modal */} Completed Features {completedFeatures.length === 0 ? "No completed features yet. Features you complete will appear here." : `${completedFeatures.length} completed feature${ completedFeatures.length === 1 ? "" : "s" }`}
{completedFeatures.length === 0 ? (

No completed features

Complete features from the Verified column to archive them here.

) : (
{completedFeatures.map((feature) => ( {feature.description || feature.summary || feature.id} {feature.category || "Uncategorized"}
))}
)}
{/* Delete Completed Feature Confirmation Dialog */} !open && setDeleteCompletedFeature(null)} > Delete Feature Are you sure you want to permanently delete this feature? "{deleteCompletedFeature?.description?.slice(0, 100)} {(deleteCompletedFeature?.description?.length ?? 0) > 100 ? "..." : ""} " This action cannot be undone. {/* Add Feature Dialog */} { setShowAddDialog(open); // Clear preview map, validation error, and reset advanced options when dialog closes if (!open) { setNewFeaturePreviewMap(new Map()); setShowAdvancedOptions(false); setDescriptionError(false); } }} > { // Prevent dialog from closing when clicking on category autocomplete dropdown const target = e.target as HTMLElement; if (target.closest('[data-testid="category-autocomplete-list"]')) { e.preventDefault(); } }} onInteractOutside={(e) => { // Prevent dialog from closing when clicking on category autocomplete dropdown const target = e.target as HTMLElement; if (target.closest('[data-testid="category-autocomplete-list"]')) { e.preventDefault(); } }} > Add New Feature Create a new feature card for the Kanban board. Prompt Model Testing {/* Prompt Tab */}
{ setNewFeature({ ...newFeature, description: value }); if (value.trim()) { setDescriptionError(false); } }} images={newFeature.imagePaths} onImagesChange={(images) => setNewFeature({ ...newFeature, imagePaths: images }) } placeholder="Describe the feature..." previewMap={newFeaturePreviewMap} onPreviewMapChange={setNewFeaturePreviewMap} autoFocus error={descriptionError} />
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 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.

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

When enabled, this feature will use automated TDD. When disabled, it will require manual verification.

{/* 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`} /> ))}
)}
Add Feature
{/* Edit Feature Dialog */} { if (!open) { setEditingFeature(null); setShowEditAdvancedOptions(false); setEditFeaturePreviewMap(new Map()); } }} > { // Prevent dialog from closing when clicking on category autocomplete dropdown const target = e.target as HTMLElement; if (target.closest('[data-testid="category-autocomplete-list"]')) { e.preventDefault(); } }} onInteractOutside={(e) => { // Prevent dialog from closing when clicking on category autocomplete dropdown const target = e.target as HTMLElement; if (target.closest('[data-testid="category-autocomplete-list"]')) { e.preventDefault(); } }} > Edit Feature Modify the feature details. {editingFeature && ( Prompt Model Testing {/* Prompt Tab */}
setEditingFeature({ ...editingFeature, description: value, }) } images={editingFeature.imagePaths ?? []} onImagesChange={(images) => setEditingFeature({ ...editingFeature, imagePaths: images, }) } placeholder="Describe the feature..." previewMap={editFeaturePreviewMap} onPreviewMapChange={setEditFeaturePreviewMap} data-testid="edit-feature-description" />
setEditingFeature({ ...editingFeature, category: value, }) } suggestions={categorySuggestions} placeholder="e.g., Core, UI, API" data-testid="edit-feature-category" />
{/* 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 isSelected = editingFeature.model === profile.model && editingFeature.thinkingLevel === profile.thinkingLevel; return ( ); })}

Or customize below.

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

Higher levels give more time to reason through complex problems.

)}
)} {/* Testing Tab */}
setEditingFeature({ ...editingFeature, skipTests: checked !== true, }) } data-testid="edit-skip-tests-checkbox" />

When enabled, this feature will use automated TDD. When disabled, it will require manual verification.

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

Add manual steps to verify this feature works correctly.

{editingFeature.steps.map((step, index) => ( { const steps = [...editingFeature.steps]; steps[index] = e.target.value; setEditingFeature({ ...editingFeature, steps }); }} data-testid={`edit-feature-step-${index}`} /> ))}
)}
)} Save Changes
{/* Agent Output Modal */} setShowOutputModal(false)} featureDescription={outputFeature?.description || ""} featureId={outputFeature?.id || ""} featureStatus={outputFeature?.status} onNumberKeyPress={handleOutputModalNumberKeyPress} /> {/* Delete All Verified Dialog */} Delete All Verified Features Are you sure you want to delete all verified features? This action cannot be undone. {getColumnFeatures("verified").length > 0 && ( {getColumnFeatures("verified").length} feature(s) will be deleted. )} {/* Follow-Up Prompt Dialog */} { if (!open) { setShowFollowUpDialog(false); setFollowUpFeature(null); setFollowUpPrompt(""); setFollowUpImagePaths([]); setFollowUpPreviewMap(new Map()); } }} > { if ( (e.metaKey || e.ctrlKey) && e.key === "Enter" && followUpPrompt.trim() ) { e.preventDefault(); handleSendFollowUp(); } }} > Follow-Up Prompt Send additional instructions to continue working on this feature. {followUpFeature && ( Feature: {followUpFeature.description.slice(0, 100)} {followUpFeature.description.length > 100 ? "..." : ""} )}

The agent will continue from where it left off, using the existing context. You can attach screenshots to help explain the issue.

Send Follow-Up
{/* Feature Suggestions Dialog */} { setShowSuggestionsDialog(false); }} projectPath={currentProject.path} suggestions={featureSuggestions} setSuggestions={(suggestions) => { setFeatureSuggestions(suggestions); setSuggestionsCount(suggestions.length); }} isGenerating={isGeneratingSuggestions} setIsGenerating={setIsGeneratingSuggestions} />
); }