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→import type { CodebaseAnalysis } from '../lib/api'
    16→
    17→// Image upload validation constants
    18→const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5 MB
    19→const ALLOWED_TYPES = ['image/jpeg', 'image/png']
    20→
    21→type InitializerStatus = 'idle' | 'starting' | 'error'
    22→
    23→interface SpecCreationChatProps {
    24→  projectName: string
    25→  onComplete: (specPath: string, yoloMode?: boolean) => void
    26→  onCancel: () => void
    27→  onExitToProject: () => void  // Exit to project without starting agent
    28→  initializerStatus?: InitializerStatus
    29→  initializerError?: string | null
    30→  onRetryInitializer?: () => void
    31→  /** If true, use import-spec skill for existing codebases */
    32→  isImport?: boolean
    33→  /** Codebase analysis results from import */
    34→  codebaseAnalysis?: CodebaseAnalysis | null
    35→}
    36→
    37→// Format codebase analysis for Claude context
    38→function formatAnalysisForClaude(analysis: CodebaseAnalysis): string {
    39→  const lines: string[] = [
    40→    '# EXISTING PROJECT ANALYSIS',
    41→    '',
    42→    `**Project Directory:** ${analysis.project_name || 'Unknown'}`,
    43→    '',
    44→  ]
    45→
    46→  if (Object.keys(analysis.detected_stack).length > 0) {
    47→    lines.push('## Detected Technology Stack')
    48→    for (const [category, tech] of Object.entries(analysis.detected_stack)) {
    49→      lines.push(`- **${category}:** ${tech}`)
    50→    }
    51→    lines.push('')
    52→  }
    53→
    54→  if (analysis.directories.length > 0) {
    55→    lines.push('## Directory Structure')
    56→    lines.push('Top-level directories:')
    57→    for (const dir of analysis.directories.slice(0, 10)) {
    58→      lines.push(`- \`${dir}/\``)
    59→    }
    60→    if (analysis.directories.length > 10) {
    61→      lines.push(`- ... and ${analysis.directories.length - 10} more`)
    62→    }
    63→    lines.push('')
    64→  }
    65→
    66→  if (Object.keys(analysis.file_types).length > 0) {
    67→    lines.push('## File Statistics')
    68→    lines.push(`Total code files: ${analysis.total_files}`)
    69→    lines.push('')
    70→    lines.push('Files by type:')
    71→    for (const [ext, count] of Object.entries(analysis.file_types).slice(0, 8)) {
    72→      lines.push(`- \`${ext}\`: ${count} files`)
    73→    }
    74→    lines.push('')
    75→  }
    76→
    77→  if (analysis.entry_points.length > 0) {
    78→    lines.push('## Entry Points')
    79→    for (const ep of analysis.entry_points.slice(0, 5)) {
    80→      lines.push(`- ${ep}`)
    81→    }
    82→    lines.push('')
    83→  }
    84→
    85→  if (analysis.claude_md_content) {
    86→    lines.push('## Project Documentation (from CLAUDE.md)')
    87→    lines.push('')
    88→    lines.push(analysis.claude_md_content)
    89→    lines.push('')
    90→  } else if (analysis.readme_content) {
    91→    lines.push('## Project Documentation (from README.md)')
    92→    lines.push('')
    93→    lines.push(analysis.readme_content)
    94→    lines.push('')
    95→  }
    96→
    97→  lines.push('---')
    98→  lines.push('')
    99→
   100→  return lines.join('\n')
   101→}
   102→
   103→export function SpecCreationChat({
   104→  projectName,
   105→  onComplete,
   106→  onCancel,
   107→  onExitToProject,
   108→  initializerStatus = 'idle',
   109→  initializerError = null,
   110→  onRetryInitializer,
   111→  isImport = false,
   112→  codebaseAnalysis = null,
   113→}: SpecCreationChatProps) {
   114→  const [input, setInput] = useState('')
   115→  const [error, setError] = useState<string | null>(null)
   116→  const [yoloEnabled, setYoloEnabled] = useState(false)
   117→  const [pendingAttachments, setPendingAttachments] = useState<ImageAttachment[]>([])
   118→  const messagesEndRef = useRef<HTMLDivElement>(null)
   119→  const inputRef = useRef<HTMLInputElement>(null)
   120→  const fileInputRef = useRef<HTMLInputElement>(null)
   121→
   122→  const {
   123→    messages,
   124→    isLoading,
   125→    isComplete,
   126→    connectionStatus,
   127→    currentQuestions,
   128→    start,
   129→    sendMessage,
   130→    sendAnswer,
   131→    disconnect,
   132→  } = useSpecChat({
   133→    projectName,
   134→    onComplete,
   135→    onError: (err) => setError(err),
   136→    isImport,
   137→    codebaseAnalysis: codebaseAnalysis ? formatAnalysisForClaude(codebaseAnalysis) : null,
   138→  })
   139→
   140→  // Start the chat session when component mounts
   141→  useEffect(() => {
   142→    start()
   143→
   144→    return () => {
   145→      disconnect()
   146→    }
   147→  }, []) // eslint-disable-line react-hooks/exhaustive-deps
   148→
   149→  // Scroll to bottom when messages change
   150→  useEffect(() => {
   151→    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
   152→  }, [messages, currentQuestions, isLoading])
   153→
   154→  // Focus input when not loading and no questions
   155→  useEffect(() => {
   156→    if (!isLoading && !currentQuestions && inputRef.current) {
   157→      inputRef.current.focus()
   158→    }
   159→  }, [isLoading, currentQuestions])
   160→
   161→  const handleSendMessage = () => {
   162→    const trimmed = input.trim()
   163→    // Allow sending if there's text OR attachments
   164→    if ((!trimmed && pendingAttachments.length === 0) || isLoading) return
   165→
   166→    // Detect /exit command - exit to project without sending to Claude
   167→    if (/^\s*\/exit\s*$/i.test(trimmed)) {
   168→      setInput('')
   169→      onExitToProject()
   170→      return
   171→    }
   172→
   173→    sendMessage(trimmed, pendingAttachments.length > 0 ? pendingAttachments : undefined)
   174→    setInput('')
   175→    setPendingAttachments([]) // Clear attachments after sending
   176→  }
   177→
   178→  const handleKeyDown = (e: React.KeyboardEvent) => {
   179→    if (e.key === 'Enter' && !e.shiftKey) {
   180→      e.preventDefault()
   181→      handleSendMessage()
   182→    }
   183→  }
   184→
   185→  const handleAnswerSubmit = (answers: Record<string, string | string[]>) => {
   186→    sendAnswer(answers)
   187→  }
   188→
   189→  // File handling for image attachments
   190→  const handleFileSelect = useCallback((files: FileList | null) => {
   191→    if (!files) return
   192→
   193→    Array.from(files).forEach((file) => {
   194→      // Validate file type
   195→      if (!ALLOWED_TYPES.includes(file.type)) {
   196→        setError(`Invalid file type: ${file.name}. Only JPEG and PNG are supported.`)
   197→        return
   198→      }
   199→
   200→      // Validate file size
   201→      if (file.size > MAX_FILE_SIZE) {
   202→        setError(`File too large: ${file.name}. Maximum size is 5 MB.`)
   203→        return
   204→      }
   205→
   206→      // Read and convert to base64
   207→      const reader = new FileReader()
   208→      reader.onload = (e) => {
   209→        const dataUrl = e.target?.result as string
   210→        // dataUrl is "data:image/png;base64,XXXXXX"
   211→        const base64Data = dataUrl.split(',')[1]
   212→
   213→        const attachment: ImageAttachment = {
   214→          id: `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
   215→          filename: file.name,
   216→          mimeType: file.type as 'image/jpeg' | 'image/png',
   217→          base64Data,
   218→          previewUrl: dataUrl,
   219→          size: file.size,
   220→        }
   221→
   222→        setPendingAttachments((prev) => [...prev, attachment])
   223→      }
   224→      reader.readAsDataURL(file)
   225→    })
   226→  }, [])
   227→
   228→  const handleRemoveAttachment = useCallback((id: string) => {
   229→    setPendingAttachments((prev) => prev.filter((a) => a.id !== id))
   230→  }, [])
   231→
   232→  const handleDrop = useCallback(
   233→    (e: React.DragEvent) => {
   234→      e.preventDefault()
   235→      handleFileSelect(e.dataTransfer.files)
   236→    },
   237→    [handleFileSelect]
   238→  )
   239→
   240→  const handleDragOver = useCallback((e: React.DragEvent) => {
   241→    e.preventDefault()
   242→  }, [])
   243→
   244→  // Connection status indicator
   245→  const ConnectionIndicator = () => {
   246→    switch (connectionStatus) {
   247→      case 'connected':
   248→        return (
   249→
   250→            <Wifi size={12} />
   251→            Connected
   252→
   253→        )
   254→      case 'connecting':
   255→        return (
   256→
   257→            <Wifi size={12} className="animate-pulse" />
   258→            Connecting...
   259→
   260→        )
   261→      case 'error':
   262→        return (
   263→
   264→            <WifiOff size={12} />
   265→            Error
   266→
   267→        )
   268→      default:
   269→        return (
   270→
   271→            <WifiOff size={12} />
   272→            Disconnected
   273→
   274→        )
   275→    }
   276→  }
   277→
   278→  return (
   279→

   280→      {/* Header */}
   281→

   282→

   283→

   284→            {isImport ? 'Import Spec' : 'Create Spec'}: {projectName}
   285→

   286→          <ConnectionIndicator />
   287→

   288→
   289→

   290→          {isComplete && (
   291→
   292→              <CheckCircle2 size={16} />
   293→              Complete
   294→
   295→          )}
   296→
   297→          {/* Exit to Project - always visible escape hatch */}
   298→          <button
   299→            onClick={onExitToProject}
   300→            className="neo-btn neo-btn-ghost text-sm py-2"
   301→            title="Exit chat and go to project (you can start the agent manually)"
   302→          >
   303→            <ExternalLink size={16} />
   304→            Exit to Project
   305→          </button>
   306→
   307→          <button
   308→            onClick={onCancel}
   309→            className="neo-btn neo-btn-ghost p-2"
   310→            title="Cancel"
   311→          >
   312→            <X size={20} />
   313→          </button>
   314→

   315→

   316→
   317→      {/* Error banner */}
   318→      {error && (
   319→

   320→          <AlertCircle size={16} />
   321→          {error}
   322→          <button
   323→            onClick={() => setError(null)}
   324→            className="p-1 hover:bg-white/20 rounded"
   325→          >
   326→            <X size={14} />
   327→          </button>
   328→

   329→      )}
   330→
   331→      {/* Messages area */}
   332→

   333→        {messages.length === 0 && !isLoading && (
   334→

   335→

   336→

   337→                Starting Spec Creation
   338→

   339→

   340→                Connecting to Claude to help you create your app specification...
   341→

   342→              {connectionStatus === 'error' && (
   343→                <button
   344→                  onClick={start}
   345→                  className="neo-btn neo-btn-primary mt-4 text-sm"
   346→                >
   347→                  <RotateCcw size={14} />
   348→                  Retry Connection
   349→                </button>
   350→              )}
   351→

   352→

   353→        )}
   354→
   355→        {messages.map((message) => (
   356→          <ChatMessage key={message.id} message={message} />
   357→        ))}
   358→
   359→        {/* Structured questions */}
   360→        {currentQuestions && currentQuestions.length > 0 && (
   361→          <QuestionOptions
   362→            questions={currentQuestions}
   363→            onSubmit={handleAnswerSubmit}
   364→            disabled={isLoading}
   365→          />
   366→        )}
   367→
   368→        {/* Typing indicator - don't show when we have questions (waiting for user) */}
   369→        {isLoading && !currentQuestions && <TypingIndicator />}
   370→
   371→        {/* Scroll anchor */}
   372→

   373→

   374→
   375→      {/* Input area */}
   376→      {!isComplete && (
   377→

   382→          {/* Attachment previews */}
   383→          {pendingAttachments.length > 0 && (
   384→

   385→              {pendingAttachments.map((attachment) => (
   386→

   390→
   395→                  <button
   396→                    onClick={() => handleRemoveAttachment(attachment.id)}
   397→                    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"
   398→                    title="Remove attachment"
   399→                  >
   400→                    <X size={12} />
   401→                  </button>
   402→
   403→                    {attachment.filename.length > 10
   404→                      ? `${attachment.filename.substring(0, 7)}...`
   405→                      : attachment.filename}
   406→
   407→

   408→              ))}
   409→

   410→          )}
   411→
   412→

   413→            {/* Hidden file input */}
   414→            <input
   415→              ref={fileInputRef}
   416→              type="file"
   417→              accept="image/jpeg,image/png"
   418→              multiple
   419→              onChange={(e) => handleFileSelect(e.target.files)}
   420→              className="hidden"
   421→            />
   422→
   423→            {/* Attach button */}
   424→            <button
   425→              onClick={() => fileInputRef.current?.click()}
   426→              disabled={connectionStatus !== 'connected'}
   427→              className="neo-btn neo-btn-ghost p-3"
   428→              title="Attach image (JPEG, PNG - max 5MB)"
   429→            >
   430→              <Paperclip size={18} />
   431→            </button>
   432→
   433→            <input
   434→              ref={inputRef}
   435→              type="text"
   436→              value={input}
   437→              onChange={(e) => setInput(e.target.value)}
   438→              onKeyDown={handleKeyDown}
   439→              placeholder={
   440→                currentQuestions
   441→                  ? 'Or type a custom response...'
   442→                  : pendingAttachments.length > 0
   443→                    ? 'Add a message with your image(s)...'
   444→                    : 'Type your response... (or /exit to go to project)'
   445→              }
   446→              className="neo-input flex-1"
   447→              disabled={(isLoading && !currentQuestions) || connectionStatus !== 'connected'}
   448→            />
   449→            <button
   450→              onClick={handleSendMessage}
   451→              disabled={
   452→                (!input.trim() && pendingAttachments.length === 0) ||
   453→                (isLoading && !currentQuestions) ||
   454→                connectionStatus !== 'connected'
   455→              }
   456→              className="neo-btn neo-btn-primary px-6"
   457→            >
   458→              <Send size={18} />
   459→            </button>
   460→

   461→
   462→          {/* Help text */}
   463→

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

   466→

   467→      )}
   468→
   469→      {/* Completion footer */}
   470→      {isComplete && (
   471→

   474→

   475→

   476→              {initializerStatus === 'starting' ? (
   477→                <>
   478→                  <Loader2 size={20} className="animate-spin" />
   479→
   480→                    Starting agent{yoloEnabled ? ' (YOLO mode)' : ''}...
   481→
   482→                </>
   483→              ) : initializerStatus === 'error' ? (
   484→                <>
   485→                  <AlertCircle size={20} className="text-white" />
   486→
   487→                    {initializerError || 'Failed to start agent'}
   488→
   489→                </>
   490→              ) : (
   491→                <>
   492→                  <CheckCircle2 size={20} />
   493→                  Specification created successfully!
   494→                </>
   495→              )}
   496→

   497→

   498→              {initializerStatus === 'error' && onRetryInitializer && (
   499→                <button
   500→                  onClick={onRetryInitializer}
   501→                  className="neo-btn bg-white"
   502→                >
   503→                  <RotateCcw size={14} />
   504→                  Retry
   505→                </button>
   506→              )}
   507→              {initializerStatus === 'idle' && (
   508→                <>
   509→                  {/* YOLO Mode Toggle */}
   510→                  <button
   511→                    onClick={() => setYoloEnabled(!yoloEnabled)}
   512→                    className={`neo-btn text-sm py-2 px-3 ${
   513→                      yoloEnabled ? 'neo-btn-warning' : 'bg-white'
   514→                    }`}
   515→                    title="YOLO Mode: Skip testing for rapid prototyping"
   516→                  >
   517→                    <Zap size={16} className={yoloEnabled ? 'text-yellow-900' : ''} />
   518→
   519→                      YOLO
   520→
   521→                  </button>
   522→                  <button
   523→                    onClick={() => onComplete('', yoloEnabled)}
   524→                    className="neo-btn neo-btn-primary"
   525→                  >
   526→                    Continue to Project
   527→                    <ArrowRight size={16} />
   528→                  </button>
   529→                </>
   530→              )}
   531→

   532→

   533→

   534→      )}
   535→

   536→  )
   537→}
   538→

<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'
import type { CodebaseAnalysis } from '../lib/api'

// 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
  /** If true, use import-spec skill for existing codebases */
  isImport?: boolean
  /** Codebase analysis results from import */
  codebaseAnalysis?: CodebaseAnalysis | null
}

// Format codebase analysis for Claude context
function formatAnalysisForClaude(analysis: CodebaseAnalysis): string {
  const lines: string[] = [
    '# EXISTING PROJECT ANALYSIS',
    '',
    `**Project Directory:** ${analysis.project_name || 'Unknown'}`,
    '',
  ]

  if (Object.keys(analysis.detected_stack).length > 0) {
    lines.push('## Detected Technology Stack')
    for (const [category, tech] of Object.entries(analysis.detected_stack)) {
      lines.push(`- **${category}:** ${tech}`)
    }
    lines.push('')
  }

  if (analysis.directories.length > 0) {
    lines.push('## Directory Structure')
    lines.push('Top-level directories:')
    for (const dir of analysis.directories.slice(0, 10)) {
      lines.push(`- \`${dir}/\``)
    }
    if (analysis.directories.length > 10) {
      lines.push(`- ... and ${analysis.directories.length - 10} more`)
    }
    lines.push('')
  }

  if (Object.keys(analysis.file_types).length > 0) {
    lines.push('## File Statistics')
    lines.push(`Total code files: ${analysis.total_files}`)
    lines.push('')
    lines.push('Files by type:')
    for (const [ext, count] of Object.entries(analysis.file_types).slice(0, 8)) {
      lines.push(`- \`${ext}\`: ${count} files`)
    }
    lines.push('')
  }

  if (analysis.entry_points.length > 0) {
    lines.push('## Entry Points')
    for (const ep of analysis.entry_points.slice(0, 5)) {
      lines.push(`- ${ep}`)
    }
    lines.push('')
  }

  if (analysis.claude_md_content) {
    lines.push('## Project Documentation (from CLAUDE.md)')
    lines.push('')
    lines.push(analysis.claude_md_content)
    lines.push('')
  } else if (analysis.readme_content) {
    lines.push('## Project Documentation (from README.md)')
    lines.push('')
    lines.push(analysis.readme_content)
    lines.push('')
  }

  lines.push('---')
  lines.push('')

  return lines.join('\n')
}

export function SpecCreationChat({
  projectName,
  onComplete,
  onCancel,
  onExitToProject,
  initializerStatus = 'idle',
  initializerError = null,
  onRetryInitializer,
  isImport = false,
  codebaseAnalysis = null,
}: 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),
    isImport,
    codebaseAnalysis: codebaseAnalysis ? formatAnalysisForClaude(codebaseAnalysis) : null,
  })

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

            {isImport ? 'Import Spec' : '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>
                </>
              )}

      )}

  )
}


