add claude spec generation

This commit is contained in:
Auto
2025-12-30 14:35:51 +02:00
parent 38b1c03c23
commit 5ffb6a4c5e
13 changed files with 2051 additions and 68 deletions

View File

@@ -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)
# ============================================================================

View File

@@ -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"]

View File

@@ -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

View File

@@ -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}")

View File

@@ -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 (
<div className={`flex ${config.align} px-4 py-2`}>
<div
className={`
${config.bgColor}
border-2 ${config.borderColor}
px-4 py-2
text-sm font-mono
shadow-[2px_2px_0px_rgba(0,0,0,1)]
`}
>
<span className="flex items-center gap-2">
<Icon size={14} />
{content}
</span>
</div>
</div>
)
}
return (
<div className={`flex ${config.align} px-4 py-2`}>
<div className={`flex flex-col ${config.bubbleAlign} max-w-[80%] gap-1`}>
{/* Message bubble */}
<div className="flex items-start gap-2">
{role === 'assistant' && (
<div
className={`
${config.iconBg}
border-2 border-[var(--color-neo-border)]
p-1.5
shadow-[2px_2px_0px_rgba(0,0,0,1)]
flex-shrink-0
`}
>
<Icon size={16} className="text-white" />
</div>
)}
<div
className={`
${config.bgColor}
border-3 ${config.borderColor}
px-4 py-3
shadow-[4px_4px_0px_rgba(0,0,0,1)]
${isStreaming ? 'animate-pulse-neo' : ''}
`}
>
{/* Parse content for basic markdown-like formatting */}
<div className="whitespace-pre-wrap text-sm leading-relaxed text-[#1a1a1a]">
{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(
<strong key={`bold-${i}-${match.index}`} className="font-bold">
{match[1]}
</strong>
)
lastIndex = match.index + match[0].length
}
if (lastIndex < line.length) {
parts.push(line.slice(lastIndex))
}
return (
<span key={i}>
{parts.length > 0 ? parts : line}
{i < content.split('\n').length - 1 && '\n'}
</span>
)
})}
</div>
{/* Streaming indicator */}
{isStreaming && (
<span className="inline-block w-2 h-4 bg-[var(--color-neo-accent)] ml-1 animate-pulse" />
)}
</div>
{role === 'user' && (
<div
className={`
${config.iconBg}
border-2 border-[var(--color-neo-border)]
p-1.5
shadow-[2px_2px_0px_rgba(0,0,0,1)]
flex-shrink-0
`}
>
<Icon size={16} />
</div>
)}
</div>
{/* Timestamp */}
<span className="text-xs text-[var(--color-neo-text-secondary)] font-mono px-2">
{timeString}
</span>
</div>
</div>
)
}

View File

