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 (
+
+ )
+ }
+
+ 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' && (
+
+ )}
+
+ {/* 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 ? (
-
- ) : (
-
- )}
+
>
)}
+
+ {/* 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