From 658cbb8bd607b86f251787c9f6ba85e4de74d671 Mon Sep 17 00:00:00 2001 From: Kacper Date: Tue, 16 Dec 2025 01:02:54 +0100 Subject: [PATCH] refactor(board-view): reorganize into modular folder structure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract board-view into organized subfolders following new pattern: - components/: kanban-card, kanban-column - dialogs/: all dialog and modal components (8 files) - hooks/: all board-specific hooks (10 files) - shared/: reusable components between dialogs (model-selector, etc.) - Rename all files to kebab-case convention - Add barrel exports (index.ts) for clean imports - Add docs/folder-pattern.md documenting the folder structure - Reduce board-view.tsx from ~3600 lines to ~490 lines 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/app/src/components/views/board-view.tsx | 2471 ++--------------- .../views/board-view/AddFeatureDialog.tsx | 578 ---- .../views/board-view/EditFeatureDialog.tsx | 541 ---- .../views/board-view/board-controls.tsx | 137 + .../{BoardHeader.tsx => board-header.tsx} | 0 ...oardSearchBar.tsx => board-search-bar.tsx} | 0 .../views/board-view/components/index.ts | 2 + .../components}/kanban-card.tsx | 0 .../components}/kanban-column.tsx | 0 .../components/views/board-view/constants.ts | 22 + .../board-view/dialogs/add-feature-dialog.tsx | 335 +++ .../dialogs}/agent-output-modal.tsx | 0 .../dialogs/completed-features-modal.tsx | 104 + .../dialogs/delete-all-verified-dialog.tsx | 54 + .../delete-completed-feature-dialog.tsx | 67 + .../dialogs/edit-feature-dialog.tsx | 316 +++ .../dialogs}/feature-suggestions-dialog.tsx | 0 .../board-view/dialogs/follow-up-dialog.tsx | 121 + .../views/board-view/dialogs/index.ts | 8 + .../views/board-view/hooks/index.ts | 10 + .../board-view/hooks/use-board-actions.ts | 621 +++++ .../board-view/hooks/use-board-background.ts | 47 + .../hooks/use-board-column-features.ts | 81 + .../board-view/hooks/use-board-drag-drop.ts | 216 ++ .../board-view/hooks/use-board-effects.ts | 166 ++ .../board-view/hooks/use-board-features.ts | 268 ++ .../hooks/use-board-keyboard-shortcuts.ts | 78 + .../board-view/hooks/use-board-persistence.ts | 90 + .../board-view/hooks/use-follow-up-state.ts | 48 + .../board-view/hooks/use-suggestions-state.ts | 34 + .../views/board-view/kanban-board.tsx | 244 ++ .../views/board-view/shared/index.ts | 5 + .../board-view/shared/model-constants.ts | 70 + .../board-view/shared/model-selector.tsx | 56 + .../shared/profile-quick-select.tsx | 99 + .../board-view/shared/testing-tab-content.tsx | 86 + .../shared/thinking-level-selector.tsx | 49 + .../views/profiles-view/constants.ts | 1 + .../components/views/profiles-view/utils.ts | 1 + apps/app/src/lib/utils.ts | 10 + docs/folder-pattern.md | 163 ++ 41 files changed, 3894 insertions(+), 3305 deletions(-) delete mode 100644 apps/app/src/components/views/board-view/AddFeatureDialog.tsx delete mode 100644 apps/app/src/components/views/board-view/EditFeatureDialog.tsx create mode 100644 apps/app/src/components/views/board-view/board-controls.tsx rename apps/app/src/components/views/board-view/{BoardHeader.tsx => board-header.tsx} (100%) rename apps/app/src/components/views/board-view/{BoardSearchBar.tsx => board-search-bar.tsx} (100%) create mode 100644 apps/app/src/components/views/board-view/components/index.ts rename apps/app/src/components/views/{ => board-view/components}/kanban-card.tsx (100%) rename apps/app/src/components/views/{ => board-view/components}/kanban-column.tsx (100%) create mode 100644 apps/app/src/components/views/board-view/constants.ts create mode 100644 apps/app/src/components/views/board-view/dialogs/add-feature-dialog.tsx rename apps/app/src/components/views/{ => board-view/dialogs}/agent-output-modal.tsx (100%) create mode 100644 apps/app/src/components/views/board-view/dialogs/completed-features-modal.tsx create mode 100644 apps/app/src/components/views/board-view/dialogs/delete-all-verified-dialog.tsx create mode 100644 apps/app/src/components/views/board-view/dialogs/delete-completed-feature-dialog.tsx create mode 100644 apps/app/src/components/views/board-view/dialogs/edit-feature-dialog.tsx rename apps/app/src/components/views/{ => board-view/dialogs}/feature-suggestions-dialog.tsx (100%) create mode 100644 apps/app/src/components/views/board-view/dialogs/follow-up-dialog.tsx create mode 100644 apps/app/src/components/views/board-view/dialogs/index.ts create mode 100644 apps/app/src/components/views/board-view/hooks/index.ts create mode 100644 apps/app/src/components/views/board-view/hooks/use-board-actions.ts create mode 100644 apps/app/src/components/views/board-view/hooks/use-board-background.ts create mode 100644 apps/app/src/components/views/board-view/hooks/use-board-column-features.ts create mode 100644 apps/app/src/components/views/board-view/hooks/use-board-drag-drop.ts create mode 100644 apps/app/src/components/views/board-view/hooks/use-board-effects.ts create mode 100644 apps/app/src/components/views/board-view/hooks/use-board-features.ts create mode 100644 apps/app/src/components/views/board-view/hooks/use-board-keyboard-shortcuts.ts create mode 100644 apps/app/src/components/views/board-view/hooks/use-board-persistence.ts create mode 100644 apps/app/src/components/views/board-view/hooks/use-follow-up-state.ts create mode 100644 apps/app/src/components/views/board-view/hooks/use-suggestions-state.ts create mode 100644 apps/app/src/components/views/board-view/kanban-board.tsx create mode 100644 apps/app/src/components/views/board-view/shared/index.ts create mode 100644 apps/app/src/components/views/board-view/shared/model-constants.ts create mode 100644 apps/app/src/components/views/board-view/shared/model-selector.tsx create mode 100644 apps/app/src/components/views/board-view/shared/profile-quick-select.tsx create mode 100644 apps/app/src/components/views/board-view/shared/testing-tab-content.tsx create mode 100644 apps/app/src/components/views/board-view/shared/thinking-level-selector.tsx create mode 100644 docs/folder-pattern.md diff --git a/apps/app/src/components/views/board-view.tsx b/apps/app/src/components/views/board-view.tsx index a9fb5662..3b7c361c 100644 --- a/apps/app/src/components/views/board-view.tsx +++ b/apps/app/src/components/views/board-view.tsx @@ -1,168 +1,72 @@ "use client"; -import { useEffect, useState, useCallback, useMemo, useRef } from "react"; +import { useEffect, useState, useCallback, useMemo } from "react"; import { - DndContext, - DragEndEvent, - DragOverlay, - DragStartEvent, PointerSensor, useSensor, useSensors, rectIntersection, pointerWithin, } from "@dnd-kit/core"; -import { - SortableContext, - verticalListSortingStrategy, -} from "@dnd-kit/sortable"; -import { - useAppStore, - Feature, - FeatureImage, - FeatureImagePath, - AgentModel, - ThinkingLevel, - AIProfile, - defaultBackgroundSettings, -} from "@/store/app-store"; +import { useAppStore, Feature } 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 { AddFeatureDialog } from "./board-view/AddFeatureDialog"; -import { EditFeatureDialog } from "./board-view/EditFeatureDialog"; -import { BoardHeader } from "./board-view/BoardHeader"; -import { BoardSearchBar } from "./board-view/BoardSearchBar"; -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 { RefreshCw } from "lucide-react"; import { useAutoMode } from "@/hooks/use-auto-mode"; -import { - useKeyboardShortcuts, - useKeyboardShortcutsConfig, - KeyboardShortcut, -} from "@/hooks/use-keyboard-shortcuts"; +import { useKeyboardShortcutsConfig } 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)]", - }, -]; - - +// Board-view specific imports +import { BoardHeader } from "./board-view/board-header"; +import { BoardSearchBar } from "./board-view/board-search-bar"; +import { BoardControls } from "./board-view/board-controls"; +import { KanbanBoard } from "./board-view/kanban-board"; +import { + AddFeatureDialog, + AgentOutputModal, + CompletedFeaturesModal, + DeleteAllVerifiedDialog, + DeleteCompletedFeatureDialog, + EditFeatureDialog, + FeatureSuggestionsDialog, + FollowUpDialog, +} from "./board-view/dialogs"; +import { COLUMNS } from "./board-view/constants"; +import { + useBoardFeatures, + useBoardDragDrop, + useBoardActions, + useBoardKeyboardShortcuts, + useBoardColumnFeatures, + useBoardEffects, + useBoardBackground, + useBoardPersistence, + useFollowUpState, + useSuggestionsState, +} from "./board-view/hooks"; 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 { + features: hookFeatures, + isLoading, + persistedCategories, + loadFeatures, + saveCategory, + } = useBoardFeatures({ currentProject }); const [editingFeature, setEditingFeature] = useState(null); const [showAddDialog, setShowAddDialog] = useState(false); - const [isLoading, setIsLoading] = useState(true); const [isMounted, setIsMounted] = useState(false); const [showOutputModal, setShowOutputModal] = useState(false); const [outputFeature, setOutputFeature] = useState(null); @@ -176,95 +80,78 @@ export function BoardView() { 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 [followUpPreviewMap, setFollowUpPreviewMap] = useState( - () => new Map() - ); - const [showSuggestionsDialog, setShowSuggestionsDialog] = useState(false); - const [suggestionsCount, setSuggestionsCount] = useState(0); - const [featureSuggestions, setFeatureSuggestions] = useState< - import("@/lib/electron").FeatureSuggestion[] - >([]); - const [isGeneratingSuggestions, setIsGeneratingSuggestions] = useState(false); + + // Follow-up state hook + const { + showFollowUpDialog, + followUpFeature, + followUpPrompt, + followUpImagePaths, + followUpPreviewMap, + setShowFollowUpDialog, + setFollowUpFeature, + setFollowUpPrompt, + setFollowUpImagePaths, + setFollowUpPreviewMap, + handleFollowUpDialogChange, + } = useFollowUpState(); + + // Suggestions state hook + const { + showSuggestionsDialog, + suggestionsCount, + featureSuggestions, + isGeneratingSuggestions, + setShowSuggestionsDialog, + setSuggestionsCount, + setFeatureSuggestions, + setIsGeneratingSuggestions, + updateSuggestions, + closeSuggestionsDialog, + } = useSuggestionsState(); // Search filter for Kanban cards const [searchQuery, setSearchQuery] = useState(""); // Derive spec creation state from store - check if current project is the one being created const isCreatingSpec = specCreatingForProject === currentProject?.path; - const creatingSpecProjectPath = specCreatingForProject; + const creatingSpecProjectPath = specCreatingForProject ?? undefined; - // Make current project available globally for modal - useEffect(() => { - if (currentProject) { - (window as any).__currentProject = currentProject; - } - return () => { - (window as any).__currentProject = null; - }; - }, [currentProject]); + const checkContextExists = useCallback( + async (featureId: string): Promise => { + if (!currentProject) return false; - // Listen for suggestions events to update count (persists even when dialog is closed) - useEffect(() => { - const api = getElectronAPI(); - if (!api?.suggestions) return; + try { + const api = getElectronAPI(); + if (!api?.autoMode?.contextExists) { + return false; + } - 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); + 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; } - }); + }, + [currentProject] + ); - 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); + // Use board effects hook + useBoardEffects({ + currentProject, + specCreatingForProject, + setSpecCreatingForProject, + setSuggestionsCount, + setFeatureSuggestions, + setIsGeneratingSuggestions, + checkContextExists, + features: hookFeatures, + isLoading, + setFeaturesWithContext, + }); // Auto mode hook const autoMode = useAutoMode(); @@ -274,51 +161,7 @@ export function BoardView() { // 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 - - // 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", - }, - ]; - - // 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); + // Keyboard shortcuts hook will be initialized after actions hook // Prevent hydration issues useEffect(() => { @@ -335,1356 +178,124 @@ export function BoardView() { // Get unique categories from existing features AND persisted categories for autocomplete suggestions const categorySuggestions = useMemo(() => { - const featureCategories = features.map((f) => f.category).filter(Boolean); + const featureCategories = hookFeatures + .map((f) => f.category) + .filter(Boolean); // Merge feature categories with persisted categories const allCategories = [...featureCategories, ...persistedCategories]; return [...new Set(allCategories)].sort(); - }, [features, persistedCategories]); + }, [hookFeatures, 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` + 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 (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([]); + // If we found a column collision, use that + if (columnCollisions.length > 0) { + return columnCollisions; } - } 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); - } + // Otherwise, use rectangle intersection for cards + return rectIntersection(args); }, - [currentProject, persistedCategories] + [] ); - - // 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 = (featureData: { - category: string; - description: string; - steps: string[]; - images: FeatureImage[]; - imagePaths: DescriptionImagePath[]; - skipTests: boolean; - model: AgentModel; - thinkingLevel: ThinkingLevel; - }) => { - const newFeatureData = { - ...featureData, - status: "backlog" as const, - }; - const createdFeature = addFeature(newFeatureData); - persistFeatureCreate(createdFeature); - // Persist the category - saveCategory(featureData.category); - }; - - const handleUpdateFeature = ( - featureId: string, - updates: { - category: string; - description: string; - steps: string[]; - skipTests: boolean; - model: AgentModel; - thinkingLevel: ThinkingLevel; - imagePaths: DescriptionImagePath[]; - } - ) => { - updateFeature(featureId, updates); - persistFeatureUpdate(featureId, updates); - // Persist the category if it's new - if (updates.category) { - saveCategory(updates.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" + // Use persistence hook + const { + persistFeatureCreate, + persistFeatureUpdate, + persistFeatureDelete, + } = useBoardPersistence({ currentProject }); + + // Get in-progress features for keyboard shortcuts (needed before actions hook) + const inProgressFeaturesForShortcuts = useMemo(() => { + return hookFeatures.filter((f) => { 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); - } - } + return isRunning || f.status === "in_progress"; }); + }, [hookFeatures, runningAutoTasks]); - // 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; - }); + // Extract all action handlers into a hook + const { + handleAddFeature, + handleUpdateFeature, + handleDeleteFeature, + handleStartImplementation, + handleVerifyFeature, + handleResumeFeature, + handleManualVerify, + handleMoveBackToInProgress, + handleOpenFollowUp, + handleSendFollowUp, + handleCommitFeature, + handleRevertFeature, + handleMergeFeature, + handleCompleteFeature, + handleUnarchiveFeature, + handleViewOutput, + handleOutputModalNumberKeyPress, + handleForceStopFeature, + handleStartNextFeatures, + handleDeleteAllVerified, + } = useBoardActions({ + currentProject, + features: hookFeatures, + runningAutoTasks, + loadFeatures, + persistFeatureCreate, + persistFeatureUpdate, + persistFeatureDelete, + saveCategory, + setEditingFeature, + setShowOutputModal, + setOutputFeature, + followUpFeature, + followUpPrompt, + followUpImagePaths, + setFollowUpFeature, + setFollowUpPrompt, + setFollowUpImagePaths, + setFollowUpPreviewMap, + setShowFollowUpDialog, + inProgressFeaturesForShortcuts, + outputFeature, + }); - return map; - }, [features, runningAutoTasks, searchQuery]); + // Use keyboard shortcuts hook (after actions hook) + useBoardKeyboardShortcuts({ + features: hookFeatures, + runningAutoTasks, + onAddFeature: () => setShowAddDialog(true), + onStartNextFeatures: handleStartNextFeatures, + onViewOutput: handleViewOutput, + }); - 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]); + // Use drag and drop hook + const { activeFeature, handleDragStart, handleDragEnd } = useBoardDragDrop({ + features: hookFeatures, + currentProject, + runningAutoTasks, + persistFeatureUpdate, + handleStartImplementation, + }); + // Use column features hook + const { getColumnFeatures, completedFeatures } = useBoardColumnFeatures({ + features: hookFeatures, + runningAutoTasks, + searchQuery, + }); + // Use background hook + const { backgroundSettings, backgroundImageStyle } = useBoardBackground({ + currentProject, + }); if (!currentProject) { return ( @@ -1722,7 +333,11 @@ export function BoardView() { onStartAutoMode={() => autoMode.start()} onStopAutoMode={() => autoMode.stop()} onAddFeature={() => setShowAddDialog(true)} - addFeatureShortcut={shortcuts.addFeature} + addFeatureShortcut={{ + key: shortcuts.addFeature, + action: () => setShowAddDialog(true), + description: "Add new feature", + }} isMounted={isMounted} /> @@ -1734,314 +349,52 @@ export function BoardView() { searchQuery={searchQuery} onSearchChange={setSearchQuery} isCreatingSpec={isCreatingSpec} - creatingSpecProjectPath={creatingSpecProjectPath} + creatingSpecProjectPath={creatingSpecProjectPath ?? undefined} currentProjectPath={currentProject?.path} /> {/* 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

