Write

/**
 * Spec Interview Component
 *
 * Interactive chat interface for creating app specifications with Claude.
 * Connects to WebSocket endpoint for real-time conversation.
 */

import { useState, useEffect, useRef, useCallback } from 'react'

interface Message {
  id: string
  role: 'user' | 'assistant' | 'system'
  content: string
  timestamp: Date
  isStreaming?: boolean
}

type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error'

interface SpecInterviewProps {
  projectName: string
  onBack: () => void
}

function generateId(): string {
  return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
}

export function SpecInterview({ projectName, onBack }: SpecInterviewProps) {
  const [messages, setMessages] = useState<Message[]>([])
  const [input, setInput] = useState('')
  const [isLoading, setIsLoading] = useState(false)
  const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>('disconnected')
  const [error, setError] = useState<string | null>(null)
  const [isComplete, setIsComplete] = useState(false)

  const messagesEndRef = useRef<HTMLDivElement>(null)
  const inputRef = useRef<HTMLInputElement>(null)
  const wsRef = useRef<WebSocket | null>(null)

  // Scroll to bottom when messages change
  useEffect(() => {
    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
  }, [messages])

  // Focus input when not loading
  useEffect(() => {
    if (!isLoading && inputRef.current) {
      inputRef.current.focus()
    }
  }, [isLoading])

  // Connect to WebSocket
  const connect = useCallback(() => {
    if (wsRef.current?.readyState === WebSocket.OPEN) {
      return
    }

    setConnectionStatus('connecting')
    setError(null)

    const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
    const host = window.location.host
    const wsUrl = `${protocol}//${host}/api/spec/ws/${encodeURIComponent(projectName)}`

    try {
      const ws = new WebSocket(wsUrl)
      wsRef.current = ws

      ws.onopen = () => {
        setConnectionStatus('connected')
        // Send start message
        ws.send(JSON.stringify({ type: 'start' }))
        setIsLoading(true)
      }

      ws.onclose = () => {
        setConnectionStatus('disconnected')
      }

      ws.onerror = () => {
        setConnectionStatus('error')
        setError('Failed to connect to interview server. The WebSocket endpoint may not be available yet.')
      }

      ws.onmessage = (event) => {
        try {
          const data = JSON.parse(event.data)

          switch (data.type) {
            case 'text': {
              setMessages((prev) => {
                const lastMessage = prev[prev.length - 1]
                if (lastMessage?.role === 'assistant' && lastMessage.isStreaming) {
                  return [
                    ...prev.slice(0, -1),
                    { ...lastMessage, content: lastMessage.content + data.content },
                  ]
                } else {
                  return [
                    ...prev,
                    {
                      id: generateId(),
                      role: 'assistant',
                      content: data.content,
                      timestamp: new Date(),
                      isStreaming: true,
                    },
                  ]
                }
              })
              break
            }

            case 'response_done': {
              setIsLoading(false)
              setMessages((prev) => {
                const lastMessage = prev[prev.length - 1]
                if (lastMessage?.role === 'assistant' && lastMessage.isStreaming) {
                  return [
                    ...prev.slice(0, -1),
                    { ...lastMessage, isStreaming: false },
                  ]
                }
                return prev
              })
              break
            }

            case 'spec_complete':
            case 'complete': {
              setIsComplete(true)
              setIsLoading(false)
              setMessages((prev) => [
                ...prev,
                {
                  id: generateId(),
                  role: 'system',
                  content: data.path ? `Specification created: ${data.path}` : 'Specification complete!',
                  timestamp: new Date(),
                },
              ])
              break
            }

            case 'error': {
              setIsLoading(false)
              setError(data.content)
              break
            }

            case 'pong':
              break
          }
        } catch (e) {
          console.error('Failed to parse WebSocket message:', e)
        }
      }
    } catch (e) {
      setConnectionStatus('error')
      setError('Failed to create WebSocket connection')
    }
  }, [projectName])

  // Disconnect
  const disconnect = useCallback(() => {
    if (wsRef.current) {
      wsRef.current.close()
      wsRef.current = null
    }
    setConnectionStatus('disconnected')
  }, [])

  // Clean up on unmount
  useEffect(() => {
    return () => {
      disconnect()
    }
  }, [disconnect])

  // Send message
  const handleSendMessage = () => {
    const trimmed = input.trim()
    if (!trimmed || isLoading || connectionStatus !== 'connected') return

    // Add user message
    setMessages((prev) => [
      ...prev,
      {
        id: generateId(),
        role: 'user',
        content: trimmed,
        timestamp: new Date(),
      },
    ])

    // Send to server
    if (wsRef.current?.readyState === WebSocket.OPEN) {
      wsRef.current.send(JSON.stringify({ type: 'message', content: trimmed }))
      setIsLoading(true)
    }

    setInput('')
  }

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

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

            Connected

        )
      case 'connecting':
        return (

            Connecting...

        )
      case 'error':
        return (

            Error

        )
      default:
        return (

            Disconnected

        )
    }
  }

  return (

      {/* Header */}

Spec Interview: {projectName}

          <ConnectionIndicator />

          {isComplete && (

              <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
              </svg>
              Complete

          )}
          <button
            onClick={onBack}
            className="p-2 rounded hover:bg-surface-elevated transition-colors"
            title="Back to project"
          >
            <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
            </svg>
          </button>

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

          <svg className="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
          </svg>
          {error}
          <button onClick={() => setError(null)} className="p-1 hover:bg-error/20 rounded">
            <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
            </svg>
          </button>

      )}

      {/* Messages area */}

        {connectionStatus === 'disconnected' && messages.length === 0 && (

              <svg className="w-12 h-12 mx-auto mb-4 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
              </svg>

Spec Interview

                Start an interactive conversation with Claude to create your app specification.
                Claude will guide you through defining features, requirements, and architecture.

              <button
                onClick={connect}
                className="px-4 py-2 bg-accent text-white rounded hover:bg-accent/80 transition-colors"
              >
                Start Interview
              </button>

        )}

        {connectionStatus === 'connecting' && messages.length === 0 && (

Connecting to Claude...

        )}

        {connectionStatus === 'error' && messages.length === 0 && (

              <svg className="w-12 h-12 mx-auto mb-4 text-error" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
              </svg>

Connection Failed

                The spec interview WebSocket endpoint is not available.
                This feature requires the backend WebSocket server to be running.

                <button
                  onClick={connect}
                  className="px-4 py-2 bg-accent text-white rounded hover:bg-accent/80 transition-colors"
                >
                  Retry
                </button>
                <button
                  onClick={onBack}
                  className="px-4 py-2 border border-border rounded hover:bg-surface-elevated transition-colors"
                >
                  Go Back
                </button>

        )}

        {messages.map((message) => (
          <div
            key={message.id}
            className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}
          >

{message.content}

              {message.isStreaming && (

              )}

        ))}

        {isLoading && messages.length > 0 && !messages[messages.length - 1]?.isStreaming && (

        )}

      {/* Input area */}
      {!isComplete && connectionStatus === 'connected' && (

            <input
              ref={inputRef}
              type="text"
              value={input}
              onChange={(e) => setInput(e.target.value)}
              onKeyDown={handleKeyDown}
              placeholder="Type your response..."
              className="flex-1 px-4 py-2 bg-surface-elevated border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent"
              disabled={isLoading}
            />
            <button
              onClick={handleSendMessage}
              disabled={!input.trim() || isLoading}
              className="px-4 py-2 bg-accent text-white rounded-lg hover:bg-accent/80 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
            >
              <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
              </svg>
            </button>

            Press Enter to send

      )}

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

              <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
              </svg>
              Specification created successfully!

            <button
              onClick={onBack}
              className="px-4 py-2 bg-accent text-white rounded-lg hover:bg-accent/80 transition-colors"
            >
              Continue to Project
            </button>

      )}

  )
}

