I'd like the ability to import existing project folders.  Select a folder like GuruConnect (a project we've already worked on, but not within Autocoder) and have it process that folder as though it were a new project, using what is in there, plus the app_spec interview it would normally do.


The user wants to add an "import existing project" feature to autocoder+. This would:

1. Allow selecting an existing folder (like GuruConnect)
2. Process it as though it were a new project
3. Use the existing code/files that are already there
4. Still run the app_spec interview to understand what the project is/should be

This is a good feature request. Let me think about what's involved:

1. **UI Changes**: Need a way to select/browse to an existing folder (they already have a FolderBrowser component)
2. **Backend Changes**: Need an endpoint to import a project - register it, but instead of scaffolding empty prompts, it should:
   - Analyze the existing codebase
   - Start the spec creation chat to build an app_spec based on what's already there
3. **Flow**:
   - User selects "Import Existing Project"
   - User browses to folder
   - User gives project a name
   - System registers the project
   - System starts spec creation interview, but with context about what's already in the folder

Let me look at the existing project creation flow and the spec creation chat to understand how to implement this. I should explore:
1. NewProjectModal.tsx - how projects are created
2. SpecCreationChat.tsx - how the spec interview works
3. The backend routers for project creation and spec creation

This is a non-trivial feature that will need planning. Let me use EnterPlanMode since this requires architectural decisions.


That's a great feature idea - importing an existing codebase and having the spec interview analyze what's already there to build the app_spec.

Let me explore the current project creation and spec flows to plan this properly.


     1→"""
     2→Projects Router
     3→===============
     4→
     5→API endpoints for project management.
     6→"""
     7→
     8→from datetime import datetime
     9→from typing import Optional, List
    10→
    11→from fastapi import APIRouter, Depends, HTTPException, status
    12→from pydantic import BaseModel, Field
    13→from sqlalchemy import select
    14→from sqlalchemy.ext.asyncio import AsyncSession
    15→
    16→from ..database import get_db
    17→from ..models import Project, Permission, Infrastructure
    18→from ..path_security import validate_project_path
    19→
    20→router = APIRouter(prefix="/api/projects", tags=["projects"])
    21→
    22→
    23→# ============================================================================
    24→# Pydantic Schemas
    25→# ============================================================================
    26→
    27→class ProjectCreate(BaseModel):
    28→    """Schema for creating a new project."""
    29→    name: str = Field(..., min_length=1, max_length=255, description="Unique project name")
    30→    path: str = Field(..., min_length=1, max_length=1024, description="Project directory path")
    31→
    32→
    33→class ProjectUpdate(BaseModel):
    34→    """Schema for updating a project."""
    35→    path: Optional[str] = Field(None, min_length=1, max_length=1024)
    36→
    37→
    38→class ProjectResponse(BaseModel):
    39→    """Schema for project response."""
    40→    id: int
    41→    name: str
    42→    path: str
    43→    created_at: datetime
    44→    updated_at: datetime
    45→    spec_version: int
    46→
    47→    class Config:
    48→        from_attributes = True
    49→
    50→
    51→class ProjectListResponse(BaseModel):
    52→    """Schema for project list response."""
    53→    projects: List[ProjectResponse]
    54→    count: int
    55→
    56→
    57→# ============================================================================
    58→# API Endpoints
    59→# ============================================================================
    60→
    61→@router.get("", response_model=ProjectListResponse)
    62→async def list_projects(db: AsyncSession = Depends(get_db)):
    63→    """List all projects."""
    64→    result = await db.execute(select(Project).order_by(Project.name))
    65→    projects = result.scalars().all()
    66→    return ProjectListResponse(
    67→        projects=[ProjectResponse.model_validate(p) for p in projects],
    68→        count=len(projects)
    69→    )
    70→
    71→
    72→@router.post("", response_model=ProjectResponse, status_code=status.HTTP_201_CREATED)
    73→async def create_project(project: ProjectCreate, db: AsyncSession = Depends(get_db)):
    74→    """Create a new project."""
    75→    # Validate path for security
    76→    validate_project_path(project.path)
    77→    # Check if project with this name already exists
    78→    result = await db.execute(select(Project).where(Project.name == project.name))
    79→    if result.scalar_one_or_none():
    80→        raise HTTPException(
    81→            status_code=status.HTTP_409_CONFLICT,
    82→            detail=f"Project with name '{project.name}' already exists"
    83→        )
    84→
    85→    # Create new project
    86→    db_project = Project(
    87→        name=project.name,
    88→        path=project.path,
    89→    )
    90→    db.add(db_project)
    91→    await db.commit()
    92→    await db.refresh(db_project)
    93→
    94→    return ProjectResponse.model_validate(db_project)
    95→
    96→
    97→@router.get("/{name}", response_model=ProjectResponse)
    98→async def get_project(name: str, db: AsyncSession = Depends(get_db)):
    99→    """Get a project by name."""
   100→    result = await db.execute(select(Project).where(Project.name == name))
   101→    project = result.scalar_one_or_none()
   102→
   103→    if not project:
   104→        raise HTTPException(
   105→            status_code=status.HTTP_404_NOT_FOUND,
   106→            detail=f"Project '{name}' not found"
   107→        )
   108→
   109→    return ProjectResponse.model_validate(project)
   110→
   111→
   112→@router.put("/{name}", response_model=ProjectResponse)
   113→async def update_project(name: str, project_update: ProjectUpdate, db: AsyncSession = Depends(get_db)):
   114→    """Update a project by name."""
   115→    result = await db.execute(select(Project).where(Project.name == name))
   116→    project = result.scalar_one_or_none()
   117→
   118→    if not project:
   119→        raise HTTPException(
   120→            status_code=status.HTTP_404_NOT_FOUND,
   121→            detail=f"Project '{name}' not found"
   122→        )
   123→
   124→    if project_update.path is not None:
   125→        # Validate path for security
   126→        validate_project_path(project_update.path)
   127→        project.path = project_update.path
   128→
   129→    project.updated_at = datetime.utcnow()
   130→    await db.commit()
   131→    await db.refresh(project)
   132→
   133→    return ProjectResponse.model_validate(project)
   134→
   135→
   136→@router.delete("/{name}", status_code=status.HTTP_204_NO_CONTENT)
   137→async def delete_project(name: str, db: AsyncSession = Depends(get_db)):
   138→    """Delete a project by name."""
   139→    result = await db.execute(select(Project).where(Project.name == name))
   140→    project = result.scalar_one_or_none()
   141→
   142→    if not project:
   143→        raise HTTPException(
   144→            status_code=status.HTTP_404_NOT_FOUND,
   145→            detail=f"Project '{name}' not found"
   146→        )
   147→
   148→    await db.delete(project)
   149→    await db.commit()
   150→
   151→
   152→class DuplicateProjectRequest(BaseModel):
   153→    new_name: str
   154→
   155→
   156→@router.post("/{name}/duplicate", response_model=ProjectResponse, status_code=status.HTTP_201_CREATED)
   157→async def duplicate_project(name: str, request: DuplicateProjectRequest, db: AsyncSession = Depends(get_db)):
   158→    """Duplicate a project with a new name, including permissions and infrastructure."""
   159→    new_name = request.new_name
   160→    # Get source project
   161→    result = await db.execute(select(Project).where(Project.name == name))
   162→    source_project = result.scalar_one_or_none()
   163→
   164→    if not source_project:
   165→        raise HTTPException(
   166→            status_code=status.HTTP_404_NOT_FOUND,
   167→            detail=f"Project '{name}' not found"
   168→        )
   169→
   170→    # Check if new name already exists
   171→    result = await db.execute(select(Project).where(Project.name == new_name))
   172→    if result.scalar_one_or_none():
   173→        raise HTTPException(
   174→            status_code=status.HTTP_409_CONFLICT,
   175→            detail=f"Project with name '{new_name}' already exists"
   176→        )
   177→
   178→    # Create duplicate project
   179→    new_project = Project(
   180→        name=new_name,
   181→        path=source_project.path,
   182→    )
   183→    db.add(new_project)
   184→    await db.flush()  # Get the new project ID without committing
   185→
   186→    # Copy permissions from source project
   187→    permissions_result = await db.execute(
   188→        select(Permission).where(Permission.project_id == source_project.id)
   189→    )
   190→    source_permissions = permissions_result.scalars().all()
   191→    for perm in source_permissions:
   192→        new_perm = Permission(
   193→            project_id=new_project.id,
   194→            permission_type=perm.permission_type,
   195→            permission_value=perm.permission_value,
   196→            granted=perm.granted,
   197→        )
   198→        db.add(new_perm)
   199→
   200→    # Copy infrastructure from source project
   201→    infra_result = await db.execute(
   202→        select(Infrastructure).where(Infrastructure.project_id == source_project.id)
   203→    )
   204→    source_infrastructure = infra_result.scalars().all()
   205→    for infra in source_infrastructure:
   206→        new_infra = Infrastructure(
   207→            project_id=new_project.id,
   208→            service_type=infra.service_type,
   209→            service_name=infra.service_name,
   210→            host=infra.host,
   211→            port=infra.port,
   212→            credentials_ref=infra.credentials_ref,
   213→            health_check_url=infra.health_check_url,
   214→            is_healthy=False,  # Reset health status for new project
   215→        )
   216→        db.add(new_infra)
   217→
   218→    await db.commit()
   219→    await db.refresh(new_project)
   220→
   221→    return ProjectResponse.model_validate(new_project)
   222→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

"""
Projects Router
===============

API endpoints for project management.
"""

from datetime import datetime
from typing import Optional, List

from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from ..database import get_db
from ..models import Project, Permission, Infrastructure
from ..path_security import validate_project_path

router = APIRouter(prefix="/api/projects", tags=["projects"])

