diff --git a/server/main.py b/server/main.py index 00c18b7..3528f5b 100644 --- a/server/main.py +++ b/server/main.py @@ -15,7 +15,7 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse -from .routers import projects_router, features_router, agent_router +from .routers import projects_router, features_router, agent_router, spec_creation_router from .websocket import project_websocket from .services.process_manager import cleanup_all_managers from .schemas import SetupStatus @@ -81,6 +81,7 @@ async def require_localhost(request: Request, call_next): app.include_router(projects_router) app.include_router(features_router) app.include_router(agent_router) +app.include_router(spec_creation_router) # ============================================================================ diff --git a/server/routers/__init__.py b/server/routers/__init__.py index c8411c1..381f6c3 100644 --- a/server/routers/__init__.py +++ b/server/routers/__init__.py @@ -8,5 +8,6 @@ FastAPI routers for different API endpoints. from .projects import router as projects_router from .features import router as features_router from .agent import router as agent_router +from .spec_creation import router as spec_creation_router -__all__ = ["projects_router", "features_router", "agent_router"] +__all__ = ["projects_router", "features_router", "agent_router", "spec_creation_router"] diff --git a/server/routers/spec_creation.py b/server/routers/spec_creation.py new file mode 100644 index 0000000..1f289e9 --- /dev/null +++ b/server/routers/spec_creation.py @@ -0,0 +1,233 @@ +""" +Spec Creation Router +==================== + +WebSocket and REST endpoints for interactive spec creation with Claude. +""" + +import asyncio +import json +import logging +import re +from pathlib import Path +from typing import Any, Optional + +from fastapi import APIRouter, WebSocket, WebSocketDisconnect, HTTPException +from pydantic import BaseModel + +from ..services.spec_chat_session import ( + SpecChatSession, + get_session, + create_session, + remove_session, + list_sessions, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/spec", tags=["spec-creation"]) + +# Root directory +ROOT_DIR = Path(__file__).parent.parent.parent + + +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 SpecSessionStatus(BaseModel): + """Status of a spec creation session.""" + project_name: str + is_active: bool + is_complete: bool + message_count: int + + +@router.get("/sessions", response_model=list[str]) +async def list_spec_sessions(): + """List all active spec creation sessions.""" + return list_sessions() + + +@router.get("/sessions/{project_name}", response_model=SpecSessionStatus) +async def get_session_status(project_name: str): + """Get status of a spec creation session.""" + if not validate_project_name(project_name): + raise HTTPException(status_code=400, detail="Invalid project name") + + session = get_session(project_name) + if not session: + raise HTTPException(status_code=404, detail="No active session for this project") + + return SpecSessionStatus( + project_name=project_name, + is_active=True, + is_complete=session.is_complete(), + message_count=len(session.get_messages()), + ) + + +@router.delete("/sessions/{project_name}") +async def cancel_session(project_name: str): + """Cancel and remove a spec creation session.""" + if not validate_project_name(project_name): + raise HTTPException(status_code=400, detail="Invalid project name") + + session = get_session(project_name) + if not session: + raise HTTPException(status_code=404, detail="No active session for this project") + + await remove_session(project_name) + return {"success": True, "message": "Session cancelled"} + + +# ============================================================================ +# WebSocket Endpoint +# ============================================================================ + +@router.websocket("/ws/{project_name}") +async def spec_chat_websocket(websocket: WebSocket, project_name: str): + """ + WebSocket endpoint for interactive spec creation chat. + + Message protocol: + + Client -> Server: + - {"type": "start"} - Start the spec creation session + - {"type": "message", "content": "..."} - Send user message + - {"type": "answer", "answers": {...}, "tool_id": "..."} - Answer structured question + - {"type": "ping"} - Keep-alive ping + + Server -> Client: + - {"type": "text", "content": "..."} - Text chunk from Claude + - {"type": "question", "questions": [...], "tool_id": "..."} - Structured question + - {"type": "spec_complete", "path": "..."} - Spec file created + - {"type": "file_written", "path": "..."} - Other file written + - {"type": "complete"} - Session 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 + + await websocket.accept() + + session: Optional[SpecChatSession] = 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 session + session = await create_session(project_name) + + # Stream the initial greeting + async for chunk in session.start(): + await websocket.send_json(chunk) + + # Check for completion + if chunk.get("type") == "spec_complete": + await websocket.send_json({"type": "complete"}) + + elif msg_type == "message": + # User sent a message + if not session: + session = get_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() + if not user_content: + await websocket.send_json({ + "type": "error", + "content": "Empty message" + }) + continue + + # Stream Claude's response + async for chunk in session.send_message(user_content): + await websocket.send_json(chunk) + + # Check for completion + if chunk.get("type") == "spec_complete": + await websocket.send_json({"type": "complete"}) + + elif msg_type == "answer": + # User answered a structured question + if not session: + session = get_session(project_name) + if not session: + await websocket.send_json({ + "type": "error", + "content": "No active session" + }) + continue + + # Format the answers as a natural response + answers = message.get("answers", {}) + if isinstance(answers, dict): + # Convert structured answers to a message + response_parts = [] + for question_idx, answer_value in answers.items(): + if isinstance(answer_value, list): + response_parts.append(", ".join(answer_value)) + else: + response_parts.append(str(answer_value)) + user_response = "; ".join(response_parts) if response_parts else "OK" + else: + user_response = str(answers) + + # Stream Claude's response + async for chunk in session.send_message(user_response): + await websocket.send_json(chunk) + + if chunk.get("type") == "spec_complete": + await websocket.send_json({"type": "complete"}) + + 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"Spec chat WebSocket disconnected for {project_name}") + + except Exception as e: + logger.exception(f"Spec 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/services/spec_chat_session.py b/server/services/spec_chat_session.py new file mode 100644 index 0000000..6ee6e11 --- /dev/null +++ b/server/services/spec_chat_session.py @@ -0,0 +1,322 @@ +""" +Spec Creation Chat Session +========================== + +Manages interactive spec creation conversation with Claude. +Uses the create-spec.md skill to guide users through app spec creation. +""" + +import asyncio +import logging +import threading +from datetime import datetime +from pathlib import Path +from typing import AsyncGenerator, Optional + +from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient + +logger = logging.getLogger(__name__) + +# Root directory of the project +ROOT_DIR = Path(__file__).parent.parent.parent + + +class SpecChatSession: + """ + Manages a spec creation conversation for one project. + + Uses the create-spec skill to guide users through: + - Phase 1: Project Overview (name, description, audience) + - Phase 2: Involvement Level (Quick vs Detailed mode) + - Phase 3: Technology Preferences + - Phase 4: Features (main exploration phase) + - Phase 5: Technical Details (derived or discussed) + - Phase 6-7: Success Criteria & Approval + """ + + def __init__(self, project_name: str): + """ + Initialize the session. + + Args: + project_name: Name of the project being created + """ + self.project_name = project_name + self.project_dir = ROOT_DIR / "generations" / project_name + 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 # Track if context manager is active + + 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 create-spec skill + skill_path = ROOT_DIR / ".claude" / "commands" / "create-spec.md" + + if not skill_path.exists(): + yield { + "type": "error", + "content": f"Spec creation skill not found at {skill_path}" + } + return + + try: + skill_content = skill_path.read_text(encoding="utf-8") + except UnicodeDecodeError: + skill_content = skill_path.read_text(encoding="utf-8", errors="replace") + + # Replace $ARGUMENTS with the project path (use forward slashes for consistency) + project_path = f"generations/{self.project_name}" + system_prompt = skill_content.replace("$ARGUMENTS", project_path) + + # Create Claude SDK client with limited tools for spec creation + try: + self.client = ClaudeSDKClient( + options=ClaudeAgentOptions( + model="claude-sonnet-4-20250514", + system_prompt=system_prompt, + allowed_tools=[ + "Read", + "Write", + "AskUserQuestion", + ], + max_turns=100, + cwd=str(ROOT_DIR.resolve()), + ) + ) + # Enter the async context and track it + 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 - Claude will send the Phase 1 greeting + try: + async for chunk in self._query_claude("Begin the spec creation process."): + yield chunk + # Signal that the response is complete (for UI to hide loading indicator) + yield {"type": "response_done"} + except Exception as e: + logger.exception("Failed to start spec chat") + yield { + "type": "error", + "content": f"Failed to start conversation: {str(e)}" + } + + async def send_message(self, user_message: str) -> AsyncGenerator[dict, None]: + """ + Send user message and stream Claude's response. + + Args: + user_message: The user's response + + Yields: + Message chunks of various types: + - {"type": "text", "content": str} + - {"type": "question", "questions": list} + - {"type": "spec_complete", "path": str} + - {"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, + "timestamp": datetime.now().isoformat() + }) + + try: + async for chunk in self._query_claude(user_message): + yield chunk + # Signal that the response is complete (for UI to hide loading indicator) + 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) -> AsyncGenerator[dict, None]: + """ + Internal method to query Claude and stream responses. + + Handles tool calls (AskUserQuestion, Write) and text responses. + """ + if not self.client: + return + + # Send the message to Claude using the SDK's query method + await self.client.query(message) + + current_text = "" + + # Stream the response using receive_response + async for msg in self.client.receive_response(): + msg_type = type(msg).__name__ + + if msg_type == "AssistantMessage" and hasattr(msg, "content"): + # Process content blocks in the assistant message + for block in msg.content: + block_type = type(block).__name__ + + if block_type == "TextBlock" and hasattr(block, "text"): + # Accumulate text and yield it + text = block.text + if text: + current_text += text + yield {"type": "text", "content": text} + + # Store in message history + self.messages.append({ + "role": "assistant", + "content": text, + "timestamp": datetime.now().isoformat() + }) + + elif block_type == "ToolUseBlock" and hasattr(block, "name"): + tool_name = block.name + tool_input = getattr(block, "input", {}) + tool_id = getattr(block, "id", "") + + if tool_name == "AskUserQuestion": + # Convert AskUserQuestion to structured UI + questions = tool_input.get("questions", []) + yield { + "type": "question", + "questions": questions, + "tool_id": tool_id + } + # The SDK handles tool results internally + + elif tool_name == "Write": + # File being written - the SDK handles this + file_path = tool_input.get("file_path", "") + + # Check if this is the app_spec.txt file + if "app_spec.txt" in str(file_path): + self.complete = True + yield { + "type": "spec_complete", + "path": str(file_path) + } + + elif "initializer_prompt.md" in str(file_path): + yield { + "type": "file_written", + "path": str(file_path) + } + + elif msg_type == "UserMessage" and hasattr(msg, "content"): + # Tool results - the SDK handles these automatically + # We just watch for any errors + for block in msg.content: + block_type = type(block).__name__ + if block_type == "ToolResultBlock": + is_error = getattr(block, "is_error", False) + if is_error: + content = getattr(block, "content", "Unknown error") + logger.warning(f"Tool error: {content}") + + def is_complete(self) -> bool: + """Check if spec creation 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 +_sessions: dict[str, SpecChatSession] = {} +_sessions_lock = threading.Lock() + + +def get_session(project_name: str) -> Optional[SpecChatSession]: + """Get an existing session for a project.""" + with _sessions_lock: + return _sessions.get(project_name) + + +async def create_session(project_name: str) -> SpecChatSession: + """Create a new session for a project, closing any existing one.""" + old_session: Optional[SpecChatSession] = None + + with _sessions_lock: + # Get existing session to close later (outside the lock) + old_session = _sessions.pop(project_name, None) + session = SpecChatSession(project_name) + _sessions[project_name] = session + + # Close old session outside the lock to avoid blocking + if old_session: + try: + await old_session.close() + except Exception as e: + logger.warning(f"Error closing old session for {project_name}: {e}") + + return session + + +async def remove_session(project_name: str) -> None: + """Remove and close a session.""" + session: Optional[SpecChatSession] = None + + with _sessions_lock: + session = _sessions.pop(project_name, None) + + # Close session outside the lock + if session: + try: + await session.close() + except Exception as e: + logger.warning(f"Error closing session for {project_name}: {e}") + + +def list_sessions() -> list[str]: + """List all active session project names.""" + with _sessions_lock: + return list(_sessions.keys()) + + +async def cleanup_all_sessions() -> None: + """Close all active sessions. Called on server shutdown.""" + sessions_to_close: list[SpecChatSession] = [] + + with _sessions_lock: + sessions_to_close = list(_sessions.values()) + _sessions.clear() + + for session in sessions_to_close: + try: + await session.close() + except Exception as e: + logger.warning(f"Error closing session {session.project_name}: {e}") diff --git a/ui/src/components/ChatMessage.tsx b/ui/src/components/ChatMessage.tsx new file mode 100644 index 0000000..66341ad --- /dev/null +++ b/ui/src/components/ChatMessage.tsx @@ -0,0 +1,167 @@ +/** + * Chat Message Component + * + * Displays a single message in the spec creation chat. + * Supports user, assistant, and system messages with neobrutalism styling. + */ + +import { Bot, User, Info } from 'lucide-react' +import type { ChatMessage as ChatMessageType } from '../lib/types' + +interface ChatMessageProps { + message: ChatMessageType +} + +export function ChatMessage({ message }: ChatMessageProps) { + const { role, content, timestamp, isStreaming } = message + + // Format timestamp + const timeString = timestamp.toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }) + + // Role-specific styling + const roleConfig = { + user: { + icon: User, + bgColor: 'bg-[var(--color-neo-pending)]', + borderColor: 'border-[var(--color-neo-border)]', + align: 'justify-end', + bubbleAlign: 'items-end', + iconBg: 'bg-[var(--color-neo-pending)]', + }, + assistant: { + icon: Bot, + bgColor: 'bg-white', + borderColor: 'border-[var(--color-neo-border)]', + align: 'justify-start', + bubbleAlign: 'items-start', + iconBg: 'bg-[var(--color-neo-progress)]', + }, + system: { + icon: Info, + bgColor: 'bg-[var(--color-neo-done)]', + borderColor: 'border-[var(--color-neo-border)]', + align: 'justify-center', + bubbleAlign: 'items-center', + iconBg: 'bg-[var(--color-neo-done)]', + }, + } + + const config = roleConfig[role] + const Icon = config.icon + + // System messages are styled differently + if (role === 'system') { + return ( +
+
+ + + {content} + +
+
+ ) + } + + return ( +
+
+ {/* Message bubble */} +
+ {role === 'assistant' && ( +
+ +
+ )} + +
+ {/* Parse content for basic markdown-like formatting */} +
+ {content.split('\n').map((line, i) => { + // Bold text + const boldRegex = /\*\*(.*?)\*\*/g + const parts = [] + let lastIndex = 0 + let match + + while ((match = boldRegex.exec(line)) !== null) { + if (match.index > lastIndex) { + parts.push(line.slice(lastIndex, match.index)) + } + parts.push( + + {match[1]} + + ) + lastIndex = match.index + match[0].length + } + + if (lastIndex < line.length) { + parts.push(line.slice(lastIndex)) + } + + return ( + + {parts.length > 0 ? parts : line} + {i < content.split('\n').length - 1 && '\n'} + + ) + })} +
+ + {/* Streaming indicator */} + {isStreaming && ( + + )} +
+ + {role === 'user' && ( +
+ +
+ )} +
+ + {/* Timestamp */} + + {timeString} + +
+
+ ) +} diff --git a/ui/src/components/NewProjectModal.tsx b/ui/src/components/NewProjectModal.tsx new file mode 100644 index 0000000..bbbd58c --- /dev/null +++ b/ui/src/components/NewProjectModal.tsx @@ -0,0 +1,315 @@ +/** + * New Project Modal Component + * + * Multi-step modal for creating new projects: + * 1. Enter project name + * 2. Choose spec method (Claude or manual) + * 3a. If Claude: Show SpecCreationChat + * 3b. If manual: Create project and close + */ + +import { useState } from 'react' +import { X, Bot, FileEdit, ArrowRight, ArrowLeft, Loader2, CheckCircle2 } from 'lucide-react' +import { useCreateProject } from '../hooks/useProjects' +import { SpecCreationChat } from './SpecCreationChat' + +type Step = 'name' | 'method' | 'chat' | 'complete' +type SpecMethod = 'claude' | 'manual' + +interface NewProjectModalProps { + isOpen: boolean + onClose: () => void + onProjectCreated: (projectName: string) => void +} + +export function NewProjectModal({ + isOpen, + onClose, + onProjectCreated, +}: NewProjectModalProps) { + const [step, setStep] = useState('name') + const [projectName, setProjectName] = useState('') + const [_specMethod, setSpecMethod] = useState(null) + const [error, setError] = useState(null) + + // Suppress unused variable warning - specMethod may be used in future + void _specMethod + + const createProject = useCreateProject() + + if (!isOpen) return null + + const handleNameSubmit = (e: React.FormEvent) => { + e.preventDefault() + const trimmed = projectName.trim() + + if (!trimmed) { + setError('Please enter a project name') + return + } + + if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) { + setError('Project name can only contain letters, numbers, hyphens, and underscores') + return + } + + setError(null) + setStep('method') + } + + const handleMethodSelect = async (method: SpecMethod) => { + setSpecMethod(method) + + if (method === 'manual') { + // Create project immediately with manual method + try { + const project = await createProject.mutateAsync({ + name: projectName.trim(), + specMethod: 'manual', + }) + setStep('complete') + setTimeout(() => { + onProjectCreated(project.name) + handleClose() + }, 1500) + } catch (err: unknown) { + setError(err instanceof Error ? err.message : 'Failed to create project') + } + } else { + // Create project then show chat + try { + await createProject.mutateAsync({ + name: projectName.trim(), + specMethod: 'claude', + }) + setStep('chat') + } catch (err: unknown) { + setError(err instanceof Error ? err.message : 'Failed to create project') + } + } + } + + const handleSpecComplete = () => { + setStep('complete') + setTimeout(() => { + onProjectCreated(projectName.trim()) + handleClose() + }, 1500) + } + + const handleChatCancel = () => { + // Go back to method selection but keep the project + setStep('method') + setSpecMethod(null) + } + + const handleClose = () => { + setStep('name') + setProjectName('') + setSpecMethod(null) + setError(null) + onClose() + } + + const handleBack = () => { + if (step === 'method') { + setStep('name') + setSpecMethod(null) + } + } + + // Full-screen chat view + if (step === 'chat') { + return ( +
+ +
+ ) + } + + return ( +
+
e.stopPropagation()} + > + {/* Header */} +
+

+ {step === 'name' && 'Create New Project'} + {step === 'method' && 'Choose Setup Method'} + {step === 'complete' && 'Project Created!'} +

+ +
+ + {/* Content */} +
+ {/* Step 1: Project Name */} + {step === 'name' && ( +
+
+ + setProjectName(e.target.value)} + placeholder="my-awesome-app" + className="neo-input" + pattern="^[a-zA-Z0-9_-]+$" + autoFocus + /> +

+ Use letters, numbers, hyphens, and underscores only. +

+
+ + {error && ( +
+ {error} +
+ )} + +
+ +
+
+ )} + + {/* Step 2: Spec Method */} + {step === 'method' && ( +
+

+ How would you like to define your project? +

+ +
+ {/* Claude option */} + + + {/* Manual option */} + +
+ + {error && ( +
+ {error} +
+ )} + + {createProject.isPending && ( +
+ + Creating project... +
+ )} + +
+ +
+
+ )} + + {/* Step 3: Complete */} + {step === 'complete' && ( +
+
+ +
+

+ {projectName} +

+

+ Your project has been created successfully! +

+
+ + Redirecting... +
+
+ )} +
+
+
+ ) +} diff --git a/ui/src/components/ProjectSelector.tsx b/ui/src/components/ProjectSelector.tsx index cf7657a..03e620b 100644 --- a/ui/src/components/ProjectSelector.tsx +++ b/ui/src/components/ProjectSelector.tsx @@ -1,7 +1,7 @@ import { useState } from 'react' import { ChevronDown, Plus, FolderOpen, Loader2 } from 'lucide-react' -import { useCreateProject } from '../hooks/useProjects' import type { ProjectSummary } from '../lib/types' +import { NewProjectModal } from './NewProjectModal' interface ProjectSelectorProps { projects: ProjectSummary[] @@ -17,27 +17,11 @@ export function ProjectSelector({ isLoading, }: ProjectSelectorProps) { const [isOpen, setIsOpen] = useState(false) - const [showCreate, setShowCreate] = useState(false) - const [newProjectName, setNewProjectName] = useState('') + const [showNewProjectModal, setShowNewProjectModal] = useState(false) - const createProject = useCreateProject() - - const handleCreateProject = async (e: React.FormEvent) => { - e.preventDefault() - if (!newProjectName.trim()) return - - try { - const project = await createProject.mutateAsync({ - name: newProjectName.trim(), - specMethod: 'manual', - }) - onSelectProject(project.name) - setNewProjectName('') - setShowCreate(false) - setIsOpen(false) - } catch (error) { - console.error('Failed to create project:', error) - } + const handleProjectCreated = (projectName: string) => { + onSelectProject(projectName) + setIsOpen(false) } const selectedProjectData = projects.find(p => p.name === selectedProject) @@ -120,53 +104,26 @@ export function ProjectSelector({
{/* Create New */} - {showCreate ? ( -
- setNewProjectName(e.target.value)} - placeholder="project-name" - className="neo-input text-sm mb-2" - pattern="^[a-zA-Z0-9_-]+$" - autoFocus - /> -
- - -
-
- ) : ( - - )} +
)} + + {/* New Project Modal */} + setShowNewProjectModal(false)} + onProjectCreated={handleProjectCreated} + /> ) } diff --git a/ui/src/components/QuestionOptions.tsx b/ui/src/components/QuestionOptions.tsx new file mode 100644 index 0000000..fd2cba0 --- /dev/null +++ b/ui/src/components/QuestionOptions.tsx @@ -0,0 +1,230 @@ +/** + * Question Options Component + * + * Renders structured questions from AskUserQuestion tool. + * Shows clickable option buttons in neobrutalism style. + */ + +import { useState } from 'react' +import { Check } from 'lucide-react' +import type { SpecQuestion } from '../lib/types' + +interface QuestionOptionsProps { + questions: SpecQuestion[] + onSubmit: (answers: Record) => void + disabled?: boolean +} + +export function QuestionOptions({ + questions, + onSubmit, + disabled = false, +}: QuestionOptionsProps) { + // Track selected answers for each question + const [answers, setAnswers] = useState>({}) + const [customInputs, setCustomInputs] = useState>({}) + const [showCustomInput, setShowCustomInput] = useState>({}) + + const handleOptionClick = (questionIdx: number, optionLabel: string, multiSelect: boolean) => { + const key = String(questionIdx) + + if (optionLabel === 'Other') { + setShowCustomInput((prev) => ({ ...prev, [key]: true })) + return + } + + setShowCustomInput((prev) => ({ ...prev, [key]: false })) + + setAnswers((prev) => { + if (multiSelect) { + const current = (prev[key] as string[]) || [] + if (current.includes(optionLabel)) { + return { ...prev, [key]: current.filter((o) => o !== optionLabel) } + } else { + return { ...prev, [key]: [...current, optionLabel] } + } + } else { + return { ...prev, [key]: optionLabel } + } + }) + } + + const handleCustomInputChange = (questionIdx: number, value: string) => { + const key = String(questionIdx) + setCustomInputs((prev) => ({ ...prev, [key]: value })) + setAnswers((prev) => ({ ...prev, [key]: value })) + } + + const handleSubmit = () => { + // Ensure all questions have answers + const finalAnswers: Record = {} + + questions.forEach((_, idx) => { + const key = String(idx) + if (showCustomInput[key] && customInputs[key]) { + finalAnswers[key] = customInputs[key] + } else if (answers[key]) { + finalAnswers[key] = answers[key] + } + }) + + onSubmit(finalAnswers) + } + + const isOptionSelected = (questionIdx: number, optionLabel: string, multiSelect: boolean) => { + const key = String(questionIdx) + const answer = answers[key] + + if (multiSelect) { + return Array.isArray(answer) && answer.includes(optionLabel) + } + return answer === optionLabel + } + + const hasAnswer = (questionIdx: number) => { + const key = String(questionIdx) + return !!(answers[key] || (showCustomInput[key] && customInputs[key])) + } + + const allQuestionsAnswered = questions.every((_, idx) => hasAnswer(idx)) + + return ( +
+ {questions.map((q, questionIdx) => ( +
+ {/* Question header */} +
+ + {q.header} + + + {q.question} + + {q.multiSelect && ( + + (select multiple) + + )} +
+ + {/* Options grid */} +
+ {q.options.map((opt, optIdx) => { + const isSelected = isOptionSelected(questionIdx, opt.label, q.multiSelect) + + return ( + + ) + })} + + {/* "Other" option */} + +
+ + {/* Custom input field */} + {showCustomInput[String(questionIdx)] && ( +
+ handleCustomInputChange(questionIdx, e.target.value)} + placeholder="Type your answer..." + className="neo-input" + autoFocus + disabled={disabled} + /> +
+ )} +
+ ))} + + {/* Submit button */} +
+ +
+
+ ) +} diff --git a/ui/src/components/SpecCreationChat.tsx b/ui/src/components/SpecCreationChat.tsx new file mode 100644 index 0000000..caa7b50 --- /dev/null +++ b/ui/src/components/SpecCreationChat.tsx @@ -0,0 +1,261 @@ +/** + * Spec Creation Chat Component + * + * Full chat interface for interactive spec creation with Claude. + * Handles the 7-phase conversation flow for creating app specifications. + */ + +import { useEffect, useRef, useState } from 'react' +import { Send, X, CheckCircle2, AlertCircle, Wifi, WifiOff, RotateCcw } from 'lucide-react' +import { useSpecChat } from '../hooks/useSpecChat' +import { ChatMessage } from './ChatMessage' +import { QuestionOptions } from './QuestionOptions' +import { TypingIndicator } from './TypingIndicator' + +interface SpecCreationChatProps { + projectName: string + onComplete: (specPath: string) => void + onCancel: () => void +} + +export function SpecCreationChat({ + projectName, + onComplete, + onCancel, +}: SpecCreationChatProps) { + const [input, setInput] = useState('') + const [error, setError] = useState(null) + const messagesEndRef = useRef(null) + const inputRef = useRef(null) + + const { + messages, + isLoading, + isComplete, + connectionStatus, + currentQuestions, + start, + sendMessage, + sendAnswer, + disconnect, + } = useSpecChat({ + 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, currentQuestions, isLoading]) + + // Focus input when not loading and no questions + useEffect(() => { + if (!isLoading && !currentQuestions && inputRef.current) { + inputRef.current.focus() + } + }, [isLoading, currentQuestions]) + + const handleSendMessage = () => { + const trimmed = input.trim() + if (!trimmed || isLoading) return + + sendMessage(trimmed) + setInput('') + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleSendMessage() + } + } + + const handleAnswerSubmit = (answers: Record) => { + sendAnswer(answers) + } + + // Connection status indicator + const ConnectionIndicator = () => { + switch (connectionStatus) { + case 'connected': + return ( + + + Connected + + ) + case 'connecting': + return ( + + + Connecting... + + ) + case 'error': + return ( + + + Error + + ) + default: + return ( + + + Disconnected + + ) + } + } + + return ( +
+ {/* Header */} +
+
+

+ Create Spec: {projectName} +

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

+ Starting Spec Creation +

+

+ Connecting to Claude to help you create your app specification... +

+ {connectionStatus === 'error' && ( + + )} +
+
+ )} + + {messages.map((message) => ( + + ))} + + {/* Structured questions */} + {currentQuestions && currentQuestions.length > 0 && ( + + )} + + {/* Typing indicator - don't show when we have questions (waiting for user) */} + {isLoading && !currentQuestions && } + + {/* Scroll anchor */} +
+
+ + {/* Input area */} + {!isComplete && ( +
+
+ setInput(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={ + currentQuestions + ? 'Or type a custom response...' + : 'Type your response...' + } + className="neo-input flex-1" + disabled={(isLoading && !currentQuestions) || connectionStatus !== 'connected'} + /> + +
+ + {/* Help text */} +

+ Press Enter to send. Claude will guide you through creating your app specification. +

+
+ )} + + {/* Completion footer */} + {isComplete && ( +
+
+
+ + Specification created successfully! +
+ +
+
+ )} +
+ ) +} diff --git a/ui/src/components/TypingIndicator.tsx b/ui/src/components/TypingIndicator.tsx new file mode 100644 index 0000000..7cfc927 --- /dev/null +++ b/ui/src/components/TypingIndicator.tsx @@ -0,0 +1,30 @@ +/** + * Typing Indicator Component + * + * Shows animated dots to indicate Claude is typing/thinking. + * Styled in neobrutalism aesthetic. + */ + +export function TypingIndicator() { + return ( +
+
+ + + +
+ + Claude is thinking... + +
+ ) +} diff --git a/ui/src/hooks/useSpecChat.ts b/ui/src/hooks/useSpecChat.ts new file mode 100644 index 0000000..0c435e6 --- /dev/null +++ b/ui/src/hooks/useSpecChat.ts @@ -0,0 +1,392 @@ +/** + * Hook for managing spec creation chat WebSocket connection + */ + +import { useState, useCallback, useRef, useEffect } from 'react' +import type { ChatMessage, SpecChatServerMessage, SpecQuestion } from '../lib/types' + +type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error' + +interface UseSpecChatOptions { + projectName: string + onComplete?: (specPath: string) => void + onError?: (error: string) => void +} + +interface UseSpecChatReturn { + messages: ChatMessage[] + isLoading: boolean + isComplete: boolean + connectionStatus: ConnectionStatus + currentQuestions: SpecQuestion[] | null + currentToolId: string | null + start: () => void + sendMessage: (content: string) => void + sendAnswer: (answers: Record) => void + disconnect: () => void +} + +function generateId(): string { + return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}` +} + +export function useSpecChat({ + projectName, + onComplete, + onError, +}: UseSpecChatOptions): UseSpecChatReturn { + const [messages, setMessages] = useState([]) + const [isLoading, setIsLoading] = useState(false) + const [isComplete, setIsComplete] = useState(false) + const [connectionStatus, setConnectionStatus] = useState('disconnected') + const [currentQuestions, setCurrentQuestions] = useState(null) + const [currentToolId, setCurrentToolId] = useState(null) + + 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/spec/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 SpecChatServerMessage + + 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 'question': { + // Show structured question UI + setCurrentQuestions(data.questions) + setCurrentToolId(data.tool_id || null) + 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, + questions: data.questions, + }, + ] + } + return prev + }) + break + } + + case 'spec_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 + }) + + // Add system message about spec completion + setMessages((prev) => [ + ...prev, + { + id: generateId(), + role: 'system', + content: `Specification file created: ${data.path}`, + timestamp: new Date(), + }, + ]) + + onComplete?.(data.path) + break + } + + case 'file_written': { + // Optional: notify about other files being written + setMessages((prev) => [ + ...prev, + { + id: generateId(), + role: 'system', + content: `File created: ${data.path}`, + timestamp: new Date(), + }, + ]) + break + } + + case '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 + }) + 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) => { + if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) { + onError?.('Not connected') + return + } + + // Add user message to chat + setMessages((prev) => [ + ...prev, + { + id: generateId(), + role: 'user', + content, + timestamp: new Date(), + }, + ]) + + // Clear current questions + setCurrentQuestions(null) + setCurrentToolId(null) + setIsLoading(true) + + // Send to server + wsRef.current.send(JSON.stringify({ type: 'message', content })) + }, [onError]) + + const sendAnswer = useCallback((answers: Record) => { + if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) { + onError?.('Not connected') + return + } + + // Format answers for display + const answerText = Object.values(answers) + .map((v) => (Array.isArray(v) ? v.join(', ') : v)) + .join('; ') + + // Add user message + setMessages((prev) => [ + ...prev, + { + id: generateId(), + role: 'user', + content: answerText, + timestamp: new Date(), + }, + ]) + + // Clear current questions + setCurrentQuestions(null) + setCurrentToolId(null) + setIsLoading(true) + + // Send to server + wsRef.current.send( + JSON.stringify({ + type: 'answer', + answers, + tool_id: currentToolId, + }) + ) + }, [currentToolId, 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, + currentQuestions, + currentToolId, + start, + sendMessage, + sendAnswer, + disconnect, + } +} diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts index a5aacec..6fc102f 100644 --- a/ui/src/lib/types.ts +++ b/ui/src/lib/types.ts @@ -110,3 +110,77 @@ export type WSMessage = | WSLogMessage | WSAgentStatusMessage | WSPongMessage + +// ============================================================================ +// Spec Chat Types +// ============================================================================ + +export interface SpecQuestionOption { + label: string + description: string +} + +export interface SpecQuestion { + question: string + header: string + options: SpecQuestionOption[] + multiSelect: boolean +} + +export interface SpecChatTextMessage { + type: 'text' + content: string +} + +export interface SpecChatQuestionMessage { + type: 'question' + questions: SpecQuestion[] + tool_id?: string +} + +export interface SpecChatCompleteMessage { + type: 'spec_complete' + path: string +} + +export interface SpecChatFileWrittenMessage { + type: 'file_written' + path: string +} + +export interface SpecChatSessionCompleteMessage { + type: 'complete' +} + +export interface SpecChatErrorMessage { + type: 'error' + content: string +} + +export interface SpecChatPongMessage { + type: 'pong' +} + +export interface SpecChatResponseDoneMessage { + type: 'response_done' +} + +export type SpecChatServerMessage = + | SpecChatTextMessage + | SpecChatQuestionMessage + | SpecChatCompleteMessage + | SpecChatFileWrittenMessage + | SpecChatSessionCompleteMessage + | SpecChatErrorMessage + | SpecChatPongMessage + | SpecChatResponseDoneMessage + +// UI chat message for display +export interface ChatMessage { + id: string + role: 'user' | 'assistant' | 'system' + content: string + timestamp: Date + questions?: SpecQuestion[] + isStreaming?: boolean +} diff --git a/ui/tsconfig.tsbuildinfo b/ui/tsconfig.tsbuildinfo index c33c415..2d7a635 100644 --- a/ui/tsconfig.tsbuildinfo +++ b/ui/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/addfeatureform.tsx","./src/components/agentcontrol.tsx","./src/components/featurecard.tsx","./src/components/featuremodal.tsx","./src/components/kanbanboard.tsx","./src/components/kanbancolumn.tsx","./src/components/progressdashboard.tsx","./src/components/projectselector.tsx","./src/components/setupwizard.tsx","./src/hooks/useprojects.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/lib/types.ts"],"version":"5.6.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/addfeatureform.tsx","./src/components/agentcontrol.tsx","./src/components/chatmessage.tsx","./src/components/featurecard.tsx","./src/components/featuremodal.tsx","./src/components/kanbanboard.tsx","./src/components/kanbancolumn.tsx","./src/components/newprojectmodal.tsx","./src/components/progressdashboard.tsx","./src/components/projectselector.tsx","./src/components/questionoptions.tsx","./src/components/setupwizard.tsx","./src/components/speccreationchat.tsx","./src/components/typingindicator.tsx","./src/hooks/useprojects.ts","./src/hooks/usespecchat.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/lib/types.ts"],"version":"5.6.3"} \ No newline at end of file