diff --git a/.claude/commands/expand-project.md b/.claude/commands/expand-project.md new file mode 100644 index 0000000..06c3df8 --- /dev/null +++ b/.claude/commands/expand-project.md @@ -0,0 +1,241 @@ +--- +description: Expand an existing project with new features +--- + +# PROJECT DIRECTORY + +This command **requires** the project directory as an argument via `$ARGUMENTS`. + +**Example:** `/expand-project generations/my-app` + +If `$ARGUMENTS` is empty, inform the user they must provide a project path and exit. + +--- + +# GOAL + +Help the user add new features to an existing project. You will: +1. Understand the current project by reading its specification +2. Discuss what NEW capabilities they want to add +3. Create features directly in the database (no file generation needed) + +This is different from `/create-spec` because: +- The project already exists with features +- We're ADDING to it, not creating from scratch +- Features go directly to the database + +--- + +# YOUR ROLE + +You are the **Project Expansion Assistant** - an expert at understanding existing projects and adding new capabilities. Your job is to: + +1. Read and understand the existing project specification +2. Ask about what NEW features the user wants +3. Clarify requirements through focused conversation +4. Create features that integrate well with existing ones + +**IMPORTANT:** Like create-spec, cater to all skill levels. Many users are product owners. Ask about WHAT they want, not HOW to build it. + +--- + +# FIRST: Read and Understand Existing Project + +**Step 1:** Read the existing specification: +- Read `$ARGUMENTS/prompts/app_spec.txt` + +**Step 2:** Present a summary to the user: + +> "I've reviewed your **[Project Name]** project. Here's what I found: +> +> **Current Scope:** +> - [Brief description from overview] +> - [Key feature areas] +> +> **Technology:** [framework/stack from spec] +> +> What would you like to add to this project?" + +**STOP HERE and wait for their response.** + +--- + +# CONVERSATION FLOW + +## Phase 1: Understand Additions + +Start with open questions: + +> "Tell me about what you want to add. What new things should users be able to do?" + +**Follow-up questions:** +- How does this connect to existing features? +- Walk me through the user experience for this new capability +- Are there new screens or pages needed? +- What data will this create or use? + +**Keep asking until you understand:** +- What the user sees +- What actions they can take +- What happens as a result +- What errors could occur + +## Phase 2: Clarify Details + +For each new capability, understand: + +**User flows:** +- What triggers this feature? +- What steps does the user take? +- What's the success state? +- What's the error state? + +**Integration:** +- Does this modify existing features? +- Does this need new data/fields? +- What permissions apply? + +**Edge cases:** +- What validation is needed? +- What happens with empty/invalid input? +- What about concurrent users? + +## Phase 3: Derive Features + +**Count the testable behaviors** for additions: + +For each new capability, estimate features: +- Each CRUD operation = 1 feature +- Each UI interaction = 1 feature +- Each validation/error case = 1 feature +- Each visual requirement = 1 feature + +**Present breakdown for approval:** + +> "Based on what we discussed, here's my feature breakdown for the additions: +> +> **[New Category 1]:** ~X features +> - [Brief description of what's covered] +> +> **[New Category 2]:** ~Y features +> - [Brief description of what's covered] +> +> **Total: ~N new features** +> +> These will be added to your existing features. The agent will implement them in order. Does this look right?" + +**Wait for approval before creating features.** + +--- + +# FEATURE CREATION + +Once the user approves, create features directly. + +**Signal that you're ready to create features by saying:** + +> "Great! I'll create these N features now. Each feature will include: +> - Category +> - Name (what's being tested) +> - Description (how to verify it) +> - Test steps +> +> Creating features..." + +**Then output the features in this exact JSON format (the system will parse this):** + +``` + +[ + { + "category": "functional", + "name": "Brief feature name", + "description": "What this feature tests and how to verify it works", + "steps": [ + "Step 1: Action to take", + "Step 2: Expected result", + "Step 3: Verification" + ] + }, + { + "category": "style", + "name": "Another feature name", + "description": "Description of visual/style requirement", + "steps": [ + "Step 1: Navigate to page", + "Step 2: Check visual element", + "Step 3: Verify styling" + ] + } +] + +``` + +**CRITICAL:** +- Wrap the JSON array in `` tags exactly as shown +- Use valid JSON (double quotes, no trailing commas) +- Include ALL features you promised to create +- Each feature needs: category, name, description, steps (array of strings) + +--- + +# FEATURE QUALITY STANDARDS + +**Categories to use:** +- `security` - Authentication, authorization, access control +- `functional` - Core functionality, CRUD operations, workflows +- `style` - Visual design, layout, responsive behavior +- `navigation` - Routing, links, breadcrumbs +- `error-handling` - Error states, validation, edge cases +- `data` - Data integrity, persistence, relationships + +**Good feature names:** +- Start with what the user does: "User can create new task" +- Or what happens: "Login form validates email format" +- Be specific: "Dashboard shows task count per category" + +**Good descriptions:** +- Explain what's being tested +- Include the expected behavior +- Make it clear how to verify success + +**Good test steps:** +- 2-5 steps for simple features +- 5-10 steps for complex workflows +- Each step is a concrete action or verification +- Include setup, action, and verification + +--- + +# AFTER FEATURE CREATION + +Once features are created, tell the user: + +> "I've created N new features for your project! +> +> **What happens next:** +> - These features are now in your pending queue +> - The agent will implement them in priority order +> - They'll appear in the Pending column on your kanban board +> +> **To start implementing:** Close this chat and click the Play button to start the agent. +> +> Would you like to add more features, or are you done for now?" + +If they want to add more, go back to Phase 1. + +--- + +# IMPORTANT GUIDELINES + +1. **Preserve existing features** - We're adding, not replacing +2. **Integration focus** - New features should work with existing ones +3. **Quality standards** - Same thoroughness as initial features +4. **Incremental is fine** - Multiple expansion sessions are OK +5. **Don't over-engineer** - Only add what the user asked for + +--- + +# BEGIN + +Start by reading the app specification file at `$ARGUMENTS/prompts/app_spec.txt`, then greet the user with a summary of their existing project and ask what they want to add. diff --git a/server/main.py b/server/main.py index f48e9f2..596650d 100644 --- a/server/main.py +++ b/server/main.py @@ -18,6 +18,7 @@ from fastapi.staticfiles import StaticFiles from .routers import ( agent_router, assistant_chat_router, + expand_project_router, features_router, filesystem_router, projects_router, @@ -25,6 +26,7 @@ from .routers import ( ) from .schemas import SetupStatus from .services.assistant_chat_session import cleanup_all_sessions as cleanup_assistant_sessions +from .services.expand_chat_session import cleanup_all_expand_sessions from .services.process_manager import cleanup_all_managers from .websocket import project_websocket @@ -38,9 +40,10 @@ async def lifespan(app: FastAPI): """Lifespan context manager for startup and shutdown.""" # Startup yield - # Shutdown - cleanup all running agents and assistant sessions + # Shutdown - cleanup all running agents and sessions await cleanup_all_managers() await cleanup_assistant_sessions() + await cleanup_all_expand_sessions() # Create FastAPI app @@ -90,6 +93,7 @@ app.include_router(projects_router) app.include_router(features_router) app.include_router(agent_router) app.include_router(spec_creation_router) +app.include_router(expand_project_router) app.include_router(filesystem_router) app.include_router(assistant_chat_router) diff --git a/server/routers/__init__.py b/server/routers/__init__.py index 48b4f80..71a6013 100644 --- a/server/routers/__init__.py +++ b/server/routers/__init__.py @@ -7,6 +7,7 @@ FastAPI routers for different API endpoints. from .agent import router as agent_router from .assistant_chat import router as assistant_chat_router +from .expand_project import router as expand_project_router from .features import router as features_router from .filesystem import router as filesystem_router from .projects import router as projects_router @@ -17,6 +18,7 @@ __all__ = [ "features_router", "agent_router", "spec_creation_router", + "expand_project_router", "filesystem_router", "assistant_chat_router", ] diff --git a/server/routers/expand_project.py b/server/routers/expand_project.py new file mode 100644 index 0000000..a325649 --- /dev/null +++ b/server/routers/expand_project.py @@ -0,0 +1,246 @@ +""" +Expand Project Router +===================== + +WebSocket and REST endpoints for interactive project expansion with Claude. +Allows adding multiple features to existing projects via natural language. +""" + +import json +import logging +import re +from pathlib import Path +from typing import Optional + +from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect +from pydantic import BaseModel, ValidationError + +from ..schemas import ImageAttachment +from ..services.expand_chat_session import ( + ExpandChatSession, + create_expand_session, + get_expand_session, + list_expand_sessions, + remove_expand_session, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/expand", tags=["expand-project"]) + +# Root directory +ROOT_DIR = Path(__file__).parent.parent.parent + + +def _get_project_path(project_name: str) -> Path: + """Get project path from registry.""" + import sys + root = Path(__file__).parent.parent.parent + if str(root) not in sys.path: + sys.path.insert(0, str(root)) + + from registry import get_project_path + return get_project_path(project_name) + + +def validate_project_name(name: str) -> bool: + """Validate project name to prevent path traversal.""" + return bool(re.match(r'^[a-zA-Z0-9_-]{1,50}$', name)) + + +# ============================================================================ +# REST Endpoints +# ============================================================================ + +class ExpandSessionStatus(BaseModel): + """Status of an expansion session.""" + project_name: str + is_active: bool + is_complete: bool + features_created: int + message_count: int + + +@router.get("/sessions", response_model=list[str]) +async def list_expand_sessions_endpoint(): + """List all active expansion sessions.""" + return list_expand_sessions() + + +@router.get("/sessions/{project_name}", response_model=ExpandSessionStatus) +async def get_expand_session_status(project_name: str): + """Get status of an expansion session.""" + if not validate_project_name(project_name): + raise HTTPException(status_code=400, detail="Invalid project name") + + session = get_expand_session(project_name) + if not session: + raise HTTPException(status_code=404, detail="No active expansion session for this project") + + return ExpandSessionStatus( + project_name=project_name, + is_active=True, + is_complete=session.is_complete(), + features_created=session.get_features_created(), + message_count=len(session.get_messages()), + ) + + +@router.delete("/sessions/{project_name}") +async def cancel_expand_session(project_name: str): + """Cancel and remove an expansion session.""" + if not validate_project_name(project_name): + raise HTTPException(status_code=400, detail="Invalid project name") + + session = get_expand_session(project_name) + if not session: + raise HTTPException(status_code=404, detail="No active expansion session for this project") + + await remove_expand_session(project_name) + return {"success": True, "message": "Expansion session cancelled"} + + +# ============================================================================ +# WebSocket Endpoint +# ============================================================================ + +@router.websocket("/ws/{project_name}") +async def expand_project_websocket(websocket: WebSocket, project_name: str): + """ + WebSocket endpoint for interactive project expansion chat. + + Message protocol: + + Client -> Server: + - {"type": "start"} - Start the expansion session + - {"type": "message", "content": "..."} - Send user message + - {"type": "ping"} - Keep-alive ping + + Server -> Client: + - {"type": "text", "content": "..."} - Text chunk from Claude + - {"type": "features_created", "count": N, "features": [...]} - Features added + - {"type": "expansion_complete", "total_added": N} - Session complete + - {"type": "response_done"} - Response complete + - {"type": "error", "content": "..."} - Error message + - {"type": "pong"} - Keep-alive pong + """ + if not validate_project_name(project_name): + await websocket.close(code=4000, reason="Invalid project name") + return + + # Look up project directory from registry + project_dir = _get_project_path(project_name) + if not project_dir: + await websocket.close(code=4004, reason="Project not found in registry") + return + + if not project_dir.exists(): + await websocket.close(code=4004, reason="Project directory not found") + return + + # Verify project has app_spec.txt + spec_path = project_dir / "prompts" / "app_spec.txt" + if not spec_path.exists(): + await websocket.close(code=4004, reason="Project has no spec. Create spec first.") + return + + await websocket.accept() + + session: Optional[ExpandChatSession] = None + + try: + while True: + try: + # Receive message from client + data = await websocket.receive_text() + message = json.loads(data) + msg_type = message.get("type") + + if msg_type == "ping": + await websocket.send_json({"type": "pong"}) + continue + + elif msg_type == "start": + # Create and start a new expansion session + session = await create_expand_session(project_name, project_dir) + + # Stream the initial greeting + async for chunk in session.start(): + await websocket.send_json(chunk) + + elif msg_type == "message": + # User sent a message + if not session: + session = get_expand_session(project_name) + if not session: + await websocket.send_json({ + "type": "error", + "content": "No active session. Send 'start' first." + }) + continue + + user_content = message.get("content", "").strip() + + # Parse attachments if present + attachments: list[ImageAttachment] = [] + raw_attachments = message.get("attachments", []) + if raw_attachments: + try: + for raw_att in raw_attachments: + attachments.append(ImageAttachment(**raw_att)) + except (ValidationError, Exception) as e: + logger.warning(f"Invalid attachment data: {e}") + await websocket.send_json({ + "type": "error", + "content": f"Invalid attachment: {str(e)}" + }) + continue + + # Allow empty content if attachments are present + if not user_content and not attachments: + await websocket.send_json({ + "type": "error", + "content": "Empty message" + }) + continue + + # Stream Claude's response + async for chunk in session.send_message(user_content, attachments if attachments else None): + await websocket.send_json(chunk) + + elif msg_type == "done": + # User is done adding features + if session: + await websocket.send_json({ + "type": "expansion_complete", + "total_added": session.get_features_created() + }) + + else: + await websocket.send_json({ + "type": "error", + "content": f"Unknown message type: {msg_type}" + }) + + except json.JSONDecodeError: + await websocket.send_json({ + "type": "error", + "content": "Invalid JSON" + }) + + except WebSocketDisconnect: + logger.info(f"Expand chat WebSocket disconnected for {project_name}") + + except Exception as e: + logger.exception(f"Expand chat WebSocket error for {project_name}") + try: + await websocket.send_json({ + "type": "error", + "content": f"Server error: {str(e)}" + }) + except Exception: + pass + + finally: + # Don't remove the session on disconnect - allow resume + pass diff --git a/server/routers/features.py b/server/routers/features.py index 3329a68..407a92f 100644 --- a/server/routers/features.py +++ b/server/routers/features.py @@ -13,6 +13,8 @@ from pathlib import Path from fastapi import APIRouter, HTTPException from ..schemas import ( + FeatureBulkCreate, + FeatureBulkCreateResponse, FeatureCreate, FeatureListResponse, FeatureResponse, @@ -295,3 +297,83 @@ async def skip_feature(project_name: str, feature_id: int): except Exception: logger.exception("Failed to skip feature") raise HTTPException(status_code=500, detail="Failed to skip feature") + + +@router.post("/bulk", response_model=FeatureBulkCreateResponse) +async def create_features_bulk(project_name: str, bulk: FeatureBulkCreate): + """ + Create multiple features at once. + + Features are assigned sequential priorities starting from: + - starting_priority if specified + - max(existing priorities) + 1 if not specified + + This is useful for: + - Expanding a project with new features via AI + - Importing features from external sources + - Batch operations + + Returns: + {"created": N, "features": [...]} + """ + project_name = validate_project_name(project_name) + project_dir = _get_project_path(project_name) + + if not project_dir: + raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry") + + if not project_dir.exists(): + raise HTTPException(status_code=404, detail="Project directory not found") + + if not bulk.features: + return FeatureBulkCreateResponse(created=0, features=[]) + + _, Feature = _get_db_classes() + + try: + with get_db_session(project_dir) as session: + # Determine starting priority + if bulk.starting_priority is not None: + current_priority = bulk.starting_priority + else: + max_priority_feature = session.query(Feature).order_by(Feature.priority.desc()).first() + current_priority = (max_priority_feature.priority + 1) if max_priority_feature else 1 + + created_features = [] + + for feature_data in bulk.features: + db_feature = Feature( + priority=current_priority, + category=feature_data.category, + name=feature_data.name, + description=feature_data.description, + steps=feature_data.steps, + passes=False, + ) + session.add(db_feature) + current_priority += 1 + + session.commit() + + # Refresh to get IDs and return responses + for db_feature in session.query(Feature).order_by(Feature.priority.desc()).limit(len(bulk.features)).all(): + created_features.insert(0, feature_to_response(db_feature)) + + # Re-query to get the actual created features in order + created_features = [] + start_priority = current_priority - len(bulk.features) + for db_feature in session.query(Feature).filter( + Feature.priority >= start_priority, + Feature.priority < current_priority + ).order_by(Feature.priority).all(): + created_features.append(feature_to_response(db_feature)) + + return FeatureBulkCreateResponse( + created=len(created_features), + features=created_features + ) + except HTTPException: + raise + except Exception: + logger.exception("Failed to bulk create features") + raise HTTPException(status_code=500, detail="Failed to bulk create features") diff --git a/server/schemas.py b/server/schemas.py index 5531a44..c1f384c 100644 --- a/server/schemas.py +++ b/server/schemas.py @@ -96,6 +96,18 @@ class FeatureListResponse(BaseModel): done: list[FeatureResponse] +class FeatureBulkCreate(BaseModel): + """Request schema for bulk creating features.""" + features: list[FeatureCreate] + starting_priority: int | None = None # If None, appends after max priority + + +class FeatureBulkCreateResponse(BaseModel): + """Response for bulk feature creation.""" + created: int + features: list[FeatureResponse] + + # ============================================================================ # Agent Schemas # ============================================================================ diff --git a/server/services/expand_chat_session.py b/server/services/expand_chat_session.py new file mode 100644 index 0000000..2c45827 --- /dev/null +++ b/server/services/expand_chat_session.py @@ -0,0 +1,444 @@ +""" +Expand Chat Session +=================== + +Manages interactive project expansion conversation with Claude. +Uses the expand-project.md skill to help users add features to existing projects. +""" + +import json +import logging +import re +import shutil +import threading +from datetime import datetime +from pathlib import Path +from typing import AsyncGenerator, Optional + +from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient + +from ..schemas import ImageAttachment + +logger = logging.getLogger(__name__) + + +async def _make_multimodal_message(content_blocks: list[dict]) -> AsyncGenerator[dict, None]: + """ + Create an async generator that yields a properly formatted multimodal message. + """ + yield { + "type": "user", + "message": {"role": "user", "content": content_blocks}, + "parent_tool_use_id": None, + "session_id": "default", + } + + +# Root directory of the project +ROOT_DIR = Path(__file__).parent.parent.parent + + +class ExpandChatSession: + """ + Manages a project expansion conversation. + + Unlike SpecChatSession which writes spec files, this session: + 1. Reads existing app_spec.txt for context + 2. Parses feature definitions from Claude's output + 3. Creates features via REST API + 4. Tracks which features were created during the session + """ + + def __init__(self, project_name: str, project_dir: Path): + """ + Initialize the session. + + Args: + project_name: Name of the project being expanded + project_dir: Absolute path to the project directory + """ + self.project_name = project_name + self.project_dir = project_dir + self.client: Optional[ClaudeSDKClient] = None + self.messages: list[dict] = [] + self.complete: bool = False + self.created_at = datetime.now() + self._conversation_id: Optional[str] = None + self._client_entered: bool = False + self.features_created: int = 0 + self.created_feature_ids: list[int] = [] + + async def close(self) -> None: + """Clean up resources and close the Claude client.""" + if self.client and self._client_entered: + try: + await self.client.__aexit__(None, None, None) + except Exception as e: + logger.warning(f"Error closing Claude client: {e}") + finally: + self._client_entered = False + self.client = None + + async def start(self) -> AsyncGenerator[dict, None]: + """ + Initialize session and get initial greeting from Claude. + + Yields message chunks as they stream in. + """ + # Load the expand-project skill + skill_path = ROOT_DIR / ".claude" / "commands" / "expand-project.md" + + if not skill_path.exists(): + yield { + "type": "error", + "content": f"Expand project skill not found at {skill_path}" + } + return + + # Verify project has existing spec + spec_path = self.project_dir / "prompts" / "app_spec.txt" + if not spec_path.exists(): + yield { + "type": "error", + "content": "Project has no app_spec.txt. Please create it first using spec creation." + } + return + + try: + skill_content = skill_path.read_text(encoding="utf-8") + except UnicodeDecodeError: + skill_content = skill_path.read_text(encoding="utf-8", errors="replace") + + # Create security settings file + security_settings = { + "sandbox": {"enabled": False}, + "permissions": { + "defaultMode": "acceptEdits", + "allow": [ + "Read(./**)", + "Glob(./**)", + ], + }, + } + settings_file = self.project_dir / ".claude_settings.json" + with open(settings_file, "w") as f: + json.dump(security_settings, f, indent=2) + + # Replace $ARGUMENTS with absolute project path + project_path = str(self.project_dir.resolve()) + system_prompt = skill_content.replace("$ARGUMENTS", project_path) + + # Create Claude SDK client + system_cli = shutil.which("claude") + try: + self.client = ClaudeSDKClient( + options=ClaudeAgentOptions( + model="claude-opus-4-5-20251101", + cli_path=system_cli, + system_prompt=system_prompt, + allowed_tools=[ + "Read", + "Glob", + ], + permission_mode="acceptEdits", + max_turns=100, + cwd=str(self.project_dir.resolve()), + settings=str(settings_file.resolve()), + ) + ) + await self.client.__aenter__() + self._client_entered = True + except Exception as e: + logger.exception("Failed to create Claude client") + yield { + "type": "error", + "content": f"Failed to initialize Claude: {str(e)}" + } + return + + # Start the conversation + try: + async for chunk in self._query_claude("Begin the project expansion process."): + yield chunk + yield {"type": "response_done"} + except Exception as e: + logger.exception("Failed to start expand chat") + yield { + "type": "error", + "content": f"Failed to start conversation: {str(e)}" + } + + async def send_message( + self, + user_message: str, + attachments: list[ImageAttachment] | None = None + ) -> AsyncGenerator[dict, None]: + """ + Send user message and stream Claude's response. + + Args: + user_message: The user's response + attachments: Optional list of image attachments + + Yields: + Message chunks of various types: + - {"type": "text", "content": str} + - {"type": "features_created", "count": N, "features": [...]} + - {"type": "expansion_complete", "total_added": N} + - {"type": "error", "content": str} + """ + if not self.client: + yield { + "type": "error", + "content": "Session not initialized. Call start() first." + } + return + + # Store the user message + self.messages.append({ + "role": "user", + "content": user_message, + "has_attachments": bool(attachments), + "timestamp": datetime.now().isoformat() + }) + + try: + async for chunk in self._query_claude(user_message, attachments): + yield chunk + yield {"type": "response_done"} + except Exception as e: + logger.exception("Error during Claude query") + yield { + "type": "error", + "content": f"Error: {str(e)}" + } + + async def _query_claude( + self, + message: str, + attachments: list[ImageAttachment] | None = None + ) -> AsyncGenerator[dict, None]: + """ + Internal method to query Claude and stream responses. + + Handles text responses and detects feature creation blocks. + """ + if not self.client: + return + + # Build the message content + if attachments and len(attachments) > 0: + content_blocks = [] + if message: + content_blocks.append({"type": "text", "text": message}) + for att in attachments: + content_blocks.append({ + "type": "image", + "source": { + "type": "base64", + "media_type": att.mimeType, + "data": att.base64Data, + } + }) + await self.client.query(_make_multimodal_message(content_blocks)) + logger.info(f"Sent multimodal message with {len(attachments)} image(s)") + else: + await self.client.query(message) + + # Accumulate full response to detect feature blocks + full_response = "" + + # Stream the response + async for msg in self.client.receive_response(): + msg_type = type(msg).__name__ + + if msg_type == "AssistantMessage" and hasattr(msg, "content"): + for block in msg.content: + block_type = type(block).__name__ + + if block_type == "TextBlock" and hasattr(block, "text"): + text = block.text + if text: + full_response += text + yield {"type": "text", "content": text} + + self.messages.append({ + "role": "assistant", + "content": text, + "timestamp": datetime.now().isoformat() + }) + + # Check for feature creation block in full response + features_match = re.search( + r'\s*(\[[\s\S]*?\])\s*', + full_response + ) + + if features_match: + try: + features_json = features_match.group(1) + features_data = json.loads(features_json) + + if features_data and isinstance(features_data, list): + # Create features via REST API + created = await self._create_features_bulk(features_data) + + if created: + self.features_created += len(created) + self.created_feature_ids.extend([f["id"] for f in created]) + + yield { + "type": "features_created", + "count": len(created), + "features": created + } + + logger.info(f"Created {len(created)} features for {self.project_name}") + except json.JSONDecodeError as e: + logger.error(f"Failed to parse features JSON: {e}") + yield { + "type": "error", + "content": f"Failed to parse feature definitions: {str(e)}" + } + except Exception as e: + logger.exception("Failed to create features") + yield { + "type": "error", + "content": f"Failed to create features: {str(e)}" + } + + async def _create_features_bulk(self, features: list[dict]) -> list[dict]: + """ + Create features directly in the database. + + Args: + features: List of feature dictionaries with category, name, description, steps + + Returns: + List of created feature dictionaries with IDs + """ + # Import database classes + import sys + root = Path(__file__).parent.parent.parent + if str(root) not in sys.path: + sys.path.insert(0, str(root)) + + from api.database import Feature, create_database + + # Get database session + _, SessionLocal = create_database(self.project_dir) + session = SessionLocal() + + try: + # Determine starting priority + max_priority_feature = session.query(Feature).order_by(Feature.priority.desc()).first() + current_priority = (max_priority_feature.priority + 1) if max_priority_feature else 1 + + created_features = [] + + for f in features: + db_feature = Feature( + priority=current_priority, + category=f.get("category", "functional"), + name=f.get("name", "Unnamed feature"), + description=f.get("description", ""), + steps=f.get("steps", []), + passes=False, + ) + session.add(db_feature) + current_priority += 1 + + session.commit() + + # Re-query to get the created features with IDs + start_priority = current_priority - len(features) + for db_feature in session.query(Feature).filter( + Feature.priority >= start_priority, + Feature.priority < current_priority + ).order_by(Feature.priority).all(): + created_features.append({ + "id": db_feature.id, + "name": db_feature.name, + "category": db_feature.category, + }) + + return created_features + + finally: + session.close() + + def get_features_created(self) -> int: + """Get the total number of features created in this session.""" + return self.features_created + + def is_complete(self) -> bool: + """Check if expansion session is complete.""" + return self.complete + + def get_messages(self) -> list[dict]: + """Get all messages in the conversation.""" + return self.messages.copy() + + +# Session registry with thread safety +_expand_sessions: dict[str, ExpandChatSession] = {} +_expand_sessions_lock = threading.Lock() + + +def get_expand_session(project_name: str) -> Optional[ExpandChatSession]: + """Get an existing expansion session for a project.""" + with _expand_sessions_lock: + return _expand_sessions.get(project_name) + + +async def create_expand_session(project_name: str, project_dir: Path) -> ExpandChatSession: + """Create a new expansion session for a project, closing any existing one.""" + old_session: Optional[ExpandChatSession] = None + + with _expand_sessions_lock: + old_session = _expand_sessions.pop(project_name, None) + session = ExpandChatSession(project_name, project_dir) + _expand_sessions[project_name] = session + + if old_session: + try: + await old_session.close() + except Exception as e: + logger.warning(f"Error closing old expand session for {project_name}: {e}") + + return session + + +async def remove_expand_session(project_name: str) -> None: + """Remove and close an expansion session.""" + session: Optional[ExpandChatSession] = None + + with _expand_sessions_lock: + session = _expand_sessions.pop(project_name, None) + + if session: + try: + await session.close() + except Exception as e: + logger.warning(f"Error closing expand session for {project_name}: {e}") + + +def list_expand_sessions() -> list[str]: + """List all active expansion session project names.""" + with _expand_sessions_lock: + return list(_expand_sessions.keys()) + + +async def cleanup_all_expand_sessions() -> None: + """Close all active expansion sessions. Called on server shutdown.""" + sessions_to_close: list[ExpandChatSession] = [] + + with _expand_sessions_lock: + sessions_to_close = list(_expand_sessions.values()) + _expand_sessions.clear() + + for session in sessions_to_close: + try: + await session.close() + except Exception as e: + logger.warning(f"Error closing expand session {session.project_name}: {e}") diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 794c5a2..cd39bdc 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useCallback } from 'react' +import { useQueryClient } from '@tanstack/react-query' import { useProjects, useFeatures, useAgentStatus } from './hooks/useProjects' import { useProjectWebSocket } from './hooks/useWebSocket' import { useFeatureSound } from './hooks/useFeatureSound' @@ -16,7 +17,8 @@ import { DebugLogViewer } from './components/DebugLogViewer' import { AgentThought } from './components/AgentThought' import { AssistantFAB } from './components/AssistantFAB' import { AssistantPanel } from './components/AssistantPanel' -import { Plus, Loader2 } from 'lucide-react' +import { ExpandProjectModal } from './components/ExpandProjectModal' +import { Plus, Loader2, Sparkles } from 'lucide-react' import type { Feature } from './lib/types' function App() { @@ -29,12 +31,14 @@ function App() { } }) const [showAddFeature, setShowAddFeature] = useState(false) + const [showExpandProject, setShowExpandProject] = useState(false) const [selectedFeature, setSelectedFeature] = useState(null) const [setupComplete, setSetupComplete] = useState(true) // Start optimistic const [debugOpen, setDebugOpen] = useState(false) const [debugPanelHeight, setDebugPanelHeight] = useState(288) // Default height const [assistantOpen, setAssistantOpen] = useState(false) + const queryClient = useQueryClient() const { data: projects, isLoading: projectsLoading } = useProjects() const { data: features } = useFeatures(selectedProject) const { data: agentStatusData } = useAgentStatus(selectedProject) @@ -87,6 +91,13 @@ function App() { setShowAddFeature(true) } + // E : Expand project with AI (when project selected and has features) + if ((e.key === 'e' || e.key === 'E') && selectedProject && features && + (features.pending.length + features.in_progress.length + features.done.length) > 0) { + e.preventDefault() + setShowExpandProject(true) + } + // A : Toggle assistant panel (when project selected) if ((e.key === 'a' || e.key === 'A') && selectedProject) { e.preventDefault() @@ -95,7 +106,9 @@ function App() { // Escape : Close modals if (e.key === 'Escape') { - if (assistantOpen) { + if (showExpandProject) { + setShowExpandProject(false) + } else if (assistantOpen) { setAssistantOpen(false) } else if (showAddFeature) { setShowAddFeature(false) @@ -109,7 +122,7 @@ function App() { window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) - }, [selectedProject, showAddFeature, selectedFeature, debugOpen, assistantOpen]) + }, [selectedProject, showAddFeature, showExpandProject, selectedFeature, debugOpen, assistantOpen, features]) // Combine WebSocket progress with feature data const progress = wsState.progress.total > 0 ? wsState.progress : { @@ -160,6 +173,21 @@ function App() { + {/* Expand Project - only show if project has features */} + {features && (features.pending.length + features.in_progress.length + features.done.length) > 0 && ( + + )} + )} + {/* Expand Project Modal - AI-powered bulk feature creation */} + {showExpandProject && selectedProject && ( + setShowExpandProject(false)} + onFeaturesAdded={() => { + // Invalidate features query to refresh the kanban board + queryClient.invalidateQueries({ queryKey: ['features', selectedProject] }) + }} + /> + )} + {/* Debug Log Viewer - fixed to bottom */} {selectedProject && ( )} - {/* Assistant FAB and Panel */} - {selectedProject && ( + {/* Assistant FAB and Panel - hide FAB when expand modal is open */} + {selectedProject && !showExpandProject && ( <> setAssistantOpen(!assistantOpen)} diff --git a/ui/src/components/ExpandProjectChat.tsx b/ui/src/components/ExpandProjectChat.tsx new file mode 100644 index 0000000..2d102d6 --- /dev/null +++ b/ui/src/components/ExpandProjectChat.tsx @@ -0,0 +1,375 @@ +/** + * Expand Project Chat Component + * + * Full chat interface for interactive project expansion with Claude. + * Allows users to describe new features in natural language. + */ + +import { useCallback, useEffect, useRef, useState } from 'react' +import { Send, X, CheckCircle2, AlertCircle, Wifi, WifiOff, RotateCcw, Paperclip, Plus } from 'lucide-react' +import { useExpandChat } from '../hooks/useExpandChat' +import { ChatMessage } from './ChatMessage' +import { TypingIndicator } from './TypingIndicator' +import type { ImageAttachment } from '../lib/types' + +// Image upload validation constants +const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5 MB +const ALLOWED_TYPES = ['image/jpeg', 'image/png'] + +interface ExpandProjectChatProps { + projectName: string + onComplete: (featuresAdded: number) => void + onCancel: () => void +} + +export function ExpandProjectChat({ + projectName, + onComplete, + onCancel, +}: ExpandProjectChatProps) { + const [input, setInput] = useState('') + const [error, setError] = useState(null) + const [pendingAttachments, setPendingAttachments] = useState([]) + const messagesEndRef = useRef(null) + const inputRef = useRef(null) + const fileInputRef = useRef(null) + + const { + messages, + isLoading, + isComplete, + connectionStatus, + featuresCreated, + start, + sendMessage, + disconnect, + } = useExpandChat({ + projectName, + onComplete, + onError: (err) => setError(err), + }) + + // Start the chat session when component mounts + useEffect(() => { + start() + + return () => { + disconnect() + } + }, []) // eslint-disable-line react-hooks/exhaustive-deps + + // Scroll to bottom when messages change + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [messages, isLoading]) + + // Focus input when not loading + useEffect(() => { + if (!isLoading && inputRef.current) { + inputRef.current.focus() + } + }, [isLoading]) + + const handleSendMessage = () => { + const trimmed = input.trim() + // Allow sending if there's text OR attachments + if ((!trimmed && pendingAttachments.length === 0) || isLoading) return + + sendMessage(trimmed, pendingAttachments.length > 0 ? pendingAttachments : undefined) + setInput('') + setPendingAttachments([]) // Clear attachments after sending + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleSendMessage() + } + } + + // File handling for image attachments + const handleFileSelect = useCallback((files: FileList | null) => { + if (!files) return + + Array.from(files).forEach((file) => { + // Validate file type + if (!ALLOWED_TYPES.includes(file.type)) { + setError(`Invalid file type: ${file.name}. Only JPEG and PNG are supported.`) + return + } + + // Validate file size + if (file.size > MAX_FILE_SIZE) { + setError(`File too large: ${file.name}. Maximum size is 5 MB.`) + return + } + + // Read and convert to base64 + const reader = new FileReader() + reader.onload = (e) => { + const dataUrl = e.target?.result as string + const base64Data = dataUrl.split(',')[1] + + const attachment: ImageAttachment = { + id: `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, + filename: file.name, + mimeType: file.type as 'image/jpeg' | 'image/png', + base64Data, + previewUrl: dataUrl, + size: file.size, + } + + setPendingAttachments((prev) => [...prev, attachment]) + } + reader.readAsDataURL(file) + }) + }, []) + + const handleRemoveAttachment = useCallback((id: string) => { + setPendingAttachments((prev) => prev.filter((a) => a.id !== id)) + }, []) + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault() + handleFileSelect(e.dataTransfer.files) + }, + [handleFileSelect] + ) + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault() + }, []) + + // Connection status indicator + const ConnectionIndicator = () => { + switch (connectionStatus) { + case 'connected': + return ( + + + Connected + + ) + case 'connecting': + return ( + + + Connecting... + + ) + case 'error': + return ( + + + Error + + ) + default: + return ( + + + Disconnected + + ) + } + } + + return ( +
+ {/* Header */} +
+
+