# ============================================================================
# Pydantic Schemas
# ============================================================================

class ProjectCreate(BaseModel):
    """Schema for creating a new project."""
    name: str = Field(..., min_length=1, max_length=255, description="Unique project name")
    path: str = Field(..., min_length=1, max_length=1024, description="Project directory path")

class ProjectUpdate(BaseModel):
    """Schema for updating a project."""
    path: Optional[str] = Field(None, min_length=1, max_length=1024)

class ProjectResponse(BaseModel):
    """Schema for project response."""
    id: int
    name: str
    path: str
    created_at: datetime
    updated_at: datetime
    spec_version: int

    class Config:
        from_attributes = True

class ProjectListResponse(BaseModel):
    """Schema for project list response."""
    projects: List[ProjectResponse]
    count: int

# ============================================================================
# API Endpoints
# ============================================================================

@router.get("", response_model=ProjectListResponse)
async def list_projects(db: AsyncSession = Depends(get_db)):
    """List all projects."""
    result = await db.execute(select(Project).order_by(Project.name))
    projects = result.scalars().all()
    return ProjectListResponse(
        projects=[ProjectResponse.model_validate(p) for p in projects],
        count=len(projects)
    )

@router.post("", response_model=ProjectResponse, status_code=status.HTTP_201_CREATED)
async def create_project(project: ProjectCreate, db: AsyncSession = Depends(get_db)):
    """Create a new project."""
    # Validate path for security
    validate_project_path(project.path)
    # Check if project with this name already exists
    result = await db.execute(select(Project).where(Project.name == project.name))
    if result.scalar_one_or_none():
        raise HTTPException(
            status_code=status.HTTP_409_CONFLICT,
            detail=f"Project with name '{project.name}' already exists"
        )

    # Create new project
    db_project = Project(
        name=project.name,
        path=project.path,
    )
    db.add(db_project)
    await db.commit()
    await db.refresh(db_project)

    return ProjectResponse.model_validate(db_project)

@router.get("/{name}", response_model=ProjectResponse)
async def get_project(name: str, db: AsyncSession = Depends(get_db)):
    """Get a project by name."""
    result = await db.execute(select(Project).where(Project.name == name))
    project = result.scalar_one_or_none()

    if not project:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Project '{name}' not found"
        )

    return ProjectResponse.model_validate(project)

@router.put("/{name}", response_model=ProjectResponse)
async def update_project(name: str, project_update: ProjectUpdate, db: AsyncSession = Depends(get_db)):
    """Update a project by name."""
    result = await db.execute(select(Project).where(Project.name == name))
    project = result.scalar_one_or_none()

    if not project:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Project '{name}' not found"
        )

    if project_update.path is not None:
        # Validate path for security
        validate_project_path(project_update.path)
        project.path = project_update.path

    project.updated_at = datetime.utcnow()
    await db.commit()
    await db.refresh(project)

    return ProjectResponse.model_validate(project)

@router.delete("/{name}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_project(name: str, db: AsyncSession = Depends(get_db)):
    """Delete a project by name."""
    result = await db.execute(select(Project).where(Project.name == name))
    project = result.scalar_one_or_none()

    if not project:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Project '{name}' not found"
        )

    await db.delete(project)
    await db.commit()

class DuplicateProjectRequest(BaseModel):
    new_name: str

@router.post("/{name}/duplicate", response_model=ProjectResponse, status_code=status.HTTP_201_CREATED)
async def duplicate_project(name: str, request: DuplicateProjectRequest, db: AsyncSession = Depends(get_db)):
    """Duplicate a project with a new name, including permissions and infrastructure."""
    new_name = request.new_name
    # Get source project
    result = await db.execute(select(Project).where(Project.name == name))
    source_project = result.scalar_one_or_none()

    if not source_project:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Project '{name}' not found"
        )

    # Check if new name already exists
    result = await db.execute(select(Project).where(Project.name == new_name))
    if result.scalar_one_or_none():
        raise HTTPException(
            status_code=status.HTTP_409_CONFLICT,
            detail=f"Project with name '{new_name}' already exists"
        )

    # Create duplicate project
    new_project = Project(
        name=new_name,
        path=source_project.path,
    )
    db.add(new_project)
    await db.flush()  # Get the new project ID without committing

    # Copy permissions from source project
    permissions_result = await db.execute(
        select(Permission).where(Permission.project_id == source_project.id)
    )
    source_permissions = permissions_result.scalars().all()
    for perm in source_permissions:
        new_perm = Permission(
            project_id=new_project.id,
            permission_type=perm.permission_type,
            permission_value=perm.permission_value,
            granted=perm.granted,
        )
        db.add(new_perm)

    # Copy infrastructure from source project
    infra_result = await db.execute(
        select(Infrastructure).where(Infrastructure.project_id == source_project.id)
    )
    source_infrastructure = infra_result.scalars().all()
    for infra in source_infrastructure:
        new_infra = Infrastructure(
            project_id=new_project.id,
            service_type=infra.service_type,
            service_name=infra.service_name,
            host=infra.host,
            port=infra.port,
            credentials_ref=infra.credentials_ref,
            health_check_url=infra.health_check_url,
            is_healthy=False,  # Reset health status for new project
        )
        db.add(new_infra)

    await db.commit()
    await db.refresh(new_project)

    return ProjectResponse.model_validate(new_project)