export default SpecInterview


File created successfully at: C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocode-fork\frontend\src\components\SpecInterview.tsx

/**
 * Spec Interview Component
 *
 * Interactive chat interface for creating app specifications with Claude.
 * Connects to WebSocket endpoint for real-time conversation.
 */

import { useState, useEffect, useRef, useCallback } from 'react'

interface Message {
  id: string
  role: 'user' | 'assistant' | 'system'
  content: string
  timestamp: Date
  isStreaming?: boolean
}

type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error'

interface SpecInterviewProps {
  projectName: string
  onBack: () => void
}

function generateId(): string {
  return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
}

export function SpecInterview({ projectName, onBack }: SpecInterviewProps) {
  const [messages, setMessages] = useState<Message[]>([])
  const [input, setInput] = useState('')
  const [isLoading, setIsLoading] = useState(false)
  const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>('disconnected')
  const [error, setError] = useState<string | null>(null)
  const [isComplete, setIsComplete] = useState(false)

  const messagesEndRef = useRef<HTMLDivElement>(null)
  const inputRef = useRef<HTMLInputElement>(null)
  const wsRef = useRef<WebSocket | null>(null)

  // Scroll to bottom when messages change
  useEffect(() => {
    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
  }, [messages])

  // Focus input when not loading
  useEffect(() => {
    if (!isLoading && inputRef.current) {
      inputRef.current.focus()
    }
  }, [isLoading])

  // Connect to WebSocket
  const connect = useCallback(() => {
    if (wsRef.current?.readyState === WebSocket.OPEN) {
      return
    }

    setConnectionStatus('connecting')
    setError(null)

    const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
    const host = window.location.host
    const wsUrl = `${protocol}//${host}/api/spec/ws/${encodeURIComponent(projectName)}`

    try {
      const ws = new WebSocket(wsUrl)
      wsRef.current = ws

      ws.onopen = () => {
        setConnectionStatus('connected')
        // Send start message
        ws.send(JSON.stringify({ type: 'start' }))
        setIsLoading(true)
      }

      ws.onclose = () => {
        setConnectionStatus('disconnected')
      }

      ws.onerror = () => {
        setConnectionStatus('error')
        setError('Failed to connect to interview server. The WebSocket endpoint may not be available yet.')
      }

      ws.onmessage = (event) => {
        try {
          const data = JSON.parse(event.data)

          switch (data.type) {
            case 'text': {
              setMessages((prev) => {
                const lastMessage = prev[prev.length - 1]
                if (lastMessage?.role === 'assistant' && lastMessage.isStreaming) {
                  return [
                    ...prev.slice(0, -1),
                    { ...lastMessage, content: lastMessage.content + data.content },
                  ]
                } else {
                  return [
                    ...prev,
                    {
                      id: generateId(),
                      role: 'assistant',
                      content: data.content,
                      timestamp: new Date(),
                      isStreaming: true,
                    },
                  ]
                }
              })
              break
            }

            case 'response_done': {
              setIsLoading(false)
              setMessages((prev) => {
                const lastMessage = prev[prev.length - 1]
                if (lastMessage?.role === 'assistant' && lastMessage.isStreaming) {
                  return [
                    ...prev.slice(0, -1),
                    { ...lastMessage, isStreaming: false },
                  ]
                }
                return prev
              })
              break
            }

            case 'spec_complete':
            case 'complete': {
              setIsComplete(true)
              setIsLoading(false)
              setMessages((prev) => [
                ...prev,
                {
                  id: generateId(),
                  role: 'system',
                  content: data.path ? `Specification created: ${data.path}` : 'Specification complete!',
                  timestamp: new Date(),
                },
              ])
              break
            }

            case 'error': {
              setIsLoading(false)
              setError(data.content)
              break
            }

            case 'pong':
              break
          }
        } catch (e) {
          console.error('Failed to parse WebSocket message:', e)
        }
      }
    } catch (e) {
      setConnectionStatus('error')
      setError('Failed to create WebSocket connection')
    }
  }, [projectName])

  // Disconnect
  const disconnect = useCallback(() => {
    if (wsRef.current) {
      wsRef.current.close()
      wsRef.current = null
    }
    setConnectionStatus('disconnected')
  }, [])

  // Clean up on unmount
  useEffect(() => {
    return () => {
      disconnect()
    }
  }, [disconnect])

  // Send message
  const handleSendMessage = () => {
    const trimmed = input.trim()
    if (!trimmed || isLoading || connectionStatus !== 'connected') return

    // Add user message
    setMessages((prev) => [
      ...prev,
      {
        id: generateId(),
        role: 'user',
        content: trimmed,
        timestamp: new Date(),
      },
    ])

    // Send to server
    if (wsRef.current?.readyState === WebSocket.OPEN) {
      wsRef.current.send(JSON.stringify({ type: 'message', content: trimmed }))
      setIsLoading(true)
    }

    setInput('')
  }

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

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

            Connected

        )
      case 'connecting':
        return (

            Connecting...

        )
      case 'error':
        return (

            Error

        )
      default:
        return (

            Disconnected

        )
    }
  }

  return (

      {/* Header */}

Spec Interview: {projectName}

          <ConnectionIndicator />

          {isComplete && (

              <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
              </svg>
              Complete

          )}
          <button
            onClick={onBack}
            className="p-2 rounded hover:bg-surface-elevated transition-colors"
            title="Back to project"
          >
            <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
            </svg>
          </button>

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

          <svg className="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
          </svg>
          {error}
          <button onClick={() => setError(null)} className="p-1 hover:bg-error/20 rounded">
            <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
            </svg>
          </button>

      )}

      {/* Messages area */}

        {connectionStatus === 'disconnected' && messages.length === 0 && (

              <svg className="w-12 h-12 mx-auto mb-4 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
              </svg>

Spec Interview

                Start an interactive conversation with Claude to create your app specification.
                Claude will guide you through defining features, requirements, and architecture.

              <button
                onClick={connect}
                className="px-4 py-2 bg-accent text-white rounded hover:bg-accent/80 transition-colors"
              >
                Start Interview
              </button>

        )}

        {connectionStatus === 'connecting' && messages.length === 0 && (

Connecting to Claude...

        )}

        {connectionStatus === 'error' && messages.length === 0 && (

              <svg className="w-12 h-12 mx-auto mb-4 text-error" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
              </svg>

Connection Failed

                The spec interview WebSocket endpoint is not available.
                This feature requires the backend WebSocket server to be running.

                <button
                  onClick={connect}
                  className="px-4 py-2 bg-accent text-white rounded hover:bg-accent/80 transition-colors"
                >
                  Retry
                </button>
                <button
                  onClick={onBack}
                  className="px-4 py-2 border border-border rounded hover:bg-surface-elevated transition-colors"
                >
                  Go Back
                </button>

        )}

        {messages.map((message) => (
          <div
            key={message.id}
            className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}
          >

{message.content}

              {message.isStreaming && (

              )}

        ))}

        {isLoading && messages.length > 0 && !messages[messages.length - 1]?.isStreaming && (

        )}

      {/* Input area */}
      {!isComplete && connectionStatus === 'connected' && (

            <input
              ref={inputRef}
              type="text"
              value={input}
              onChange={(e) => setInput(e.target.value)}
              onKeyDown={handleKeyDown}
              placeholder="Type your response..."
              className="flex-1 px-4 py-2 bg-surface-elevated border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent"
              disabled={isLoading}
            />
            <button
              onClick={handleSendMessage}
              disabled={!input.trim() || isLoading}
              className="px-4 py-2 bg-accent text-white rounded-lg hover:bg-accent/80 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
            >
              <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
              </svg>
            </button>

            Press Enter to send

      )}

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

              <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
              </svg>
              Specification created successfully!

            <button
              onClick={onBack}
              className="px-4 py-2 bg-accent text-white rounded-lg hover:bg-accent/80 transition-colors"
            >
              Continue to Project
            </button>

      )}

  )
}

