diff --git a/api/database.py b/api/database.py index f3a0cce..90dc49a 100644 --- a/api/database.py +++ b/api/database.py @@ -336,12 +336,20 @@ def create_database(project_dir: Path) -> tuple: """ Create database and return engine + session maker. + Uses a cache to avoid creating new engines for each request, which improves + performance by reusing database connections. + Args: project_dir: Directory containing the project Returns: Tuple of (engine, SessionLocal) """ + cache_key = project_dir.as_posix() + + if cache_key in _engine_cache: + return _engine_cache[cache_key] + db_url = get_database_url(project_dir) engine = create_engine(db_url, connect_args={ "check_same_thread": False, @@ -369,12 +377,39 @@ def create_database(project_dir: Path) -> tuple: _migrate_add_schedules_tables(engine) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + # Cache the engine and session maker + _engine_cache[cache_key] = (engine, SessionLocal) + return engine, SessionLocal +def dispose_engine(project_dir: Path) -> bool: + """Dispose of and remove the cached engine for a project. + + This closes all database connections, releasing file locks on Windows. + Should be called before deleting the database file. + + Returns: + True if an engine was disposed, False if no engine was cached. + """ + cache_key = project_dir.as_posix() + + if cache_key in _engine_cache: + engine, _ = _engine_cache.pop(cache_key) + engine.dispose() + return True + + return False + + # Global session maker - will be set when server starts _session_maker: Optional[sessionmaker] = None +# Engine cache to avoid creating new engines for each request +# Key: project directory path (as posix string), Value: (engine, SessionLocal) +_engine_cache: dict[str, tuple] = {} + def set_session_maker(session_maker: sessionmaker) -> None: """Set the global session maker.""" diff --git a/server/routers/projects.py b/server/routers/projects.py index 70e27cc..0f76ff9 100644 --- a/server/routers/projects.py +++ b/server/routers/projects.py @@ -373,6 +373,87 @@ async def get_project_stats_endpoint(name: str): return get_project_stats(project_dir) +@router.post("/{name}/reset") +async def reset_project(name: str, full_reset: bool = False): + """ + Reset a project to its initial state. + + Args: + name: Project name to reset + full_reset: If True, also delete prompts/ directory (triggers setup wizard) + + Returns: + Dictionary with list of deleted files and reset type + """ + _init_imports() + (_, _, get_project_path, _, _, _, _) = _get_registry_functions() + + name = validate_project_name(name) + project_dir = get_project_path(name) + + if not project_dir: + raise HTTPException(status_code=404, detail=f"Project '{name}' not found") + + if not project_dir.exists(): + raise HTTPException(status_code=404, detail="Project directory not found") + + # Check if agent is running + lock_file = project_dir / ".agent.lock" + if lock_file.exists(): + raise HTTPException( + status_code=409, + detail="Cannot reset project while agent is running. Stop the agent first." + ) + + # Dispose of database engines to release file locks (required on Windows) + # Import here to avoid circular imports + from api.database import dispose_engine as dispose_features_engine + from server.services.assistant_database import dispose_engine as dispose_assistant_engine + + dispose_features_engine(project_dir) + dispose_assistant_engine(project_dir) + + deleted_files: list[str] = [] + + # Files to delete in quick reset + quick_reset_files = [ + "features.db", + "features.db-wal", # WAL mode journal file + "features.db-shm", # WAL mode shared memory file + "assistant.db", + "assistant.db-wal", + "assistant.db-shm", + ".claude_settings.json", + ".claude_assistant_settings.json", + ] + + for filename in quick_reset_files: + file_path = project_dir / filename + if file_path.exists(): + try: + file_path.unlink() + deleted_files.append(filename) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to delete {filename}: {e}") + + # Full reset: also delete prompts directory + if full_reset: + prompts_dir = project_dir / "prompts" + if prompts_dir.exists(): + try: + shutil.rmtree(prompts_dir) + deleted_files.append("prompts/") + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to delete prompts/: {e}") + + return { + "success": True, + "reset_type": "full" if full_reset else "quick", + "deleted_files": deleted_files, + "message": f"Project '{name}' has been reset" + (" (full reset)" if full_reset else " (quick reset)") + } + + @router.patch("/{name}/settings", response_model=ProjectDetail) async def update_project_settings(name: str, settings: ProjectSettingsUpdate): """Update project-level settings (concurrency, etc.).""" diff --git a/server/services/assistant_database.py b/server/services/assistant_database.py index 1545310..f2ade75 100644 --- a/server/services/assistant_database.py +++ b/server/services/assistant_database.py @@ -79,6 +79,26 @@ def get_engine(project_dir: Path): return _engine_cache[cache_key] +def dispose_engine(project_dir: Path) -> bool: + """Dispose of and remove the cached engine for a project. + + This closes all database connections, releasing file locks on Windows. + Should be called before deleting the database file. + + Returns: + True if an engine was disposed, False if no engine was cached. + """ + cache_key = project_dir.as_posix() + + if cache_key in _engine_cache: + engine = _engine_cache.pop(cache_key) + engine.dispose() + logger.debug(f"Disposed database engine for {cache_key}") + return True + + return False + + def get_session(project_dir: Path): """Get a new database session for a project.""" engine = get_engine(project_dir) diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 476539c..6c8fa00 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -26,8 +26,10 @@ import { ViewToggle, type ViewMode } from './components/ViewToggle' import { DependencyGraph } from './components/DependencyGraph' import { KeyboardShortcutsHelp } from './components/KeyboardShortcutsHelp' import { ThemeSelector } from './components/ThemeSelector' +import { ResetProjectModal } from './components/ResetProjectModal' +import { ProjectSetupRequired } from './components/ProjectSetupRequired' import { getDependencyGraph } from './lib/api' -import { Loader2, Settings, Moon, Sun } from 'lucide-react' +import { Loader2, Settings, Moon, Sun, RotateCcw } from 'lucide-react' import type { Feature } from './lib/types' import { Button } from '@/components/ui/button' import { Card, CardContent } from '@/components/ui/card' @@ -59,6 +61,7 @@ function App() { const [showSettings, setShowSettings] = useState(false) const [showKeyboardHelp, setShowKeyboardHelp] = useState(false) const [isSpecCreating, setIsSpecCreating] = useState(false) + const [showResetModal, setShowResetModal] = useState(false) const [showSpecChat, setShowSpecChat] = useState(false) // For "Create Spec" button in empty kanban const [viewMode, setViewMode] = useState(() => { try { @@ -203,10 +206,18 @@ function App() { setShowKeyboardHelp(true) } + // R : Open reset modal (when project selected and agent not running) + if ((e.key === 'r' || e.key === 'R') && selectedProject && wsState.agentStatus !== 'running') { + e.preventDefault() + setShowResetModal(true) + } + // Escape : Close modals if (e.key === 'Escape') { if (showKeyboardHelp) { setShowKeyboardHelp(false) + } else if (showResetModal) { + setShowResetModal(false) } else if (showExpandProject) { setShowExpandProject(false) } else if (showSettings) { @@ -225,7 +236,7 @@ function App() { window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) - }, [selectedProject, showAddFeature, showExpandProject, selectedFeature, debugOpen, debugActiveTab, assistantOpen, features, showSettings, showKeyboardHelp, isSpecCreating, viewMode]) + }, [selectedProject, showAddFeature, showExpandProject, selectedFeature, debugOpen, debugActiveTab, assistantOpen, features, showSettings, showKeyboardHelp, isSpecCreating, viewMode, showResetModal, wsState.agentStatus]) // Combine WebSocket progress with feature data const progress = wsState.progress.total > 0 ? wsState.progress : { @@ -287,6 +298,17 @@ function App() { + + {/* Ollama Mode Indicator */} {settings?.ollama_mode && (
+ ) : !hasSpec ? ( + setShowSpecChat(true)} + onEditManually={() => { + // Open debug panel for the user to see the project path + setDebugOpen(true) + }} + /> ) : (
{/* Progress Dashboard */} @@ -512,6 +544,21 @@ function App() { {/* Keyboard Shortcuts Help */} setShowKeyboardHelp(false)} /> + {/* Reset Project Modal */} + {showResetModal && selectedProject && ( + setShowResetModal(false)} + onResetComplete={(wasFullReset) => { + // If full reset, the spec was deleted - show spec creation chat + if (wasFullReset) { + setShowSpecChat(true) + } + }} + /> + )} + {/* Celebration Overlay - shows when a feature is completed by an agent */} {wsState.celebration && ( void + onEditManually: () => void +} + +export function ProjectSetupRequired({ + projectName, + projectPath, + onCreateWithClaude, + onEditManually, +}: ProjectSetupRequiredProps) { + return ( +
+ + + + Project Setup Required + + + {projectName} needs an app spec to get started + + {projectPath && ( +
+ + {projectPath} +
+ )} +
+ +

+ Choose how you want to create your app specification: +

+ +
+ {/* Create with Claude Option */} + + +
+ +
+

Create with Claude

+

+ Describe your app idea and Claude will help create a detailed specification +

+ +
+
+ + {/* Edit Manually Option */} + + +
+ +
+

Edit Templates Manually

+

+ Create the prompts directory and edit template files yourself +

+ +
+
+
+ +

+ The app spec tells the agent what to build. It includes the application name, + description, tech stack, and feature requirements. +

+
+
+
+ ) +} diff --git a/ui/src/components/ResetProjectModal.tsx b/ui/src/components/ResetProjectModal.tsx new file mode 100644 index 0000000..5dca539 --- /dev/null +++ b/ui/src/components/ResetProjectModal.tsx @@ -0,0 +1,194 @@ +import { useState } from 'react' +import { Loader2, AlertTriangle, RotateCcw, Trash2, Check, X } from 'lucide-react' +import { useResetProject } from '../hooks/useProjects' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Alert, AlertDescription } from '@/components/ui/alert' + +interface ResetProjectModalProps { + isOpen: boolean + projectName: string + onClose: () => void + onResetComplete?: (wasFullReset: boolean) => void +} + +export function ResetProjectModal({ + isOpen, + projectName, + onClose, + onResetComplete, +}: ResetProjectModalProps) { + const [resetType, setResetType] = useState<'quick' | 'full'>('quick') + const resetProject = useResetProject(projectName) + + const handleReset = async () => { + const isFullReset = resetType === 'full' + try { + await resetProject.mutateAsync(isFullReset) + onResetComplete?.(isFullReset) + onClose() + } catch { + // Error is handled by the mutation state + } + } + + const handleClose = () => { + if (!resetProject.isPending) { + resetProject.reset() + setResetType('quick') + onClose() + } + } + + return ( + !open && handleClose()}> + + + + + Reset Project + + + Reset {projectName} to start fresh + + + +
+ {/* Reset Type Toggle */} +
+ + +
+ + {/* Warning Box */} + + + +
+ {resetType === 'quick' ? 'What will be deleted:' : 'What will be deleted:'} +
+
    +
  • + + All features and progress +
  • +
  • + + Assistant chat history +
  • +
  • + + Agent settings +
  • + {resetType === 'full' && ( +
  • + + App spec and prompts +
  • + )} +
+
+
+ + {/* What will be preserved */} +
+
+ {resetType === 'quick' ? 'What will be preserved:' : 'What will be preserved:'} +
+
    + {resetType === 'quick' ? ( + <> +
  • + + App spec and prompts +
  • +
  • + + Project code and files +
  • + + ) : ( + <> +
  • + + Project code and files +
  • +
  • + + Setup wizard will appear +
  • + + )} +
+
+ + {/* Error Message */} + {resetProject.isError && ( + + + {resetProject.error instanceof Error + ? resetProject.error.message + : 'Failed to reset project. Please try again.'} + + + )} +
+ + + + + +
+
+ ) +} diff --git a/ui/src/hooks/useProjects.ts b/ui/src/hooks/useProjects.ts index 4ed3914..e914909 100644 --- a/ui/src/hooks/useProjects.ts +++ b/ui/src/hooks/useProjects.ts @@ -48,6 +48,20 @@ export function useDeleteProject() { }) } +export function useResetProject(projectName: string) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (fullReset: boolean) => api.resetProject(projectName, fullReset), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['projects'] }) + queryClient.invalidateQueries({ queryKey: ['project', projectName] }) + queryClient.invalidateQueries({ queryKey: ['features', projectName] }) + queryClient.invalidateQueries({ queryKey: ['agent-status', projectName] }) + }, + }) +} + export function useUpdateProjectSettings(projectName: string) { const queryClient = useQueryClient() diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index ce3354e..48ce30a 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -111,6 +111,23 @@ export async function updateProjectSettings( }) } +export interface ResetProjectResponse { + success: boolean + reset_type: 'quick' | 'full' + deleted_files: string[] + message: string +} + +export async function resetProject( + name: string, + fullReset: boolean = false +): Promise { + const params = fullReset ? '?full_reset=true' : '' + return fetchJSON(`/projects/${encodeURIComponent(name)}/reset${params}`, { + method: 'POST', + }) +} + // ============================================================================ // Features API // ============================================================================