1→/**
     2→ * New Project Modal Component
     3→ *
     4→ * Multi-step modal for creating new projects:
     5→ * 1. Enter project name
     6→ * 2. Select project folder
     7→ * 3. Choose spec method (Claude or manual)
     8→ * 4a. If Claude: Show SpecCreationChat
     9→ * 4b. If manual: Create project and close
    10→ */
    11→
    12→import { useState } from 'react'
    13→import { X, Bot, FileEdit, ArrowRight, ArrowLeft, Loader2, CheckCircle2, Folder } from 'lucide-react'
    14→import { useCreateProject } from '../hooks/useProjects'
    15→import { SpecCreationChat } from './SpecCreationChat'
    16→import { FolderBrowser } from './FolderBrowser'
    17→import { startAgent } from '../lib/api'
    18→
    19→type InitializerStatus = 'idle' | 'starting' | 'error'
    20→
    21→type Step = 'name' | 'folder' | 'method' | 'chat' | 'complete'
    22→type SpecMethod = 'claude' | 'manual'
    23→
    24→interface NewProjectModalProps {
    25→  isOpen: boolean
    26→  onClose: () => void
    27→  onProjectCreated: (projectName: string) => void
    28→}
    29→
    30→export function NewProjectModal({
    31→  isOpen,
    32→  onClose,
    33→  onProjectCreated,
    34→}: NewProjectModalProps) {
    35→  const [step, setStep] = useState<Step>('name')
    36→  const [projectName, setProjectName] = useState('')
    37→  const [projectPath, setProjectPath] = useState<string | null>(null)
    38→  const [_specMethod, setSpecMethod] = useState<SpecMethod | null>(null)
    39→  const [error, setError] = useState<string | null>(null)
    40→  const [initializerStatus, setInitializerStatus] = useState<InitializerStatus>('idle')
    41→  const [initializerError, setInitializerError] = useState<string | null>(null)
    42→  const [yoloModeSelected, setYoloModeSelected] = useState(false)
    43→
    44→  // Suppress unused variable warning - specMethod may be used in future
    45→  void _specMethod
    46→
    47→  const createProject = useCreateProject()
    48→
    49→  if (!isOpen) return null
    50→
    51→  const handleNameSubmit = (e: React.FormEvent) => {
    52→    e.preventDefault()
    53→    const trimmed = projectName.trim()
    54→
    55→    if (!trimmed) {
    56→      setError('Please enter a project name')
    57→      return
    58→    }
    59→
    60→    if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) {
    61→      setError('Project name can only contain letters, numbers, hyphens, and underscores')
    62→      return
    63→    }
    64→
    65→    setError(null)
    66→    setStep('folder')
    67→  }
    68→
    69→  const handleFolderSelect = (path: string) => {
    70→    // Append project name to the selected path
    71→    const fullPath = path.endsWith('/') ? `${path}${projectName.trim()}` : `${path}/${projectName.trim()}`
    72→    setProjectPath(fullPath)
    73→    setStep('method')
    74→  }
    75→
    76→  const handleFolderCancel = () => {
    77→    setStep('name')
    78→  }
    79→
    80→  const handleMethodSelect = async (method: SpecMethod) => {
    81→    setSpecMethod(method)
    82→
    83→    if (!projectPath) {
    84→      setError('Please select a project folder first')
    85→      setStep('folder')
    86→      return
    87→    }
    88→
    89→    if (method === 'manual') {
    90→      // Create project immediately with manual method
    91→      try {
    92→        const project = await createProject.mutateAsync({
    93→          name: projectName.trim(),
    94→          path: projectPath,
    95→          specMethod: 'manual',
    96→        })
    97→        setStep('complete')
    98→        setTimeout(() => {
    99→          onProjectCreated(project.name)
   100→          handleClose()
   101→        }, 1500)
   102→      } catch (err: unknown) {
   103→        setError(err instanceof Error ? err.message : 'Failed to create project')
   104→      }
   105→    } else {
   106→      // Create project then show chat
   107→      try {
   108→        await createProject.mutateAsync({
   109→          name: projectName.trim(),
   110→          path: projectPath,
   111→          specMethod: 'claude',
   112→        })
   113→        setStep('chat')
   114→      } catch (err: unknown) {
   115→        setError(err instanceof Error ? err.message : 'Failed to create project')
   116→      }
   117→    }
   118→  }
   119→
   120→  const handleSpecComplete = async (_specPath: string, yoloMode: boolean = false) => {
   121→    // Save yoloMode for retry
   122→    setYoloModeSelected(yoloMode)
   123→    // Auto-start the initializer agent
   124→    setInitializerStatus('starting')
   125→    try {
   126→      await startAgent(projectName.trim(), yoloMode)
   127→      // Success - navigate to project
   128→      setStep('complete')
   129→      setTimeout(() => {
   130→        onProjectCreated(projectName.trim())
   131→        handleClose()
   132→      }, 1500)
   133→    } catch (err) {
   134→      setInitializerStatus('error')
   135→      setInitializerError(err instanceof Error ? err.message : 'Failed to start agent')
   136→    }
   137→  }
   138→
   139→  const handleRetryInitializer = () => {
   140→    setInitializerError(null)
   141→    setInitializerStatus('idle')
   142→    handleSpecComplete('', yoloModeSelected)
   143→  }
   144→
   145→  const handleChatCancel = () => {
   146→    // Go back to method selection but keep the project
   147→    setStep('method')
   148→    setSpecMethod(null)
   149→  }
   150→
   151→  const handleExitToProject = () => {
   152→    // Exit chat and go directly to project - user can start agent manually
   153→    onProjectCreated(projectName.trim())
   154→    handleClose()
   155→  }
   156→
   157→  const handleClose = () => {
   158→    setStep('name')
   159→    setProjectName('')
   160→    setProjectPath(null)
   161→    setSpecMethod(null)
   162→    setError(null)
   163→    setInitializerStatus('idle')
   164→    setInitializerError(null)
   165→    setYoloModeSelected(false)
   166→    onClose()
   167→  }
   168→
   169→  const handleBack = () => {
   170→    if (step === 'method') {
   171→      setStep('folder')
   172→      setSpecMethod(null)
   173→    } else if (step === 'folder') {
   174→      setStep('name')
   175→      setProjectPath(null)
   176→    }
   177→  }
   178→
   179→  // Full-screen chat view
   180→  if (step === 'chat') {
   181→    return (
   182→

   183→        <SpecCreationChat
   184→          projectName={projectName.trim()}
   185→          onComplete={handleSpecComplete}
   186→          onCancel={handleChatCancel}
   187→          onExitToProject={handleExitToProject}
   188→          initializerStatus={initializerStatus}
   189→          initializerError={initializerError}
   190→          onRetryInitializer={handleRetryInitializer}
   191→        />
   192→

   193→    )
   194→  }
   195→
   196→  // Folder step uses larger modal
   197→  if (step === 'folder') {
   198→    return (
   199→

   200→
 e.stopPropagation()}
   203→        >
   204→          {/* Header */}
   205→

   206→

   207→              <Folder size={24} className="text-[var(--color-neo-progress)]" />
   208→

   209→

   210→                  Select Project Location
   211→

   212→

   213→                  A folder named {projectName} will be created inside the selected directory
   214→

   215→

   216→

   217→            <button
   218→              onClick={handleClose}
   219→              className="neo-btn neo-btn-ghost p-2"
   220→            >
   221→              <X size={20} />
   222→            </button>
   223→

   224→
   225→          {/* Folder Browser */}
   226→

   227→            <FolderBrowser
   228→              onSelect={handleFolderSelect}
   229→              onCancel={handleFolderCancel}
   230→            />
   231→

   232→

   233→

   234→    )
   235→  }
   236→
   237→  return (
   238→

   239→
 e.stopPropagation()}
   242→      >
   243→        {/* Header */}
   244→

   245→

   246→            {step === 'name' && 'Create New Project'}
   247→            {step === 'method' && 'Choose Setup Method'}
   248→            {step === 'complete' && 'Project Created!'}
   249→

   250→          <button
   251→            onClick={handleClose}
   252→            className="neo-btn neo-btn-ghost p-2"
   253→          >
   254→            <X size={20} />
   255→          </button>
   256→

   257→
   258→        {/* Content */}
   259→

   260→          {/* Step 1: Project Name */}
   261→          {step === 'name' && (
   262→            <form onSubmit={handleNameSubmit}>
   263→

   264→
   265→                  Project Name
   266→
   267→                <input
   268→                  type="text"
   269→                  value={projectName}
   270→                  onChange={(e) => setProjectName(e.target.value)}
   271→                  placeholder="my-awesome-app"
   272→                  className="neo-input"
   273→                  pattern="^[a-zA-Z0-9_-]+$"
   274→                  autoFocus
   275→                />
   276→

   277→                  Use letters, numbers, hyphens, and underscores only.
   278→

   279→

   280→
   281→              {error && (
   282→

   283→                  {error}
   284→

   285→              )}
   286→
   287→

   288→                <button
   289→                  type="submit"
   290→                  className="neo-btn neo-btn-primary"
   291→                  disabled={!projectName.trim()}
   292→                >
   293→                  Next
   294→                  <ArrowRight size={16} />
   295→                </button>
   296→

   297→            </form>
   298→          )}
   299→
   300→          {/* Step 2: Spec Method */}
   301→          {step === 'method' && (
   302→

   303→

   304→                How would you like to define your project?
   305→

   306→
   307→

   308→                {/* Claude option */}
   309→                <button
   310→                  onClick={() => handleMethodSelect('claude')}
   311→                  disabled={createProject.isPending}
   312→                  className={`
   313→                    w-full text-left p-4
   314→                    border-3 border-[var(--color-neo-border)]
   315→                    bg-white
   316→                    shadow-[4px_4px_0px_rgba(0,0,0,1)]
   317→                    hover:translate-x-[-2px] hover:translate-y-[-2px]
   318→                    hover:shadow-[6px_6px_0px_rgba(0,0,0,1)]
   319→                    transition-all duration-150
   320→                    disabled:opacity-50 disabled:cursor-not-allowed
   321→                  `}
   322→                >
   323→

   324→

   325→                      <Bot size={24} className="text-white" />
   326→

   327→

   328→

   329→                        Create with Claude
   330→
   331→                          Recommended
   332→
   333→

   334→

   335→                        Interactive conversation to define features and generate your app specification automatically.
   336→

   337→

   338→

   339→                </button>
   340→
   341→                {/* Manual option */}
   342→                <button
   343→                  onClick={() => handleMethodSelect('manual')}
   344→                  disabled={createProject.isPending}
   345→                  className={`
   346→                    w-full text-left p-4
   347→                    border-3 border-[var(--color-neo-border)]
   348→                    bg-white
   349→                    shadow-[4px_4px_0px_rgba(0,0,0,1)]
   350→                    hover:translate-x-[-2px] hover:translate-y-[-2px]
   351→                    hover:shadow-[6px_6px_0px_rgba(0,0,0,1)]
   352→                    transition-all duration-150
   353→                    disabled:opacity-50 disabled:cursor-not-allowed
   354→                  `}
   355→                >
   356→

   357→

   358→                      <FileEdit size={24} />
   359→

   360→

   361→                      Edit Templates Manually
   362→

   363→                        Edit the template files directly. Best for developers who want full control.
   364→

   365→

   366→

   367→                </button>
   368→

   369→
   370→              {error && (
   371→

   372→                  {error}
   373→

   374→              )}
   375→
   376→              {createProject.isPending && (
   377→

   378→                  <Loader2 size={16} className="animate-spin" />
   379→                  Creating project...
   380→

   381→              )}
   382→
   383→

   384→                <button
   385→                  onClick={handleBack}
   386→                  className="neo-btn neo-btn-ghost"
   387→                  disabled={createProject.isPending}
   388→                >
   389→                  <ArrowLeft size={16} />
   390→                  Back
   391→                </button>
   392→

   393→

   394→          )}
   395→
   396→          {/* Step 3: Complete */}
   397→          {step === 'complete' && (
   398→

   399→

   400→                <CheckCircle2 size={32} />
   401→

   402→

   403→                {projectName}
   404→

   405→

   406→                Your project has been created successfully!
   407→

   408→

   409→                <Loader2 size={16} className="animate-spin" />
   410→                Redirecting...
   411→

   412→

   413→          )}
   414→

   415→

   416→

   417→  )
   418→}
   419→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

/**
 * New Project Modal Component
 *
 * Multi-step modal for creating new projects:
 * 1. Enter project name
 * 2. Select project folder
 * 3. Choose spec method (Claude or manual)
 * 4a. If Claude: Show SpecCreationChat
 * 4b. If manual: Create project and close
 */

import { useState } from 'react'
import { X, Bot, FileEdit, ArrowRight, ArrowLeft, Loader2, CheckCircle2, Folder } from 'lucide-react'
import { useCreateProject } from '../hooks/useProjects'
import { SpecCreationChat } from './SpecCreationChat'
import { FolderBrowser } from './FolderBrowser'
import { startAgent } from '../lib/api'

type InitializerStatus = 'idle' | 'starting' | 'error'