export default SpecInterview


Good, the frontend component is created. Now I need to add the WebSocket endpoint to the backend. Let me check the existing websocket router to understand the pattern used.


Now let me add the WebSocket endpoint for spec creation to the backend. First, let me check the existing WebSocket router structure.


     1→"""
     2→WebSocket Router
     3→================
     4→
     5→WebSocket endpoints for real-time updates.
     6→"""
     7→
     8→import asyncio
     9→import json
    10→from datetime import datetime
    11→from typing import Dict, Set
    12→
    13→from fastapi import APIRouter, WebSocket, WebSocketDisconnect
    14→from sqlalchemy import select
    15→
    16→from ..database import async_session_maker
    17→from ..models import Project
    18→
    19→router = APIRouter()
    20→
    21→
    22→# ============================================================================
    23→# Connection Manager
    24→# ============================================================================
    25→
    26→class ConnectionManager:
    27→    """Manages WebSocket connections for projects."""
    28→
    29→    def __init__(self):
    30→        # Map project name to set of active connections
    31→        self.active_connections: Dict[str, Set[WebSocket]] = {}
    32→        # Track heartbeat tasks
    33→        self.heartbeat_tasks: Dict[WebSocket, asyncio.Task] = {}
    34→
    35→    async def connect(self, websocket: WebSocket, project_name: str) -> bool:
    36→        """Accept connection and add to project's connection list."""
    37→        await websocket.accept()
    38→
    39→        if project_name not in self.active_connections:
    40→            self.active_connections[project_name] = set()
    41→
    42→        self.active_connections[project_name].add(websocket)
    43→
    44→        # Start heartbeat task
    45→        task = asyncio.create_task(self._heartbeat_loop(websocket, project_name))
    46→        self.heartbeat_tasks[websocket] = task
    47→
    48→        return True
    49→
    50→    def disconnect(self, websocket: WebSocket, project_name: str):
    51→        """Remove connection from project's connection list."""
    52→        if project_name in self.active_connections:
    53→            self.active_connections[project_name].discard(websocket)
    54→            if not self.active_connections[project_name]:
    55→                del self.active_connections[project_name]
    56→
    57→        # Cancel heartbeat task
    58→        if websocket in self.heartbeat_tasks:
    59→            self.heartbeat_tasks[websocket].cancel()
    60→            del self.heartbeat_tasks[websocket]
    61→
    62→    async def broadcast_to_project(self, project_name: str, message: dict):
    63→        """Send message to all connections for a project."""
    64→        if project_name in self.active_connections:
    65→            disconnected = set()
    66→            for connection in self.active_connections[project_name]:
    67→                try:
    68→                    await connection.send_json(message)
    69→                except Exception:
    70→                    disconnected.add(connection)
    71→
    72→            # Clean up disconnected connections
    73→            for conn in disconnected:
    74→                self.disconnect(conn, project_name)
    75→
    76→    async def _heartbeat_loop(self, websocket: WebSocket, project_name: str):
    77→        """Send periodic heartbeat messages."""
    78→        try:
    79→            while True:
    80→                await asyncio.sleep(30)  # Send heartbeat every 30 seconds
    81→                try:
    82→                    await websocket.send_json({
    83→                        "type": "heartbeat",
    84→                        "timestamp": datetime.utcnow().isoformat(),
    85→                        "project": project_name
    86→                    })
    87→                except Exception:
    88→                    break
    89→        except asyncio.CancelledError:
    90→            pass
    91→
    92→
    93→# Global connection manager
    94→manager = ConnectionManager()
    95→
    96→
    97→# ============================================================================
    98→# WebSocket Endpoints
    99→# ============================================================================
   100→
   101→@router.websocket("/ws/projects/{project_name}")
   102→async def websocket_endpoint(websocket: WebSocket, project_name: str):
   103→    """
   104→    WebSocket endpoint for real-time project updates.
   105→
   106→    Message types:
   107→    - heartbeat: Periodic keepalive messages
   108→    - agent_log: Log output from agents
   109→    - agent_status: Agent status changes
   110→    - feature_update: Feature status changes
   111→    - permission_request: New permission request from agent
   112→    """
   113→    # Verify project exists
   114→    async with async_session_maker() as session:
   115→        result = await session.execute(
   116→            select(Project).where(Project.name == project_name)
   117→        )
   118→        project = result.scalar_one_or_none()
   119→
   120→        if not project:
   121→            await websocket.close(code=4004, reason=f"Project '{project_name}' not found")
   122→            return
   123→
   124→    # Accept connection
   125→    await manager.connect(websocket, project_name)
   126→
   127→    # Send initial connection confirmation
   128→    await websocket.send_json({
   129→        "type": "connected",
   130→        "project": project_name,
   131→        "timestamp": datetime.utcnow().isoformat(),
   132→        "message": f"Connected to project '{project_name}'"
   133→    })
   134→
   135→    try:
   136→        while True:
   137→            # Wait for messages from client
   138→            data = await websocket.receive_text()
   139→            try:
   140→                message = json.loads(data)
   141→                # Handle client messages if needed
   142→                if message.get("type") == "ping":
   143→                    await websocket.send_json({
   144→                        "type": "pong",
   145→                        "timestamp": datetime.utcnow().isoformat()
   146→                    })
   147→            except json.JSONDecodeError:
   148→                await websocket.send_json({
   149→                    "type": "error",
   150→                    "message": "Invalid JSON"
   151→                })
   152→    except WebSocketDisconnect:
   153→        manager.disconnect(websocket, project_name)
   154→
   155→
   156→# ============================================================================
   157→# Utility Functions for Broadcasting
   158→# ============================================================================
   159→
   160→async def broadcast_agent_log(project_name: str, agent_id: str, log_type: str, content: str):
   161→    """Broadcast agent log message to all project connections."""
   162→    await manager.broadcast_to_project(project_name, {
   163→        "type": "agent_log",
   164→        "agent_id": agent_id,
   165→        "log_type": log_type,  # "info", "error", "tool", "permission"
   166→        "content": content,
   167→        "timestamp": datetime.utcnow().isoformat()
   168→    })
   169→
   170→
   171→async def broadcast_agent_status(project_name: str, agent_id: str, status: str):
   172→    """Broadcast agent status change to all project connections."""
   173→    await manager.broadcast_to_project(project_name, {
   174→        "type": "agent_status",
   175→        "agent_id": agent_id,
   176→        "status": status,  # "running", "paused", "stopped", "stalled"
   177→        "timestamp": datetime.utcnow().isoformat()
   178→    })
   179→
   180→
   181→async def broadcast_feature_update(project_name: str, feature_id: int, changes: dict):
   182→    """Broadcast feature update to all project connections."""
   183→    await manager.broadcast_to_project(project_name, {
   184→        "type": "feature_update",
   185→        "feature_id": feature_id,
   186→        "changes": changes,
   187→        "timestamp": datetime.utcnow().isoformat()
   188→    })
   189→
   190→
   191→async def broadcast_permission_request(project_name: str, request_id: int, permission_type: str, permission_value: str, agent_id: str):
   192→    """Broadcast permission request to all project connections."""
   193→    await manager.broadcast_to_project(project_name, {
   194→        "type": "permission_request",
   195→        "request_id": request_id,
   196→        "permission_type": permission_type,
   197→        "permission_value": permission_value,
   198→        "agent_id": agent_id,
   199→        "timestamp": datetime.utcnow().isoformat()
   200→    })
   201→

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