This is a complex component with several dependencies. Let me check what hooks and sub-components it uses that I would need to port:

1. `useSpecChat` hook
2. `ChatMessage` component
3. `QuestionOptions` component
4. `TypingIndicator` component
5. Types like `ImageAttachment`, `CodebaseAnalysis`

This is significant porting effort. For now, let me create a simpler SpecInterview component that shows the basic structure and connects to a WebSocket endpoint. I can start with a minimal version that still provides value.

Let me check the useSpecChat hook to understand the WebSocket connection.


Let me check the useSpecChat hook to understand the WebSocket protocol.


     1→/**
     2→ * Hook for managing spec creation chat WebSocket connection
     3→ */
     4→
     5→import { useState, useCallback, useRef, useEffect } from 'react'
     6→import type { ChatMessage, ImageAttachment, SpecChatServerMessage, SpecQuestion } from '../lib/types'
     7→import { getSpecStatus } from '../lib/api'
     8→
     9→type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error'
    10→
    11→interface UseSpecChatOptions {
    12→  projectName: string
    13→  onComplete?: (specPath: string) => void
    14→  onError?: (error: string) => void
    15→  /** If true, use import-spec skill instead of create-spec */
    16→  isImport?: boolean
    17→  /** Formatted codebase analysis for import mode */
    18→  codebaseAnalysis?: string | null
    19→}
    20→
    21→interface UseSpecChatReturn {
    22→  messages: ChatMessage[]
    23→  isLoading: boolean
    24→  isComplete: boolean
    25→  connectionStatus: ConnectionStatus
    26→  currentQuestions: SpecQuestion[] | null
    27→  currentToolId: string | null
    28→  start: () => void
    29→  sendMessage: (content: string, attachments?: ImageAttachment[]) => void
    30→  sendAnswer: (answers: Record<string, string | string[]>) => void
    31→  disconnect: () => void
    32→}
    33→
    34→function generateId(): string {
    35→  return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
    36→}
    37→
    38→export function useSpecChat({
    39→  projectName,
    40→  onComplete,
    41→  onError,
    42→  isImport = false,
    43→  codebaseAnalysis = null,
    44→}: UseSpecChatOptions): UseSpecChatReturn {
    45→  const [messages, setMessages] = useState<ChatMessage[]>([])
    46→  const [isLoading, setIsLoading] = useState(false)
    47→  const [isComplete, setIsComplete] = useState(false)
    48→  const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>('disconnected')
    49→  const [currentQuestions, setCurrentQuestions] = useState<SpecQuestion[] | null>(null)
    50→  const [currentToolId, setCurrentToolId] = useState<string | null>(null)
    51→
    52→  const wsRef = useRef<WebSocket | null>(null)
    53→  const currentAssistantMessageRef = useRef<string | null>(null)
    54→  const reconnectAttempts = useRef(0)
    55→  const maxReconnectAttempts = 3
    56→  const pingIntervalRef = useRef<number | null>(null)
    57→  const reconnectTimeoutRef = useRef<number | null>(null)
    58→  const isCompleteRef = useRef(false)
    59→
    60→  // Keep isCompleteRef in sync with isComplete state
    61→  useEffect(() => {
    62→    isCompleteRef.current = isComplete
    63→  }, [isComplete])
    64→
    65→  // Clean up on unmount
    66→  useEffect(() => {
    67→    return () => {
    68→      if (pingIntervalRef.current) {
    69→        clearInterval(pingIntervalRef.current)
    70→      }
    71→      if (reconnectTimeoutRef.current) {
    72→        clearTimeout(reconnectTimeoutRef.current)
    73→      }
    74→      if (wsRef.current) {
    75→        wsRef.current.close()
    76→      }
    77→    }
    78→  }, [])
    79→
    80→  // Poll status file as fallback completion detection
    81→  // Claude writes .spec_status.json when done with all spec work
    82→  useEffect(() => {
    83→    // Don't poll if already complete
    84→    if (isComplete) return
    85→
    86→    // Start polling after initial delay (let WebSocket try first)
    87→    const startDelay = setTimeout(() => {
    88→      const pollInterval = setInterval(async () => {
    89→        // Stop if already complete
    90→        if (isCompleteRef.current) {
    91→          clearInterval(pollInterval)
    92→          return
    93→        }
    94→
    95→        try {
    96→          const status = await getSpecStatus(projectName)
    97→
    98→          if (status.exists && status.status === 'complete') {
    99→            // Status file indicates completion - set complete state
   100→            setIsComplete(true)
   101→            setIsLoading(false)
   102→
   103→            // Mark any streaming message as done
   104→            setMessages((prev) => {
   105→              const lastMessage = prev[prev.length - 1]
   106→              if (lastMessage?.role === 'assistant' && lastMessage.isStreaming) {
   107→                return [
   108→                  ...prev.slice(0, -1),
   109→                  { ...lastMessage, isStreaming: false },
   110→                ]
   111→              }
   112→              return prev
   113→            })
   114→
   115→            // Add system message about completion
   116→            setMessages((prev) => [
   117→              ...prev,
   118→              {
   119→                id: generateId(),
   120→                role: 'system',
   121→                content: `Spec creation complete! Files written: ${status.files_written.join(', ')}${status.feature_count ? ` (${status.feature_count} features)` : ''}`,
   122→                timestamp: new Date(),
   123→              },
   124→            ])
   125→
   126→            clearInterval(pollInterval)
   127→          }
   128→        } catch {
   129→          // Silently ignore polling errors - WebSocket is primary mechanism
   130→        }
   131→      }, 3000) // Poll every 3 seconds
   132→
   133→      // Cleanup interval on unmount
   134→      return () => clearInterval(pollInterval)
   135→    }, 3000) // Start polling after 3 second delay
   136→
   137→    return () => clearTimeout(startDelay)
   138→  }, [projectName, isComplete])
   139→
   140→  const connect = useCallback(() => {
   141→    if (wsRef.current?.readyState === WebSocket.OPEN) {
   142→      return
   143→    }
   144→
   145→    setConnectionStatus('connecting')
   146→
   147→    const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
   148→    const host = window.location.host
   149→    const wsUrl = `${protocol}//${host}/api/spec/ws/${encodeURIComponent(projectName)}`
   150→
   151→    const ws = new WebSocket(wsUrl)
   152→    wsRef.current = ws
   153→
   154→    ws.onopen = () => {
   155→      setConnectionStatus('connected')
   156→      reconnectAttempts.current = 0
   157→
   158→      // Start ping interval to keep connection alive
   159→      pingIntervalRef.current = window.setInterval(() => {
   160→        if (ws.readyState === WebSocket.OPEN) {
   161→          ws.send(JSON.stringify({ type: 'ping' }))
   162→        }
   163→      }, 30000)
   164→    }
   165→
   166→    ws.onclose = () => {
   167→      setConnectionStatus('disconnected')
   168→      if (pingIntervalRef.current) {
   169→        clearInterval(pingIntervalRef.current)
   170→        pingIntervalRef.current = null
   171→      }
   172→
   173→      // Attempt reconnection if not intentionally closed
   174→      if (reconnectAttempts.current < maxReconnectAttempts && !isCompleteRef.current) {
   175→        reconnectAttempts.current++
   176→        const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.current), 10000)
   177→        reconnectTimeoutRef.current = window.setTimeout(connect, delay)
   178→      }
   179→    }
   180→
   181→    ws.onerror = () => {
   182→      setConnectionStatus('error')
   183→      onError?.('WebSocket connection error')
   184→    }
   185→
   186→    ws.onmessage = (event) => {
   187→      try {
   188→        const data = JSON.parse(event.data) as SpecChatServerMessage
   189→
   190→        switch (data.type) {
   191→          case 'text': {
   192→            // Append text to current assistant message or create new one
   193→            setMessages((prev) => {
   194→              const lastMessage = prev[prev.length - 1]
   195→              if (lastMessage?.role === 'assistant' && lastMessage.isStreaming) {
   196→                // Append to existing streaming message
   197→                return [
   198→                  ...prev.slice(0, -1),
   199→                  {
   200→                    ...lastMessage,
   201→                    content: lastMessage.content + data.content,
   202→                  },
   203→                ]
   204→              } else {
   205→                // Create new assistant message
   206→                currentAssistantMessageRef.current = generateId()
   207→                return [
   208→                  ...prev,
   209→                  {
   210→                    id: currentAssistantMessageRef.current,
   211→                    role: 'assistant',
   212→                    content: data.content,
   213→                    timestamp: new Date(),
   214→                    isStreaming: true,
   215→                  },
   216→                ]
   217→              }
   218→            })
   219→            break
   220→          }
   221→
   222→          case 'question': {
   223→            // Show structured question UI
   224→            setCurrentQuestions(data.questions)
   225→            setCurrentToolId(data.tool_id || null)
   226→            setIsLoading(false)
   227→
   228→            // Mark current message as done streaming
   229→            setMessages((prev) => {
   230→              const lastMessage = prev[prev.length - 1]
   231→              if (lastMessage?.role === 'assistant' && lastMessage.isStreaming) {
   232→                return [
   233→                  ...prev.slice(0, -1),
   234→                  {
   235→                    ...lastMessage,
   236→                    isStreaming: false,
   237→                    questions: data.questions,
   238→                  },
   239→                ]
   240→              }
   241→              return prev
   242→            })
   243→            break
   244→          }
   245→
   246→          case 'spec_complete': {
   247→            setIsComplete(true)
   248→            setIsLoading(false)
   249→
   250→            // Mark current message as done
   251→            setMessages((prev) => {
   252→              const lastMessage = prev[prev.length - 1]
   253→              if (lastMessage?.role === 'assistant' && lastMessage.isStreaming) {
   254→                return [
   255→                  ...prev.slice(0, -1),
   256→                  { ...lastMessage, isStreaming: false },
   257→                ]
   258→              }
   259→              return prev
   260→            })
   261→
   262→            // Add system message about spec completion
   263→            setMessages((prev) => [
   264→              ...prev,
   265→              {
   266→                id: generateId(),
   267→                role: 'system',
   268→                content: `Specification file created: ${data.path}`,
   269→                timestamp: new Date(),
   270→              },
   271→            ])
   272→
   273→            // NOTE: Do NOT auto-call onComplete here!
   274→            // User should click "Continue to Project" button to start the agent.
   275→            // This matches the CLI behavior where user closes the chat manually.
   276→            break
   277→          }
   278→
   279→          case 'file_written': {
   280→            // Optional: notify about other files being written
   281→            setMessages((prev) => [
   282→              ...prev,
   283→              {
   284→                id: generateId(),
   285→                role: 'system',
   286→                content: `File created: ${data.path}`,
   287→                timestamp: new Date(),
   288→              },
   289→            ])
   290→            break
   291→          }
   292→
   293→          case 'complete': {
   294→            setIsComplete(true)
   295→            setIsLoading(false)
   296→
   297→            // Mark current message as done
   298→            setMessages((prev) => {
   299→              const lastMessage = prev[prev.length - 1]
   300→              if (lastMessage?.role === 'assistant' && lastMessage.isStreaming) {
   301→                return [
   302→                  ...prev.slice(0, -1),
   303→                  { ...lastMessage, isStreaming: false },
   304→                ]
   305→              }
   306→              return prev
   307→            })
   308→            break
   309→          }
   310→
   311→          case 'error': {
   312→            setIsLoading(false)
   313→            onError?.(data.content)
   314→
   315→            // Add error as system message
   316→            setMessages((prev) => [
   317→              ...prev,
   318→              {
   319→                id: generateId(),
   320→                role: 'system',
   321→                content: `Error: ${data.content}`,
   322→                timestamp: new Date(),
   323→              },
   324→            ])
   325→            break
   326→          }
   327→
   328→          case 'pong': {
   329→            // Keep-alive response, nothing to do
   330→            break
   331→          }
   332→
   333→          case 'response_done': {
   334→            // Response complete - hide loading indicator and mark message as done
   335→            setIsLoading(false)
   336→
   337→            // Mark current message as done streaming
   338→            setMessages((prev) => {
   339→              const lastMessage = prev[prev.length - 1]
   340→              if (lastMessage?.role === 'assistant' && lastMessage.isStreaming) {
   341→                return [
   342→                  ...prev.slice(0, -1),
   343→                  { ...lastMessage, isStreaming: false },
   344→                ]
   345→              }
   346→              return prev
   347→            })
   348→            break
   349→          }
   350→        }
   351→      } catch (e) {
   352→        console.error('Failed to parse WebSocket message:', e)
   353→      }
   354→    }
   355→  }, [projectName, onComplete, onError])
   356→
   357→  const start = useCallback(() => {
   358→    connect()
   359→
   360→    // Wait for connection then send start message
   361→    const checkAndSend = () => {
   362→      if (wsRef.current?.readyState === WebSocket.OPEN) {
   363→        setIsLoading(true)
   364→        // Include import mode and codebase analysis in start message
   365→        const startMessage: {
   366→          type: string
   367→          is_import?: boolean
   368→          codebase_analysis?: string
   369→        } = { type: 'start' }
   370→
   371→        if (isImport) {
   372→          startMessage.is_import = true
   373→          if (codebaseAnalysis) {
   374→            startMessage.codebase_analysis = codebaseAnalysis
   375→          }
   376→        }
   377→
   378→        wsRef.current.send(JSON.stringify(startMessage))
   379→      } else if (wsRef.current?.readyState === WebSocket.CONNECTING) {
   380→        setTimeout(checkAndSend, 100)
   381→      }
   382→    }
   383→
   384→    setTimeout(checkAndSend, 100)
   385→  }, [connect, isImport, codebaseAnalysis])
   386→
   387→  const sendMessage = useCallback((content: string, attachments?: ImageAttachment[]) => {
   388→    if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
   389→      onError?.('Not connected')
   390→      return
   391→    }
   392→
   393→    // Add user message to chat (with attachments for display)
   394→    setMessages((prev) => [
   395→      ...prev,
   396→      {
   397→        id: generateId(),
   398→        role: 'user',
   399→        content,
   400→        attachments,
   401→        timestamp: new Date(),
   402→      },
   403→    ])
   404→
   405→    // Clear current questions
   406→    setCurrentQuestions(null)
   407→    setCurrentToolId(null)
   408→    setIsLoading(true)
   409→
   410→    // Build message payload
   411→    const payload: { type: string; content: string; attachments?: Array<{ filename: string; mimeType: string; base64Data: string }> } = {
   412→      type: 'message',
   413→      content,
   414→    }
   415→
   416→    // Add attachments if present (send base64 data, not preview URL)
   417→    if (attachments && attachments.length > 0) {
   418→      payload.attachments = attachments.map((a) => ({
   419→        filename: a.filename,
   420→        mimeType: a.mimeType,
   421→        base64Data: a.base64Data,
   422→      }))
   423→    }
   424→
   425→    // Send to server
   426→    wsRef.current.send(JSON.stringify(payload))
   427→  }, [onError])
   428→
   429→  const sendAnswer = useCallback((answers: Record<string, string | string[]>) => {
   430→    if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
   431→      onError?.('Not connected')
   432→      return
   433→    }
   434→
   435→    // Format answers for display
   436→    const answerText = Object.values(answers)
   437→      .map((v) => (Array.isArray(v) ? v.join(', ') : v))
   438→      .join('; ')
   439→
   440→    // Add user message
   441→    setMessages((prev) => [
   442→      ...prev,
   443→      {
   444→        id: generateId(),
   445→        role: 'user',
   446→        content: answerText,
   447→        timestamp: new Date(),
   448→      },
   449→    ])
   450→
   451→    // Clear current questions
   452→    setCurrentQuestions(null)
   453→    setCurrentToolId(null)
   454→    setIsLoading(true)
   455→
   456→    // Send to server
   457→    wsRef.current.send(
   458→      JSON.stringify({
   459→        type: 'answer',
   460→        answers,
   461→        tool_id: currentToolId,
   462→      })
   463→    )
   464→  }, [currentToolId, onError])
   465→
   466→  const disconnect = useCallback(() => {
   467→    reconnectAttempts.current = maxReconnectAttempts // Prevent reconnection
   468→    if (pingIntervalRef.current) {
   469→      clearInterval(pingIntervalRef.current)
   470→      pingIntervalRef.current = null
   471→    }
   472→    if (wsRef.current) {
   473→      wsRef.current.close()
   474→      wsRef.current = null
   475→    }
   476→    setConnectionStatus('disconnected')
   477→  }, [])
   478→
   479→  return {
   480→    messages,
   481→    isLoading,
   482→    isComplete,
   483→    connectionStatus,
   484→    currentQuestions,
   485→    currentToolId,
   486→    start,
   487→    sendMessage,
   488→    sendAnswer,
   489→    disconnect,
   490→  }
   491→}
   492→

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