type Step = 'name' | 'folder' | '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 [projectPath, setProjectPath] = useState<string | null>(null)
  const [_specMethod, setSpecMethod] = useState<SpecMethod | null>(null)
  const [error, setError] = useState<string | null>(null)
  const [initializerStatus, setInitializerStatus] = useState<InitializerStatus>('idle')
  const [initializerError, setInitializerError] = useState<string | null>(null)
  const [yoloModeSelected, setYoloModeSelected] = useState(false)

  // 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('folder')
  }

  const handleFolderSelect = (path: string) => {
    // Append project name to the selected path
    const fullPath = path.endsWith('/') ? `${path}${projectName.trim()}` : `${path}/${projectName.trim()}`
    setProjectPath(fullPath)
    setStep('method')
  }

  const handleFolderCancel = () => {
    setStep('name')
  }

  const handleMethodSelect = async (method: SpecMethod) => {
    setSpecMethod(method)

    if (!projectPath) {
      setError('Please select a project folder first')
      setStep('folder')
      return
    }

    if (method === 'manual') {
      // Create project immediately with manual method
      try {
        const project = await createProject.mutateAsync({
          name: projectName.trim(),
          path: projectPath,
          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(),
          path: projectPath,
          specMethod: 'claude',
        })
        setStep('chat')
      } catch (err: unknown) {
        setError(err instanceof Error ? err.message : 'Failed to create project')
      }
    }
  }

  const handleSpecComplete = async (_specPath: string, yoloMode: boolean = false) => {
    // Save yoloMode for retry
    setYoloModeSelected(yoloMode)
    // Auto-start the initializer agent
    setInitializerStatus('starting')
    try {
      await startAgent(projectName.trim(), yoloMode)
      // Success - navigate to project
      setStep('complete')
      setTimeout(() => {
        onProjectCreated(projectName.trim())
        handleClose()
      }, 1500)
    } catch (err) {
      setInitializerStatus('error')
      setInitializerError(err instanceof Error ? err.message : 'Failed to start agent')
    }
  }

  const handleRetryInitializer = () => {
    setInitializerError(null)
    setInitializerStatus('idle')
    handleSpecComplete('', yoloModeSelected)
  }

  const handleChatCancel = () => {
    // Go back to method selection but keep the project
    setStep('method')
    setSpecMethod(null)
  }

  const handleExitToProject = () => {
    // Exit chat and go directly to project - user can start agent manually
    onProjectCreated(projectName.trim())
    handleClose()
  }

  const handleClose = () => {
    setStep('name')
    setProjectName('')
    setProjectPath(null)
    setSpecMethod(null)
    setError(null)
    setInitializerStatus('idle')
    setInitializerError(null)
    setYoloModeSelected(false)
    onClose()
  }

  const handleBack = () => {
    if (step === 'method') {
      setStep('folder')
      setSpecMethod(null)
    } else if (step === 'folder') {
      setStep('name')
      setProjectPath(null)
    }
  }

  // Full-screen chat view
  if (step === 'chat') {
    return (

        <SpecCreationChat
          projectName={projectName.trim()}
          onComplete={handleSpecComplete}
          onCancel={handleChatCancel}
          onExitToProject={handleExitToProject}
          initializerStatus={initializerStatus}
          initializerError={initializerError}
          onRetryInitializer={handleRetryInitializer}
        />

    )
  }

  // Folder step uses larger modal
  if (step === 'folder') {
    return (

 e.stopPropagation()}
        >
          {/* Header */}

              <Folder size={24} className="text-[var(--color-neo-progress)]" />

                  Select Project Location

                  A folder named {projectName} will be created inside the selected directory

            <button
              onClick={handleClose}
              className="neo-btn neo-btn-ghost p-2"
            >
              <X size={20} />
            </button>

          {/* Folder Browser */}

            <FolderBrowser
              onSelect={handleFolderSelect}
              onCancel={handleFolderCancel}
            />

    )
  }

  return (

 e.stopPropagation()}
      >
        {/* Header */}

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

          <button
            onClick={handleClose}
            className="neo-btn neo-btn-ghost p-2"
          >
            <X size={20} />
          </button>

        {/* Content */}

          {/* Step 1: Project Name */}
          {step === 'name' && (
            <form onSubmit={handleNameSubmit}>

                  Project Name

                <input
                  type="text"
                  value={projectName}
                  onChange={(e) => setProjectName(e.target.value)}
                  placeholder="my-awesome-app"
                  className="neo-input"
                  pattern="^[a-zA-Z0-9_-]+$"
                  autoFocus
                />

                  Use letters, numbers, hyphens, and underscores only.

              {error && (

                  {error}

              )}

                <button
                  type="submit"
                  className="neo-btn neo-btn-primary"
                  disabled={!projectName.trim()}
                >
                  Next
                  <ArrowRight size={16} />
                </button>

            </form>
          )}

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

                How would you like to define your project?

                {/* 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
                  `}
                >

                      <Bot size={24} className="text-white" />

                        Create with Claude

                          Recommended

                        Interactive conversation to define features and generate your app specification automatically.

                </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
                  `}
                >

                      <FileEdit size={24} />

                      Edit Templates Manually

                        Edit the template files directly. Best for developers who want full control.

                </button>

              {error && (

                  {error}

              )}

              {createProject.isPending && (

                  <Loader2 size={16} className="animate-spin" />
                  Creating project...

              )}

                <button
                  onClick={handleBack}
                  className="neo-btn neo-btn-ghost"
                  disabled={createProject.isPending}
                >
                  <ArrowLeft size={16} />
                  Back
                </button>

          )}

          {/* Step 3: Complete */}
          {step === 'complete' && (

                <CheckCircle2 size={32} />

                {projectName}

                Your project has been created successfully!

                <Loader2 size={16} className="animate-spin" />
                Redirecting...

          )}

  )
}


Now let me look at the SpecCreationChat to understand how the spec interview works, and then I can plan the import feature.