"""
WebSocket Router
================

WebSocket endpoints for real-time updates.
"""

import asyncio
import json
from datetime import datetime
from typing import Dict, Set

from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from sqlalchemy import select

from ..database import async_session_maker
from ..models import Project

router = APIRouter()

# ============================================================================
# Connection Manager
# ============================================================================

class ConnectionManager:
    """Manages WebSocket connections for projects."""

    def __init__(self):
        # Map project name to set of active connections
        self.active_connections: Dict[str, Set[WebSocket]] = {}
        # Track heartbeat tasks
        self.heartbeat_tasks: Dict[WebSocket, asyncio.Task] = {}

    async def connect(self, websocket: WebSocket, project_name: str) -> bool:
        """Accept connection and add to project's connection list."""
        await websocket.accept()

        if project_name not in self.active_connections:
            self.active_connections[project_name] = set()

        self.active_connections[project_name].add(websocket)

        # Start heartbeat task
        task = asyncio.create_task(self._heartbeat_loop(websocket, project_name))
        self.heartbeat_tasks[websocket] = task

        return True

    def disconnect(self, websocket: WebSocket, project_name: str):
        """Remove connection from project's connection list."""
        if project_name in self.active_connections:
            self.active_connections[project_name].discard(websocket)
            if not self.active_connections[project_name]:
                del self.active_connections[project_name]

        # Cancel heartbeat task
        if websocket in self.heartbeat_tasks:
            self.heartbeat_tasks[websocket].cancel()
            del self.heartbeat_tasks[websocket]

    async def broadcast_to_project(self, project_name: str, message: dict):
        """Send message to all connections for a project."""
        if project_name in self.active_connections:
            disconnected = set()
            for connection in self.active_connections[project_name]:
                try:
                    await connection.send_json(message)
                except Exception:
                    disconnected.add(connection)

            # Clean up disconnected connections
            for conn in disconnected:
                self.disconnect(conn, project_name)

    async def _heartbeat_loop(self, websocket: WebSocket, project_name: str):
        """Send periodic heartbeat messages."""
        try:
            while True:
                await asyncio.sleep(30)  # Send heartbeat every 30 seconds
                try:
                    await websocket.send_json({
                        "type": "heartbeat",
                        "timestamp": datetime.utcnow().isoformat(),
                        "project": project_name
                    })
                except Exception:
                    break
        except asyncio.CancelledError:
            pass

# Global connection manager
manager = ConnectionManager()

# ============================================================================
# WebSocket Endpoints
# ============================================================================

@router.websocket("/ws/projects/{project_name}")
async def websocket_endpoint(websocket: WebSocket, project_name: str):
    """
    WebSocket endpoint for real-time project updates.

    Message types:
    - heartbeat: Periodic keepalive messages
    - agent_log: Log output from agents
    - agent_status: Agent status changes
    - feature_update: Feature status changes
    - permission_request: New permission request from agent
    """
    # Verify project exists
    async with async_session_maker() as session:
        result = await session.execute(
            select(Project).where(Project.name == project_name)
        )
        project = result.scalar_one_or_none()

        if not project:
            await websocket.close(code=4004, reason=f"Project '{project_name}' not found")
            return

    # Accept connection
    await manager.connect(websocket, project_name)

    # Send initial connection confirmation
    await websocket.send_json({
        "type": "connected",
        "project": project_name,
        "timestamp": datetime.utcnow().isoformat(),
        "message": f"Connected to project '{project_name}'"
    })

    try:
        while True:
            # Wait for messages from client
            data = await websocket.receive_text()
            try:
                message = json.loads(data)
                # Handle client messages if needed
                if message.get("type") == "ping":
                    await websocket.send_json({
                        "type": "pong",
                        "timestamp": datetime.utcnow().isoformat()
                    })
            except json.JSONDecodeError:
                await websocket.send_json({
                    "type": "error",
                    "message": "Invalid JSON"
                })
    except WebSocketDisconnect:
        manager.disconnect(websocket, project_name)