/**
 * Hook for managing spec creation chat WebSocket connection
 */

import { useState, useCallback, useRef, useEffect } from 'react'
import type { ChatMessage, ImageAttachment, SpecChatServerMessage, SpecQuestion } from '../lib/types'
import { getSpecStatus } from '../lib/api'

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

interface UseSpecChatOptions {
  projectName: string
  onComplete?: (specPath: string) => void
  onError?: (error: string) => void
  /** If true, use import-spec skill instead of create-spec */
  isImport?: boolean
  /** Formatted codebase analysis for import mode */
  codebaseAnalysis?: string | null
}

interface UseSpecChatReturn {
  messages: ChatMessage[]
  isLoading: boolean
  isComplete: boolean
  connectionStatus: ConnectionStatus
  currentQuestions: SpecQuestion[] | null
  currentToolId: string | null
  start: () => void
  sendMessage: (content: string, attachments?: ImageAttachment[]) => void
  sendAnswer: (answers: Record<string, string | string[]>) => void
  disconnect: () => void
}

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

export function useSpecChat({
  projectName,
  onComplete,
  onError,
  isImport = false,
  codebaseAnalysis = null,
}: UseSpecChatOptions): UseSpecChatReturn {
  const [messages, setMessages] = useState<ChatMessage[]>([])
  const [isLoading, setIsLoading] = useState(false)
  const [isComplete, setIsComplete] = useState(false)
  const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>('disconnected')
  const [currentQuestions, setCurrentQuestions] = useState<SpecQuestion[] | null>(null)
  const [currentToolId, setCurrentToolId] = useState<string | null>(null)

  const wsRef = useRef<WebSocket | null>(null)
  const currentAssistantMessageRef = useRef<string | null>(null)
  const reconnectAttempts = useRef(0)
  const maxReconnectAttempts = 3
  const pingIntervalRef = useRef<number | null>(null)
  const reconnectTimeoutRef = useRef<number | null>(null)
  const isCompleteRef = useRef(false)

  // Keep isCompleteRef in sync with isComplete state
  useEffect(() => {
    isCompleteRef.current = isComplete
  }, [isComplete])

  // Clean up on unmount
  useEffect(() => {
    return () => {
      if (pingIntervalRef.current) {
        clearInterval(pingIntervalRef.current)
      }
      if (reconnectTimeoutRef.current) {
        clearTimeout(reconnectTimeoutRef.current)
      }
      if (wsRef.current) {
        wsRef.current.close()
      }
    }
  }, [])

  // Poll status file as fallback completion detection
  // Claude writes .spec_status.json when done with all spec work
  useEffect(() => {
    // Don't poll if already complete
    if (isComplete) return

    // Start polling after initial delay (let WebSocket try first)
    const startDelay = setTimeout(() => {
      const pollInterval = setInterval(async () => {
        // Stop if already complete
        if (isCompleteRef.current) {
          clearInterval(pollInterval)
          return
        }

        try {
          const status = await getSpecStatus(projectName)

          if (status.exists && status.status === 'complete') {
            // Status file indicates completion - set complete state
            setIsComplete(true)
            setIsLoading(false)

            // Mark any streaming message as done
            setMessages((prev) => {
              const lastMessage = prev[prev.length - 1]
              if (lastMessage?.role === 'assistant' && lastMessage.isStreaming) {
                return [
                  ...prev.slice(0, -1),
                  { ...lastMessage, isStreaming: false },
                ]
              }
              return prev
            })

            // Add system message about completion
            setMessages((prev) => [
              ...prev,
              {
                id: generateId(),
                role: 'system',
                content: `Spec creation complete! Files written: ${status.files_written.join(', ')}${status.feature_count ? ` (${status.feature_count} features)` : ''}`,
                timestamp: new Date(),
              },
            ])

            clearInterval(pollInterval)
          }
        } catch {
          // Silently ignore polling errors - WebSocket is primary mechanism
        }
      }, 3000) // Poll every 3 seconds

      // Cleanup interval on unmount
      return () => clearInterval(pollInterval)
    }, 3000) // Start polling after 3 second delay

    return () => clearTimeout(startDelay)
  }, [projectName, isComplete])

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

    setConnectionStatus('connecting')

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

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

    ws.onopen = () => {
      setConnectionStatus('connected')
      reconnectAttempts.current = 0

      // Start ping interval to keep connection alive
      pingIntervalRef.current = window.setInterval(() => {
        if (ws.readyState === WebSocket.OPEN) {
          ws.send(JSON.stringify({ type: 'ping' }))
        }
      }, 30000)
    }

    ws.onclose = () => {
      setConnectionStatus('disconnected')
      if (pingIntervalRef.current) {
        clearInterval(pingIntervalRef.current)
        pingIntervalRef.current = null
      }

      // Attempt reconnection if not intentionally closed
      if (reconnectAttempts.current < maxReconnectAttempts && !isCompleteRef.current) {
        reconnectAttempts.current++
        const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.current), 10000)
        reconnectTimeoutRef.current = window.setTimeout(connect, delay)
      }
    }

    ws.onerror = () => {
      setConnectionStatus('error')
      onError?.('WebSocket connection error')
    }

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

        switch (data.type) {
          case 'text': {
            // Append text to current assistant message or create new one
            setMessages((prev) => {
              const lastMessage = prev[prev.length - 1]
              if (lastMessage?.role === 'assistant' && lastMessage.isStreaming) {
                // Append to existing streaming message
                return [
                  ...prev.slice(0, -1),
                  {
                    ...lastMessage,
                    content: lastMessage.content + data.content,
                  },
                ]
              } else {
                // Create new assistant message
                currentAssistantMessageRef.current = generateId()
                return [
                  ...prev,
                  {
                    id: currentAssistantMessageRef.current,
                    role: 'assistant',
                    content: data.content,
                    timestamp: new Date(),
                    isStreaming: true,
                  },
                ]
              }
            })
            break
          }

          case 'question': {
            // Show structured question UI
            setCurrentQuestions(data.questions)
            setCurrentToolId(data.tool_id || null)
            setIsLoading(false)

            // Mark current message as done streaming
            setMessages((prev) => {
              const lastMessage = prev[prev.length - 1]
              if (lastMessage?.role === 'assistant' && lastMessage.isStreaming) {
                return [
                  ...prev.slice(0, -1),
                  {
                    ...lastMessage,
                    isStreaming: false,
                    questions: data.questions,
                  },
                ]
              }
              return prev
            })
            break
          }

          case 'spec_complete': {
            setIsComplete(true)
            setIsLoading(false)

            // Mark current message as done
            setMessages((prev) => {
              const lastMessage = prev[prev.length - 1]
              if (lastMessage?.role === 'assistant' && lastMessage.isStreaming) {
                return [
                  ...prev.slice(0, -1),
                  { ...lastMessage, isStreaming: false },
                ]
              }
              return prev
            })

            // Add system message about spec completion
            setMessages((prev) => [
              ...prev,
              {
                id: generateId(),
                role: 'system',
                content: `Specification file created: ${data.path}`,
                timestamp: new Date(),
              },
            ])

            // NOTE: Do NOT auto-call onComplete here!
            // User should click "Continue to Project" button to start the agent.
            // This matches the CLI behavior where user closes the chat manually.
            break
          }

          case 'file_written': {
            // Optional: notify about other files being written
            setMessages((prev) => [
              ...prev,
              {
                id: generateId(),
                role: 'system',
                content: `File created: ${data.path}`,
                timestamp: new Date(),
              },
            ])
            break
          }

          case 'complete': {
            setIsComplete(true)
            setIsLoading(false)

            // Mark current message as done
            setMessages((prev) => {
              const lastMessage = prev[prev.length - 1]
              if (lastMessage?.role === 'assistant' && lastMessage.isStreaming) {
                return [
                  ...prev.slice(0, -1),
                  { ...lastMessage, isStreaming: false },
                ]
              }
              return prev
            })
            break
          }

          case 'error': {
            setIsLoading(false)
            onError?.(data.content)

            // Add error as system message
            setMessages((prev) => [
              ...prev,
              {
                id: generateId(),
                role: 'system',
                content: `Error: ${data.content}`,
                timestamp: new Date(),
              },
            ])
            break
          }

          case 'pong': {
            // Keep-alive response, nothing to do
            break
          }

          case 'response_done': {
            // Response complete - hide loading indicator and mark message as done
            setIsLoading(false)

            // Mark current message as done streaming
            setMessages((prev) => {
              const lastMessage = prev[prev.length - 1]
              if (lastMessage?.role === 'assistant' && lastMessage.isStreaming) {
                return [
                  ...prev.slice(0, -1),
                  { ...lastMessage, isStreaming: false },
                ]
              }
              return prev
            })
            break
          }
        }
      } catch (e) {
        console.error('Failed to parse WebSocket message:', e)
      }
    }
  }, [projectName, onComplete, onError])

  const start = useCallback(() => {
    connect()

    // Wait for connection then send start message
    const checkAndSend = () => {
      if (wsRef.current?.readyState === WebSocket.OPEN) {
        setIsLoading(true)
        // Include import mode and codebase analysis in start message
        const startMessage: {
          type: string
          is_import?: boolean
          codebase_analysis?: string
        } = { type: 'start' }

        if (isImport) {
          startMessage.is_import = true
          if (codebaseAnalysis) {
            startMessage.codebase_analysis = codebaseAnalysis
          }
        }

        wsRef.current.send(JSON.stringify(startMessage))
      } else if (wsRef.current?.readyState === WebSocket.CONNECTING) {
        setTimeout(checkAndSend, 100)
      }
    }

    setTimeout(checkAndSend, 100)
  }, [connect, isImport, codebaseAnalysis])

  const sendMessage = useCallback((content: string, attachments?: ImageAttachment[]) => {
    if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
      onError?.('Not connected')
      return
    }

    // Add user message to chat (with attachments for display)
    setMessages((prev) => [
      ...prev,
      {
        id: generateId(),
        role: 'user',
        content,
        attachments,
        timestamp: new Date(),
      },
    ])

    // Clear current questions
    setCurrentQuestions(null)
    setCurrentToolId(null)
    setIsLoading(true)

    // Build message payload
    const payload: { type: string; content: string; attachments?: Array<{ filename: string; mimeType: string; base64Data: string }> } = {
      type: 'message',
      content,
    }

    // Add attachments if present (send base64 data, not preview URL)
    if (attachments && attachments.length > 0) {
      payload.attachments = attachments.map((a) => ({
        filename: a.filename,
        mimeType: a.mimeType,
        base64Data: a.base64Data,
      }))
    }

    // Send to server
    wsRef.current.send(JSON.stringify(payload))
  }, [onError])

  const sendAnswer = useCallback((answers: Record<string, string | string[]>) => {
    if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
      onError?.('Not connected')
      return
    }

    // Format answers for display
    const answerText = Object.values(answers)
      .map((v) => (Array.isArray(v) ? v.join(', ') : v))
      .join('; ')

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

    // Clear current questions
    setCurrentQuestions(null)
    setCurrentToolId(null)
    setIsLoading(true)

    // Send to server
    wsRef.current.send(
      JSON.stringify({
        type: 'answer',
        answers,
        tool_id: currentToolId,
      })
    )
  }, [currentToolId, onError])

  const disconnect = useCallback(() => {
    reconnectAttempts.current = maxReconnectAttempts // Prevent reconnection
    if (pingIntervalRef.current) {
      clearInterval(pingIntervalRef.current)
      pingIntervalRef.current = null
    }
    if (wsRef.current) {
      wsRef.current.close()
      wsRef.current = null
    }
    setConnectionStatus('disconnected')
  }, [])

  return {
    messages,
    isLoading,
    isComplete,
    connectionStatus,
    currentQuestions,
    currentToolId,
    start,
    sendMessage,
    sendAnswer,
    disconnect,
  }
}


