From 5f06dcf4646cd3cc7297c9ab2769ec578e2d3e52 Mon Sep 17 00:00:00 2001 From: Dan Gentry Date: Fri, 9 Jan 2026 15:56:01 -0500 Subject: [PATCH 1/3] feat: Add "Expand Project" for bulk AI-powered feature creation Adds the ability to add multiple features to an existing project through a natural language conversation with Claude, similar to how initial spec creation works. Features: - New "Expand" button in header (keyboard shortcut: E) - Full-screen chat interface for describing new features - Claude reads existing app_spec.txt for context - Features created directly in database after user approval - Bulk feature creation endpoint for batch operations New files: - .claude/commands/expand-project.md - Claude skill for expansion - server/services/expand_chat_session.py - Chat session service - server/routers/expand_project.py - WebSocket endpoint - ui/src/components/ExpandProjectChat.tsx - Chat UI - ui/src/components/ExpandProjectModal.tsx - Modal wrapper - ui/src/hooks/useExpandChat.ts - WebSocket hook Modified: - Added POST /bulk endpoint to features router - Added FeatureBulkCreate schemas - Integrated Expand button and modal in App.tsx Co-Authored-By: Claude --- .claude/commands/expand-project.md | 241 ++++++++++++ server/main.py | 6 +- server/routers/__init__.py | 2 + server/routers/expand_project.py | 246 +++++++++++++ server/routers/features.py | 82 +++++ server/schemas.py | 12 + server/services/expand_chat_session.py | 444 +++++++++++++++++++++++ ui/src/App.tsx | 51 ++- ui/src/components/ExpandProjectChat.tsx | 375 +++++++++++++++++++ ui/src/components/ExpandProjectModal.tsx | 41 +++ ui/src/hooks/useExpandChat.ts | 323 +++++++++++++++++ ui/src/lib/api.ts | 12 + ui/src/lib/types.ts | 34 ++ 13 files changed, 1863 insertions(+), 6 deletions(-) create mode 100644 .claude/commands/expand-project.md create mode 100644 server/routers/expand_project.py create mode 100644 server/services/expand_chat_session.py create mode 100644 ui/src/components/ExpandProjectChat.tsx create mode 100644 ui/src/components/ExpandProjectModal.tsx create mode 100644 ui/src/hooks/useExpandChat.ts 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[] +} From 75f2bf2a1001cc1e8fa3ce826739f4a581e68c37 Mon Sep 17 00:00:00 2001 From: Dan Gentry Date: Fri, 9 Jan 2026 17:16:06 -0500 Subject: [PATCH 2/3] fix: address code review feedback from coderabbitai - Add language specifier to fenced code block in expand-project.md - Remove detailed exception strings from WebSocket responses (security) - Make WebSocket "start" message idempotent to avoid session reset - Fix race condition in bulk feature creation with row-level lock - Add validation for starting_priority (must be >= 1) - Fix _query_claude to handle multiple feature blocks and deduplicate - Add FileReader error handling in ExpandProjectChat - Fix disconnect() to clear pending reconnect timeout - Enable sandbox mode and validate CLI path in expand_chat_session - Clean up temporary settings file on session close Co-Authored-By: Claude Opus 4.5 --- .claude/commands/expand-project.md | 2 +- server/routers/expand_project.py | 24 ++++++--- server/routers/features.py | 30 ++++++----- server/services/expand_chat_session.py | 70 +++++++++++++++++-------- ui/src/components/ExpandProjectChat.tsx | 3 ++ ui/src/hooks/useExpandChat.ts | 4 ++ 6 files changed, 90 insertions(+), 43 deletions(-) diff --git a/.claude/commands/expand-project.md b/.claude/commands/expand-project.md index 06c3df8..bd02731 100644 --- a/.claude/commands/expand-project.md +++ b/.claude/commands/expand-project.md @@ -144,7 +144,7 @@ Once the user approves, create features directly. **Then output the features in this exact JSON format (the system will parse this):** -``` +```json [ { diff --git a/server/routers/expand_project.py b/server/routers/expand_project.py index a325649..0d806d8 100644 --- a/server/routers/expand_project.py +++ b/server/routers/expand_project.py @@ -161,12 +161,22 @@ async def expand_project_websocket(websocket: WebSocket, project_name: str): continue elif msg_type == "start": - # Create and start a new expansion session - session = await create_expand_session(project_name, project_dir) + # Check if session already exists (idempotent start) + existing_session = get_expand_session(project_name) + if existing_session: + session = existing_session + await websocket.send_json({ + "type": "text", + "content": "Resuming existing expansion session. What would you like to add?" + }) + await websocket.send_json({"type": "response_done"}) + else: + # 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) + # Stream the initial greeting + async for chunk in session.start(): + await websocket.send_json(chunk) elif msg_type == "message": # User sent a message @@ -192,7 +202,7 @@ async def expand_project_websocket(websocket: WebSocket, project_name: str): logger.warning(f"Invalid attachment data: {e}") await websocket.send_json({ "type": "error", - "content": f"Invalid attachment: {str(e)}" + "content": "Invalid attachment format" }) continue @@ -236,7 +246,7 @@ async def expand_project_websocket(websocket: WebSocket, project_name: str): try: await websocket.send_json({ "type": "error", - "content": f"Server error: {str(e)}" + "content": "Internal server error" }) except Exception: pass diff --git a/server/routers/features.py b/server/routers/features.py index 407a92f..0a5849c 100644 --- a/server/routers/features.py +++ b/server/routers/features.py @@ -305,7 +305,7 @@ 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 + - starting_priority if specified (must be >= 1) - max(existing priorities) + 1 if not specified This is useful for: @@ -328,18 +328,28 @@ async def create_features_bulk(project_name: str, bulk: FeatureBulkCreate): if not bulk.features: return FeatureBulkCreateResponse(created=0, features=[]) + # Validate starting_priority if provided + if bulk.starting_priority is not None and bulk.starting_priority < 1: + raise HTTPException(status_code=400, detail="starting_priority must be >= 1") + _, Feature = _get_db_classes() try: with get_db_session(project_dir) as session: - # Determine starting priority + # Determine starting priority with row-level lock to prevent race conditions 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() + # Lock the max priority row to prevent concurrent inserts from getting same priority + max_priority_feature = ( + session.query(Feature) + .order_by(Feature.priority.desc()) + .with_for_update() + .first() + ) current_priority = (max_priority_feature.priority + 1) if max_priority_feature else 1 - created_features = [] + created_ids = [] for feature_data in bulk.features: db_feature = Feature( @@ -351,20 +361,16 @@ async def create_features_bulk(project_name: str, bulk: FeatureBulkCreate): passes=False, ) session.add(db_feature) + session.flush() # Flush to get the ID immediately + created_ids.append(db_feature.id) 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 + # Query created features by their IDs (avoids relying on priority range) 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 + Feature.id.in_(created_ids) ).order_by(Feature.priority).all(): created_features.append(feature_to_response(db_feature)) diff --git a/server/services/expand_chat_session.py b/server/services/expand_chat_session.py index 2c45827..fdd90e9 100644 --- a/server/services/expand_chat_session.py +++ b/server/services/expand_chat_session.py @@ -67,6 +67,7 @@ class ExpandChatSession: self._client_entered: bool = False self.features_created: int = 0 self.created_feature_ids: list[int] = [] + self._settings_file: Optional[Path] = None async def close(self) -> None: """Clean up resources and close the Claude client.""" @@ -79,6 +80,13 @@ class ExpandChatSession: self._client_entered = False self.client = None + # Clean up temporary settings file + if self._settings_file and self._settings_file.exists(): + try: + self._settings_file.unlink() + except Exception as e: + logger.warning(f"Error removing settings file: {e}") + async def start(self) -> AsyncGenerator[dict, None]: """ Initialize session and get initial greeting from Claude. @@ -111,7 +119,7 @@ class ExpandChatSession: # Create security settings file security_settings = { - "sandbox": {"enabled": False}, + "sandbox": {"enabled": True}, "permissions": { "defaultMode": "acceptEdits", "allow": [ @@ -121,6 +129,7 @@ class ExpandChatSession: }, } settings_file = self.project_dir / ".claude_settings.json" + self._settings_file = settings_file with open(settings_file, "w") as f: json.dump(security_settings, f, indent=2) @@ -128,8 +137,14 @@ class ExpandChatSession: project_path = str(self.project_dir.resolve()) system_prompt = skill_content.replace("$ARGUMENTS", project_path) - # Create Claude SDK client + # Find and validate Claude CLI system_cli = shutil.which("claude") + if not system_cli: + yield { + "type": "error", + "content": "Claude CLI not found. Please install Claude Code." + } + return try: self.client = ClaudeSDKClient( options=ClaudeAgentOptions( @@ -268,20 +283,35 @@ class ExpandChatSession: "timestamp": datetime.now().isoformat() }) - # Check for feature creation block in full response - features_match = re.search( + # Check for feature creation blocks in full response (handle multiple blocks) + features_matches = re.findall( 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_matches: + # Collect all features from all blocks, deduplicating by name + all_features: list[dict] = [] + seen_names: set[str] = set() - if features_data and isinstance(features_data, list): - # Create features via REST API - created = await self._create_features_bulk(features_data) + for features_json in features_matches: + try: + features_data = json.loads(features_json) + + if features_data and isinstance(features_data, list): + for feature in features_data: + name = feature.get("name", "") + if name and name not in seen_names: + seen_names.add(name) + all_features.append(feature) + except json.JSONDecodeError as e: + logger.error(f"Failed to parse features JSON block: {e}") + # Continue processing other blocks + + if all_features: + try: + # Create all deduplicated features + created = await self._create_features_bulk(all_features) if created: self.features_created += len(created) @@ -294,18 +324,12 @@ class ExpandChatSession: } 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)}" - } + except Exception as e: + logger.exception("Failed to create features") + yield { + "type": "error", + "content": "Failed to create features" + } async def _create_features_bulk(self, features: list[dict]) -> list[dict]: """ diff --git a/ui/src/components/ExpandProjectChat.tsx b/ui/src/components/ExpandProjectChat.tsx index 2d102d6..1484933 100644 --- a/ui/src/components/ExpandProjectChat.tsx +++ b/ui/src/components/ExpandProjectChat.tsx @@ -121,6 +121,9 @@ export function ExpandProjectChat({ setPendingAttachments((prev) => [...prev, attachment]) } + reader.onerror = () => { + setError(`Failed to read file: ${file.name}`) + } reader.readAsDataURL(file) }) }, []) diff --git a/ui/src/hooks/useExpandChat.ts b/ui/src/hooks/useExpandChat.ts index 0bc48d5..6a7e73e 100644 --- a/ui/src/hooks/useExpandChat.ts +++ b/ui/src/hooks/useExpandChat.ts @@ -302,6 +302,10 @@ export function useExpandChat({ clearInterval(pingIntervalRef.current) pingIntervalRef.current = null } + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current) + reconnectTimeoutRef.current = null + } if (wsRef.current) { wsRef.current.close() wsRef.current = null From cdcbd112726e25f5d7c20950ee4caea9868265fd Mon Sep 17 00:00:00 2001 From: Dan Gentry Date: Fri, 9 Jan 2026 23:57:50 -0500 Subject: [PATCH 3/3] fix: address second round of code review feedback Backend improvements: - Create shared validation utility for project name validation - Add asyncio.Lock to prevent concurrent _query_claude calls - Fix _create_features_bulk: use flush() for IDs, add rollback on error - Use unique temp settings file instead of overwriting .claude_settings.json - Remove exception details from error messages (security) Frontend improvements: - Memoize onError callback in ExpandProjectChat for stable dependencies - Add timeout to start() checkAndSend loop to prevent infinite retries - Add manuallyDisconnectedRef to prevent reconnection after explicit disconnect - Clear pending reconnect timeout in disconnect() Co-Authored-By: Claude Opus 4.5 --- server/routers/expand_project.py | 15 +++--- server/routers/features.py | 12 +---- server/services/expand_chat_session.py | 72 +++++++++++++++---------- server/utils/__init__.py | 1 + server/utils/validation.py | 28 ++++++++++ ui/src/components/ExpandProjectChat.tsx | 5 +- ui/src/hooks/useExpandChat.ts | 26 +++++++-- 7 files changed, 106 insertions(+), 53 deletions(-) create mode 100644 server/utils/__init__.py create mode 100644 server/utils/validation.py diff --git a/server/routers/expand_project.py b/server/routers/expand_project.py index 0d806d8..d894719 100644 --- a/server/routers/expand_project.py +++ b/server/routers/expand_project.py @@ -8,7 +8,6 @@ Allows adding multiple features to existing projects via natural language. import json import logging -import re from pathlib import Path from typing import Optional @@ -23,6 +22,7 @@ from ..services.expand_chat_session import ( list_expand_sessions, remove_expand_session, ) +from ..utils.validation import validate_project_name logger = logging.getLogger(__name__) @@ -43,9 +43,6 @@ def _get_project_path(project_name: str) -> 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)) # ============================================================================ @@ -70,8 +67,7 @@ async def list_expand_sessions_endpoint(): @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") + project_name = validate_project_name(project_name) session = get_expand_session(project_name) if not session: @@ -89,8 +85,7 @@ async def get_expand_session_status(project_name: str): @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") + project_name = validate_project_name(project_name) session = get_expand_session(project_name) if not session: @@ -124,7 +119,9 @@ async def expand_project_websocket(websocket: WebSocket, project_name: str): - {"type": "error", "content": "..."} - Error message - {"type": "pong"} - Keep-alive pong """ - if not validate_project_name(project_name): + try: + project_name = validate_project_name(project_name) + except HTTPException: await websocket.close(code=4000, reason="Invalid project name") return diff --git a/server/routers/features.py b/server/routers/features.py index 0a5849c..ce0f388 100644 --- a/server/routers/features.py +++ b/server/routers/features.py @@ -6,7 +6,6 @@ API endpoints for feature/test case management. """ import logging -import re from contextlib import contextmanager from pathlib import Path @@ -19,6 +18,7 @@ from ..schemas import ( FeatureListResponse, FeatureResponse, ) +from ..utils.validation import validate_project_name # Lazy imports to avoid circular dependencies _create_database = None @@ -56,16 +56,6 @@ def _get_db_classes(): router = APIRouter(prefix="/api/projects/{project_name}/features", tags=["features"]) -def validate_project_name(name: str) -> str: - """Validate and sanitize project name to prevent path traversal.""" - if not re.match(r'^[a-zA-Z0-9_-]{1,50}$', name): - raise HTTPException( - status_code=400, - detail="Invalid project name" - ) - return name - - @contextmanager def get_db_session(project_dir: Path): """ diff --git a/server/services/expand_chat_session.py b/server/services/expand_chat_session.py index fdd90e9..a6825f6 100644 --- a/server/services/expand_chat_session.py +++ b/server/services/expand_chat_session.py @@ -6,11 +6,13 @@ Manages interactive project expansion conversation with Claude. Uses the expand-project.md skill to help users add features to existing projects. """ +import asyncio import json import logging import re import shutil import threading +import uuid from datetime import datetime from pathlib import Path from typing import AsyncGenerator, Optional @@ -68,6 +70,7 @@ class ExpandChatSession: self.features_created: int = 0 self.created_feature_ids: list[int] = [] self._settings_file: Optional[Path] = None + self._query_lock = asyncio.Lock() async def close(self) -> None: """Clean up resources and close the Claude client.""" @@ -117,7 +120,16 @@ class ExpandChatSession: except UnicodeDecodeError: skill_content = skill_path.read_text(encoding="utf-8", errors="replace") - # Create security settings file + # Find and validate Claude CLI before creating temp files + system_cli = shutil.which("claude") + if not system_cli: + yield { + "type": "error", + "content": "Claude CLI not found. Please install Claude Code." + } + return + + # Create temporary security settings file (unique per session to avoid conflicts) security_settings = { "sandbox": {"enabled": True}, "permissions": { @@ -128,23 +140,16 @@ class ExpandChatSession: ], }, } - settings_file = self.project_dir / ".claude_settings.json" + settings_file = self.project_dir / f".claude_settings.expand.{uuid.uuid4().hex}.json" self._settings_file = settings_file - with open(settings_file, "w") as f: + with open(settings_file, "w", encoding="utf-8") 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) - # Find and validate Claude CLI - system_cli = shutil.which("claude") - if not system_cli: - yield { - "type": "error", - "content": "Claude CLI not found. Please install Claude Code." - } - return + # Create Claude SDK client try: self.client = ClaudeSDKClient( options=ClaudeAgentOptions( @@ -167,20 +172,21 @@ class ExpandChatSession: logger.exception("Failed to create Claude client") yield { "type": "error", - "content": f"Failed to initialize Claude: {str(e)}" + "content": "Failed to initialize Claude" } return # Start the conversation try: - async for chunk in self._query_claude("Begin the project expansion process."): - yield chunk + async with self._query_lock: + 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)}" + "content": "Failed to start conversation" } async def send_message( @@ -218,14 +224,16 @@ class ExpandChatSession: }) try: - async for chunk in self._query_claude(user_message, attachments): - yield chunk + # Use lock to prevent concurrent queries from corrupting the response stream + async with self._query_lock: + 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)}" + "content": "Error while processing message" } async def _query_claude( @@ -340,6 +348,10 @@ class ExpandChatSession: Returns: List of created feature dictionaries with IDs + + Note: + Uses flush() to get IDs immediately without re-querying by priority range, + which could pick up rows from concurrent writers. """ # Import database classes import sys @@ -358,7 +370,7 @@ class ExpandChatSession: 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 = [] + created_rows: list = [] for f in features: db_feature = Feature( @@ -370,24 +382,28 @@ class ExpandChatSession: passes=False, ) session.add(db_feature) + created_rows.append(db_feature) current_priority += 1 - session.commit() + # Flush to get IDs without relying on priority range query + session.flush() - # 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({ + # Build result from the flushed objects (IDs are now populated) + created_features = [ + { "id": db_feature.id, "name": db_feature.name, "category": db_feature.category, - }) + } + for db_feature in created_rows + ] + session.commit() return created_features + except Exception: + session.rollback() + raise finally: session.close() diff --git a/server/utils/__init__.py b/server/utils/__init__.py new file mode 100644 index 0000000..8ed4d66 --- /dev/null +++ b/server/utils/__init__.py @@ -0,0 +1 @@ +# Server utilities diff --git a/server/utils/validation.py b/server/utils/validation.py new file mode 100644 index 0000000..9f1bf11 --- /dev/null +++ b/server/utils/validation.py @@ -0,0 +1,28 @@ +""" +Shared validation utilities for the server. +""" + +import re + +from fastapi import HTTPException + + +def validate_project_name(name: str) -> str: + """ + Validate and sanitize project name to prevent path traversal. + + Args: + name: Project name to validate + + Returns: + The validated project name + + Raises: + HTTPException: If name is invalid + """ + if not re.match(r'^[a-zA-Z0-9_-]{1,50}$', name): + raise HTTPException( + status_code=400, + detail="Invalid project name. Use only letters, numbers, hyphens, and underscores (1-50 chars)." + ) + return name diff --git a/ui/src/components/ExpandProjectChat.tsx b/ui/src/components/ExpandProjectChat.tsx index 1484933..1077a6d 100644 --- a/ui/src/components/ExpandProjectChat.tsx +++ b/ui/src/components/ExpandProjectChat.tsx @@ -34,6 +34,9 @@ export function ExpandProjectChat({ const inputRef = useRef(null) const fileInputRef = useRef(null) + // Memoize error handler to keep hook dependencies stable + const handleError = useCallback((err: string) => setError(err), []) + const { messages, isLoading, @@ -46,7 +49,7 @@ export function ExpandProjectChat({ } = useExpandChat({ projectName, onComplete, - onError: (err) => setError(err), + onError: handleError, }) // Start the chat session when component mounts diff --git a/ui/src/hooks/useExpandChat.ts b/ui/src/hooks/useExpandChat.ts index 6a7e73e..9150885 100644 --- a/ui/src/hooks/useExpandChat.ts +++ b/ui/src/hooks/useExpandChat.ts @@ -54,6 +54,7 @@ export function useExpandChat({ const pingIntervalRef = useRef(null) const reconnectTimeoutRef = useRef(null) const isCompleteRef = useRef(false) + const manuallyDisconnectedRef = useRef(false) // Keep isCompleteRef in sync with isComplete state useEffect(() => { @@ -76,6 +77,10 @@ export function useExpandChat({ }, []) const connect = useCallback(() => { + // Don't reconnect if manually disconnected + if (manuallyDisconnectedRef.current) { + return + } if (wsRef.current?.readyState === WebSocket.OPEN) { return } @@ -92,6 +97,7 @@ export function useExpandChat({ ws.onopen = () => { setConnectionStatus('connected') reconnectAttempts.current = 0 + manuallyDisconnectedRef.current = false // Start ping interval to keep connection alive pingIntervalRef.current = window.setInterval(() => { @@ -109,7 +115,11 @@ export function useExpandChat({ } // Attempt reconnection if not intentionally closed - if (reconnectAttempts.current < maxReconnectAttempts && !isCompleteRef.current) { + if ( + !manuallyDisconnectedRef.current && + reconnectAttempts.current < maxReconnectAttempts && + !isCompleteRef.current + ) { reconnectAttempts.current++ const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.current), 10000) reconnectTimeoutRef.current = window.setTimeout(connect, delay) @@ -244,18 +254,25 @@ export function useExpandChat({ const start = useCallback(() => { connect() - // Wait for connection then send start message + // Wait for connection then send start message (with timeout to prevent infinite loop) + let attempts = 0 + const maxAttempts = 50 // 5 seconds max (50 * 100ms) 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) + if (attempts++ < maxAttempts) { + setTimeout(checkAndSend, 100) + } else { + onError?.('Connection timeout') + setIsLoading(false) + } } } setTimeout(checkAndSend, 100) - }, [connect]) + }, [connect, onError]) const sendMessage = useCallback((content: string, attachments?: ImageAttachment[]) => { if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) { @@ -297,6 +314,7 @@ export function useExpandChat({ }, [onError]) const disconnect = useCallback(() => { + manuallyDisconnectedRef.current = true reconnectAttempts.current = maxReconnectAttempts // Prevent reconnection if (pingIntervalRef.current) { clearInterval(pingIntervalRef.current)