@@ -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<Step>('name')
const [projectName, setProjectName] = useState('')
const [_specMethod, setSpecMethod] = useState<SpecMethod | null>(null)
const [error, setError] = useState<string | null>(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 (
<div className="fixed inset-0 z-50 bg-[var(--color-neo-bg)]">
<SpecCreationChat
projectName={projectName.trim()}
onComplete={handleSpecComplete}
onCancel={handleChatCancel}
/>
</div>
)
}
return (
<div className="neo-modal-backdrop" onClick={handleClose}>
<div
className="neo-modal w-full max-w-lg"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b-3 border-[var(--color-neo-border)]">
<h2 className="font-display font-bold text-xl text-[#1a1a1a]">
{step === 'name' && 'Create New Project'}
{step === 'method' && 'Choose Setup Method'}
{step === 'complete' && 'Project Created!'}
</h2>
<button
onClick={handleClose}
className="neo-btn neo-btn-ghost p-2"
>
<X size={20} />
</button>
</div>
{/* Content */}
<div className="p-6">
{/* Step 1: Project Name */}
{step === 'name' && (
<form onSubmit={handleNameSubmit}>
<div className="mb-6">
<label className="block font-bold mb-2 text-[#1a1a1a]">
Project Name
</label>
<input
type="text"
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
placeholder="my-awesome-app"
className="neo-input"
pattern="^[a-zA-Z0-9_-]+$"
autoFocus
/>
<p className="text-sm text-[var(--color-neo-text-secondary)] mt-2">
Use letters, numbers, hyphens, and underscores only.
</p>
</div>
{error && (
<div className="mb-4 p-3 bg-[var(--color-neo-danger)] text-white text-sm border-2 border-[var(--color-neo-border)]">
{error}
</div>
)}
<div className="flex justify-end">
<button
type="submit"
className="neo-btn neo-btn-primary"
disabled={!projectName.trim()}
>
Next
<ArrowRight size={16} />
</button>
</div>
</form>
)}
{/* Step 2: Spec Method */}
{step === 'method' && (
<div>
<p className="text-[var(--color-neo-text-secondary)] mb-6">
How would you like to define your project?
</p>
<div className="space-y-4">
{/* Claude option */}
<button
onClick={() => handleMethodSelect('claude')}
disabled={createProject.isPending}
className={`
w-full text-left p-4
border-3 border-[var(--color-neo-border)]
bg-white
shadow-[4px_4px_0px_rgba(0,0,0,1)]
hover:translate-x-[-2px] hover:translate-y-[-2px]
hover:shadow-[6px_6px_0px_rgba(0,0,0,1)]
transition-all duration-150
disabled:opacity-50 disabled:cursor-not-allowed
`}
>
<div className="flex items-start gap-4">
<div className="p-2 bg-[var(--color-neo-progress)] border-2 border-[var(--color-neo-border)] shadow-[2px_2px_0px_rgba(0,0,0,1)]">
<Bot size={24} className="text-white" />
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-bold text-lg text-[#1a1a1a]">Create with Claude</span>
<span className="neo-badge bg-[var(--color-neo-done)] text-xs">
Recommended
</span>
</div>
<p className="text-sm text-[var(--color-neo-text-secondary)] mt-1">
Interactive conversation to define features and generate your app specification automatically.
</p>
</div>
</div>
</button>
{/* Manual option */}
<button
onClick={() => handleMethodSelect('manual')}
disabled={createProject.isPending}
className={`
w-full text-left p-4
border-3 border-[var(--color-neo-border)]
bg-white
shadow-[4px_4px_0px_rgba(0,0,0,1)]
hover:translate-x-[-2px] hover:translate-y-[-2px]
hover:shadow-[6px_6px_0px_rgba(0,0,0,1)]
transition-all duration-150
disabled:opacity-50 disabled:cursor-not-allowed
`}
>
<div className="flex items-start gap-4">
<div className="p-2 bg-[var(--color-neo-pending)] border-2 border-[var(--color-neo-border)] shadow-[2px_2px_0px_rgba(0,0,0,1)]">
<FileEdit size={24} />
</div>
<div className="flex-1">
<span className="font-bold text-lg text-[#1a1a1a]">Edit Templates Manually</span>
<p className="text-sm text-[var(--color-neo-text-secondary)] mt-1">
Edit the template files directly. Best for developers who want full control.
</p>
</div>
</div>
</button>
</div>
{error && (
<div className="mt-4 p-3 bg-[var(--color-neo-danger)] text-white text-sm border-2 border-[var(--color-neo-border)]">
{error}
</div>
)}
{createProject.isPending && (
<div className="mt-4 flex items-center justify-center gap-2 text-[var(--color-neo-text-secondary)]">
<Loader2 size={16} className="animate-spin" />
<span>Creating project...</span>
</div>
)}
<div className="flex justify-start mt-6">
<button
onClick={handleBack}
className="neo-btn neo-btn-ghost"
disabled={createProject.isPending}
>
<ArrowLeft size={16} />
Back
</button>
</div>
</div>
)}
{/* Step 3: Complete */}
{step === 'complete' && (
<div className="text-center py-8">
<div className="inline-flex items-center justify-center w-16 h-16 bg-[var(--color-neo-done)] border-3 border-[var(--color-neo-border)] shadow-[4px_4px_0px_rgba(0,0,0,1)] mb-4">
<CheckCircle2 size={32} />
</div>
<h3 className="font-display font-bold text-xl mb-2">
{projectName}
</h3>
<p className="text-[var(--color-neo-text-secondary)]">
Your project has been created successfully!
</p>
<div className="mt-4 flex items-center justify-center gap-2">
<Loader2 size={16} className="animate-spin" />
<span className="text-sm">Redirecting...</span>
</div>
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -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({
<div className="border-t-3 border-[var(--color-neo-border)]" />
{/* Create New */}
{showCreate ? (
<form onSubmit={handleCreateProject} className="p-3">
<input
type="text"
value={newProjectName}
onChange={(e) => setNewProjectName(e.target.value)}
placeholder="project-name"
className="neo-input text-sm mb-2"
pattern="^[a-zA-Z0-9_-]+$"
autoFocus
/>
<div className="flex gap-2">
<button
type="submit"
className="neo-btn neo-btn-success text-xs flex-1"
disabled={createProject.isPending || !newProjectName.trim()}
>
{createProject.isPending ? (
<Loader2 size={14} className="animate-spin" />
) : (
'Create'
)}
</button>
<button
type="button"
onClick={() => {
setShowCreate(false)
setNewProjectName('')
}}
className="neo-btn neo-btn-ghost text-xs"
>
Cancel
</button>
</div>
</form>
) : (
<button
onClick={() => setShowCreate(true)}
className="w-full neo-dropdown-item flex items-center gap-2 font-bold"
>
<Plus size={16} />
New Project
</button>
)}
<button
onClick={() => {
setShowNewProjectModal(true)
setIsOpen(false)
}}
className="w-full neo-dropdown-item flex items-center gap-2 font-bold"
>
<Plus size={16} />
New Project
</button>
</div>
</>
)}
{/* New Project Modal */}
<NewProjectModal
isOpen={showNewProjectModal}
onClose={() => setShowNewProjectModal(false)}
onProjectCreated={handleProjectCreated}
/>
</div>
)
}

View File

@@ -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<string, string | string[]>) => void
disabled?: boolean
}
export function QuestionOptions({
questions,
onSubmit,
disabled = false,
}: QuestionOptionsProps) {
// Track selected answers for each question
const [answers, setAnswers] = useState<Record<string, string | string[]>>({})
const [customInputs, setCustomInputs] = useState<Record<string, string>>({})
const [showCustomInput, setShowCustomInput] = useState<Record<string, boolean>>({})
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<string, string | string[]> = {}
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 (
<div className="space-y-6 p-4">
{questions.map((q, questionIdx) => (
<div
key={questionIdx}
className="neo-card p-4 bg-white"
>
{/* Question header */}
<div className="flex items-center gap-3 mb-4">
<span className="neo-badge bg-[var(--color-neo-accent)] text-white">
{q.header}
</span>
<span className="font-bold text-[var(--color-neo-text)]">
{q.question}
</span>
{q.multiSelect && (
<span className="text-xs text-[var(--color-neo-text-secondary)] font-mono">
(select multiple)
</span>
)}
</div>
{/* Options grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{q.options.map((opt, optIdx) => {
const isSelected = isOptionSelected(questionIdx, opt.label, q.multiSelect)
return (
<button
key={optIdx}
onClick={() => handleOptionClick(questionIdx, opt.label, q.multiSelect)}
disabled={disabled}
className={`
text-left p-4
border-3 border-[var(--color-neo-border)]
transition-all duration-150
${
isSelected
? 'bg-[var(--color-neo-pending)] shadow-[2px_2px_0px_rgba(0,0,0,1)] translate-x-[1px] translate-y-[1px]'
: 'bg-white shadow-[4px_4px_0px_rgba(0,0,0,1)] hover:translate-x-[-1px] hover:translate-y-[-1px] hover:shadow-[5px_5px_0px_rgba(0,0,0,1)]'
}
disabled:opacity-50 disabled:cursor-not-allowed
`}
>
<div className="flex items-start gap-2">
{/* Checkbox/Radio indicator */}
<div
className={`
w-5 h-5 flex-shrink-0 mt-0.5
border-2 border-[var(--color-neo-border)]
flex items-center justify-center
${q.multiSelect ? '' : 'rounded-full'}
${isSelected ? 'bg-[var(--color-neo-done)]' : 'bg-white'}
`}
>
{isSelected && <Check size={12} strokeWidth={3} />}
</div>
<div className="flex-1">
<div className="font-bold text-[var(--color-neo-text)]">
{opt.label}
</div>
<div className="text-sm text-[var(--color-neo-text-secondary)] mt-1">
{opt.description}
</div>
</div>
</div>
</button>
)
})}
{/* "Other" option */}
<button
onClick={() => handleOptionClick(questionIdx, 'Other', q.multiSelect)}
disabled={disabled}
className={`
text-left p-4
border-3 border-[var(--color-neo-border)]
transition-all duration-150
${
showCustomInput[String(questionIdx)]
? 'bg-[var(--color-neo-pending)] shadow-[2px_2px_0px_rgba(0,0,0,1)] translate-x-[1px] translate-y-[1px]'
: 'bg-white shadow-[4px_4px_0px_rgba(0,0,0,1)] hover:translate-x-[-1px] hover:translate-y-[-1px] hover:shadow-[5px_5px_0px_rgba(0,0,0,1)]'
}
disabled:opacity-50 disabled:cursor-not-allowed
`}
>
<div className="flex items-start gap-2">
<div
className={`
w-5 h-5 flex-shrink-0 mt-0.5
border-2 border-[var(--color-neo-border)]
flex items-center justify-center
${q.multiSelect ? '' : 'rounded-full'}
${showCustomInput[String(questionIdx)] ? 'bg-[var(--color-neo-done)]' : 'bg-white'}
`}
>
{showCustomInput[String(questionIdx)] && <Check size={12} strokeWidth={3} />}
</div>
<div className="flex-1">
<div className="font-bold text-[var(--color-neo-text)]">Other</div>
<div className="text-sm text-[var(--color-neo-text-secondary)] mt-1">
Provide a custom answer
</div>
</div>
</div>
</button>
</div>
{/* Custom input field */}
{showCustomInput[String(questionIdx)] && (
<div className="mt-4">
<input
type="text"
value={customInputs[String(questionIdx)] || ''}
onChange={(e) => handleCustomInputChange(questionIdx, e.target.value)}
placeholder="Type your answer..."
className="neo-input"
autoFocus
disabled={disabled}
/>
</div>
)}
</div>
))}
{/* Submit button */}
<div className="flex justify-end">
<button
onClick={handleSubmit}
disabled={disabled || !allQuestionsAnswered}
className="neo-btn neo-btn-primary"
>
Continue
</button>
</div>
</div>
)
}