1→/**
     2→ * Spec Creation Chat Component
     3→ *
     4→ * Full chat interface for interactive spec creation with Claude.
     5→ * Handles the 7-phase conversation flow for creating app specifications.
     6→ */
     7→
     8→import { useCallback, useEffect, useRef, useState } from 'react'
     9→import { Send, X, CheckCircle2, AlertCircle, Wifi, WifiOff, RotateCcw, Loader2, ArrowRight, Zap, Paperclip, ExternalLink } from 'lucide-react'
    10→import { useSpecChat } from '../hooks/useSpecChat'
    11→import { ChatMessage } from './ChatMessage'
    12→import { QuestionOptions } from './QuestionOptions'
    13→import { TypingIndicator } from './TypingIndicator'
    14→import type { ImageAttachment } from '../lib/types'
    15→
    16→// Image upload validation constants
    17→const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5 MB
    18→const ALLOWED_TYPES = ['image/jpeg', 'image/png']
    19→
    20→type InitializerStatus = 'idle' | 'starting' | 'error'
    21→
    22→interface SpecCreationChatProps {
    23→  projectName: string
    24→  onComplete: (specPath: string, yoloMode?: boolean) => void
    25→  onCancel: () => void
    26→  onExitToProject: () => void  // Exit to project without starting agent
    27→  initializerStatus?: InitializerStatus
    28→  initializerError?: string | null
    29→  onRetryInitializer?: () => void
    30→}
    31→
    32→export function SpecCreationChat({
    33→  projectName,
    34→  onComplete,
    35→  onCancel,
    36→  onExitToProject,
    37→  initializerStatus = 'idle',
    38→  initializerError = null,
    39→  onRetryInitializer,
    40→}: SpecCreationChatProps) {
    41→  const [input, setInput] = useState('')
    42→  const [error, setError] = useState<string | null>(null)
    43→  const [yoloEnabled, setYoloEnabled] = useState(false)
    44→  const [pendingAttachments, setPendingAttachments] = useState<ImageAttachment[]>([])
    45→  const messagesEndRef = useRef<HTMLDivElement>(null)
    46→  const inputRef = useRef<HTMLInputElement>(null)
    47→  const fileInputRef = useRef<HTMLInputElement>(null)
    48→
    49→  const {
    50→    messages,
    51→    isLoading,
    52→    isComplete,
    53→    connectionStatus,
    54→    currentQuestions,
    55→    start,
    56→    sendMessage,
    57→    sendAnswer,
    58→    disconnect,
    59→  } = useSpecChat({
    60→    projectName,
    61→    onComplete,
    62→    onError: (err) => setError(err),
    63→  })
    64→
    65→  // Start the chat session when component mounts
    66→  useEffect(() => {
    67→    start()
    68→
    69→    return () => {
    70→      disconnect()
    71→    }
    72→  }, []) // eslint-disable-line react-hooks/exhaustive-deps
    73→
    74→  // Scroll to bottom when messages change
    75→  useEffect(() => {
    76→    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
    77→  }, [messages, currentQuestions, isLoading])
    78→
    79→  // Focus input when not loading and no questions
    80→  useEffect(() => {
    81→    if (!isLoading && !currentQuestions && inputRef.current) {
    82→      inputRef.current.focus()
    83→    }
    84→  }, [isLoading, currentQuestions])
    85→
    86→  const handleSendMessage = () => {
    87→    const trimmed = input.trim()
    88→    // Allow sending if there's text OR attachments
    89→    if ((!trimmed && pendingAttachments.length === 0) || isLoading) return
    90→
    91→    // Detect /exit command - exit to project without sending to Claude
    92→    if (/^\s*\/exit\s*$/i.test(trimmed)) {
    93→      setInput('')
    94→      onExitToProject()
    95→      return
    96→    }
    97→
    98→    sendMessage(trimmed, pendingAttachments.length > 0 ? pendingAttachments : undefined)
    99→    setInput('')
   100→    setPendingAttachments([]) // Clear attachments after sending
   101→  }
   102→
   103→  const handleKeyDown = (e: React.KeyboardEvent) => {
   104→    if (e.key === 'Enter' && !e.shiftKey) {
   105→      e.preventDefault()
   106→      handleSendMessage()
   107→    }
   108→  }
   109→
   110→  const handleAnswerSubmit = (answers: Record<string, string | string[]>) => {
   111→    sendAnswer(answers)
   112→  }
   113→
   114→  // File handling for image attachments
   115→  const handleFileSelect = useCallback((files: FileList | null) => {
   116→    if (!files) return
   117→
   118→    Array.from(files).forEach((file) => {
   119→      // Validate file type
   120→      if (!ALLOWED_TYPES.includes(file.type)) {
   121→        setError(`Invalid file type: ${file.name}. Only JPEG and PNG are supported.`)
   122→        return
   123→      }
   124→
   125→      // Validate file size
   126→      if (file.size > MAX_FILE_SIZE) {
   127→        setError(`File too large: ${file.name}. Maximum size is 5 MB.`)
   128→        return
   129→      }
   130→
   131→      // Read and convert to base64
   132→      const reader = new FileReader()
   133→      reader.onload = (e) => {
   134→        const dataUrl = e.target?.result as string
   135→        // dataUrl is "data:image/png;base64,XXXXXX"
   136→        const base64Data = dataUrl.split(',')[1]
   137→
   138→        const attachment: ImageAttachment = {
   139→          id: `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
   140→          filename: file.name,
   141→          mimeType: file.type as 'image/jpeg' | 'image/png',
   142→          base64Data,
   143→          previewUrl: dataUrl,
   144→          size: file.size,
   145→        }
   146→
   147→        setPendingAttachments((prev) => [...prev, attachment])
   148→      }
   149→      reader.readAsDataURL(file)
   150→    })
   151→  }, [])
   152→
   153→  const handleRemoveAttachment = useCallback((id: string) => {
   154→    setPendingAttachments((prev) => prev.filter((a) => a.id !== id))
   155→  }, [])
   156→
   157→  const handleDrop = useCallback(
   158→    (e: React.DragEvent) => {
   159→      e.preventDefault()
   160→      handleFileSelect(e.dataTransfer.files)
   161→    },
   162→    [handleFileSelect]
   163→  )
   164→
   165→  const handleDragOver = useCallback((e: React.DragEvent) => {
   166→    e.preventDefault()
   167→  }, [])
   168→
   169→  // Connection status indicator
   170→  const ConnectionIndicator = () => {
   171→    switch (connectionStatus) {
   172→      case 'connected':
   173→        return (
   174→
   175→            <Wifi size={12} />
   176→            Connected
   177→
   178→        )
   179→      case 'connecting':
   180→        return (
   181→
   182→            <Wifi size={12} className="animate-pulse" />
   183→            Connecting...
   184→
   185→        )
   186→      case 'error':
   187→        return (
   188→
   189→            <WifiOff size={12} />
   190→            Error
   191→
   192→        )
   193→      default:
   194→        return (
   195→
   196→            <WifiOff size={12} />
   197→            Disconnected
   198→
   199→        )
   200→    }
   201→  }
   202→
   203→  return (
   204→

   205→      {/* Header */}
   206→

   207→

   208→

   209→            Create Spec: {projectName}
   210→

   211→          <ConnectionIndicator />
   212→

   213→
   214→

   215→          {isComplete && (
   216→
   217→              <CheckCircle2 size={16} />
   218→              Complete
   219→
   220→          )}
   221→
   222→          {/* Exit to Project - always visible escape hatch */}
   223→          <button
   224→            onClick={onExitToProject}
   225→            className="neo-btn neo-btn-ghost text-sm py-2"
   226→            title="Exit chat and go to project (you can start the agent manually)"
   227→          >
   228→            <ExternalLink size={16} />
   229→            Exit to Project
   230→          </button>
   231→
   232→          <button
   233→            onClick={onCancel}
   234→            className="neo-btn neo-btn-ghost p-2"
   235→            title="Cancel"
   236→          >
   237→            <X size={20} />
   238→          </button>
   239→

   240→

   241→
   242→      {/* Error banner */}
   243→      {error && (
   244→

   245→          <AlertCircle size={16} />
   246→          {error}
   247→          <button
   248→            onClick={() => setError(null)}
   249→            className="p-1 hover:bg-white/20 rounded"
   250→          >
   251→            <X size={14} />
   252→          </button>
   253→

   254→      )}
   255→
   256→      {/* Messages area */}
   257→

   258→        {messages.length === 0 && !isLoading && (
   259→

   260→

   261→

   262→                Starting Spec Creation
   263→

   264→

   265→                Connecting to Claude to help you create your app specification...
   266→

   267→              {connectionStatus === 'error' && (
   268→                <button
   269→                  onClick={start}
   270→                  className="neo-btn neo-btn-primary mt-4 text-sm"
   271→                >
   272→                  <RotateCcw size={14} />
   273→                  Retry Connection
   274→                </button>
   275→              )}
   276→

   277→

   278→        )}
   279→
   280→        {messages.map((message) => (
   281→          <ChatMessage key={message.id} message={message} />
   282→        ))}
   283→
   284→        {/* Structured questions */}
   285→        {currentQuestions && currentQuestions.length > 0 && (
   286→          <QuestionOptions
   287→            questions={currentQuestions}
   288→            onSubmit={handleAnswerSubmit}
   289→            disabled={isLoading}
   290→          />
   291→        )}
   292→
   293→        {/* Typing indicator - don't show when we have questions (waiting for user) */}
   294→        {isLoading && !currentQuestions && <TypingIndicator />}
   295→
   296→        {/* Scroll anchor */}
   297→

   298→

   299→
   300→      {/* Input area */}
   301→      {!isComplete && (
   302→

   307→          {/* Attachment previews */}
   308→          {pendingAttachments.length > 0 && (
   309→

   310→              {pendingAttachments.map((attachment) => (
   311→

   315→
   320→                  <button
   321→                    onClick={() => handleRemoveAttachment(attachment.id)}
   322→                    className="absolute -top-2 -right-2 bg-[var(--color-neo-danger)] text-white rounded-full p-0.5 border-2 border-[var(--color-neo-border)] hover:scale-110 transition-transform"
   323→                    title="Remove attachment"
   324→                  >
   325→                    <X size={12} />
   326→                  </button>
   327→
   328→                    {attachment.filename.length > 10
   329→                      ? `${attachment.filename.substring(0, 7)}...`
   330→                      : attachment.filename}
   331→
   332→

   333→              ))}
   334→

   335→          )}
   336→
   337→

   338→            {/* Hidden file input */}
   339→            <input
   340→              ref={fileInputRef}
   341→              type="file"
   342→              accept="image/jpeg,image/png"
   343→              multiple
   344→              onChange={(e) => handleFileSelect(e.target.files)}
   345→              className="hidden"
   346→            />
   347→
   348→            {/* Attach button */}
   349→            <button
   350→              onClick={() => fileInputRef.current?.click()}
   351→              disabled={connectionStatus !== 'connected'}
   352→              className="neo-btn neo-btn-ghost p-3"
   353→              title="Attach image (JPEG, PNG - max 5MB)"
   354→            >
   355→              <Paperclip size={18} />
   356→            </button>
   357→
   358→            <input
   359→              ref={inputRef}
   360→              type="text"
   361→              value={input}
   362→              onChange={(e) => setInput(e.target.value)}
   363→              onKeyDown={handleKeyDown}
   364→              placeholder={
   365→                currentQuestions
   366→                  ? 'Or type a custom response...'
   367→                  : pendingAttachments.length > 0
   368→                    ? 'Add a message with your image(s)...'
   369→                    : 'Type your response... (or /exit to go to project)'
   370→              }
   371→              className="neo-input flex-1"
   372→              disabled={(isLoading && !currentQuestions) || connectionStatus !== 'connected'}
   373→            />
   374→            <button
   375→              onClick={handleSendMessage}
   376→              disabled={
   377→                (!input.trim() && pendingAttachments.length === 0) ||
   378→                (isLoading && !currentQuestions) ||
   379→                connectionStatus !== 'connected'
   380→              }
   381→              className="neo-btn neo-btn-primary px-6"
   382→            >
   383→              <Send size={18} />
   384→            </button>
   385→

   386→
   387→          {/* Help text */}
   388→

   389→            Press Enter to send. Drag & drop or click <Paperclip size={12} className="inline" /> to attach images (JPEG/PNG, max 5MB).
   390→

   391→

   392→      )}
   393→
   394→      {/* Completion footer */}
   395→      {isComplete && (
   396→

   399→

   400→

   401→              {initializerStatus === 'starting' ? (
   402→                <>
   403→                  <Loader2 size={20} className="animate-spin" />
   404→
   405→                    Starting agent{yoloEnabled ? ' (YOLO mode)' : ''}...
   406→
   407→                </>
   408→              ) : initializerStatus === 'error' ? (
   409→                <>
   410→                  <AlertCircle size={20} className="text-white" />
   411→
   412→                    {initializerError || 'Failed to start agent'}
   413→
   414→                </>
   415→              ) : (
   416→                <>
   417→                  <CheckCircle2 size={20} />
   418→                  Specification created successfully!
   419→                </>
   420→              )}
   421→

   422→

   423→              {initializerStatus === 'error' && onRetryInitializer && (
   424→                <button
   425→                  onClick={onRetryInitializer}
   426→                  className="neo-btn bg-white"
   427→                >
   428→                  <RotateCcw size={14} />
   429→                  Retry
   430→                </button>
   431→              )}
   432→              {initializerStatus === 'idle' && (
   433→                <>
   434→                  {/* YOLO Mode Toggle */}
   435→                  <button
   436→                    onClick={() => setYoloEnabled(!yoloEnabled)}
   437→                    className={`neo-btn text-sm py-2 px-3 ${
   438→                      yoloEnabled ? 'neo-btn-warning' : 'bg-white'
   439→                    }`}
   440→                    title="YOLO Mode: Skip testing for rapid prototyping"
   441→                  >
   442→                    <Zap size={16} className={yoloEnabled ? 'text-yellow-900' : ''} />
   443→
   444→                      YOLO
   445→
   446→                  </button>
   447→                  <button
   448→                    onClick={() => onComplete('', yoloEnabled)}
   449→                    className="neo-btn neo-btn-primary"
   450→                  >
   451→                    Continue to Project
   452→                    <ArrowRight size={16} />
   453→                  </button>
   454→                </>
   455→              )}
   456→

   457→

   458→

   459→      )}
   460→

   461→  )
   462→}
   463→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

/**
 * Spec Creation Chat Component
 *
 * Full chat interface for interactive spec creation with Claude.
 * Handles the 7-phase conversation flow for creating app specifications.
 */

import { useCallback, useEffect, useRef, useState } from 'react'
import { Send, X, CheckCircle2, AlertCircle, Wifi, WifiOff, RotateCcw, Loader2, ArrowRight, Zap, Paperclip, ExternalLink } from 'lucide-react'
import { useSpecChat } from '../hooks/useSpecChat'
import { ChatMessage } from './ChatMessage'
import { QuestionOptions } from './QuestionOptions'
import { TypingIndicator } from './TypingIndicator'
import type { ImageAttachment } from '../lib/types'

// Image upload validation constants
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5 MB
const ALLOWED_TYPES = ['image/jpeg', 'image/png']

type InitializerStatus = 'idle' | 'starting' | 'error'

interface SpecCreationChatProps {
  projectName: string
  onComplete: (specPath: string, yoloMode?: boolean) => void
  onCancel: () => void
  onExitToProject: () => void  // Exit to project without starting agent
  initializerStatus?: InitializerStatus
  initializerError?: string | null
  onRetryInitializer?: () => void
}

export function SpecCreationChat({
  projectName,
  onComplete,
  onCancel,
  onExitToProject,
  initializerStatus = 'idle',
  initializerError = null,
  onRetryInitializer,
}: SpecCreationChatProps) {
  const [input, setInput] = useState('')
  const [error, setError] = useState<string | null>(null)
  const [yoloEnabled, setYoloEnabled] = useState(false)
  const [pendingAttachments, setPendingAttachments] = useState<ImageAttachment[]>([])
  const messagesEndRef = useRef<HTMLDivElement>(null)
  const inputRef = useRef<HTMLInputElement>(null)
  const fileInputRef = 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()
    // Allow sending if there's text OR attachments
    if ((!trimmed && pendingAttachments.length === 0) || isLoading) return

    // Detect /exit command - exit to project without sending to Claude
    if (/^\s*\/exit\s*$/i.test(trimmed)) {
      setInput('')
      onExitToProject()
      return
    }

    sendMessage(trimmed, pendingAttachments.length > 0 ? pendingAttachments : undefined)
    setInput('')
    setPendingAttachments([]) // Clear attachments after sending
  }

  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === 'Enter' && !e.shiftKey) {
      e.preventDefault()
      handleSendMessage()
    }
  }

  const handleAnswerSubmit = (answers: Record<string, string | string[]>) => {
    sendAnswer(answers)
  }

  // File handling for image attachments
  const handleFileSelect = useCallback((files: FileList | null) => {
    if (!files) return

    Array.from(files).forEach((file) => {
      // Validate file type
      if (!ALLOWED_TYPES.includes(file.type)) {
        setError(`Invalid file type: ${file.name}. Only JPEG and PNG are supported.`)
        return
      }

      // Validate file size
      if (file.size > MAX_FILE_SIZE) {
        setError(`File too large: ${file.name}. Maximum size is 5 MB.`)
        return
      }

      // Read and convert to base64
      const reader = new FileReader()
      reader.onload = (e) => {
        const dataUrl = e.target?.result as string
        // dataUrl is "data:image/png;base64,XXXXXX"
        const base64Data = dataUrl.split(',')[1]

        const attachment: ImageAttachment = {
          id: `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
          filename: file.name,
          mimeType: file.type as 'image/jpeg' | 'image/png',
          base64Data,
          previewUrl: dataUrl,
          size: file.size,
        }

        setPendingAttachments((prev) => [...prev, attachment])
      }
      reader.readAsDataURL(file)
    })
  }, [])

  const handleRemoveAttachment = useCallback((id: string) => {
    setPendingAttachments((prev) => prev.filter((a) => a.id !== id))
  }, [])

  const handleDrop = useCallback(
    (e: React.DragEvent) => {
      e.preventDefault()
      handleFileSelect(e.dataTransfer.files)
    },
    [handleFileSelect]
  )

  const handleDragOver = useCallback((e: React.DragEvent) => {
    e.preventDefault()
  }, [])

  // Connection status indicator
  const ConnectionIndicator = () => {
    switch (connectionStatus) {
      case 'connected':
        return (

            <Wifi size={12} />
            Connected

        )
      case 'connecting':
        return (

            <Wifi size={12} className="animate-pulse" />
            Connecting...

        )
      case 'error':
        return (

            <WifiOff size={12} />
            Error

        )
      default:
        return (

            <WifiOff size={12} />
            Disconnected

        )
    }
  }

  return (

      {/* Header */}

            Create Spec: {projectName}

          <ConnectionIndicator />

          {isComplete && (

              <CheckCircle2 size={16} />
              Complete

          )}

          {/* Exit to Project - always visible escape hatch */}
          <button
            onClick={onExitToProject}
            className="neo-btn neo-btn-ghost text-sm py-2"
            title="Exit chat and go to project (you can start the agent manually)"
          >
            <ExternalLink size={16} />
            Exit to Project
          </button>

          <button
            onClick={onCancel}
            className="neo-btn neo-btn-ghost p-2"
            title="Cancel"
          >
            <X size={20} />
          </button>

      {/* Error banner */}
      {error && (

          <AlertCircle size={16} />
          {error}
          <button
            onClick={() => setError(null)}
            className="p-1 hover:bg-white/20 rounded"
          >
            <X size={14} />
          </button>

      )}

      {/* Messages area */}

        {messages.length === 0 && !isLoading && (

                Starting Spec Creation

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

              {connectionStatus === 'error' && (
                <button
                  onClick={start}
                  className="neo-btn neo-btn-primary mt-4 text-sm"
                >
                  <RotateCcw size={14} />
                  Retry Connection
                </button>
              )}

        )}

        {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 */}

      {/* Input area */}
      {!isComplete && (

          {/* Attachment previews */}
          {pendingAttachments.length > 0 && (

              {pendingAttachments.map((attachment) => (

                  <button
                    onClick={() => handleRemoveAttachment(attachment.id)}
                    className="absolute -top-2 -right-2 bg-[var(--color-neo-danger)] text-white rounded-full p-0.5 border-2 border-[var(--color-neo-border)] hover:scale-110 transition-transform"
                    title="Remove attachment"
                  >
                    <X size={12} />
                  </button>

                    {attachment.filename.length > 10
                      ? `${attachment.filename.substring(0, 7)}...`
                      : attachment.filename}

              ))}

          )}

            {/* Hidden file input */}
            <input
              ref={fileInputRef}
              type="file"
              accept="image/jpeg,image/png"
              multiple
              onChange={(e) => handleFileSelect(e.target.files)}
              className="hidden"
            />

            {/* Attach button */}
            <button
              onClick={() => fileInputRef.current?.click()}
              disabled={connectionStatus !== 'connected'}
              className="neo-btn neo-btn-ghost p-3"
              title="Attach image (JPEG, PNG - max 5MB)"
            >
              <Paperclip size={18} />
            </button>

            <input
              ref={inputRef}
              type="text"
              value={input}
              onChange={(e) => setInput(e.target.value)}
              onKeyDown={handleKeyDown}
              placeholder={
                currentQuestions
                  ? 'Or type a custom response...'
                  : pendingAttachments.length > 0
                    ? 'Add a message with your image(s)...'
                    : 'Type your response... (or /exit to go to project)'
              }
              className="neo-input flex-1"
              disabled={(isLoading && !currentQuestions) || connectionStatus !== 'connected'}
            />
            <button
              onClick={handleSendMessage}
              disabled={
                (!input.trim() && pendingAttachments.length === 0) ||
                (isLoading && !currentQuestions) ||
                connectionStatus !== 'connected'
              }
              className="neo-btn neo-btn-primary px-6"
            >
              <Send size={18} />
            </button>

          {/* Help text */}

            Press Enter to send. Drag & drop or click <Paperclip size={12} className="inline" /> to attach images (JPEG/PNG, max 5MB).

      )}

      {/* Completion footer */}
      {isComplete && (

              {initializerStatus === 'starting' ? (
                <>
                  <Loader2 size={20} className="animate-spin" />

                    Starting agent{yoloEnabled ? ' (YOLO mode)' : ''}...

                </>
              ) : initializerStatus === 'error' ? (
                <>
                  <AlertCircle size={20} className="text-white" />

                    {initializerError || 'Failed to start agent'}

                </>
              ) : (
                <>
                  <CheckCircle2 size={20} />
                  Specification created successfully!
                </>
              )}

              {initializerStatus === 'error' && onRetryInitializer && (
                <button
                  onClick={onRetryInitializer}
                  className="neo-btn bg-white"
                >
                  <RotateCcw size={14} />
                  Retry
                </button>
              )}
              {initializerStatus === 'idle' && (
                <>
                  {/* YOLO Mode Toggle */}
                  <button
                    onClick={() => setYoloEnabled(!yoloEnabled)}
                    className={`neo-btn text-sm py-2 px-3 ${
                      yoloEnabled ? 'neo-btn-warning' : 'bg-white'
                    }`}
                    title="YOLO Mode: Skip testing for rapid prototyping"
                  >
                    <Zap size={16} className={yoloEnabled ? 'text-yellow-900' : ''} />

                      YOLO

                  </button>
                  <button
                    onClick={() => onComplete('', yoloEnabled)}
                    className="neo-btn neo-btn-primary"
                  >
                    Continue to Project
                    <ArrowRight size={16} />
                  </button>
                </>
              )}

      )}

  )
}


     1→"""
     2→Spec Router
     3→===========
     4→
     5→API endpoints for project specification management.
     6→"""
     7→
     8→from datetime import datetime
     9→from typing import Optional, List
    10→
    11→from fastapi import APIRouter, Depends, HTTPException, status
    12→from pydantic import BaseModel, Field
    13→from sqlalchemy import select, and_, func
    14→from sqlalchemy.ext.asyncio import AsyncSession
    15→
    16→from ..database import get_db
    17→from ..models import Project, SpecHistory, Feature
    18→
    19→router = APIRouter(prefix="/api/projects", tags=["spec"])
    20→
    21→
    22→# ============================================================================
    23→# Pydantic Schemas
    24→# ============================================================================
    25→
    26→class SpecResponse(BaseModel):
    27→    """Schema for current spec response."""
    28→    project_id: int
    29→    version: int
    30→    content: str
    31→    created_at: datetime
    32→    change_summary: Optional[str]
    33→
    34→    class Config:
    35→        from_attributes = True
    36→
    37→
    38→class SpecHistoryResponse(BaseModel):
    39→    """Schema for spec history entry."""
    40→    id: int
    41→    project_id: int
    42→    version: int
    43→    content: str
    44→    created_at: datetime
    45→    change_summary: Optional[str]
    46→
    47→    class Config:
    48→        from_attributes = True
    49→
    50→
    51→class SpecHistoryListResponse(BaseModel):
    52→    """Schema for spec history list."""
    53→    history: List[SpecHistoryResponse]
    54→    count: int
    55→
    56→
    57→class SpecUpdate(BaseModel):
    58→    """Schema for updating a spec."""
    59→    content: str = Field(..., min_length=1)
    60→    change_summary: Optional[str] = None
    61→
    62→
    63→class AffectedFeatureResponse(BaseModel):
    64→    """Schema for an affected feature in change analysis."""
    65→    id: int
    66→    name: str
    67→    category: str
    68→    reason: str
    69→
    70→    class Config:
    71→        from_attributes = True
    72→
    73→
    74→class SpecAnalysisResponse(BaseModel):
    75→    """Schema for spec change analysis response."""
    76→    affected_features: List[AffectedFeatureResponse]
    77→    affected_count: int
    78→    change_summary: str
    79→
    80→
    81→# ============================================================================
    82→# API Endpoints
    83→# ============================================================================
    84→
    85→@router.get("/{name}/spec", response_model=SpecResponse)
    86→async def get_current_spec(name: str, db: AsyncSession = Depends(get_db)):
    87→    """Get the current (latest) spec for a project."""
    88→    # Verify project exists
    89→    result = await db.execute(select(Project).where(Project.name == name))
    90→    project = result.scalar_one_or_none()
    91→
    92→    if not project:
    93→        raise HTTPException(
    94→            status_code=status.HTTP_404_NOT_FOUND,
    95→            detail=f"Project '{name}' not found"
    96→        )
    97→
    98→    # Get latest spec version
    99→    result = await db.execute(
   100→        select(SpecHistory)
   101→        .where(SpecHistory.project_id == project.id)
   102→        .order_by(SpecHistory.version.desc())
   103→        .limit(1)
   104→    )
   105→    spec = result.scalar_one_or_none()
   106→
   107→    if not spec:
   108→        # Return empty spec if none exists
   109→        return SpecResponse(
   110→            project_id=project.id,
   111→            version=0,
   112→            content="",
   113→            created_at=project.created_at,
   114→            change_summary="No spec yet"
   115→        )
   116→
   117→    return SpecResponse(
   118→        project_id=spec.project_id,
   119→        version=spec.version,
   120→        content=spec.content,
   121→        created_at=spec.created_at,
   122→        change_summary=spec.change_summary
   123→    )
   124→
   125→
   126→@router.put("/{name}/spec", response_model=SpecResponse)
   127→async def update_spec(name: str, spec_update: SpecUpdate, db: AsyncSession = Depends(get_db)):
   128→    """Update the spec (creates a new version)."""
   129→    # Verify project exists
   130→    result = await db.execute(select(Project).where(Project.name == name))
   131→    project = result.scalar_one_or_none()
   132→
   133→    if not project:
   134→        raise HTTPException(
   135→            status_code=status.HTTP_404_NOT_FOUND,
   136→            detail=f"Project '{name}' not found"
   137→        )
   138→
   139→    # Get current version
   140→    result = await db.execute(
   141→        select(func.max(SpecHistory.version))
   142→        .where(SpecHistory.project_id == project.id)
   143→    )
   144→    current_version = result.scalar() or 0
   145→
   146→    # Create new version
   147→    new_version = current_version + 1
   148→    new_spec = SpecHistory(
   149→        project_id=project.id,
   150→        version=new_version,
   151→        content=spec_update.content,
   152→        change_summary=spec_update.change_summary,
   153→    )
   154→    db.add(new_spec)
   155→
   156→    # Update project's spec_version
   157→    project.spec_version = new_version
   158→    project.updated_at = datetime.utcnow()
   159→
   160→    await db.commit()
   161→    await db.refresh(new_spec)
   162→
   163→    return SpecResponse(
   164→        project_id=new_spec.project_id,
   165→        version=new_spec.version,
   166→        content=new_spec.content,
   167→        created_at=new_spec.created_at,
   168→        change_summary=new_spec.change_summary
   169→    )
   170→
   171→
   172→@router.get("/{name}/spec/history", response_model=SpecHistoryListResponse)
   173→async def get_spec_history(name: str, db: AsyncSession = Depends(get_db)):
   174→    """Get the spec version history for a project."""
   175→    # Verify project exists
   176→    result = await db.execute(select(Project).where(Project.name == name))
   177→    project = result.scalar_one_or_none()
   178→
   179→    if not project:
   180→        raise HTTPException(
   181→            status_code=status.HTTP_404_NOT_FOUND,
   182→            detail=f"Project '{name}' not found"
   183→        )
   184→
   185→    # Get all spec versions
   186→    result = await db.execute(
   187→        select(SpecHistory)
   188→        .where(SpecHistory.project_id == project.id)
   189→        .order_by(SpecHistory.version.desc())
   190→    )
   191→    history = result.scalars().all()
   192→
   193→    return SpecHistoryListResponse(
   194→        history=[SpecHistoryResponse.model_validate(h) for h in history],
   195→        count=len(history)
   196→    )
   197→
   198→
   199→@router.post("/{name}/spec/rollback/{version}", response_model=SpecResponse)
   200→async def rollback_spec(name: str, version: int, db: AsyncSession = Depends(get_db)):
   201→    """Rollback to a previous spec version."""
   202→    # Verify project exists
   203→    result = await db.execute(select(Project).where(Project.name == name))
   204→    project = result.scalar_one_or_none()
   205→
   206→    if not project:
   207→        raise HTTPException(
   208→            status_code=status.HTTP_404_NOT_FOUND,
   209→            detail=f"Project '{name}' not found"
   210→        )
   211→
   212→    # Get the target version
   213→    result = await db.execute(
   214→        select(SpecHistory).where(and_(
   215→            SpecHistory.project_id == project.id,
   216→            SpecHistory.version == version
   217→        ))
   218→    )
   219→    target_spec = result.scalar_one_or_none()
   220→
   221→    if not target_spec:
   222→        raise HTTPException(
   223→            status_code=status.HTTP_404_NOT_FOUND,
   224→            detail=f"Spec version {version} not found"
   225→        )
   226→
   227→    # Get current version number
   228→    result = await db.execute(
   229→        select(func.max(SpecHistory.version))
   230→        .where(SpecHistory.project_id == project.id)
   231→    )
   232→    current_version = result.scalar() or 0
   233→
   234→    # Create new version with the old content
   235→    new_version = current_version + 1
   236→    new_spec = SpecHistory(
   237→        project_id=project.id,
   238→        version=new_version,
   239→        content=target_spec.content,
   240→        change_summary=f"Rollback to version {version}",
   241→    )
   242→    db.add(new_spec)
   243→
   244→    # Update project's spec_version
   245→    project.spec_version = new_version
   246→    project.updated_at = datetime.utcnow()
   247→
   248→    await db.commit()
   249→    await db.refresh(new_spec)
   250→
   251→    return SpecResponse(
   252→        project_id=new_spec.project_id,
   253→        version=new_spec.version,
   254→        content=new_spec.content,
   255→        created_at=new_spec.created_at,
   256→        change_summary=new_spec.change_summary
   257→    )
   258→
   259→
   260→@router.post("/{name}/spec/analyze-changes", response_model=SpecAnalysisResponse)
   261→async def analyze_spec_changes(name: str, spec_update: SpecUpdate, db: AsyncSession = Depends(get_db)):
   262→    """Preview the impact of spec changes before applying them."""
   263→    # Verify project exists
   264→    result = await db.execute(select(Project).where(Project.name == name))
   265→    project = result.scalar_one_or_none()
   266→
   267→    if not project:
   268→        raise HTTPException(
   269→            status_code=status.HTTP_404_NOT_FOUND,
   270→            detail=f"Project '{name}' not found"
   271→        )
   272→
   273→    # Get current spec content for comparison
   274→    result = await db.execute(
   275→        select(SpecHistory)
   276→        .where(SpecHistory.project_id == project.id)
   277→        .order_by(SpecHistory.version.desc())
   278→        .limit(1)
   279→    )
   280→    current_spec = result.scalar_one_or_none()
   281→    current_content = current_spec.content if current_spec else ""
   282→
   283→    # Get all features for this project
   284→    result = await db.execute(
   285→        select(Feature)
   286→        .where(Feature.project_id == project.id)
   287→    )
   288→    features = result.scalars().all()
   289→
   290→    # Analyze which features might be affected by the spec change
   291→    # Simple heuristic: features whose names or descriptions contain words that appear
   292→    # in the changed parts of the spec
   293→    affected = []
   294→    new_content = spec_update.content.lower()
   295→    old_content = current_content.lower()
   296→
   297→    # Find words that are new in the spec
   298→    new_words = set(new_content.split()) - set(old_content.split())
   299→    changed_words = set(new_content.split()) ^ set(old_content.split())  # symmetric difference
   300→
   301→    for feature in features:
   302→        feature_text = f"{feature.name} {feature.description or ''} {feature.category}".lower()
   303→        feature_words = set(feature_text.split())
   304→
   305→        # Check if any changed words appear in the feature
   306→        matching_words = feature_words & changed_words
   307→        if matching_words:
   308→            affected.append(AffectedFeatureResponse(
   309→                id=feature.id,
   310→                name=feature.name,
   311→                category=feature.category,
   312→                reason=f"Contains changed terms: {', '.join(list(matching_words)[:3])}"
   313→            ))
   314→
   315→    # Generate change summary
   316→    if current_content:
   317→        lines_old = len(current_content.split('\n'))
   318→        lines_new = len(spec_update.content.split('\n'))
   319→        line_diff = lines_new - lines_old
   320→        change_summary = spec_update.change_summary or f"Spec change: {line_diff:+d} lines, {len(changed_words)} terms changed"
   321→    else:
   322→        change_summary = spec_update.change_summary or "Initial spec creation"
   323→
   324→    return SpecAnalysisResponse(
   325→        affected_features=affected,
   326→        affected_count=len(affected),
   327→        change_summary=change_summary
   328→    )
   329→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