# ============================================================================
# Utility Functions for Broadcasting
# ============================================================================

async def broadcast_agent_log(project_name: str, agent_id: str, log_type: str, content: str):
    """Broadcast agent log message to all project connections."""
    await manager.broadcast_to_project(project_name, {
        "type": "agent_log",
        "agent_id": agent_id,
        "log_type": log_type,  # "info", "error", "tool", "permission"
        "content": content,
        "timestamp": datetime.utcnow().isoformat()
    })

async def broadcast_agent_status(project_name: str, agent_id: str, status: str):
    """Broadcast agent status change to all project connections."""
    await manager.broadcast_to_project(project_name, {
        "type": "agent_status",
        "agent_id": agent_id,
        "status": status,  # "running", "paused", "stopped", "stalled"
        "timestamp": datetime.utcnow().isoformat()
    })

async def broadcast_feature_update(project_name: str, feature_id: int, changes: dict):
    """Broadcast feature update to all project connections."""
    await manager.broadcast_to_project(project_name, {
        "type": "feature_update",
        "feature_id": feature_id,
        "changes": changes,
        "timestamp": datetime.utcnow().isoformat()
    })

async def broadcast_permission_request(project_name: str, request_id: int, permission_type: str, permission_value: str, agent_id: str):
    """Broadcast permission request to all project connections."""
    await manager.broadcast_to_project(project_name, {
        "type": "permission_request",
        "request_id": request_id,
        "permission_type": permission_type,
        "permission_value": permission_value,
        "agent_id": agent_id,
        "timestamp": datetime.utcnow().isoformat()
    })


Good, I can see the existing WebSocket pattern. Now I need to add a spec interview WebSocket endpoint. The original autocoder-master has a separate spec_creation router for this. I should add a new endpoint for spec interview at `/api/spec/ws/{project_name}`.

Looking at the original autocoder-master implementation, the spec creation involves running Claude through a chat session. For the remix version, I'll need to:

1. Create a spec interview WebSocket endpoint
2. Have it interact with Claude for the interview

However, the actual Claude integration is complex - it requires:
- Claude API integration
- The create-spec skill/command
- Managing conversation state

For now, let me create a placeholder WebSocket endpoint that simulates a spec interview. This will allow the frontend to connect and test the UI. The actual Claude integration can be added later.

Actually, let me look at how autocoder-master handles the spec creation backend.


Let me check how the autocoder-master handles the spec creation WebSocket backend.