Now I understand the WebSocket protocol. The spec creation uses:
- `/api/spec/ws/{projectName}` WebSocket endpoint
- Messages: `start`, `message`, `answer`, `ping`
- Server sends: `text`, `question`, `spec_complete`, `file_written`, `complete`, `error`, `pong`, `response_done`

I need to check if the remix backend has this WebSocket endpoint. Let me look at the spec router.


Let me check if the remix backend has the spec WebSocket endpoint.


     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
    )


The remix backend's spec router doesn't have a WebSocket endpoint for spec creation. It only has REST endpoints for managing spec content in the database.

The autocoder-master uses WebSocket for interactive Claude chat. For the remix, I have a few options:

1. Port the full WebSocket spec creation endpoint from autocoder-master
2. Create a simpler SpecInterview component that shows info and allows launching the interview through the CLI (like a placeholder)
3. Redirect to use the autocoder-master spec creation somehow

Given the complexity, let me create an initial SpecInterview component that:
1. Shows the project info
2. Displays codebase analysis (if available from import)
3. Has a button to start the interview (which could launch a terminal or show instructions)

For the full interview to work, we need to add the WebSocket endpoint to the remix backend, which requires significant work. Let me create a basic component first and mark it as needing the WebSocket backend.

I'll create a SpecInterview component that:
- Shows project info
- Has a placeholder for the chat interface
- Explains that the full interview feature is coming