"""
Spec Router
===========

API endpoints for project specification management.
"""

from datetime import datetime
from typing import Optional, List

from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy import select, and_, func
from sqlalchemy.ext.asyncio import AsyncSession

from ..database import get_db
from ..models import Project, SpecHistory, Feature

router = APIRouter(prefix="/api/projects", tags=["spec"])

# ============================================================================
# Pydantic Schemas
# ============================================================================

class SpecResponse(BaseModel):
    """Schema for current spec response."""
    project_id: int
    version: int
    content: str
    created_at: datetime
    change_summary: Optional[str]

    class Config:
        from_attributes = True

class SpecHistoryResponse(BaseModel):
    """Schema for spec history entry."""
    id: int
    project_id: int
    version: int
    content: str
    created_at: datetime
    change_summary: Optional[str]

    class Config:
        from_attributes = True

class SpecHistoryListResponse(BaseModel):
    """Schema for spec history list."""
    history: List[SpecHistoryResponse]
    count: int

class SpecUpdate(BaseModel):
    """Schema for updating a spec."""
    content: str = Field(..., min_length=1)
    change_summary: Optional[str] = None

class AffectedFeatureResponse(BaseModel):
    """Schema for an affected feature in change analysis."""
    id: int
    name: str
    category: str
    reason: str

    class Config:
        from_attributes = True