Let me look at this file to understand how the spec creation WebSocket works.


     1→"""
     2→Spec Creation Router
     3→====================
     4→
     5→WebSocket and REST endpoints for interactive spec creation with Claude.
     6→"""
     7→
     8→import json
     9→import logging
    10→import re
    11→from pathlib import Path
    12→from typing import Optional
    13→
    14→from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
    15→from pydantic import BaseModel, ValidationError
    16→
    17→from ..schemas import ImageAttachment
    18→from ..services.spec_chat_session import (
    19→    SpecChatSession,
    20→    create_session,
    21→    get_session,
    22→    list_sessions,
    23→    remove_session,
    24→)
    25→
    26→logger = logging.getLogger(__name__)
    27→
    28→router = APIRouter(prefix="/api/spec", tags=["spec-creation"])
    29→
    30→# Root directory
    31→ROOT_DIR = Path(__file__).parent.parent.parent
    32→
    33→
    34→def _get_project_path(project_name: str) -> Path:
    35→    """Get project path from registry."""
    36→    import sys
    37→    root = Path(__file__).parent.parent.parent
    38→    if str(root) not in sys.path:
    39→        sys.path.insert(0, str(root))
    40→
    41→    from registry import get_project_path
    42→    return get_project_path(project_name)
    43→
    44→
    45→def validate_project_name(name: str) -> bool:
    46→    """Validate project name to prevent path traversal."""
    47→    return bool(re.match(r'^[a-zA-Z0-9_-]{1,50}$', name))
    48→
    49→
    50→# ============================================================================
    51→# REST Endpoints
    52→# ============================================================================
    53→
    54→class SpecSessionStatus(BaseModel):
    55→    """Status of a spec creation session."""
    56→    project_name: str
    57→    is_active: bool
    58→    is_complete: bool
    59→    message_count: int
    60→
    61→
    62→@router.get("/sessions", response_model=list[str])
    63→async def list_spec_sessions():
    64→    """List all active spec creation sessions."""
    65→    return list_sessions()
    66→
    67→
    68→@router.get("/sessions/{project_name}", response_model=SpecSessionStatus)
    69→async def get_session_status(project_name: str):
    70→    """Get status of a spec creation session."""
    71→    if not validate_project_name(project_name):
    72→        raise HTTPException(status_code=400, detail="Invalid project name")
    73→
    74→    session = get_session(project_name)
    75→    if not session:
    76→        raise HTTPException(status_code=404, detail="No active session for this project")
    77→
    78→    return SpecSessionStatus(
    79→        project_name=project_name,
    80→        is_active=True,
    81→        is_complete=session.is_complete(),
    82→        message_count=len(session.get_messages()),
    83→    )
    84→
    85→
    86→@router.delete("/sessions/{project_name}")
    87→async def cancel_session(project_name: str):
    88→    """Cancel and remove a spec creation session."""
    89→    if not validate_project_name(project_name):
    90→        raise HTTPException(status_code=400, detail="Invalid project name")
    91→
    92→    session = get_session(project_name)
    93→    if not session:
    94→        raise HTTPException(status_code=404, detail="No active session for this project")
    95→
    96→    await remove_session(project_name)
    97→    return {"success": True, "message": "Session cancelled"}
    98→
    99→
   100→class SpecFileStatus(BaseModel):
   101→    """Status of spec files on disk (from .spec_status.json)."""
   102→    exists: bool
   103→    status: str  # "complete" | "in_progress" | "not_started"
   104→    feature_count: Optional[int] = None
   105→    timestamp: Optional[str] = None
   106→    files_written: list[str] = []
   107→
   108→
   109→@router.get("/status/{project_name}", response_model=SpecFileStatus)
   110→async def get_spec_file_status(project_name: str):
   111→    """
   112→    Get spec creation status by reading .spec_status.json from the project.
   113→
   114→    This is used for polling to detect when Claude has finished writing spec files.
   115→    Claude writes this status file as the final step after completing all spec work.
   116→    """
   117→    if not validate_project_name(project_name):
   118→        raise HTTPException(status_code=400, detail="Invalid project name")
   119→
   120→    project_dir = _get_project_path(project_name)
   121→    if not project_dir:
   122→        raise HTTPException(status_code=404, detail="Project not found in registry")
   123→
   124→    if not project_dir.exists():
   125→        raise HTTPException(status_code=404, detail="Project directory not found")
   126→
   127→    status_file = project_dir / "prompts" / ".spec_status.json"
   128→
   129→    if not status_file.exists():
   130→        return SpecFileStatus(
   131→            exists=False,
   132→            status="not_started",
   133→            feature_count=None,
   134→            timestamp=None,
   135→            files_written=[],
   136→        )
   137→
   138→    try:
   139→        data = json.loads(status_file.read_text(encoding="utf-8"))
   140→        return SpecFileStatus(
   141→            exists=True,
   142→            status=data.get("status", "unknown"),
   143→            feature_count=data.get("feature_count"),
   144→            timestamp=data.get("timestamp"),
   145→            files_written=data.get("files_written", []),
   146→        )
   147→    except json.JSONDecodeError as e:
   148→        logger.warning(f"Invalid JSON in spec status file: {e}")
   149→        return SpecFileStatus(
   150→            exists=True,
   151→            status="error",
   152→            feature_count=None,
   153→            timestamp=None,
   154→            files_written=[],
   155→        )
   156→    except Exception as e:
   157→        logger.error(f"Error reading spec status file: {e}")
   158→        raise HTTPException(status_code=500, detail="Failed to read status file")
   159→
   160→
   161→# ============================================================================
   162→# WebSocket Endpoint
   163→# ============================================================================
   164→
   165→@router.websocket("/ws/{project_name}")
   166→async def spec_chat_websocket(websocket: WebSocket, project_name: str):
   167→    """
   168→    WebSocket endpoint for interactive spec creation chat.
   169→
   170→    Message protocol:
   171→
   172→    Client -> Server:
   173→    - {"type": "start"} - Start the spec creation session
   174→    - {"type": "message", "content": "..."} - Send user message
   175→    - {"type": "answer", "answers": {...}, "tool_id": "..."} - Answer structured question
   176→    - {"type": "ping"} - Keep-alive ping
   177→
   178→    Server -> Client:
   179→    - {"type": "text", "content": "..."} - Text chunk from Claude
   180→    - {"type": "question", "questions": [...], "tool_id": "..."} - Structured question
   181→    - {"type": "spec_complete", "path": "..."} - Spec file created
   182→    - {"type": "file_written", "path": "..."} - Other file written
   183→    - {"type": "complete"} - Session complete
   184→    - {"type": "error", "content": "..."} - Error message
   185→    - {"type": "pong"} - Keep-alive pong
   186→    """
   187→    if not validate_project_name(project_name):
   188→        await websocket.close(code=4000, reason="Invalid project name")
   189→        return
   190→
   191→    # Look up project directory from registry
   192→    project_dir = _get_project_path(project_name)
   193→    if not project_dir:
   194→        await websocket.close(code=4004, reason="Project not found in registry")
   195→        return
   196→
   197→    if not project_dir.exists():
   198→        await websocket.close(code=4004, reason="Project directory not found")
   199→        return
   200→
   201→    await websocket.accept()
   202→
   203→    session: Optional[SpecChatSession] = None
   204→
   205→    try:
   206→        while True:
   207→            try:
   208→                # Receive message from client
   209→                data = await websocket.receive_text()
   210→                message = json.loads(data)
   211→                msg_type = message.get("type")
   212→
   213→                if msg_type == "ping":
   214→                    await websocket.send_json({"type": "pong"})
   215→                    continue
   216→
   217→                elif msg_type == "start":
   218→                    # Create and start a new session
   219→                    # Check for import mode parameters
   220→                    is_import = message.get("is_import", False)
   221→                    codebase_analysis = message.get("codebase_analysis")
   222→
   223→                    session = await create_session(
   224→                        project_name,
   225→                        project_dir,
   226→                        is_import=is_import,
   227→                        codebase_analysis=codebase_analysis,
   228→                    )
   229→
   230→                    # Track spec completion state
   231→                    spec_complete_received = False
   232→                    spec_path = None
   233→
   234→                    # Stream the initial greeting
   235→                    async for chunk in session.start():
   236→                        # Track spec_complete but don't send complete yet
   237→                        if chunk.get("type") == "spec_complete":
   238→                            spec_complete_received = True
   239→                            spec_path = chunk.get("path")
   240→                            await websocket.send_json(chunk)
   241→                            continue
   242→
   243→                        # When response_done arrives, send complete if spec was done
   244→                        if chunk.get("type") == "response_done":
   245→                            await websocket.send_json(chunk)
   246→                            if spec_complete_received:
   247→                                await websocket.send_json({"type": "complete", "path": spec_path})
   248→                            continue
   249→
   250→                        await websocket.send_json(chunk)
   251→
   252→                elif msg_type == "message":
   253→                    # User sent a message
   254→                    if not session:
   255→                        session = get_session(project_name)
   256→                        if not session:
   257→                            await websocket.send_json({
   258→                                "type": "error",
   259→                                "content": "No active session. Send 'start' first."
   260→                            })
   261→                            continue
   262→
   263→                    user_content = message.get("content", "").strip()
   264→
   265→                    # Parse attachments if present
   266→                    attachments: list[ImageAttachment] = []
   267→                    raw_attachments = message.get("attachments", [])
   268→                    if raw_attachments:
   269→                        try:
   270→                            for raw_att in raw_attachments:
   271→                                attachments.append(ImageAttachment(**raw_att))
   272→                        except (ValidationError, Exception) as e:
   273→                            logger.warning(f"Invalid attachment data: {e}")
   274→                            await websocket.send_json({
   275→                                "type": "error",
   276→                                "content": f"Invalid attachment: {str(e)}"
   277→                            })
   278→                            continue
   279→
   280→                    # Allow empty content if attachments are present
   281→                    if not user_content and not attachments:
   282→                        await websocket.send_json({
   283→                            "type": "error",
   284→                            "content": "Empty message"
   285→                        })
   286→                        continue
   287→
   288→                    # Track spec completion state
   289→                    spec_complete_received = False
   290→                    spec_path = None
   291→
   292→                    # Stream Claude's response (with attachments if present)
   293→                    async for chunk in session.send_message(user_content, attachments if attachments else None):
   294→                        # Track spec_complete but don't send complete yet
   295→                        if chunk.get("type") == "spec_complete":
   296→                            spec_complete_received = True
   297→                            spec_path = chunk.get("path")
   298→                            await websocket.send_json(chunk)
   299→                            continue
   300→
   301→                        # When response_done arrives, send complete if spec was done
   302→                        if chunk.get("type") == "response_done":
   303→                            await websocket.send_json(chunk)
   304→                            if spec_complete_received:
   305→                                await websocket.send_json({"type": "complete", "path": spec_path})
   306→                            continue
   307→
   308→                        await websocket.send_json(chunk)
   309→
   310→                elif msg_type == "answer":
   311→                    # User answered a structured question
   312→                    if not session:
   313→                        session = get_session(project_name)
   314→                        if not session:
   315→                            await websocket.send_json({
   316→                                "type": "error",
   317→                                "content": "No active session"
   318→                            })
   319→                            continue
   320→
   321→                    # Format the answers as a natural response
   322→                    answers = message.get("answers", {})
   323→                    if isinstance(answers, dict):
   324→                        # Convert structured answers to a message
   325→                        response_parts = []
   326→                        for question_idx, answer_value in answers.items():
   327→                            if isinstance(answer_value, list):
   328→                                response_parts.append(", ".join(answer_value))
   329→                            else:
   330→                                response_parts.append(str(answer_value))
   331→                        user_response = "; ".join(response_parts) if response_parts else "OK"
   332→                    else:
   333→                        user_response = str(answers)
   334→
   335→                    # Track spec completion state
   336→                    spec_complete_received = False
   337→                    spec_path = None
   338→
   339→                    # Stream Claude's response
   340→                    async for chunk in session.send_message(user_response):
   341→                        # Track spec_complete but don't send complete yet
   342→                        if chunk.get("type") == "spec_complete":
   343→                            spec_complete_received = True
   344→                            spec_path = chunk.get("path")
   345→                            await websocket.send_json(chunk)
   346→                            continue
   347→
   348→                        # When response_done arrives, send complete if spec was done
   349→                        if chunk.get("type") == "response_done":
   350→                            await websocket.send_json(chunk)
   351→                            if spec_complete_received:
   352→                                await websocket.send_json({"type": "complete", "path": spec_path})
   353→                            continue
   354→
   355→                        await websocket.send_json(chunk)
   356→
   357→                else:
   358→                    await websocket.send_json({
   359→                        "type": "error",
   360→                        "content": f"Unknown message type: {msg_type}"
   361→                    })
   362→
   363→            except json.JSONDecodeError:
   364→                await websocket.send_json({
   365→                    "type": "error",
   366→                    "content": "Invalid JSON"
   367→                })
   368→
   369→    except WebSocketDisconnect:
   370→        logger.info(f"Spec chat WebSocket disconnected for {project_name}")
   371→
   372→    except Exception as e:
   373→        logger.exception(f"Spec chat WebSocket error for {project_name}")
   374→        try:
   375→            await websocket.send_json({
   376→                "type": "error",
   377→                "content": f"Server error: {str(e)}"
   378→            })
   379→        except Exception:
   380→            pass
   381→
   382→    finally:
   383→        # Don't remove the session on disconnect - allow resume
   384→        pass
   385→