Actually, let me check if there's any way to connect to the autocoder-master's WebSocket. The autocoder-master server runs on a different port. But that would be messy.

Better approach: Create the frontend component with the chat UI structure, and add a TODO for the WebSocket backend. This way the UI is ready and we just need to wire up the backend.


The remix backend doesn't have the WebSocket endpoint for spec creation yet. Let me create a SpecInterview component that shows the chat interface structure and can be wired up once the backend WebSocket is added. I'll create a simplified version first.


TodoWrite

Add Start Interview button to project page

Create SpecInterview component UI

Add WebSocket endpoint for spec chat in remix backend

Wire up frontend to WebSocket

Test full interview flow


Todos have been modified successfully. Ensure that you continue to use the todo list to track your progress. Please proceed with the current tasks if applicable

Add Start Interview button to project page

Port SpecCreationChat component to remix

Wire up WebSocket for spec creation

Test full interview flow

Create SpecInterview component UI

Add WebSocket endpoint for spec chat in remix backend

Wire up frontend to WebSocket

Test full interview flow


Now let me create the SpecInterview component. I'll create a component that:
1. Shows the current state (connecting, connected, etc.)
2. Has a messages area
3. Has an input area
4. Tries to connect to a WebSocket endpoint (which we'll need to add to the backend)

For now, I'll make it functional but it won't have a backend to connect to. The UI will be ready.