class SpecAnalysisResponse(BaseModel):
    """Schema for spec change analysis response."""
    affected_features: List[AffectedFeatureResponse]
    affected_count: int
    change_summary: str

# ============================================================================
# API Endpoints
# ============================================================================

@router.get("/{name}/spec", response_model=SpecResponse)
async def get_current_spec(name: str, db: AsyncSession = Depends(get_db)):
    """Get the current (latest) spec for a project."""
    # Verify project exists
    result = await db.execute(select(Project).where(Project.name == name))
    project = result.scalar_one_or_none()

    if not project:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Project '{name}' not found"
        )

    # Get latest spec version
    result = await db.execute(
        select(SpecHistory)
        .where(SpecHistory.project_id == project.id)
        .order_by(SpecHistory.version.desc())
        .limit(1)
    )
    spec = result.scalar_one_or_none()

    if not spec:
        # Return empty spec if none exists
        return SpecResponse(
            project_id=project.id,
            version=0,
            content="",
            created_at=project.created_at,
            change_summary="No spec yet"
        )

    return SpecResponse(
        project_id=spec.project_id,
        version=spec.version,
        content=spec.content,
        created_at=spec.created_at,
        change_summary=spec.change_summary
    )

@router.put("/{name}/spec", response_model=SpecResponse)
async def update_spec(name: str, spec_update: SpecUpdate, db: AsyncSession = Depends(get_db)):
    """Update the spec (creates a new version)."""
    # Verify project exists
    result = await db.execute(select(Project).where(Project.name == name))
    project = result.scalar_one_or_none()

    if not project:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Project '{name}' not found"
        )

    # Get current version
    result = await db.execute(
        select(func.max(SpecHistory.version))
        .where(SpecHistory.project_id == project.id)
    )
    current_version = result.scalar() or 0

    # Create new version
    new_version = current_version + 1
    new_spec = SpecHistory(
        project_id=project.id,
        version=new_version,
        content=spec_update.content,
        change_summary=spec_update.change_summary,
    )
    db.add(new_spec)

    # Update project's spec_version
    project.spec_version = new_version
    project.updated_at = datetime.utcnow()

    await db.commit()
    await db.refresh(new_spec)

    return SpecResponse(
        project_id=new_spec.project_id,
        version=new_spec.version,
        content=new_spec.content,
        created_at=new_spec.created_at,
        change_summary=new_spec.change_summary
    )