<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 Router
====================

WebSocket and REST endpoints for interactive spec creation with Claude.
"""

import json
import logging
import re
from pathlib import Path
from typing import Optional

from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
from pydantic import BaseModel, ValidationError

from ..schemas import ImageAttachment
from ..services.spec_chat_session import (
    SpecChatSession,
    create_session,
    get_session,
    list_sessions,
    remove_session,
)

logger = logging.getLogger(__name__)

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

# Root directory
ROOT_DIR = Path(__file__).parent.parent.parent

def _get_project_path(project_name: str) -> Path:
    """Get project path from registry."""
    import sys
    root = Path(__file__).parent.parent.parent
    if str(root) not in sys.path:
        sys.path.insert(0, str(root))

    from registry import get_project_path
    return get_project_path(project_name)

def validate_project_name(name: str) -> bool:
    """Validate project name to prevent path traversal."""
    return bool(re.match(r'^[a-zA-Z0-9_-]{1,50}$', name))

# ============================================================================
# REST Endpoints
# ============================================================================

class SpecSessionStatus(BaseModel):
    """Status of a spec creation session."""
    project_name: str
    is_active: bool
    is_complete: bool
    message_count: int

@router.get("/sessions", response_model=list[str])
async def list_spec_sessions():
    """List all active spec creation sessions."""
    return list_sessions()

@router.get("/sessions/{project_name}", response_model=SpecSessionStatus)
async def get_session_status(project_name: str):
    """Get status of a spec creation session."""
    if not validate_project_name(project_name):
        raise HTTPException(status_code=400, detail="Invalid project name")

    session = get_session(project_name)
    if not session:
        raise HTTPException(status_code=404, detail="No active session for this project")

    return SpecSessionStatus(
        project_name=project_name,
        is_active=True,
        is_complete=session.is_complete(),
        message_count=len(session.get_messages()),
    )

@router.delete("/sessions/{project_name}")
async def cancel_session(project_name: str):
    """Cancel and remove a spec creation session."""
    if not validate_project_name(project_name):
        raise HTTPException(status_code=400, detail="Invalid project name")

    session = get_session(project_name)
    if not session:
        raise HTTPException(status_code=404, detail="No active session for this project")

    await remove_session(project_name)
    return {"success": True, "message": "Session cancelled"}

class SpecFileStatus(BaseModel):
    """Status of spec files on disk (from .spec_status.json)."""
    exists: bool
    status: str  # "complete" | "in_progress" | "not_started"
    feature_count: Optional[int] = None
    timestamp: Optional[str] = None
    files_written: list[str] = []

@router.get("/status/{project_name}", response_model=SpecFileStatus)
async def get_spec_file_status(project_name: str):
    """
    Get spec creation status by reading .spec_status.json from the project.

    This is used for polling to detect when Claude has finished writing spec files.
    Claude writes this status file as the final step after completing all spec work.
    """
    if not validate_project_name(project_name):
        raise HTTPException(status_code=400, detail="Invalid project name")

    project_dir = _get_project_path(project_name)
    if not project_dir:
        raise HTTPException(status_code=404, detail="Project not found in registry")

    if not project_dir.exists():
        raise HTTPException(status_code=404, detail="Project directory not found")

    status_file = project_dir / "prompts" / ".spec_status.json"

    if not status_file.exists():
        return SpecFileStatus(
            exists=False,
            status="not_started",
            feature_count=None,
            timestamp=None,
            files_written=[],
        )

    try:
        data = json.loads(status_file.read_text(encoding="utf-8"))
        return SpecFileStatus(
            exists=True,
            status=data.get("status", "unknown"),
            feature_count=data.get("feature_count"),
            timestamp=data.get("timestamp"),
            files_written=data.get("files_written", []),
        )
    except json.JSONDecodeError as e:
        logger.warning(f"Invalid JSON in spec status file: {e}")
        return SpecFileStatus(
            exists=True,
            status="error",
            feature_count=None,
            timestamp=None,
            files_written=[],
        )
    except Exception as e:
        logger.error(f"Error reading spec status file: {e}")
        raise HTTPException(status_code=500, detail="Failed to read status file")

# ============================================================================
# WebSocket Endpoint
# ============================================================================

@router.websocket("/ws/{project_name}")
async def spec_chat_websocket(websocket: WebSocket, project_name: str):
    """
    WebSocket endpoint for interactive spec creation chat.

    Message protocol:

    Client -> Server:
    - {"type": "start"} - Start the spec creation session
    - {"type": "message", "content": "..."} - Send user message
    - {"type": "answer", "answers": {...}, "tool_id": "..."} - Answer structured question
    - {"type": "ping"} - Keep-alive ping

    Server -> Client:
    - {"type": "text", "content": "..."} - Text chunk from Claude
    - {"type": "question", "questions": [...], "tool_id": "..."} - Structured question
    - {"type": "spec_complete", "path": "..."} - Spec file created
    - {"type": "file_written", "path": "..."} - Other file written
    - {"type": "complete"} - Session complete
    - {"type": "error", "content": "..."} - Error message
    - {"type": "pong"} - Keep-alive pong
    """
    if not validate_project_name(project_name):
        await websocket.close(code=4000, reason="Invalid project name")
        return

    # Look up project directory from registry
    project_dir = _get_project_path(project_name)
    if not project_dir:
        await websocket.close(code=4004, reason="Project not found in registry")
        return

    if not project_dir.exists():
        await websocket.close(code=4004, reason="Project directory not found")
        return

    await websocket.accept()

    session: Optional[SpecChatSession] = None

    try:
        while True:
            try:
                # Receive message from client
                data = await websocket.receive_text()
                message = json.loads(data)
                msg_type = message.get("type")

                if msg_type == "ping":
                    await websocket.send_json({"type": "pong"})
                    continue

                elif msg_type == "start":
                    # Create and start a new session
                    # Check for import mode parameters
                    is_import = message.get("is_import", False)
                    codebase_analysis = message.get("codebase_analysis")

                    session = await create_session(
                        project_name,
                        project_dir,
                        is_import=is_import,
                        codebase_analysis=codebase_analysis,
                    )

                    # Track spec completion state
                    spec_complete_received = False
                    spec_path = None

                    # Stream the initial greeting
                    async for chunk in session.start():
                        # Track spec_complete but don't send complete yet
                        if chunk.get("type") == "spec_complete":
                            spec_complete_received = True
                            spec_path = chunk.get("path")
                            await websocket.send_json(chunk)
                            continue

                        # When response_done arrives, send complete if spec was done
                        if chunk.get("type") == "response_done":
                            await websocket.send_json(chunk)
                            if spec_complete_received:
                                await websocket.send_json({"type": "complete", "path": spec_path})
                            continue

                        await websocket.send_json(chunk)

                elif msg_type == "message":
                    # User sent a message
                    if not session:
                        session = get_session(project_name)
                        if not session:
                            await websocket.send_json({
                                "type": "error",
                                "content": "No active session. Send 'start' first."
                            })
                            continue

                    user_content = message.get("content", "").strip()

                    # Parse attachments if present
                    attachments: list[ImageAttachment] = []
                    raw_attachments = message.get("attachments", [])
                    if raw_attachments:
                        try:
                            for raw_att in raw_attachments:
                                attachments.append(ImageAttachment(**raw_att))
                        except (ValidationError, Exception) as e:
                            logger.warning(f"Invalid attachment data: {e}")
                            await websocket.send_json({
                                "type": "error",
                                "content": f"Invalid attachment: {str(e)}"
                            })
                            continue

                    # Allow empty content if attachments are present
                    if not user_content and not attachments:
                        await websocket.send_json({
                            "type": "error",
                            "content": "Empty message"
                        })
                        continue

                    # Track spec completion state
                    spec_complete_received = False
                    spec_path = None

                    # Stream Claude's response (with attachments if present)
                    async for chunk in session.send_message(user_content, attachments if attachments else None):
                        # Track spec_complete but don't send complete yet
                        if chunk.get("type") == "spec_complete":
                            spec_complete_received = True
                            spec_path = chunk.get("path")
                            await websocket.send_json(chunk)
                            continue

                        # When response_done arrives, send complete if spec was done
                        if chunk.get("type") == "response_done":
                            await websocket.send_json(chunk)
                            if spec_complete_received:
                                await websocket.send_json({"type": "complete", "path": spec_path})
                            continue

                        await websocket.send_json(chunk)

                elif msg_type == "answer":
                    # User answered a structured question
                    if not session:
                        session = get_session(project_name)
                        if not session:
                            await websocket.send_json({
                                "type": "error",
                                "content": "No active session"
                            })
                            continue

                    # Format the answers as a natural response
                    answers = message.get("answers", {})
                    if isinstance(answers, dict):
                        # Convert structured answers to a message
                        response_parts = []
                        for question_idx, answer_value in answers.items():
                            if isinstance(answer_value, list):
                                response_parts.append(", ".join(answer_value))
                            else:
                                response_parts.append(str(answer_value))
                        user_response = "; ".join(response_parts) if response_parts else "OK"
                    else:
                        user_response = str(answers)

                    # Track spec completion state
                    spec_complete_received = False
                    spec_path = None

                    # Stream Claude's response
                    async for chunk in session.send_message(user_response):
                        # Track spec_complete but don't send complete yet
                        if chunk.get("type") == "spec_complete":
                            spec_complete_received = True
                            spec_path = chunk.get("path")
                            await websocket.send_json(chunk)
                            continue

                        # When response_done arrives, send complete if spec was done
                        if chunk.get("type") == "response_done":
                            await websocket.send_json(chunk)
                            if spec_complete_received:
                                await websocket.send_json({"type": "complete", "path": spec_path})
                            continue

                        await websocket.send_json(chunk)

                else:
                    await websocket.send_json({
                        "type": "error",
                        "content": f"Unknown message type: {msg_type}"
                    })

            except json.JSONDecodeError:
                await websocket.send_json({
                    "type": "error",
                    "content": "Invalid JSON"
                })

    except WebSocketDisconnect:
        logger.info(f"Spec chat WebSocket disconnected for {project_name}")

    except Exception as e:
        logger.exception(f"Spec chat WebSocket error for {project_name}")
        try:
            await websocket.send_json({
                "type": "error",
                "content": f"Server error: {str(e)}"
            })
        except Exception:
            pass

    finally:
        # Don't remove the session on disconnect - allow resume
        pass


This is quite complex. The spec creation uses:1. `SpecChatSession` from `spec_chat_session.py` - manages the Claude conversation
2. A registry to find project paths
3. Streaming responses

The core logic is in `spec_chat_session.py`. This is a significant piece of infrastructure. Let me check what that file looks like.