-
-
-
-
-
- )} + setShowBoardBackgroundModal(true)} + onShowCompletedModal={() => setShowCompletedModal(true)} + completedCount={completedFeatures.length} + kanbanCardDetailLevel={kanbanCardDetailLevel} + onDetailLevelChange={setKanbanCardDetailLevel} + /> {/* 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} - - - - )} - -
-
- ); - })()} + setEditingFeature(feature)} + onDelete={(featureId) => handleDeleteFeature(featureId)} + onViewOutput={handleViewOutput} + onVerify={handleVerifyFeature} + onResume={handleResumeFeature} + onForceStop={handleForceStopFeature} + onManualVerify={handleManualVerify} + onMoveBackToInProgress={handleMoveBackToInProgress} + onFollowUp={handleOpenFollowUp} + onCommit={handleCommitFeature} + onRevert={handleRevertFeature} + onMerge={handleMergeFeature} + onComplete={handleCompleteFeature} + onImplement={handleStartImplementation} + featuresWithContext={featuresWithContext} + runningAutoTasks={runningAutoTasks} + shortcuts={shortcuts} + onStartNextFeatures={handleStartNextFeatures} + onShowSuggestions={() => setShowSuggestionsDialog(true)} + suggestionsCount={suggestionsCount} + onDeleteAllVerified={() => setShowDeleteAllVerifiedDialog(true)} + /> {/* Board Background Modal */} @@ -2051,134 +404,25 @@ export function BoardView() { /> {/* 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"} - - -
- - -
-
- ))} -
- )} -
- - - -
-
+ setDeleteCompletedFeature(feature)} + /> {/* 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. - - - - - - - - - + setDeleteCompletedFeature(null)} + onConfirm={async () => { + if (deleteCompletedFeature) { + await handleDeleteFeature(deleteCompletedFeature.id); + setDeleteCompletedFeature(null); + } + }} + /> {/* Add Feature Dialog */} {/* 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. - - )} - - - - - - - - + verifiedCount={getColumnFeatures("verified").length} + onConfirm={async () => { + await handleDeleteAllVerified(); + setShowDeleteAllVerifiedDialog(false); + }} + /> {/* 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 - - -
-
+ onOpenChange={handleFollowUpDialogChange} + feature={followUpFeature} + prompt={followUpPrompt} + imagePaths={followUpImagePaths} + previewMap={followUpPreviewMap} + onPromptChange={setFollowUpPrompt} + onImagePathsChange={setFollowUpImagePaths} + onPreviewMapChange={setFollowUpPreviewMap} + onSend={handleSendFollowUp} + isMaximized={isMaximized} + /> {/* Feature Suggestions Dialog */} { - setShowSuggestionsDialog(false); - }} + onClose={closeSuggestionsDialog} projectPath={currentProject.path} suggestions={featureSuggestions} - setSuggestions={(suggestions) => { - setFeatureSuggestions(suggestions); - setSuggestionsCount(suggestions.length); - }} + setSuggestions={updateSuggestions} isGenerating={isGeneratingSuggestions} setIsGenerating={setIsGeneratingSuggestions} /> diff --git a/apps/app/src/components/views/board-view/AddFeatureDialog.tsx b/apps/app/src/components/views/board-view/AddFeatureDialog.tsx deleted file mode 100644 index 54a70c57..00000000 --- a/apps/app/src/components/views/board-view/AddFeatureDialog.tsx +++ /dev/null @@ -1,578 +0,0 @@ -"use client"; - -import { useState, useEffect } from "react"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; -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 { CategoryAutocomplete } from "@/components/ui/category-autocomplete"; -import { - DescriptionImageDropZone, - FeatureImagePath as DescriptionImagePath, - ImagePreviewMap, -} from "@/components/ui/description-image-dropzone"; -import { Checkbox } from "@/components/ui/checkbox"; -import { - MessageSquare, - Settings2, - FlaskConical, - Plus, - Brain, - UserCircle, - Zap, - Scale, - Cpu, - Rocket, - Sparkles, -} from "lucide-react"; -import { cn, modelSupportsThinking } from "@/lib/utils"; -import { - useAppStore, - AgentModel, - ThinkingLevel, - FeatureImage, - AIProfile, -} from "@/store/app-store"; - -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, -}; - -interface AddFeatureDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - onAdd: (feature: { - category: string; - description: string; - steps: string[]; - images: FeatureImage[]; - imagePaths: DescriptionImagePath[]; - skipTests: boolean; - model: AgentModel; - thinkingLevel: ThinkingLevel; - }) => void; - categorySuggestions: string[]; - defaultSkipTests: boolean; - isMaximized: boolean; - showProfilesOnly: boolean; - aiProfiles: AIProfile[]; -} - -export function AddFeatureDialog({ - open, - onOpenChange, - onAdd, - categorySuggestions, - defaultSkipTests, - isMaximized, - showProfilesOnly, - aiProfiles, -}: AddFeatureDialogProps) { - const [newFeature, setNewFeature] = useState({ - category: "", - description: "", - steps: [""], - images: [] as FeatureImage[], - imagePaths: [] as DescriptionImagePath[], - skipTests: false, - model: "opus" as AgentModel, - thinkingLevel: "none" as ThinkingLevel, - }); - const [newFeaturePreviewMap, setNewFeaturePreviewMap] = - useState(() => new Map()); - const [showAdvancedOptions, setShowAdvancedOptions] = useState(false); - const [descriptionError, setDescriptionError] = useState(false); - - // Sync skipTests default when dialog opens - useEffect(() => { - if (open) { - setNewFeature((prev) => ({ - ...prev, - skipTests: defaultSkipTests, - })); - } - }, [open, defaultSkipTests]); - - const handleAdd = () => { - // 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"; - - onAdd({ - category, - description: newFeature.description, - steps: newFeature.steps.filter((s) => s.trim()), - images: newFeature.images, - imagePaths: newFeature.imagePaths, - skipTests: newFeature.skipTests, - model: selectedModel, - thinkingLevel: normalizedThinking, - }); - - // Reset form - setNewFeature({ - category: "", - description: "", - steps: [""], - images: [], - imagePaths: [], - skipTests: defaultSkipTests, - model: "opus", - thinkingLevel: "none", - }); - setNewFeaturePreviewMap(new Map()); - setShowAdvancedOptions(false); - setDescriptionError(false); - onOpenChange(false); - }; - - const handleDialogClose = (open: boolean) => { - onOpenChange(open); - // Clear preview map, validation error, and reset advanced options when dialog closes - if (!open) { - setNewFeaturePreviewMap(new Map()); - setShowAdvancedOptions(false); - setDescriptionError(false); - } - }; - - 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); - - return ( - - { - // 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 - - - -
- ); -} diff --git a/apps/app/src/components/views/board-view/EditFeatureDialog.tsx b/apps/app/src/components/views/board-view/EditFeatureDialog.tsx deleted file mode 100644 index a41c8fdd..00000000 --- a/apps/app/src/components/views/board-view/EditFeatureDialog.tsx +++ /dev/null @@ -1,541 +0,0 @@ -"use client"; - -import { useState, useEffect } from "react"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; -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 { CategoryAutocomplete } from "@/components/ui/category-autocomplete"; -import { - DescriptionImageDropZone, - FeatureImagePath as DescriptionImagePath, - ImagePreviewMap, -} from "@/components/ui/description-image-dropzone"; -import { Checkbox } from "@/components/ui/checkbox"; -import { - MessageSquare, - Settings2, - FlaskConical, - Plus, - Brain, - UserCircle, - Zap, - Scale, - Cpu, - Rocket, - Sparkles, -} from "lucide-react"; -import { cn, modelSupportsThinking } from "@/lib/utils"; -import { - Feature, - AgentModel, - ThinkingLevel, - AIProfile, -} from "@/store/app-store"; - -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, -}; - -interface EditFeatureDialogProps { - feature: Feature | null; - onClose: () => void; - onUpdate: (featureId: string, updates: { - category: string; - description: string; - steps: string[]; - skipTests: boolean; - model: AgentModel; - thinkingLevel: ThinkingLevel; - imagePaths: DescriptionImagePath[]; - }) => void; - categorySuggestions: string[]; - isMaximized: boolean; - showProfilesOnly: boolean; - aiProfiles: AIProfile[]; -} - -export function EditFeatureDialog({ - feature, - onClose, - onUpdate, - categorySuggestions, - isMaximized, - showProfilesOnly, - aiProfiles, -}: EditFeatureDialogProps) { - const [editingFeature, setEditingFeature] = useState(feature); - const [editFeaturePreviewMap, setEditFeaturePreviewMap] = - useState(() => new Map()); - const [showEditAdvancedOptions, setShowEditAdvancedOptions] = useState(false); - - // Update local state when feature prop changes - useEffect(() => { - setEditingFeature(feature); - if (!feature) { - setEditFeaturePreviewMap(new Map()); - setShowEditAdvancedOptions(false); - } - }, [feature]); - - const handleUpdate = () => { - 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 ?? [], - }; - - onUpdate(editingFeature.id, updates); - setEditFeaturePreviewMap(new Map()); - setShowEditAdvancedOptions(false); - onClose(); - }; - - const handleDialogClose = (open: boolean) => { - if (!open) { - onClose(); - } - }; - - 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 editModelAllowsThinking = modelSupportsThinking(editingFeature?.model); - - if (!editingFeature) { - return null; - } - - return ( - - { - // 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. - - - - - - 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 - - - -
- ); -} diff --git a/apps/app/src/components/views/board-view/board-controls.tsx b/apps/app/src/components/views/board-view/board-controls.tsx new file mode 100644 index 00000000..1509d408 --- /dev/null +++ b/apps/app/src/components/views/board-view/board-controls.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { ImageIcon, Archive, Minimize2, Square, Maximize2 } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface BoardControlsProps { + isMounted: boolean; + onShowBoardBackground: () => void; + onShowCompletedModal: () => void; + completedCount: number; + kanbanCardDetailLevel: "minimal" | "standard" | "detailed"; + onDetailLevelChange: (level: "minimal" | "standard" | "detailed") => void; +} + +export function BoardControls({ + isMounted, + onShowBoardBackground, + onShowCompletedModal, + completedCount, + kanbanCardDetailLevel, + onDetailLevelChange, +}: BoardControlsProps) { + if (!isMounted) return null; + + return ( + +
+ {/* Board Background Button */} + + + + + +

Board Background Settings

+
+
+ + {/* Completed/Archived Features Button */} + + + + + +

Completed Features ({completedCount})

+
+
+ + {/* Kanban Card Detail Level Toggle */} +
+ + + + + +

Minimal - Title & category only

+
+
+ + + + + +

Standard - Steps & progress

+
+
+ + + + + +

Detailed - Model, tools & tasks

+
+
+
+
+
+ ); +} diff --git a/apps/app/src/components/views/board-view/BoardHeader.tsx b/apps/app/src/components/views/board-view/board-header.tsx similarity index 100% rename from apps/app/src/components/views/board-view/BoardHeader.tsx rename to apps/app/src/components/views/board-view/board-header.tsx diff --git a/apps/app/src/components/views/board-view/BoardSearchBar.tsx b/apps/app/src/components/views/board-view/board-search-bar.tsx similarity index 100% rename from apps/app/src/components/views/board-view/BoardSearchBar.tsx rename to apps/app/src/components/views/board-view/board-search-bar.tsx diff --git a/apps/app/src/components/views/board-view/components/index.ts b/apps/app/src/components/views/board-view/components/index.ts new file mode 100644 index 00000000..49cf06ef --- /dev/null +++ b/apps/app/src/components/views/board-view/components/index.ts @@ -0,0 +1,2 @@ +export { KanbanCard } from "./kanban-card"; +export { KanbanColumn } from "./kanban-column"; diff --git a/apps/app/src/components/views/kanban-card.tsx b/apps/app/src/components/views/board-view/components/kanban-card.tsx similarity index 100% rename from apps/app/src/components/views/kanban-card.tsx rename to apps/app/src/components/views/board-view/components/kanban-card.tsx diff --git a/apps/app/src/components/views/kanban-column.tsx b/apps/app/src/components/views/board-view/components/kanban-column.tsx similarity index 100% rename from apps/app/src/components/views/kanban-column.tsx rename to apps/app/src/components/views/board-view/components/kanban-column.tsx diff --git a/apps/app/src/components/views/board-view/constants.ts b/apps/app/src/components/views/board-view/constants.ts new file mode 100644 index 00000000..9a4d0fc1 --- /dev/null +++ b/apps/app/src/components/views/board-view/constants.ts @@ -0,0 +1,22 @@ +import { Feature } from "@/store/app-store"; + +export type ColumnId = Feature["status"]; + +export 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)]", + }, +]; diff --git a/apps/app/src/components/views/board-view/dialogs/add-feature-dialog.tsx b/apps/app/src/components/views/board-view/dialogs/add-feature-dialog.tsx new file mode 100644 index 00000000..7a91459b --- /dev/null +++ b/apps/app/src/components/views/board-view/dialogs/add-feature-dialog.tsx @@ -0,0 +1,335 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import { Button } from "@/components/ui/button"; +import { HotkeyButton } from "@/components/ui/hotkey-button"; +import { Label } from "@/components/ui/label"; +import { CategoryAutocomplete } from "@/components/ui/category-autocomplete"; +import { + DescriptionImageDropZone, + FeatureImagePath as DescriptionImagePath, + ImagePreviewMap, +} from "@/components/ui/description-image-dropzone"; +import { MessageSquare, Settings2, FlaskConical } from "lucide-react"; +import { modelSupportsThinking } from "@/lib/utils"; +import { + useAppStore, + AgentModel, + ThinkingLevel, + FeatureImage, + AIProfile, +} from "@/store/app-store"; +import { + ModelSelector, + ThinkingLevelSelector, + ProfileQuickSelect, + TestingTabContent, +} from "../shared"; + +interface AddFeatureDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onAdd: (feature: { + category: string; + description: string; + steps: string[]; + images: FeatureImage[]; + imagePaths: DescriptionImagePath[]; + skipTests: boolean; + model: AgentModel; + thinkingLevel: ThinkingLevel; + }) => void; + categorySuggestions: string[]; + defaultSkipTests: boolean; + isMaximized: boolean; + showProfilesOnly: boolean; + aiProfiles: AIProfile[]; +} + +export function AddFeatureDialog({ + open, + onOpenChange, + onAdd, + categorySuggestions, + defaultSkipTests, + isMaximized, + showProfilesOnly, + aiProfiles, +}: AddFeatureDialogProps) { + const [newFeature, setNewFeature] = useState({ + category: "", + description: "", + steps: [""], + images: [] as FeatureImage[], + imagePaths: [] as DescriptionImagePath[], + skipTests: false, + model: "opus" as AgentModel, + thinkingLevel: "none" as ThinkingLevel, + }); + const [newFeaturePreviewMap, setNewFeaturePreviewMap] = + useState(() => new Map()); + const [showAdvancedOptions, setShowAdvancedOptions] = useState(false); + const [descriptionError, setDescriptionError] = useState(false); + + // Sync skipTests default when dialog opens + useEffect(() => { + if (open) { + setNewFeature((prev) => ({ + ...prev, + skipTests: defaultSkipTests, + })); + } + }, [open, defaultSkipTests]); + + const handleAdd = () => { + if (!newFeature.description.trim()) { + setDescriptionError(true); + return; + } + + const category = newFeature.category || "Uncategorized"; + const selectedModel = newFeature.model; + const normalizedThinking = modelSupportsThinking(selectedModel) + ? newFeature.thinkingLevel + : "none"; + + onAdd({ + category, + description: newFeature.description, + steps: newFeature.steps.filter((s) => s.trim()), + images: newFeature.images, + imagePaths: newFeature.imagePaths, + skipTests: newFeature.skipTests, + model: selectedModel, + thinkingLevel: normalizedThinking, + }); + + // Reset form + setNewFeature({ + category: "", + description: "", + steps: [""], + images: [], + imagePaths: [], + skipTests: defaultSkipTests, + model: "opus", + thinkingLevel: "none", + }); + setNewFeaturePreviewMap(new Map()); + setShowAdvancedOptions(false); + setDescriptionError(false); + onOpenChange(false); + }; + + const handleDialogClose = (open: boolean) => { + onOpenChange(open); + if (!open) { + setNewFeaturePreviewMap(new Map()); + setShowAdvancedOptions(false); + setDescriptionError(false); + } + }; + + const handleModelSelect = (model: AgentModel) => { + setNewFeature({ + ...newFeature, + model, + thinkingLevel: modelSupportsThinking(model) + ? newFeature.thinkingLevel + : "none", + }); + }; + + const handleProfileSelect = (model: AgentModel, thinkingLevel: ThinkingLevel) => { + setNewFeature({ + ...newFeature, + model, + thinkingLevel, + }); + }; + + const newModelAllowsThinking = modelSupportsThinking(newFeature.model); + + return ( + + { + const target = e.target as HTMLElement; + if (target.closest('[data-testid="category-autocomplete-list"]')) { + e.preventDefault(); + } + }} + onInteractOutside={(e) => { + 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 */} + {showProfilesOnly && ( +
+
+