+ Expand Project: {projectName} +

+ + {featuresCreated > 0 && ( + + + {featuresCreated} added + + )} +
+ +
+ {isComplete && ( + + + Complete + + )} + + +
+
+ + {/* Error banner */} + {error && ( +
+ + {error} + +
+ )} + + {/* Messages area */} +
+ {messages.length === 0 && !isLoading && ( +
+
+

+ Starting Project Expansion +

+

+ Connecting to Claude to help you add new features to your project... +

+ {connectionStatus === 'error' && ( + + )} +
+
+ )} + + {messages.map((message) => ( + + ))} + + {/* Typing indicator */} + {isLoading && } + + {/* Scroll anchor */} +
+
+ + {/* Input area */} + {!isComplete && ( +
+ {/* Attachment previews */} + {pendingAttachments.length > 0 && ( +
+ {pendingAttachments.map((attachment) => ( +
+ {attachment.filename} + + + {attachment.filename.length > 10 + ? `${attachment.filename.substring(0, 7)}...` + : attachment.filename} + +
+ ))} +
+ )} + +
+ {/* Hidden file input */} + handleFileSelect(e.target.files)} + className="hidden" + /> + + {/* Attach button */} + + + setInput(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={ + pendingAttachments.length > 0 + ? 'Add a message with your image(s)...' + : 'Describe the features you want to add...' + } + className="neo-input flex-1" + disabled={isLoading || connectionStatus !== 'connected'} + /> + +
+ + {/* Help text */} +

+ Press Enter to send. Drag & drop or click to attach images. +

+
+ )} + + {/* Completion footer */} + {isComplete && ( +
+
+
+ + + Added {featuresCreated} new feature{featuresCreated !== 1 ? 's' : ''}! + +
+ +
+
+ )} +
+ ) +} diff --git a/ui/src/components/ExpandProjectModal.tsx b/ui/src/components/ExpandProjectModal.tsx new file mode 100644 index 0000000..af0d196 --- /dev/null +++ b/ui/src/components/ExpandProjectModal.tsx @@ -0,0 +1,41 @@ +/** + * Expand Project Modal + * + * Full-screen modal wrapper for the ExpandProjectChat component. + * Allows users to add multiple features to an existing project via AI. + */ + +import { ExpandProjectChat } from './ExpandProjectChat' + +interface ExpandProjectModalProps { + isOpen: boolean + projectName: string + onClose: () => void + onFeaturesAdded: () => void // Called to refresh feature list +} + +export function ExpandProjectModal({ + isOpen, + projectName, + onClose, + onFeaturesAdded, +}: ExpandProjectModalProps) { + if (!isOpen) return null + + const handleComplete = (featuresAdded: number) => { + if (featuresAdded > 0) { + onFeaturesAdded() + } + onClose() + } + + return ( +
+ +
+ ) +} diff --git a/ui/src/hooks/useExpandChat.ts b/ui/src/hooks/useExpandChat.ts new file mode 100644 index 0000000..0bc48d5 --- /dev/null +++ b/ui/src/hooks/useExpandChat.ts @@ -0,0 +1,323 @@ +/** + * Hook for managing project expansion chat WebSocket connection + */ + +import { useState, useCallback, useRef, useEffect } from 'react' +import type { ChatMessage, ImageAttachment, ExpandChatServerMessage } from '../lib/types' + +type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error' + +interface CreatedFeature { + id: number + name: string + category: string +} + +interface UseExpandChatOptions { + projectName: string + onComplete?: (totalAdded: number) => void + onError?: (error: string) => void +} + +interface UseExpandChatReturn { + messages: ChatMessage[] + isLoading: boolean + isComplete: boolean + connectionStatus: ConnectionStatus + featuresCreated: number + recentFeatures: CreatedFeature[] + start: () => void + sendMessage: (content: string, attachments?: ImageAttachment[]) => void + disconnect: () => void +} + +function generateId(): string { + return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}` +} + +export function useExpandChat({ + projectName, + onComplete, + onError, +}: UseExpandChatOptions): UseExpandChatReturn { + const [messages, setMessages] = useState([]) + const [isLoading, setIsLoading] = useState(false) + const [isComplete, setIsComplete] = useState(false) + const [connectionStatus, setConnectionStatus] = useState('disconnected') + const [featuresCreated, setFeaturesCreated] = useState(0) + const [recentFeatures, setRecentFeatures] = useState([]) + + const wsRef = useRef(null) + const currentAssistantMessageRef = useRef(null) + const reconnectAttempts = useRef(0) + const maxReconnectAttempts = 3 + const pingIntervalRef = useRef(null) + const reconnectTimeoutRef = useRef(null) + const isCompleteRef = useRef(false) + + // Keep isCompleteRef in sync with isComplete state + useEffect(() => { + isCompleteRef.current = isComplete + }, [isComplete]) + + // Clean up on unmount + useEffect(() => { + return () => { + if (pingIntervalRef.current) { + clearInterval(pingIntervalRef.current) + } + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current) + } + if (wsRef.current) { + wsRef.current.close() + } + } + }, []) + + const connect = useCallback(() => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + return + } + + setConnectionStatus('connecting') + + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + const host = window.location.host + const wsUrl = `${protocol}//${host}/api/expand/ws/${encodeURIComponent(projectName)}` + + const ws = new WebSocket(wsUrl) + wsRef.current = ws + + ws.onopen = () => { + setConnectionStatus('connected') + reconnectAttempts.current = 0 + + // Start ping interval to keep connection alive + pingIntervalRef.current = window.setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'ping' })) + } + }, 30000) + } + + ws.onclose = () => { + setConnectionStatus('disconnected') + if (pingIntervalRef.current) { + clearInterval(pingIntervalRef.current) + pingIntervalRef.current = null + } + + // Attempt reconnection if not intentionally closed + if (reconnectAttempts.current < maxReconnectAttempts && !isCompleteRef.current) { + reconnectAttempts.current++ + const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.current), 10000) + reconnectTimeoutRef.current = window.setTimeout(connect, delay) + } + } + + ws.onerror = () => { + setConnectionStatus('error') + onError?.('WebSocket connection error') + } + + ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data) as ExpandChatServerMessage + + switch (data.type) { + case 'text': { + // Append text to current assistant message or create new one + setMessages((prev) => { + const lastMessage = prev[prev.length - 1] + if (lastMessage?.role === 'assistant' && lastMessage.isStreaming) { + // Append to existing streaming message + return [ + ...prev.slice(0, -1), + { + ...lastMessage, + content: lastMessage.content + data.content, + }, + ] + } else { + // Create new assistant message + currentAssistantMessageRef.current = generateId() + return [ + ...prev, + { + id: currentAssistantMessageRef.current, + role: 'assistant', + content: data.content, + timestamp: new Date(), + isStreaming: true, + }, + ] + } + }) + break + } + + case 'features_created': { + // Features were created + setFeaturesCreated((prev) => prev + data.count) + setRecentFeatures(data.features) + + // Add system message about feature creation + setMessages((prev) => [ + ...prev, + { + id: generateId(), + role: 'system', + content: `Created ${data.count} new feature${data.count !== 1 ? 's' : ''}!`, + timestamp: new Date(), + }, + ]) + break + } + + case 'expansion_complete': { + setIsComplete(true) + setIsLoading(false) + + // Mark current message as done + setMessages((prev) => { + const lastMessage = prev[prev.length - 1] + if (lastMessage?.role === 'assistant' && lastMessage.isStreaming) { + return [ + ...prev.slice(0, -1), + { ...lastMessage, isStreaming: false }, + ] + } + return prev + }) + + onComplete?.(data.total_added) + break + } + + case 'error': { + setIsLoading(false) + onError?.(data.content) + + // Add error as system message + setMessages((prev) => [ + ...prev, + { + id: generateId(), + role: 'system', + content: `Error: ${data.content}`, + timestamp: new Date(), + }, + ]) + break + } + + case 'pong': { + // Keep-alive response, nothing to do + break + } + + case 'response_done': { + // Response complete - hide loading indicator and mark message as done + setIsLoading(false) + + // Mark current message as done streaming + setMessages((prev) => { + const lastMessage = prev[prev.length - 1] + if (lastMessage?.role === 'assistant' && lastMessage.isStreaming) { + return [ + ...prev.slice(0, -1), + { ...lastMessage, isStreaming: false }, + ] + } + return prev + }) + break + } + } + } catch (e) { + console.error('Failed to parse WebSocket message:', e) + } + } + }, [projectName, onComplete, onError]) + + const start = useCallback(() => { + connect() + + // Wait for connection then send start message + const checkAndSend = () => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + setIsLoading(true) + wsRef.current.send(JSON.stringify({ type: 'start' })) + } else if (wsRef.current?.readyState === WebSocket.CONNECTING) { + setTimeout(checkAndSend, 100) + } + } + + setTimeout(checkAndSend, 100) + }, [connect]) + + const sendMessage = useCallback((content: string, attachments?: ImageAttachment[]) => { + if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) { + onError?.('Not connected') + return + } + + // Add user message to chat (with attachments for display) + setMessages((prev) => [ + ...prev, + { + id: generateId(), + role: 'user', + content, + attachments, + timestamp: new Date(), + }, + ]) + + setIsLoading(true) + + // Build message payload + const payload: { type: string; content: string; attachments?: Array<{ filename: string; mimeType: string; base64Data: string }> } = { + type: 'message', + content, + } + + // Add attachments if present (send base64 data, not preview URL) + if (attachments && attachments.length > 0) { + payload.attachments = attachments.map((a) => ({ + filename: a.filename, + mimeType: a.mimeType, + base64Data: a.base64Data, + })) + } + + // Send to server + wsRef.current.send(JSON.stringify(payload)) + }, [onError]) + + const disconnect = useCallback(() => { + reconnectAttempts.current = maxReconnectAttempts // Prevent reconnection + if (pingIntervalRef.current) { + clearInterval(pingIntervalRef.current) + pingIntervalRef.current = null + } + if (wsRef.current) { + wsRef.current.close() + wsRef.current = null + } + setConnectionStatus('disconnected') + }, []) + + return { + messages, + isLoading, + isComplete, + connectionStatus, + featuresCreated, + recentFeatures, + start, + sendMessage, + disconnect, + } +} diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index bfee6cc..83cf1e5 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -9,6 +9,8 @@ import type { FeatureListResponse, Feature, FeatureCreate, + FeatureBulkCreate, + FeatureBulkCreateResponse, AgentStatusResponse, AgentActionResponse, SetupStatus, @@ -111,6 +113,16 @@ export async function skipFeature(projectName: string, featureId: number): Promi }) } +export async function createFeaturesBulk( + projectName: string, + bulk: FeatureBulkCreate +): Promise { + return fetchJSON(`/projects/${encodeURIComponent(projectName)}/features/bulk`, { + method: 'POST', + body: JSON.stringify(bulk), + }) +} + // ============================================================================ // Agent API // ============================================================================ diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts index c5de195..29931a0 100644 --- a/ui/src/lib/types.ts +++ b/ui/src/lib/types.ts @@ -295,3 +295,37 @@ export type AssistantChatServerMessage = | AssistantChatErrorMessage | AssistantChatConversationCreatedMessage | AssistantChatPongMessage + +// ============================================================================ +// Expand Chat Types +// ============================================================================ + +export interface ExpandChatFeaturesCreatedMessage { + type: 'features_created' + count: number + features: { id: number; name: string; category: string }[] +} + +export interface ExpandChatCompleteMessage { + type: 'expansion_complete' + total_added: number +} + +export type ExpandChatServerMessage = + | SpecChatTextMessage // Reuse text message type + | ExpandChatFeaturesCreatedMessage + | ExpandChatCompleteMessage + | SpecChatErrorMessage // Reuse error message type + | SpecChatPongMessage // Reuse pong message type + | SpecChatResponseDoneMessage // Reuse response_done type + +// Bulk feature creation +export interface FeatureBulkCreate { + features: FeatureCreate[] + starting_priority?: number +} + +export interface FeatureBulkCreateResponse { + created: number + features: Feature[] +}