View File

@@ -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<string | null>(null)
const messagesEndRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(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<string, string | string[]>) => {
sendAnswer(answers)
}
// Connection status indicator
const ConnectionIndicator = () => {
switch (connectionStatus) {
case 'connected':
return (
<span className="flex items-center gap-1 text-xs text-[var(--color-neo-done)]">
<Wifi size={12} />
Connected
</span>
)
case 'connecting':
return (
<span className="flex items-center gap-1 text-xs text-[var(--color-neo-pending)]">
<Wifi size={12} className="animate-pulse" />
Connecting...
</span>
)
case 'error':
return (
<span className="flex items-center gap-1 text-xs text-[var(--color-neo-danger)]">
<WifiOff size={12} />
Error
</span>
)
default:
return (
<span className="flex items-center gap-1 text-xs text-[var(--color-neo-text-secondary)]">
<WifiOff size={12} />
Disconnected
</span>
)
}
}
return (
<div className="flex flex-col h-full bg-[var(--color-neo-bg)]">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b-3 border-[var(--color-neo-border)] bg-white">
<div className="flex items-center gap-3">
<h2 className="font-display font-bold text-lg text-[#1a1a1a]">
Create Spec: {projectName}
</h2>
<ConnectionIndicator />
</div>
<div className="flex items-center gap-2">
{isComplete && (
<span className="flex items-center gap-1 text-sm text-[var(--color-neo-done)] font-bold">
<CheckCircle2 size={16} />
Complete
</span>
)}
<button
onClick={onCancel}
className="neo-btn neo-btn-ghost p-2"
title="Cancel"
>
<X size={20} />
</button>
</div>
</div>
{/* Error banner */}
{error && (
<div className="flex items-center gap-2 p-3 bg-[var(--color-neo-danger)] text-white border-b-3 border-[var(--color-neo-border)]">
<AlertCircle size={16} />
<span className="flex-1 text-sm">{error}</span>
<button
onClick={() => setError(null)}
className="p-1 hover:bg-white/20 rounded"
>
<X size={14} />
</button>
</div>
)}
{/* Messages area */}
<div className="flex-1 overflow-y-auto py-4">
{messages.length === 0 && !isLoading && (
<div className="flex flex-col items-center justify-center h-full text-center p-8">
<div className="neo-card p-6 max-w-md">
<h3 className="font-display font-bold text-lg mb-2">
Starting Spec Creation
</h3>
<p className="text-sm text-[var(--color-neo-text-secondary)]">
Connecting to Claude to help you create your app specification...
</p>
{connectionStatus === 'error' && (
<button
onClick={start}
className="neo-btn neo-btn-primary mt-4 text-sm"
>
<RotateCcw size={14} />
Retry Connection
</button>
)}
</div>
</div>
)}
{messages.map((message) => (
<ChatMessage key={message.id} message={message} />
))}
{/* Structured questions */}
{currentQuestions && currentQuestions.length > 0 && (
<QuestionOptions
questions={currentQuestions}
onSubmit={handleAnswerSubmit}
disabled={isLoading}
/>
)}
{/* Typing indicator - don't show when we have questions (waiting for user) */}
{isLoading && !currentQuestions && <TypingIndicator />}
{/* Scroll anchor */}
<div ref={messagesEndRef} />
</div>
{/* Input area */}
{!isComplete && (
<div className="p-4 border-t-3 border-[var(--color-neo-border)] bg-white">
<div className="flex gap-3">
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => 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'}
/>
<button
onClick={handleSendMessage}
disabled={!input.trim() || (isLoading && !currentQuestions) || connectionStatus !== 'connected'}
className="neo-btn neo-btn-primary px-6"
>
<Send size={18} />
</button>
</div>
{/* Help text */}
<p className="text-xs text-[var(--color-neo-text-secondary)] mt-2">
Press Enter to send. Claude will guide you through creating your app specification.
</p>
</div>
)}
{/* Completion footer */}
{isComplete && (
<div className="p-4 border-t-3 border-[var(--color-neo-border)] bg-[var(--color-neo-done)]">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<CheckCircle2 size={20} />
<span className="font-bold">Specification created successfully!</span>
</div>
<button
onClick={() => onComplete('')}
className="neo-btn bg-white"
>
Continue to Project
</button>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,30 @@
/**
* Typing Indicator Component
*
* Shows animated dots to indicate Claude is typing/thinking.
* Styled in neobrutalism aesthetic.
*/
export function TypingIndicator() {
return (
<div className="flex items-center gap-2 p-4">
<div className="flex items-center gap-1">
<span
className="w-2 h-2 bg-[var(--color-neo-progress)] rounded-full animate-bounce"
style={{ animationDelay: '0ms' }}
/>
<span
className="w-2 h-2 bg-[var(--color-neo-progress)] rounded-full animate-bounce"
style={{ animationDelay: '150ms' }}
/>
<span
className="w-2 h-2 bg-[var(--color-neo-progress)] rounded-full animate-bounce"
style={{ animationDelay: '300ms' }}
/>
</div>
<span className="text-sm text-[var(--color-neo-text-secondary)] font-mono">
Claude is thinking...
</span>
</div>
)
}

392
ui/src/hooks/useSpecChat.ts Normal file
View File

@@ -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<string, string | string[]>) => 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<ChatMessage[]>([])
const [isLoading, setIsLoading] = useState(false)
const [isComplete, setIsComplete] = useState(false)
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>('disconnected')
const [currentQuestions, setCurrentQuestions] = useState<SpecQuestion[] | null>(null)
const [currentToolId, setCurrentToolId] = useState<string | null>(null)
const wsRef = useRef<WebSocket | null>(null)
const currentAssistantMessageRef = useRef<string | null>(null)
const reconnectAttempts = useRef(0)
const maxReconnectAttempts = 3
const pingIntervalRef = useRef<number | null>(null)
const reconnectTimeoutRef = useRef<number | null>(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<string, string | string[]>) => {
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,
}
}

View File

@@ -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
}

View File

@@ -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"}
{"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"}