+ Simple Mode Active +

+

+ Only showing AI profiles. Advanced model tweaking is hidden. +

+
+ +
+ )} + + {/* Quick Select Profile Section */} + { + onOpenChange(false); + useAppStore.getState().setCurrentView("profiles"); + }} + /> + + {/* Separator */} + {aiProfiles.length > 0 && + (!showProfilesOnly || showAdvancedOptions) && ( +
+ )} + + {/* Claude Models Section */} + {(!showProfilesOnly || showAdvancedOptions) && ( + <> + + {newModelAllowsThinking && ( + + setNewFeature({ ...newFeature, thinkingLevel: level }) + } + /> + )} + + )} + + + {/* Testing Tab */} + + + setNewFeature({ ...newFeature, skipTests }) + } + steps={newFeature.steps} + onStepsChange={(steps) => + setNewFeature({ ...newFeature, steps }) + } + /> + + + + + + Add Feature + + + +
+ ); +} diff --git a/apps/app/src/components/views/agent-output-modal.tsx b/apps/app/src/components/views/board-view/dialogs/agent-output-modal.tsx similarity index 100% rename from apps/app/src/components/views/agent-output-modal.tsx rename to apps/app/src/components/views/board-view/dialogs/agent-output-modal.tsx diff --git a/apps/app/src/components/views/board-view/dialogs/completed-features-modal.tsx b/apps/app/src/components/views/board-view/dialogs/completed-features-modal.tsx new file mode 100644 index 00000000..e703476e --- /dev/null +++ b/apps/app/src/components/views/board-view/dialogs/completed-features-modal.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Card, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import { ArchiveRestore, Trash2 } from "lucide-react"; +import { Feature } from "@/store/app-store"; + +interface CompletedFeaturesModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + completedFeatures: Feature[]; + onUnarchive: (feature: Feature) => void; + onDelete: (feature: Feature) => void; +} + +export function CompletedFeaturesModal({ + open, + onOpenChange, + completedFeatures, + onUnarchive, + onDelete, +}: CompletedFeaturesModalProps) { + return ( + + + + Completed Features + + {completedFeatures.length === 0 + ? "No completed features yet." + : `${completedFeatures.length} completed feature${ + completedFeatures.length > 1 ? "s" : "" + }`} + + +
+ {completedFeatures.length === 0 ? ( +
+ +

No completed features

+
+ ) : ( +
+ {completedFeatures.map((feature) => ( + + + + {feature.description || feature.summary || feature.id} + + + {feature.category || "Uncategorized"} + + +
+ + +
+
+ ))} +
+ )} +
+ + + +
+
+ ); +} diff --git a/apps/app/src/components/views/board-view/dialogs/delete-all-verified-dialog.tsx b/apps/app/src/components/views/board-view/dialogs/delete-all-verified-dialog.tsx new file mode 100644 index 00000000..9079a03b --- /dev/null +++ b/apps/app/src/components/views/board-view/dialogs/delete-all-verified-dialog.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Trash2 } from "lucide-react"; + +interface DeleteAllVerifiedDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + verifiedCount: number; + onConfirm: () => void; +} + +export function DeleteAllVerifiedDialog({ + open, + onOpenChange, + verifiedCount, + onConfirm, +}: DeleteAllVerifiedDialogProps) { + return ( + + + + Delete All Verified Features + + Are you sure you want to delete all verified features? This action + cannot be undone. + {verifiedCount > 0 && ( + + {verifiedCount} feature(s) will be deleted. + + )} + + + + + + + + + ); +} diff --git a/apps/app/src/components/views/board-view/dialogs/delete-completed-feature-dialog.tsx b/apps/app/src/components/views/board-view/dialogs/delete-completed-feature-dialog.tsx new file mode 100644 index 00000000..0e846a07 --- /dev/null +++ b/apps/app/src/components/views/board-view/dialogs/delete-completed-feature-dialog.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Trash2 } from "lucide-react"; +import { Feature } from "@/store/app-store"; + +interface DeleteCompletedFeatureDialogProps { + feature: Feature | null; + onClose: () => void; + onConfirm: () => void; +} + +export function DeleteCompletedFeatureDialog({ + feature, + onClose, + onConfirm, +}: DeleteCompletedFeatureDialogProps) { + if (!feature) return null; + + return ( + !open && onClose()}> + + + + + Delete Feature + + + Are you sure you want to permanently delete this feature? + + "{feature.description?.slice(0, 100)} + {(feature.description?.length ?? 0) > 100 ? "..." : ""}" + + + This action cannot be undone. + + + + + + + + + + ); +} diff --git a/apps/app/src/components/views/board-view/dialogs/edit-feature-dialog.tsx b/apps/app/src/components/views/board-view/dialogs/edit-feature-dialog.tsx new file mode 100644 index 00000000..fdc13d60 --- /dev/null +++ b/apps/app/src/components/views/board-view/dialogs/edit-feature-dialog.tsx @@ -0,0 +1,316 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import { Button } from "@/components/ui/button"; +import { HotkeyButton } from "@/components/ui/hotkey-button"; +import { Label } from "@/components/ui/label"; +import { CategoryAutocomplete } from "@/components/ui/category-autocomplete"; +import { + DescriptionImageDropZone, + FeatureImagePath as DescriptionImagePath, + ImagePreviewMap, +} from "@/components/ui/description-image-dropzone"; +import { MessageSquare, Settings2, FlaskConical } from "lucide-react"; +import { modelSupportsThinking } from "@/lib/utils"; +import { + Feature, + AgentModel, + ThinkingLevel, + AIProfile, +} from "@/store/app-store"; +import { + ModelSelector, + ThinkingLevelSelector, + ProfileQuickSelect, + TestingTabContent, +} from "../shared"; + +interface EditFeatureDialogProps { + feature: Feature | null; + onClose: () => void; + onUpdate: ( + featureId: string, + updates: { + category: string; + description: string; + steps: string[]; + skipTests: boolean; + model: AgentModel; + thinkingLevel: ThinkingLevel; + imagePaths: DescriptionImagePath[]; + } + ) => void; + categorySuggestions: string[]; + isMaximized: boolean; + showProfilesOnly: boolean; + aiProfiles: AIProfile[]; +} + +export function EditFeatureDialog({ + feature, + onClose, + onUpdate, + categorySuggestions, + isMaximized, + showProfilesOnly, + aiProfiles, +}: EditFeatureDialogProps) { + const [editingFeature, setEditingFeature] = useState(feature); + const [editFeaturePreviewMap, setEditFeaturePreviewMap] = + useState(() => new Map()); + const [showEditAdvancedOptions, setShowEditAdvancedOptions] = useState(false); + + useEffect(() => { + setEditingFeature(feature); + if (!feature) { + setEditFeaturePreviewMap(new Map()); + setShowEditAdvancedOptions(false); + } + }, [feature]); + + const handleUpdate = () => { + if (!editingFeature) return; + + const selectedModel = (editingFeature.model ?? "opus") as AgentModel; + const normalizedThinking: ThinkingLevel = modelSupportsThinking(selectedModel) + ? (editingFeature.thinkingLevel ?? "none") + : "none"; + + const updates = { + category: editingFeature.category, + description: editingFeature.description, + steps: editingFeature.steps, + skipTests: editingFeature.skipTests ?? false, + model: selectedModel, + thinkingLevel: normalizedThinking, + imagePaths: editingFeature.imagePaths ?? [], + }; + + onUpdate(editingFeature.id, updates); + setEditFeaturePreviewMap(new Map()); + setShowEditAdvancedOptions(false); + onClose(); + }; + + const handleDialogClose = (open: boolean) => { + if (!open) { + onClose(); + } + }; + + const handleModelSelect = (model: AgentModel) => { + if (!editingFeature) return; + setEditingFeature({ + ...editingFeature, + model, + thinkingLevel: modelSupportsThinking(model) + ? editingFeature.thinkingLevel + : "none", + }); + }; + + const handleProfileSelect = (model: AgentModel, thinkingLevel: ThinkingLevel) => { + if (!editingFeature) return; + setEditingFeature({ + ...editingFeature, + model, + thinkingLevel, + }); + }; + + const editModelAllowsThinking = modelSupportsThinking(editingFeature?.model); + + if (!editingFeature) { + return null; + } + + return ( + + { + const target = e.target as HTMLElement; + if (target.closest('[data-testid="category-autocomplete-list"]')) { + e.preventDefault(); + } + }} + onInteractOutside={(e) => { + const target = e.target as HTMLElement; + if (target.closest('[data-testid="category-autocomplete-list"]')) { + e.preventDefault(); + } + }} + > + + Edit Feature + Modify the feature details. + + + + + + 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 */} + {showProfilesOnly && ( +
+
+

+ Simple Mode Active +

+

+ Only showing AI profiles. Advanced model tweaking is hidden. +

+
+ +
+ )} + + {/* Quick Select Profile Section */} + + + {/* Separator */} + {aiProfiles.length > 0 && + (!showProfilesOnly || showEditAdvancedOptions) && ( +
+ )} + + {/* Claude Models Section */} + {(!showProfilesOnly || showEditAdvancedOptions) && ( + <> + + {editModelAllowsThinking && ( + + setEditingFeature({ + ...editingFeature, + thinkingLevel: level, + }) + } + testIdPrefix="edit-thinking-level" + /> + )} + + )} + + + {/* Testing Tab */} + + + setEditingFeature({ ...editingFeature, skipTests }) + } + steps={editingFeature.steps} + onStepsChange={(steps) => + setEditingFeature({ ...editingFeature, steps }) + } + testIdPrefix="edit" + /> + + + + + + Save Changes + + + +
+ ); +} diff --git a/apps/app/src/components/views/feature-suggestions-dialog.tsx b/apps/app/src/components/views/board-view/dialogs/feature-suggestions-dialog.tsx similarity index 100% rename from apps/app/src/components/views/feature-suggestions-dialog.tsx rename to apps/app/src/components/views/board-view/dialogs/feature-suggestions-dialog.tsx diff --git a/apps/app/src/components/views/board-view/dialogs/follow-up-dialog.tsx b/apps/app/src/components/views/board-view/dialogs/follow-up-dialog.tsx new file mode 100644 index 00000000..fa822766 --- /dev/null +++ b/apps/app/src/components/views/board-view/dialogs/follow-up-dialog.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { useState } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { HotkeyButton } from "@/components/ui/hotkey-button"; +import { + DescriptionImageDropZone, + FeatureImagePath as DescriptionImagePath, + ImagePreviewMap, +} from "@/components/ui/description-image-dropzone"; +import { MessageSquare } from "lucide-react"; +import { Feature } from "@/store/app-store"; + +interface FollowUpDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + feature: Feature | null; + prompt: string; + imagePaths: DescriptionImagePath[]; + previewMap: ImagePreviewMap; + onPromptChange: (prompt: string) => void; + onImagePathsChange: (paths: DescriptionImagePath[]) => void; + onPreviewMapChange: (map: ImagePreviewMap) => void; + onSend: () => void; + isMaximized: boolean; +} + +export function FollowUpDialog({ + open, + onOpenChange, + feature, + prompt, + imagePaths, + previewMap, + onPromptChange, + onImagePathsChange, + onPreviewMapChange, + onSend, + isMaximized, +}: FollowUpDialogProps) { + const handleClose = (open: boolean) => { + if (!open) { + onOpenChange(false); + } + }; + + return ( + + { + if ((e.metaKey || e.ctrlKey) && e.key === "Enter" && prompt.trim()) { + e.preventDefault(); + onSend(); + } + }} + > + + Follow-Up Prompt + + Send additional instructions to continue working on this feature. + {feature && ( + + Feature: {feature.description.slice(0, 100)} + {feature.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 + + +
+
+ ); +} diff --git a/apps/app/src/components/views/board-view/dialogs/index.ts b/apps/app/src/components/views/board-view/dialogs/index.ts new file mode 100644 index 00000000..5685ddcb --- /dev/null +++ b/apps/app/src/components/views/board-view/dialogs/index.ts @@ -0,0 +1,8 @@ +export { AddFeatureDialog } from "./add-feature-dialog"; +export { AgentOutputModal } from "./agent-output-modal"; +export { CompletedFeaturesModal } from "./completed-features-modal"; +export { DeleteAllVerifiedDialog } from "./delete-all-verified-dialog"; +export { DeleteCompletedFeatureDialog } from "./delete-completed-feature-dialog"; +export { EditFeatureDialog } from "./edit-feature-dialog"; +export { FeatureSuggestionsDialog } from "./feature-suggestions-dialog"; +export { FollowUpDialog } from "./follow-up-dialog"; diff --git a/apps/app/src/components/views/board-view/hooks/index.ts b/apps/app/src/components/views/board-view/hooks/index.ts new file mode 100644 index 00000000..e9c551a6 --- /dev/null +++ b/apps/app/src/components/views/board-view/hooks/index.ts @@ -0,0 +1,10 @@ +export { useBoardFeatures } from "./use-board-features"; +export { useBoardDragDrop } from "./use-board-drag-drop"; +export { useBoardActions } from "./use-board-actions"; +export { useBoardKeyboardShortcuts } from "./use-board-keyboard-shortcuts"; +export { useBoardColumnFeatures } from "./use-board-column-features"; +export { useBoardEffects } from "./use-board-effects"; +export { useBoardBackground } from "./use-board-background"; +export { useBoardPersistence } from "./use-board-persistence"; +export { useFollowUpState } from "./use-follow-up-state"; +export { useSuggestionsState } from "./use-suggestions-state"; diff --git a/apps/app/src/components/views/board-view/hooks/use-board-actions.ts b/apps/app/src/components/views/board-view/hooks/use-board-actions.ts new file mode 100644 index 00000000..af121fa6 --- /dev/null +++ b/apps/app/src/components/views/board-view/hooks/use-board-actions.ts @@ -0,0 +1,621 @@ +import { useCallback, useState } from "react"; +import { Feature, FeatureImage, AgentModel, ThinkingLevel, useAppStore } from "@/store/app-store"; +import { FeatureImagePath as DescriptionImagePath } from "@/components/ui/description-image-dropzone"; +import { getElectronAPI } from "@/lib/electron"; +import { toast } from "sonner"; +import { useAutoMode } from "@/hooks/use-auto-mode"; +import { truncateDescription } from "@/lib/utils"; + +interface UseBoardActionsProps { + currentProject: { path: string; id: string } | null; + features: Feature[]; + runningAutoTasks: string[]; + loadFeatures: () => Promise; + persistFeatureCreate: (feature: Feature) => Promise; + persistFeatureUpdate: (featureId: string, updates: Partial) => Promise; + persistFeatureDelete: (featureId: string) => Promise; + saveCategory: (category: string) => Promise; + setEditingFeature: (feature: Feature | null) => void; + setShowOutputModal: (show: boolean) => void; + setOutputFeature: (feature: Feature | null) => void; + followUpFeature: Feature | null; + followUpPrompt: string; + followUpImagePaths: DescriptionImagePath[]; + setFollowUpFeature: (feature: Feature | null) => void; + setFollowUpPrompt: (prompt: string) => void; + setFollowUpImagePaths: (paths: DescriptionImagePath[]) => void; + setFollowUpPreviewMap: (map: Map) => void; + setShowFollowUpDialog: (show: boolean) => void; + inProgressFeaturesForShortcuts: Feature[]; + outputFeature: Feature | null; +} + +export function useBoardActions({ + currentProject, + features, + runningAutoTasks, + loadFeatures, + persistFeatureCreate, + persistFeatureUpdate, + persistFeatureDelete, + saveCategory, + setEditingFeature, + setShowOutputModal, + setOutputFeature, + followUpFeature, + followUpPrompt, + followUpImagePaths, + setFollowUpFeature, + setFollowUpPrompt, + setFollowUpImagePaths, + setFollowUpPreviewMap, + setShowFollowUpDialog, + inProgressFeaturesForShortcuts, + outputFeature, +}: UseBoardActionsProps) { + const { addFeature, updateFeature, removeFeature, moveFeature, useWorktrees } = useAppStore(); + const autoMode = useAutoMode(); + + const handleAddFeature = useCallback( + (featureData: { + category: string; + description: string; + steps: string[]; + images: FeatureImage[]; + imagePaths: DescriptionImagePath[]; + skipTests: boolean; + model: AgentModel; + thinkingLevel: ThinkingLevel; + }) => { + const newFeatureData = { + ...featureData, + status: "backlog" as const, + }; + const createdFeature = addFeature(newFeatureData); + persistFeatureCreate(createdFeature); + saveCategory(featureData.category); + }, + [addFeature, persistFeatureCreate, saveCategory] + ); + + const handleUpdateFeature = useCallback( + ( + featureId: string, + updates: { + category: string; + description: string; + steps: string[]; + skipTests: boolean; + model: AgentModel; + thinkingLevel: ThinkingLevel; + imagePaths: DescriptionImagePath[]; + } + ) => { + updateFeature(featureId, updates); + persistFeatureUpdate(featureId, updates); + if (updates.category) { + saveCategory(updates.category); + } + setEditingFeature(null); + }, + [updateFeature, persistFeatureUpdate, saveCategory, setEditingFeature] + ); + + const handleDeleteFeature = useCallback( + async (featureId: string) => { + const feature = features.find((f) => f.id === featureId); + if (!feature) return; + + const isRunning = runningAutoTasks.includes(featureId); + + if (isRunning) { + try { + await autoMode.stopFeature(featureId); + toast.success("Agent stopped", { + description: `Stopped and deleted: ${truncateDescription(feature.description)}`, + }); + } catch (error) { + console.error("[Board] Error stopping feature before delete:", error); + toast.error("Failed to stop agent", { + description: "The feature will still be deleted.", + }); + } + } + + 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); + } + } + + removeFeature(featureId); + persistFeatureDelete(featureId); + }, + [features, runningAutoTasks, autoMode, removeFeature, persistFeatureDelete] + ); + + const handleRunFeature = useCallback( + async (feature: Feature) => { + if (!currentProject) return; + + try { + const api = getElectronAPI(); + if (!api?.autoMode) { + console.error("Auto mode API not available"); + return; + } + + const result = await api.autoMode.runFeature( + currentProject.path, + feature.id, + useWorktrees + ); + + if (result.success) { + console.log("[Board] Feature run started successfully"); + } else { + console.error("[Board] Failed to run feature:", result.error); + await loadFeatures(); + } + } catch (error) { + console.error("[Board] Error running feature:", error); + await loadFeatures(); + } + }, + [currentProject, useWorktrees, loadFeatures] + ); + + const handleStartImplementation = useCallback( + 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; + }, + [autoMode, updateFeature, persistFeatureUpdate, handleRunFeature] + ); + + const handleVerifyFeature = useCallback( + async (feature: Feature) => { + if (!currentProject) return; + + try { + const api = getElectronAPI(); + if (!api?.autoMode) { + console.error("Auto mode API not available"); + return; + } + + const result = await api.autoMode.verifyFeature(currentProject.path, feature.id); + + if (result.success) { + console.log("[Board] Feature verification started successfully"); + } else { + console.error("[Board] Failed to verify feature:", result.error); + await loadFeatures(); + } + } catch (error) { + console.error("[Board] Error verifying feature:", error); + await loadFeatures(); + } + }, + [currentProject, loadFeatures] + ); + + const handleResumeFeature = useCallback( + async (feature: Feature) => { + if (!currentProject) return; + + try { + const api = getElectronAPI(); + if (!api?.autoMode) { + console.error("Auto mode API not available"); + return; + } + + const result = await api.autoMode.resumeFeature(currentProject.path, feature.id); + + if (result.success) { + console.log("[Board] Feature resume started successfully"); + } else { + console.error("[Board] Failed to resume feature:", result.error); + await loadFeatures(); + } + } catch (error) { + console.error("[Board] Error resuming feature:", error); + await loadFeatures(); + } + }, + [currentProject, loadFeatures] + ); + + const handleManualVerify = useCallback( + (feature: Feature) => { + moveFeature(feature.id, "verified"); + persistFeatureUpdate(feature.id, { + status: "verified", + justFinishedAt: undefined, + }); + toast.success("Feature verified", { + description: `Marked as verified: ${truncateDescription(feature.description)}`, + }); + }, + [moveFeature, persistFeatureUpdate] + ); + + const handleMoveBackToInProgress = useCallback( + (feature: Feature) => { + 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: ${truncateDescription(feature.description)}`, + }); + }, + [updateFeature, persistFeatureUpdate] + ); + + const handleOpenFollowUp = useCallback( + (feature: Feature) => { + setFollowUpFeature(feature); + setFollowUpPrompt(""); + setFollowUpImagePaths([]); + setShowFollowUpDialog(true); + }, + [setFollowUpFeature, setFollowUpPrompt, setFollowUpImagePaths, setShowFollowUpDialog] + ); + + const handleSendFollowUp = useCallback(async () => { + if (!currentProject || !followUpFeature || !followUpPrompt.trim()) return; + + const featureId = followUpFeature.id; + const featureDescription = followUpFeature.description; + const prompt = followUpPrompt; + + 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; + } + + const updates = { + status: "in_progress" as const, + startedAt: new Date().toISOString(), + justFinishedAt: undefined, + }; + updateFeature(featureId, updates); + persistFeatureUpdate(featureId, updates); + + setShowFollowUpDialog(false); + setFollowUpFeature(null); + setFollowUpPrompt(""); + setFollowUpImagePaths([]); + setFollowUpPreviewMap(new Map()); + + toast.success("Follow-up started", { + description: `Continuing work on: ${truncateDescription(featureDescription)}`, + }); + + const imagePaths = followUpImagePaths.map((img) => img.path); + api.autoMode + .followUpFeature(currentProject.path, followUpFeature.id, followUpPrompt, 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", + }); + loadFeatures(); + }); + }, [ + currentProject, + followUpFeature, + followUpPrompt, + followUpImagePaths, + updateFeature, + persistFeatureUpdate, + setShowFollowUpDialog, + setFollowUpFeature, + setFollowUpPrompt, + setFollowUpImagePaths, + setFollowUpPreviewMap, + loadFeatures, + ]); + + const handleCommitFeature = useCallback( + async (feature: Feature) => { + if (!currentProject) return; + + 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; + } + + const result = await api.autoMode.commitFeature(currentProject.path, feature.id); + + if (result.success) { + moveFeature(feature.id, "verified"); + persistFeatureUpdate(feature.id, { status: "verified" }); + toast.success("Feature committed", { + description: `Committed and verified: ${truncateDescription(feature.description)}`, + }); + } 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(); + } + }, + [currentProject, moveFeature, persistFeatureUpdate, loadFeatures] + ); + + const handleRevertFeature = useCallback( + async (feature: Feature) => { + if (!currentProject) return; + + 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) { + await loadFeatures(); + toast.success("Feature reverted", { + description: `All changes discarded. Moved back to backlog: ${truncateDescription(feature.description)}`, + }); + } 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", + }); + } + }, + [currentProject, loadFeatures] + ); + + const handleMergeFeature = useCallback( + async (feature: Feature) => { + if (!currentProject) return; + + 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) { + await loadFeatures(); + toast.success("Feature merged", { + description: `Changes merged to main branch: ${truncateDescription(feature.description)}`, + }); + } 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", + }); + } + }, + [currentProject, loadFeatures] + ); + + const handleCompleteFeature = useCallback( + (feature: Feature) => { + const updates = { + status: "completed" as const, + }; + updateFeature(feature.id, updates); + persistFeatureUpdate(feature.id, updates); + + toast.success("Feature completed", { + description: `Archived: ${truncateDescription(feature.description)}`, + }); + }, + [updateFeature, persistFeatureUpdate] + ); + + const handleUnarchiveFeature = useCallback( + (feature: Feature) => { + const updates = { + status: "verified" as const, + }; + updateFeature(feature.id, updates); + persistFeatureUpdate(feature.id, updates); + + toast.success("Feature restored", { + description: `Moved back to verified: ${truncateDescription(feature.description)}`, + }); + }, + [updateFeature, persistFeatureUpdate] + ); + + const handleViewOutput = useCallback( + (feature: Feature) => { + setOutputFeature(feature); + setShowOutputModal(true); + }, + [setOutputFeature, setShowOutputModal] + ); + + const handleOutputModalNumberKeyPress = useCallback( + (key: string) => { + const index = key === "0" ? 9 : parseInt(key, 10) - 1; + const targetFeature = inProgressFeaturesForShortcuts[index]; + + if (!targetFeature) { + return; + } + + if (targetFeature.id === outputFeature?.id) { + setShowOutputModal(false); + } else { + setOutputFeature(targetFeature); + } + }, + [inProgressFeaturesForShortcuts, outputFeature?.id, setShowOutputModal, setOutputFeature] + ); + + const handleForceStopFeature = useCallback( + async (feature: Feature) => { + try { + await autoMode.stopFeature(feature.id); + + 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: ${truncateDescription(feature.description)}` + : `Stopped working on: ${truncateDescription(feature.description)}`, + }); + } catch (error) { + console.error("[Board] Error stopping feature:", error); + toast.error("Failed to stop agent", { + description: error instanceof Error ? error.message : "An error occurred", + }); + } + }, + [autoMode, moveFeature, persistFeatureUpdate] + ); + + const handleStartNextFeatures = useCallback(async () => { + const backlogFeatures = features.filter((f) => f.status === "backlog"); + const availableSlots = + useAppStore.getState().maxConcurrency - runningAutoTasks.length; + + if (availableSlots <= 0) { + toast.error("Concurrency limit reached", { + description: + "Wait for a task to complete or increase the concurrency limit.", + }); + return; + } + + const featuresToStart = backlogFeatures.slice(0, availableSlots); + + for (const feature of featuresToStart) { + await handleStartImplementation(feature); + } + }, [features, runningAutoTasks, handleStartImplementation]); + + const handleDeleteAllVerified = useCallback(async () => { + const verifiedFeatures = features.filter((f) => f.status === "verified"); + + for (const feature of verifiedFeatures) { + const isRunning = runningAutoTasks.includes(feature.id); + if (isRunning) { + try { + await autoMode.stopFeature(feature.id); + } catch (error) { + console.error( + "[Board] Error stopping feature before delete:", + error + ); + } + } + removeFeature(feature.id); + persistFeatureDelete(feature.id); + } + + toast.success("All verified features deleted", { + description: `Deleted ${verifiedFeatures.length} feature(s).`, + }); + }, [features, runningAutoTasks, autoMode, removeFeature, persistFeatureDelete]); + + return { + handleAddFeature, + handleUpdateFeature, + handleDeleteFeature, + handleStartImplementation, + handleVerifyFeature, + handleResumeFeature, + handleManualVerify, + handleMoveBackToInProgress, + handleOpenFollowUp, + handleSendFollowUp, + handleCommitFeature, + handleRevertFeature, + handleMergeFeature, + handleCompleteFeature, + handleUnarchiveFeature, + handleViewOutput, + handleOutputModalNumberKeyPress, + handleForceStopFeature, + handleStartNextFeatures, + handleDeleteAllVerified, + }; +} diff --git a/apps/app/src/components/views/board-view/hooks/use-board-background.ts b/apps/app/src/components/views/board-view/hooks/use-board-background.ts new file mode 100644 index 00000000..9bbac72e --- /dev/null +++ b/apps/app/src/components/views/board-view/hooks/use-board-background.ts @@ -0,0 +1,47 @@ +import { useMemo } from "react"; +import { useAppStore, defaultBackgroundSettings } from "@/store/app-store"; + +interface UseBoardBackgroundProps { + currentProject: { path: string; id: string } | null; +} + +export function useBoardBackground({ currentProject }: UseBoardBackgroundProps) { + const boardBackgroundByProject = useAppStore( + (state) => state.boardBackgroundByProject + ); + + // Get background settings for current project + const backgroundSettings = useMemo(() => { + return ( + (currentProject && boardBackgroundByProject[currentProject.path]) || + defaultBackgroundSettings + ); + }, [currentProject, boardBackgroundByProject]); + + // Build background image style if image exists + const backgroundImageStyle = useMemo(() => { + if (!backgroundSettings.imagePath || !currentProject) { + return {}; + } + + return { + 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", + } as React.CSSProperties; + }, [backgroundSettings, currentProject]); + + return { + backgroundSettings, + backgroundImageStyle, + }; +} diff --git a/apps/app/src/components/views/board-view/hooks/use-board-column-features.ts b/apps/app/src/components/views/board-view/hooks/use-board-column-features.ts new file mode 100644 index 00000000..c3944b5d --- /dev/null +++ b/apps/app/src/components/views/board-view/hooks/use-board-column-features.ts @@ -0,0 +1,81 @@ +import { useMemo, useCallback } from "react"; +import { Feature } from "@/store/app-store"; + +type ColumnId = Feature["status"]; + +interface UseBoardColumnFeaturesProps { + features: Feature[]; + runningAutoTasks: string[]; + searchQuery: string; +} + +export function useBoardColumnFeatures({ + features, + runningAutoTasks, + searchQuery, +}: UseBoardColumnFeaturesProps) { + // 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] + ); + + // Memoize completed features for the archive modal + const completedFeatures = useMemo(() => { + return features.filter((f) => f.status === "completed"); + }, [features]); + + return { + columnFeaturesMap, + getColumnFeatures, + completedFeatures, + }; +} diff --git a/apps/app/src/components/views/board-view/hooks/use-board-drag-drop.ts b/apps/app/src/components/views/board-view/hooks/use-board-drag-drop.ts new file mode 100644 index 00000000..c39ed6d1 --- /dev/null +++ b/apps/app/src/components/views/board-view/hooks/use-board-drag-drop.ts @@ -0,0 +1,216 @@ +import { useState, useCallback } from "react"; +import { DragStartEvent, DragEndEvent } from "@dnd-kit/core"; +import { Feature } from "@/store/app-store"; +import { useAppStore } from "@/store/app-store"; +import { toast } from "sonner"; +import { COLUMNS, ColumnId } from "../constants"; + +interface UseBoardDragDropProps { + features: Feature[]; + currentProject: { path: string; id: string } | null; + runningAutoTasks: string[]; + persistFeatureUpdate: ( + featureId: string, + updates: Partial + ) => Promise; + handleStartImplementation: (feature: Feature) => Promise; +} + +export function useBoardDragDrop({ + features, + currentProject, + runningAutoTasks, + persistFeatureUpdate, + handleStartImplementation, +}: UseBoardDragDropProps) { + const [activeFeature, setActiveFeature] = useState(null); + const { moveFeature } = useAppStore(); + + const handleDragStart = useCallback( + (event: DragStartEvent) => { + const { active } = event; + const feature = features.find((f) => f.id === active.id); + if (feature) { + setActiveFeature(feature); + } + }, + [features] + ); + + const handleDragEnd = useCallback( + 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 ? "..." : ""}`, + }); + } + } + }, + [ + features, + runningAutoTasks, + moveFeature, + persistFeatureUpdate, + handleStartImplementation, + ] + ); + + return { + activeFeature, + handleDragStart, + handleDragEnd, + }; +} diff --git a/apps/app/src/components/views/board-view/hooks/use-board-effects.ts b/apps/app/src/components/views/board-view/hooks/use-board-effects.ts new file mode 100644 index 00000000..a2784a8d --- /dev/null +++ b/apps/app/src/components/views/board-view/hooks/use-board-effects.ts @@ -0,0 +1,166 @@ +import { useEffect, useRef } from "react"; +import { getElectronAPI } from "@/lib/electron"; +import { useAppStore } from "@/store/app-store"; +import { useAutoMode } from "@/hooks/use-auto-mode"; + +interface UseBoardEffectsProps { + currentProject: { path: string; id: string } | null; + specCreatingForProject: string | null; + setSpecCreatingForProject: (path: string | null) => void; + setSuggestionsCount: (count: number) => void; + setFeatureSuggestions: (suggestions: any[]) => void; + setIsGeneratingSuggestions: (generating: boolean) => void; + checkContextExists: (featureId: string) => Promise; + features: any[]; + isLoading: boolean; + setFeaturesWithContext: (set: Set) => void; +} + +export function useBoardEffects({ + currentProject, + specCreatingForProject, + setSpecCreatingForProject, + setSuggestionsCount, + setFeatureSuggestions, + setIsGeneratingSuggestions, + checkContextExists, + features, + isLoading, + setFeaturesWithContext, +}: UseBoardEffectsProps) { + const autoMode = useAutoMode(); + + // 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(); + }; + }, [setSuggestionsCount, setFeatureSuggestions, setIsGeneratingSuggestions]); + + // 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 + ); + + 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]); + + // 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(); + + if (status.runningFeatures) { + console.log( + "[Board] Syncing running tasks from backend:", + status.runningFeatures + ); + + clearRunningTasks(projectId); + + status.runningFeatures.forEach((featureId: string) => { + addRunningTask(projectId, featureId); + }); + } + + 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 () => { + 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, checkContextExists, setFeaturesWithContext]); +} diff --git a/apps/app/src/components/views/board-view/hooks/use-board-features.ts b/apps/app/src/components/views/board-view/hooks/use-board-features.ts new file mode 100644 index 00000000..f2338cc8 --- /dev/null +++ b/apps/app/src/components/views/board-view/hooks/use-board-features.ts @@ -0,0 +1,268 @@ +import { useState, useCallback, useEffect, useRef } from "react"; +import { useAppStore, Feature } from "@/store/app-store"; +import { getElectronAPI } from "@/lib/electron"; +import { toast } from "sonner"; + +interface UseBoardFeaturesProps { + currentProject: { path: string; id: string } | null; +} + +export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) { + const { features, setFeatures } = useAppStore(); + const [isLoading, setIsLoading] = useState(true); + const [persistedCategories, setPersistedCategories] = useState([]); + + // Track previous project path to detect project switches + const prevProjectPathRef = useRef(null); + const isInitialLoadRef = useRef(true); + const isSwitchingProjectRef = useRef(false); + + // 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]); + + // 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] + ); + + // 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]); + + // 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]); + + return { + features, + isLoading, + persistedCategories, + loadFeatures, + loadCategories, + saveCategory, + }; +} diff --git a/apps/app/src/components/views/board-view/hooks/use-board-keyboard-shortcuts.ts b/apps/app/src/components/views/board-view/hooks/use-board-keyboard-shortcuts.ts new file mode 100644 index 00000000..72bb622b --- /dev/null +++ b/apps/app/src/components/views/board-view/hooks/use-board-keyboard-shortcuts.ts @@ -0,0 +1,78 @@ +import { useMemo, useRef, useEffect } from "react"; +import { + useKeyboardShortcuts, + useKeyboardShortcutsConfig, + KeyboardShortcut, +} from "@/hooks/use-keyboard-shortcuts"; +import { Feature } from "@/store/app-store"; + +interface UseBoardKeyboardShortcutsProps { + features: Feature[]; + runningAutoTasks: string[]; + onAddFeature: () => void; + onStartNextFeatures: () => void; + onViewOutput: (feature: Feature) => void; +} + +export function useBoardKeyboardShortcuts({ + features, + runningAutoTasks, + onAddFeature, + onStartNextFeatures, + onViewOutput, +}: UseBoardKeyboardShortcutsProps) { + const shortcuts = useKeyboardShortcutsConfig(); + + // 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>(() => {}); + + // Update ref when callback changes + useEffect(() => { + startNextFeaturesRef.current = onStartNextFeatures; + }, [onStartNextFeatures]); + + // Keyboard shortcuts for this view + const boardShortcuts: KeyboardShortcut[] = useMemo(() => { + const shortcutsList: KeyboardShortcut[] = [ + { + key: shortcuts.addFeature, + action: onAddFeature, + description: "Add new feature", + }, + { + key: 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); + shortcutsList.push({ + key, + action: () => { + onViewOutput(feature); + }, + description: `View output for in-progress card ${index + 1}`, + }); + }); + + return shortcutsList; + }, [inProgressFeaturesForShortcuts, shortcuts, onAddFeature, onViewOutput]); + + useKeyboardShortcuts(boardShortcuts); + + return { + inProgressFeaturesForShortcuts, + }; +} diff --git a/apps/app/src/components/views/board-view/hooks/use-board-persistence.ts b/apps/app/src/components/views/board-view/hooks/use-board-persistence.ts new file mode 100644 index 00000000..9ae14ab6 --- /dev/null +++ b/apps/app/src/components/views/board-view/hooks/use-board-persistence.ts @@ -0,0 +1,90 @@ +import { useCallback } from "react"; +import { Feature } from "@/store/app-store"; +import { getElectronAPI } from "@/lib/electron"; +import { useAppStore } from "@/store/app-store"; + +interface UseBoardPersistenceProps { + currentProject: { path: string; id: string } | null; +} + +export function useBoardPersistence({ + currentProject, +}: UseBoardPersistenceProps) { + const { updateFeature } = useAppStore(); + + // 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] + ); + + return { + persistFeatureCreate, + persistFeatureUpdate, + persistFeatureDelete, + }; +} diff --git a/apps/app/src/components/views/board-view/hooks/use-follow-up-state.ts b/apps/app/src/components/views/board-view/hooks/use-follow-up-state.ts new file mode 100644 index 00000000..767c4d6c --- /dev/null +++ b/apps/app/src/components/views/board-view/hooks/use-follow-up-state.ts @@ -0,0 +1,48 @@ +import { useState, useCallback } from "react"; +import { Feature } from "@/store/app-store"; +import { + FeatureImagePath as DescriptionImagePath, + ImagePreviewMap, +} from "@/components/ui/description-image-dropzone"; + +export function useFollowUpState() { + const [showFollowUpDialog, setShowFollowUpDialog] = useState(false); + const [followUpFeature, setFollowUpFeature] = useState(null); + const [followUpPrompt, setFollowUpPrompt] = useState(""); + const [followUpImagePaths, setFollowUpImagePaths] = useState([]); + const [followUpPreviewMap, setFollowUpPreviewMap] = useState(() => new Map()); + + const resetFollowUpState = useCallback(() => { + setShowFollowUpDialog(false); + setFollowUpFeature(null); + setFollowUpPrompt(""); + setFollowUpImagePaths([]); + setFollowUpPreviewMap(new Map()); + }, []); + + const handleFollowUpDialogChange = useCallback((open: boolean) => { + if (!open) { + resetFollowUpState(); + } else { + setShowFollowUpDialog(open); + } + }, [resetFollowUpState]); + + return { + // State + showFollowUpDialog, + followUpFeature, + followUpPrompt, + followUpImagePaths, + followUpPreviewMap, + // Setters + setShowFollowUpDialog, + setFollowUpFeature, + setFollowUpPrompt, + setFollowUpImagePaths, + setFollowUpPreviewMap, + // Helpers + resetFollowUpState, + handleFollowUpDialogChange, + }; +} diff --git a/apps/app/src/components/views/board-view/hooks/use-suggestions-state.ts b/apps/app/src/components/views/board-view/hooks/use-suggestions-state.ts new file mode 100644 index 00000000..7727a17b --- /dev/null +++ b/apps/app/src/components/views/board-view/hooks/use-suggestions-state.ts @@ -0,0 +1,34 @@ +import { useState, useCallback } from "react"; +import type { FeatureSuggestion } from "@/lib/electron"; + +export function useSuggestionsState() { + const [showSuggestionsDialog, setShowSuggestionsDialog] = useState(false); + const [suggestionsCount, setSuggestionsCount] = useState(0); + const [featureSuggestions, setFeatureSuggestions] = useState([]); + const [isGeneratingSuggestions, setIsGeneratingSuggestions] = useState(false); + + const updateSuggestions = useCallback((suggestions: FeatureSuggestion[]) => { + setFeatureSuggestions(suggestions); + setSuggestionsCount(suggestions.length); + }, []); + + const closeSuggestionsDialog = useCallback(() => { + setShowSuggestionsDialog(false); + }, []); + + return { + // State + showSuggestionsDialog, + suggestionsCount, + featureSuggestions, + isGeneratingSuggestions, + // Setters + setShowSuggestionsDialog, + setSuggestionsCount, + setFeatureSuggestions, + setIsGeneratingSuggestions, + // Helpers + updateSuggestions, + closeSuggestionsDialog, + }; +} diff --git a/apps/app/src/components/views/board-view/kanban-board.tsx b/apps/app/src/components/views/board-view/kanban-board.tsx new file mode 100644 index 00000000..eb8a318e --- /dev/null +++ b/apps/app/src/components/views/board-view/kanban-board.tsx @@ -0,0 +1,244 @@ +"use client"; + +import { + DndContext, + DragOverlay, +} from "@dnd-kit/core"; +import { + SortableContext, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { Card, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { HotkeyButton } from "@/components/ui/hotkey-button"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { KanbanColumn, KanbanCard } from "./components"; +import { Feature } from "@/store/app-store"; +import { FastForward, Lightbulb, Trash2 } from "lucide-react"; +import { useKeyboardShortcutsConfig } from "@/hooks/use-keyboard-shortcuts"; +import { COLUMNS, ColumnId } from "./constants"; + +interface KanbanBoardProps { + sensors: any; + collisionDetectionStrategy: (args: any) => any; + onDragStart: (event: any) => void; + onDragEnd: (event: any) => void; + activeFeature: Feature | null; + getColumnFeatures: (columnId: ColumnId) => Feature[]; + backgroundImageStyle: React.CSSProperties; + backgroundSettings: { + columnOpacity: number; + columnBorderEnabled: boolean; + hideScrollbar: boolean; + cardOpacity: number; + cardGlassmorphism: boolean; + cardBorderEnabled: boolean; + cardBorderOpacity: number; + }; + onEdit: (feature: Feature) => void; + onDelete: (featureId: string) => void; + onViewOutput: (feature: Feature) => void; + onVerify: (feature: Feature) => void; + onResume: (feature: Feature) => void; + onForceStop: (feature: Feature) => void; + onManualVerify: (feature: Feature) => void; + onMoveBackToInProgress: (feature: Feature) => void; + onFollowUp: (feature: Feature) => void; + onCommit: (feature: Feature) => void; + onRevert: (feature: Feature) => void; + onMerge: (feature: Feature) => void; + onComplete: (feature: Feature) => void; + onImplement: (feature: Feature) => void; + featuresWithContext: Set; + runningAutoTasks: string[]; + shortcuts: ReturnType; + onStartNextFeatures: () => void; + onShowSuggestions: () => void; + suggestionsCount: number; + onDeleteAllVerified: () => void; +} + +export function KanbanBoard({ + sensors, + collisionDetectionStrategy, + onDragStart, + onDragEnd, + activeFeature, + getColumnFeatures, + backgroundImageStyle, + backgroundSettings, + onEdit, + onDelete, + onViewOutput, + onVerify, + onResume, + onForceStop, + onManualVerify, + onMoveBackToInProgress, + onFollowUp, + onCommit, + onRevert, + onMerge, + onComplete, + onImplement, + featuresWithContext, + runningAutoTasks, + shortcuts, + onStartNextFeatures, + onShowSuggestions, + suggestionsCount, + onDeleteAllVerified, +}: KanbanBoardProps) { + 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 ( + onEdit(feature)} + onDelete={() => onDelete(feature.id)} + onViewOutput={() => onViewOutput(feature)} + onVerify={() => onVerify(feature)} + onResume={() => onResume(feature)} + onForceStop={() => onForceStop(feature)} + onManualVerify={() => onManualVerify(feature)} + onMoveBackToInProgress={() => + onMoveBackToInProgress(feature) + } + onFollowUp={() => onFollowUp(feature)} + onCommit={() => onCommit(feature)} + onRevert={() => onRevert(feature)} + onMerge={() => onMerge(feature)} + onComplete={() => onComplete(feature)} + onImplement={() => onImplement(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} + + + + )} + +
+
+ ); +} diff --git a/apps/app/src/components/views/board-view/shared/index.ts b/apps/app/src/components/views/board-view/shared/index.ts new file mode 100644 index 00000000..8ff3d394 --- /dev/null +++ b/apps/app/src/components/views/board-view/shared/index.ts @@ -0,0 +1,5 @@ +export * from "./model-constants"; +export * from "./model-selector"; +export * from "./thinking-level-selector"; +export * from "./profile-quick-select"; +export * from "./testing-tab-content"; diff --git a/apps/app/src/components/views/board-view/shared/model-constants.ts b/apps/app/src/components/views/board-view/shared/model-constants.ts new file mode 100644 index 00000000..d578d834 --- /dev/null +++ b/apps/app/src/components/views/board-view/shared/model-constants.ts @@ -0,0 +1,70 @@ +import { AgentModel, ThinkingLevel } from "@/store/app-store"; +import { + Brain, + Zap, + Scale, + Cpu, + Rocket, + Sparkles, +} from "lucide-react"; + +export type ModelOption = { + id: AgentModel; + label: string; + description: string; + badge?: string; + provider: "claude"; +}; + +export 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", + }, +]; + +export const THINKING_LEVELS: ThinkingLevel[] = [ + "none", + "low", + "medium", + "high", + "ultrathink", +]; + +export const THINKING_LEVEL_LABELS: Record = { + none: "None", + low: "Low", + medium: "Med", + high: "High", + ultrathink: "Ultra", +}; + +// Profile icon mapping +export const PROFILE_ICONS: Record< + string, + React.ComponentType<{ className?: string }> +> = { + Brain, + Zap, + Scale, + Cpu, + Rocket, + Sparkles, +}; diff --git a/apps/app/src/components/views/board-view/shared/model-selector.tsx b/apps/app/src/components/views/board-view/shared/model-selector.tsx new file mode 100644 index 00000000..d74845fa --- /dev/null +++ b/apps/app/src/components/views/board-view/shared/model-selector.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { Label } from "@/components/ui/label"; +import { Brain } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { AgentModel } from "@/store/app-store"; +import { CLAUDE_MODELS, ModelOption } from "./model-constants"; + +interface ModelSelectorProps { + selectedModel: AgentModel; + onModelSelect: (model: AgentModel) => void; + testIdPrefix?: string; +} + +export function ModelSelector({ + selectedModel, + onModelSelect, + testIdPrefix = "model-select", +}: ModelSelectorProps) { + return ( +
+
+ + + Native + +
+
+ {CLAUDE_MODELS.map((option) => { + const isSelected = selectedModel === option.id; + const shortName = option.label.replace("Claude ", ""); + return ( + + ); + })} +
+
+ ); +} diff --git a/apps/app/src/components/views/board-view/shared/profile-quick-select.tsx b/apps/app/src/components/views/board-view/shared/profile-quick-select.tsx new file mode 100644 index 00000000..9937800d --- /dev/null +++ b/apps/app/src/components/views/board-view/shared/profile-quick-select.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { Label } from "@/components/ui/label"; +import { Brain, UserCircle } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { AgentModel, ThinkingLevel, AIProfile } from "@/store/app-store"; +import { PROFILE_ICONS } from "./model-constants"; + +interface ProfileQuickSelectProps { + profiles: AIProfile[]; + selectedModel: AgentModel; + selectedThinkingLevel: ThinkingLevel; + onSelect: (model: AgentModel, thinkingLevel: ThinkingLevel) => void; + testIdPrefix?: string; + showManageLink?: boolean; + onManageLinkClick?: () => void; +} + +export function ProfileQuickSelect({ + profiles, + selectedModel, + selectedThinkingLevel, + onSelect, + testIdPrefix = "profile-quick-select", + showManageLink = false, + onManageLinkClick, +}: ProfileQuickSelectProps) { + if (profiles.length === 0) { + return null; + } + + return ( +
+
+ + + Presets + +
+
+ {profiles.slice(0, 6).map((profile) => { + const IconComponent = profile.icon + ? PROFILE_ICONS[profile.icon] + : Brain; + const isSelected = + selectedModel === profile.model && + selectedThinkingLevel === profile.thinkingLevel; + return ( + + ); + })} +
+

+ Or customize below. + {showManageLink && onManageLinkClick && ( + <> + {" "} + Manage profiles in{" "} + + + )} +

+
+ ); +} diff --git a/apps/app/src/components/views/board-view/shared/testing-tab-content.tsx b/apps/app/src/components/views/board-view/shared/testing-tab-content.tsx new file mode 100644 index 00000000..1d86569e --- /dev/null +++ b/apps/app/src/components/views/board-view/shared/testing-tab-content.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Checkbox } from "@/components/ui/checkbox"; +import { FlaskConical, Plus } from "lucide-react"; + +interface TestingTabContentProps { + skipTests: boolean; + onSkipTestsChange: (skipTests: boolean) => void; + steps: string[]; + onStepsChange: (steps: string[]) => void; + testIdPrefix?: string; +} + +export function TestingTabContent({ + skipTests, + onSkipTestsChange, + steps, + onStepsChange, + testIdPrefix = "", +}: TestingTabContentProps) { + const checkboxId = testIdPrefix ? `${testIdPrefix}-skip-tests` : "skip-tests"; + + const handleStepChange = (index: number, value: string) => { + const newSteps = [...steps]; + newSteps[index] = value; + onStepsChange(newSteps); + }; + + const handleAddStep = () => { + onStepsChange([...steps, ""]); + }; + + return ( +
+
+ onSkipTestsChange(checked !== true)} + data-testid={`${testIdPrefix ? testIdPrefix + "-" : ""}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 */} + {skipTests && ( +
+ +

+ Add manual steps to verify this feature works correctly. +

+ {steps.map((step, index) => ( + handleStepChange(index, e.target.value)} + data-testid={`${testIdPrefix ? testIdPrefix + "-" : ""}feature-step-${index}${testIdPrefix ? "" : "-input"}`} + /> + ))} + +
+ )} +
+ ); +} diff --git a/apps/app/src/components/views/board-view/shared/thinking-level-selector.tsx b/apps/app/src/components/views/board-view/shared/thinking-level-selector.tsx new file mode 100644 index 00000000..dde4d9c6 --- /dev/null +++ b/apps/app/src/components/views/board-view/shared/thinking-level-selector.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { Label } from "@/components/ui/label"; +import { Brain } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { ThinkingLevel } from "@/store/app-store"; +import { THINKING_LEVELS, THINKING_LEVEL_LABELS } from "./model-constants"; + +interface ThinkingLevelSelectorProps { + selectedLevel: ThinkingLevel; + onLevelSelect: (level: ThinkingLevel) => void; + testIdPrefix?: string; +} + +export function ThinkingLevelSelector({ + selectedLevel, + onLevelSelect, + testIdPrefix = "thinking-level", +}: ThinkingLevelSelectorProps) { + return ( +
+ +
+ {THINKING_LEVELS.map((level) => ( + + ))} +
+

+ Higher levels give more time to reason through complex problems. +

+
+ ); +} diff --git a/apps/app/src/components/views/profiles-view/constants.ts b/apps/app/src/components/views/profiles-view/constants.ts index 84957d51..5f08fc00 100644 --- a/apps/app/src/components/views/profiles-view/constants.ts +++ b/apps/app/src/components/views/profiles-view/constants.ts @@ -46,3 +46,4 @@ export const THINKING_LEVELS: { id: ThinkingLevel; label: string }[] = [ { id: "ultrathink", label: "Ultrathink" }, ]; + diff --git a/apps/app/src/components/views/profiles-view/utils.ts b/apps/app/src/components/views/profiles-view/utils.ts index a760b3e0..c41313c5 100644 --- a/apps/app/src/components/views/profiles-view/utils.ts +++ b/apps/app/src/components/views/profiles-view/utils.ts @@ -5,3 +5,4 @@ export function getProviderFromModel(model: AgentModel): ModelProvider { return "claude"; } + diff --git a/apps/app/src/lib/utils.ts b/apps/app/src/lib/utils.ts index b4081da9..d5580da5 100644 --- a/apps/app/src/lib/utils.ts +++ b/apps/app/src/lib/utils.ts @@ -25,3 +25,13 @@ export function getModelDisplayName(model: AgentModel | string): string { }; return displayNames[model] || model; } + +/** + * Truncate a description string with ellipsis + */ +export function truncateDescription(description: string, maxLength = 50): string { + if (description.length <= maxLength) { + return description; + } + return `${description.slice(0, maxLength)}...`; +} diff --git a/docs/folder-pattern.md b/docs/folder-pattern.md new file mode 100644 index 00000000..e0e595b9 --- /dev/null +++ b/docs/folder-pattern.md @@ -0,0 +1,163 @@ +# Folder & Naming Pattern Guide + +This document defines the folder structure and naming conventions used in this codebase. + +## File Naming Convention + +**All files use kebab-case** (lowercase with hyphens): + +``` +✅ add-feature-dialog.tsx +✅ use-board-actions.ts +✅ board-view.tsx + +❌ AddFeatureDialog.tsx +❌ useBoardActions.ts +❌ BoardView.tsx +``` + +## Export Naming Convention + +While files use kebab-case, **exports use PascalCase for components and camelCase for hooks/functions**: + +```tsx +// File: add-feature-dialog.tsx +export function AddFeatureDialog() { ... } + +// File: use-board-actions.ts +export function useBoardActions() { ... } +``` + +## View Folder Structure + +Each complex view should have its own folder with the following structure: + +``` +components/views/ +├── [view-name].tsx # Entry point (exports the main view) +└── [view-name]/ # Subfolder for complex views + ├── components/ # View-specific reusable components + │ ├── index.ts # Barrel export + │ └── [component].tsx # Individual components + ├── dialogs/ # View-specific dialogs and modals + │ ├── index.ts # Barrel export + │ └── [dialog-name].tsx # Individual dialogs/modals + ├── hooks/ # View-specific hooks + │ ├── index.ts # Barrel export + │ └── use-[name].ts # Individual hooks + ├── shared/ # Shared utilities between components + │ ├── index.ts # Barrel export + │ └── [name].ts # Shared code + ├── constants.ts # View constants + ├── types.ts # View-specific types (optional) + ├── utils.ts # View utilities (optional) + └── [main-component].tsx # Main view components (e.g., kanban-board.tsx) +``` + +## Example: board-view + +``` +components/views/ +├── board-view.tsx # Entry point +└── board-view/ + ├── components/ + │ ├── index.ts + │ ├── kanban-card.tsx + │ └── kanban-column.tsx + ├── dialogs/ + │ ├── index.ts + │ ├── add-feature-dialog.tsx + │ ├── edit-feature-dialog.tsx + │ ├── follow-up-dialog.tsx + │ ├── delete-all-verified-dialog.tsx + │ ├── delete-completed-feature-dialog.tsx + │ ├── completed-features-modal.tsx + │ ├── agent-output-modal.tsx + │ └── feature-suggestions-dialog.tsx + ├── hooks/ + │ ├── index.ts + │ ├── use-board-actions.ts + │ ├── use-board-features.ts + │ └── use-board-drag-drop.ts + ├── shared/ + │ ├── index.ts + │ ├── model-constants.ts + │ └── model-selector.tsx + ├── constants.ts + └── kanban-board.tsx +``` + +## Global vs View-Specific Code + +### Global (`src/hooks/`, `src/lib/`, etc.) +Code that is used across **multiple views**: +- `src/hooks/use-auto-mode.ts` - Used by board-view, agent-view, etc. +- `src/hooks/use-keyboard-shortcuts.ts` - Used across the app +- `src/lib/utils.ts` - Global utilities + +### View-Specific (`[view-name]/hooks/`, `[view-name]/components/`) +Code that is **only used within a single view**: +- `board-view/hooks/use-board-actions.ts` - Only used by board-view +- `board-view/components/kanban-card.tsx` - Only used by board-view + +## Barrel Exports + +Use `index.ts` files to create clean import paths: + +```tsx +// board-view/hooks/index.ts +export { useBoardActions } from "./use-board-actions"; +export { useBoardFeatures } from "./use-board-features"; + +// Usage in board-view.tsx +import { useBoardActions, useBoardFeatures } from "./board-view/hooks"; +``` + +## When to Create a Subfolder + +Create a subfolder for a view when: +1. The view file exceeds ~500 lines +2. The view has 3+ related components +3. The view has 2+ custom hooks +4. Multiple dialogs/modals are specific to the view + +## Dialogs Folder + +The `dialogs/` folder contains all dialog and modal components specific to a view: + +### What goes in `dialogs/`: +- Confirmation dialogs (e.g., `delete-all-verified-dialog.tsx`) +- Form dialogs (e.g., `add-feature-dialog.tsx`, `edit-feature-dialog.tsx`) +- Modal overlays (e.g., `agent-output-modal.tsx`, `completed-features-modal.tsx`) +- Any component that renders as an overlay/popup + +### Naming convention: +- Use `-dialog.tsx` suffix for confirmation/form dialogs +- Use `-modal.tsx` suffix for content-heavy modals + +### Barrel export pattern: +```tsx +// dialogs/index.ts +export { AddFeatureDialog } from "./add-feature-dialog"; +export { EditFeatureDialog } from "./edit-feature-dialog"; +export { AgentOutputModal } from "./agent-output-modal"; +// ... etc + +// Usage in view entry point +import { + AddFeatureDialog, + EditFeatureDialog, + AgentOutputModal, +} from "./board-view/dialogs"; +``` + +## Quick Reference + +| Location | File Naming | Export Naming | +|----------|-------------|---------------| +| Components | `kebab-case.tsx` | `PascalCase` | +| Dialogs | `*-dialog.tsx` or `*-modal.tsx` | `PascalCase` | +| Hooks | `use-kebab-case.ts` | `camelCase` | +| Utils/Lib | `kebab-case.ts` | `camelCase` | +| Types | `kebab-case.ts` | `PascalCase` | +| Constants | `constants.ts` | `SCREAMING_SNAKE_CASE` |