@router.get("/{name}/spec/history", response_model=SpecHistoryListResponse)
async def get_spec_history(name: str, db: AsyncSession = Depends(get_db)):
    """Get the spec version history for a project."""
    # Verify project exists
    result = await db.execute(select(Project).where(Project.name == name))
    project = result.scalar_one_or_none()

    if not project:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Project '{name}' not found"
        )

    # Get all spec versions
    result = await db.execute(
        select(SpecHistory)
        .where(SpecHistory.project_id == project.id)
        .order_by(SpecHistory.version.desc())
    )
    history = result.scalars().all()

    return SpecHistoryListResponse(
        history=[SpecHistoryResponse.model_validate(h) for h in history],
        count=len(history)
    )

@router.post("/{name}/spec/rollback/{version}", response_model=SpecResponse)
async def rollback_spec(name: str, version: int, db: AsyncSession = Depends(get_db)):
    """Rollback to a previous spec version."""
    # Verify project exists
    result = await db.execute(select(Project).where(Project.name == name))
    project = result.scalar_one_or_none()

    if not project:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Project '{name}' not found"
        )

    # Get the target version
    result = await db.execute(
        select(SpecHistory).where(and_(
            SpecHistory.project_id == project.id,
            SpecHistory.version == version
        ))
    )
    target_spec = result.scalar_one_or_none()

    if not target_spec:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Spec version {version} not found"
        )

    # Get current version number
    result = await db.execute(
        select(func.max(SpecHistory.version))
        .where(SpecHistory.project_id == project.id)
    )
    current_version = result.scalar() or 0

    # Create new version with the old content
    new_version = current_version + 1
    new_spec = SpecHistory(
        project_id=project.id,
        version=new_version,
        content=target_spec.content,
        change_summary=f"Rollback to version {version}",
    )
    db.add(new_spec)

    # Update project's spec_version
    project.spec_version = new_version
    project.updated_at = datetime.utcnow()

    await db.commit()
    await db.refresh(new_spec)

    return SpecResponse(
        project_id=new_spec.project_id,
        version=new_spec.version,
        content=new_spec.content,
        created_at=new_spec.created_at,
        change_summary=new_spec.change_summary
    )

@router.post("/{name}/spec/analyze-changes", response_model=SpecAnalysisResponse)
async def analyze_spec_changes(name: str, spec_update: SpecUpdate, db: AsyncSession = Depends(get_db)):
    """Preview the impact of spec changes before applying them."""
    # Verify project exists
    result = await db.execute(select(Project).where(Project.name == name))
    project = result.scalar_one_or_none()

    if not project:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Project '{name}' not found"
        )

    # Get current spec content for comparison
    result = await db.execute(
        select(SpecHistory)
        .where(SpecHistory.project_id == project.id)
        .order_by(SpecHistory.version.desc())
        .limit(1)
    )
    current_spec = result.scalar_one_or_none()
    current_content = current_spec.content if current_spec else ""

    # Get all features for this project
    result = await db.execute(
        select(Feature)
        .where(Feature.project_id == project.id)
    )
    features = result.scalars().all()

    # Analyze which features might be affected by the spec change
    # Simple heuristic: features whose names or descriptions contain words that appear
    # in the changed parts of the spec
    affected = []
    new_content = spec_update.content.lower()
    old_content = current_content.lower()

    # Find words that are new in the spec
    new_words = set(new_content.split()) - set(old_content.split())
    changed_words = set(new_content.split()) ^ set(old_content.split())  # symmetric difference

    for feature in features:
        feature_text = f"{feature.name} {feature.description or ''} {feature.category}".lower()
        feature_words = set(feature_text.split())

        # Check if any changed words appear in the feature
        matching_words = feature_words & changed_words
        if matching_words:
            affected.append(AffectedFeatureResponse(
                id=feature.id,
                name=feature.name,
                category=feature.category,
                reason=f"Contains changed terms: {', '.join(list(matching_words)[:3])}"
            ))

    # Generate change summary
    if current_content:
        lines_old = len(current_content.split('\n'))
        lines_new = len(spec_update.content.split('\n'))
        line_diff = lines_new - lines_old
        change_summary = spec_update.change_summary or f"Spec change: {line_diff:+d} lines, {len(changed_words)} terms changed"
    else:
        change_summary = spec_update.change_summary or "Initial spec creation"

    return SpecAnalysisResponse(
        affected_features=affected,
        affected_count=len(affected),
        change_summary=change_summary
    )


Now I have a good understanding of how the project creation and spec flows work. Let me also check the useSpecChat hook to understand how the WebSocket-based spec creation chat works.


Let me also check the spec chat WebSocket handler:


Bash

List backend routers