← back to Letsbegin

components/RalphyBoyStep.tsx

477 lines

'use client'

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

interface UserStory {
  id: string
  title: string
  description: string
  acceptanceCriteria: string[]
  priority: number
  passes: boolean
  notes: string
}

interface RalphData {
  project: string
  branchName: string
  description: string
  userStories: UserStory[]
}

interface Props {
  ralphData: RalphData | null
  projectPath: string
  isRunning: boolean
  setIsRunning: (running: boolean) => void
  currentStoryIndex: number
  setCurrentStoryIndex: (index: number) => void
  logs: string[]
  setLogs: React.Dispatch<React.SetStateAction<string[]>>
  onBack: () => void
}

export default function RalphyBoyStep({
  ralphData,
  projectPath,
  isRunning,
  setIsRunning,
  currentStoryIndex,
  setCurrentStoryIndex,
  logs,
  setLogs,
  onBack
}: Props) {
  const [status, setStatus] = useState<'ready' | 'running' | 'paused' | 'completed' | 'error'>('ready')
  const [storyResults, setStoryResults] = useState<Record<string, 'pending' | 'running' | 'pass' | 'fail'>>({})
  const [storyTimes, setStoryTimes] = useState<Record<string, number>>({})
  const [storyNotes, setStoryNotes] = useState<Record<string, string>>({})
  const logsEndRef = useRef<HTMLDivElement>(null)

  useEffect(() => {
    if (logsEndRef.current) {
      logsEndRef.current.scrollIntoView({ behavior: 'smooth' })
    }
  }, [logs])

  // Proper polling with useEffect + setInterval (fixes stale closure bug)
  useEffect(() => {
    if (!isRunning) return

    const pollStatus = async () => {
      try {
        const res = await fetch('/api/ralph/status')
        const data = await res.json()

        if (data.logs) {
          setLogs(data.logs)
        }
        if (data.currentStory) {
          setCurrentStoryIndex(data.currentStoryIndex)
          setStoryResults(prev => ({ ...prev, [data.currentStory]: 'running' }))
        }
        if (data.completed) {
          setStoryResults(data.storyResults || {})
          setStoryTimes(data.storyTimes || {})
          setStoryNotes(data.storyNotes || {})
          setStatus('completed')
          setIsRunning(false)
        }
      } catch (err) {
        console.error('Poll error:', err)
      }
    }

    // Initial poll immediately
    pollStatus()

    // Set up interval for subsequent polls
    const interval = setInterval(pollStatus, 2000)

    // Cleanup on unmount or when isRunning changes
    return () => clearInterval(interval)
  }, [isRunning, setLogs, setCurrentStoryIndex, setIsRunning])

  async function startRalph() {
    if (!ralphData) return

    setIsRunning(true)
    setStatus('running')
    setLogs(['> Starting Ralphy Boy...', `> Branch: ${ralphData.branchName}`, ''])

    // Initialize story results
    const initial: Record<string, 'pending' | 'running' | 'pass' | 'fail'> = {}
    ralphData.userStories.forEach(s => { initial[s.id] = 'pending' })
    setStoryResults(initial)

    try {
      addLog(`> Sending request to /api/ralph/run...`)
      addLog(`> Project: ${projectPath}`)
      addLog(`> Stories: ${ralphData.userStories.length}`)

      const res = await fetch('/api/ralph/run', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ ralphData, projectPath })
      })

      addLog(`> Response status: ${res.status}`)
      const data = await res.json()

      if (!res.ok || data.error) {
        setStatus('error')
        addLog(`> Error: ${data.error || 'Failed to start Ralph'}`)
        setIsRunning(false)
        return
      }

      addLog(`> Ralph started with PID ${data.pid}`)
      addLog(`> Branch: ${data.branchName}`)
      addLog('')
      // Polling is now handled by useEffect when isRunning becomes true
    } catch (err: any) {
      setStatus('error')
      addLog(`> Error starting Ralph: ${err?.message || err}`)
      addLog(`> Error type: ${err?.name || 'Unknown'}`)
      if (err?.cause) addLog(`> Cause: ${err.cause}`)
      setIsRunning(false)
    }
  }

  async function continueRalph() {
    if (!projectPath) return

    setIsRunning(true)
    setStatus('running')
    addLog('> Continuing Ralphy Boy from where we left off...')
    addLog(`> Project: ${projectPath}`)
    addLog('')

    try {
      const res = await fetch('/api/ralph/continue', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ projectPath })
      })

      const data = await res.json()

      if (!res.ok || data.error) {
        setStatus('error')
        addLog(`> Error: ${data.error || 'Failed to continue Ralph'}`)
        setIsRunning(false)
        return
      }

      if (data.completed) {
        setStatus('completed')
        addLog('> All stories already completed!')
        setIsRunning(false)
        return
      }

      addLog(`> Continuing with ${data.pendingStories?.length || 0} pending stories`)
      addLog(`> Stories: ${data.pendingStories?.join(', ') || 'none'}`)
      addLog(`> PID: ${data.pid}`)
      addLog('')
      // Polling is handled by useEffect when isRunning becomes true
    } catch (err: any) {
      setStatus('error')
      addLog(`> Error: ${err?.message || err}`)
      setIsRunning(false)
    }
  }

  function stopRalph() {
    setIsRunning(false)
    setStatus('ready')
    addLog('> Stopped by user')
    fetch('/api/ralph/stop', { method: 'POST' })
  }

  function addLog(message: string) {
    setLogs(prev => [...prev, message])
  }

  const completedCount = Object.values(storyResults).filter(r => r === 'pass').length
  const totalCount = ralphData?.userStories.length || 0
  const progressPercent = totalCount > 0 ? (completedCount / totalCount) * 100 : 0

  return (
    <div className="space-y-6">
      {/* Control Panel */}
      <div className="card p-6">
        <h3 className="font-medium mb-4 flex items-center gap-2">
          <span>🎮</span>
          Control Panel
        </h3>
        <div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
          <div>
            <div className="text-sm text-[var(--foreground-secondary)]">Branch:</div>
            <div className="font-mono text-[var(--primary)]">{ralphData?.branchName || 'N/A'}</div>
            <div className="text-sm mt-1">
              Status: <span className={`font-medium ${
                status === 'running' ? 'text-[var(--warning)]' :
                status === 'completed' ? 'text-[var(--success)]' :
                status === 'error' ? 'text-[var(--error)]' :
                'text-[var(--foreground-secondary)]'
              }`}>{status.toUpperCase()}</span>
            </div>
          </div>
          <div className="flex items-center gap-3">
            <button
              onClick={startRalph}
              className="btn-secondary"
              disabled={isRunning || !ralphData}
              title="Start fresh from the beginning"
            >
              🔄 RESTART
            </button>
            <button
              onClick={continueRalph}
              className="btn-primary"
              disabled={isRunning || !projectPath}
              title="Continue from where we left off"
            >
              ▶️ CONTINUE
            </button>
            <button
              onClick={stopRalph}
              className="btn-secondary"
              disabled={!isRunning && status === 'ready'}
            >
              ⏹️ STOP
            </button>
          </div>
        </div>
      </div>

      {/* Progress Board */}
      <div className="card p-6">
        <h3 className="font-medium mb-4 flex items-center gap-2">
          <span>📊</span>
          Progress Board
        </h3>
        <div className="overflow-x-auto">
          <table className="w-full text-sm">
            <thead>
              <tr className="text-left text-[var(--foreground-secondary)] border-b border-[var(--border)]">
                <th className="pb-3 pr-4">Story</th>
                <th className="pb-3 pr-4">Status</th>
                <th className="pb-3 pr-4">Time</th>
                <th className="pb-3">Notes</th>
              </tr>
            </thead>
            <tbody>
              {ralphData?.userStories.map((story, index) => {
                const result = storyResults[story.id] || 'pending'
                const time = storyTimes[story.id]
                const notes = storyNotes[story.id]

                return (
                  <tr key={story.id} className="border-b border-[var(--border)]">
                    <td className="py-3 pr-4">
                      <div className="font-medium">{story.id}</div>
                      <div className="text-xs text-[var(--foreground-tertiary)] truncate max-w-[200px]">
                        {story.title}
                      </div>
                    </td>
                    <td className="py-3 pr-4">
                      {result === 'pass' && <span className="badge badge-success">✅ PASS</span>}
                      {result === 'fail' && <span className="badge badge-error">❌ FAIL</span>}
                      {result === 'running' && (
                        <span className="badge badge-warning">
                          <svg className="w-3 h-3 animate-spin mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
                          </svg>
                          RUNNING
                        </span>
                      )}
                      {result === 'pending' && <span className="badge">⏳ PENDING</span>}
                    </td>
                    <td className="py-3 pr-4 font-mono text-[var(--foreground-secondary)]">
                      {time ? `${Math.floor(time / 60)}m ${time % 60}s` : '-'}
                    </td>
                    <td className="py-3 text-[var(--foreground-secondary)] truncate max-w-[200px]">
                      {notes || '-'}
                    </td>
                  </tr>
                )
              })}
            </tbody>
          </table>
        </div>

        {/* Progress Bar */}
        <div className="mt-6">
          <div className="flex items-center justify-between text-sm mb-2">
            <span>Overall Progress</span>
            <span>{completedCount}/{totalCount} stories ({Math.round(progressPercent)}%)</span>
          </div>
          <div className="progress-bar">
            <div className="progress-fill" style={{ width: `${progressPercent}%` }} />
          </div>
        </div>
      </div>

      {/* Live Output - Expanded */}
      <div className="card">
        <div className="p-4 border-b border-[var(--border)] flex items-center justify-between">
          <h3 className="font-medium flex items-center gap-2">
            <span>📺</span>
            Live Output
            {isRunning && (
              <span className="flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-green-500/20 text-green-400">
                <span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
                LIVE
              </span>
            )}
          </h3>
          <div className="flex items-center gap-3">
            <span className="text-xs text-[var(--foreground-tertiary)]">
              {logs.length} lines
            </span>
            <button
              onClick={() => setLogs([])}
              className="text-sm text-[var(--foreground-secondary)] hover:text-[var(--foreground)] px-2 py-1 rounded hover:bg-[var(--surface-alt)]"
            >
              Clear
            </button>
            <button
              onClick={() => {
                const text = logs.join('\n')
                const blob = new Blob([text], { type: 'text/plain' })
                const url = URL.createObjectURL(blob)
                const a = document.createElement('a')
                a.href = url
                a.download = 'ralph-log.txt'
                a.click()
              }}
              className="text-sm text-[var(--foreground-secondary)] hover:text-[var(--foreground)] px-2 py-1 rounded hover:bg-[var(--surface-alt)]"
            >
              Export
            </button>
          </div>
        </div>
        <div className="p-4 bg-black/50 min-h-[400px] max-h-[600px] overflow-y-auto font-mono text-sm">
          {logs.length === 0 ? (
            <div className="text-[var(--foreground-tertiary)] text-center py-8">
              No output yet. Click "START RALPHY BOY" to begin.
            </div>
          ) : (
            logs.map((log, i) => {
              // Determine log type and styling
              const isSuccess = log.includes('✓') || log.includes('PASS') || log.includes('Completed') || log.includes('success')
              const isError = log.includes('Error') || log.includes('FAIL') || log.includes('failed') || log.includes('❌')
              const isWarning = log.includes('Warning') || log.includes('⚠')
              const isRunningLog = log.includes('Running') || log.includes('Starting') || log.includes('...')
              const isInfo = log.startsWith('> ') || log.startsWith('[')
              const isFile = log.includes('.ts') || log.includes('.tsx') || log.includes('.js') || log.includes('.json')
              const isCommand = log.startsWith('$') || log.startsWith('npm') || log.startsWith('git')
              const isEmpty = log.trim() === ''

              return (
                <div
                  key={i}
                  className={`py-0.5 leading-relaxed ${
                    isSuccess ? 'text-green-400' :
                    isError ? 'text-red-400 font-medium' :
                    isWarning ? 'text-yellow-400' :
                    isRunningLog ? 'text-blue-400' :
                    isFile ? 'text-purple-400' :
                    isCommand ? 'text-cyan-400' :
                    isInfo ? 'text-gray-300' :
                    isEmpty ? '' :
                    'text-gray-400'
                  }`}
                >
                  {log}
                </div>
              )
            })
          )}
          {isRunning && (
            <div className="flex items-center gap-2 mt-2 text-blue-400">
              <span className="inline-block w-2 h-4 bg-blue-400 animate-pulse">█</span>
              <span className="text-xs animate-pulse">Processing...</span>
            </div>
          )}
          <div ref={logsEndRef} />
        </div>

        {/* Log Legend */}
        <div className="px-4 py-2 border-t border-[var(--border)] bg-[var(--surface-alt)] flex items-center gap-4 text-xs">
          <span className="text-[var(--foreground-tertiary)]">Legend:</span>
          <span className="text-green-400">● Success</span>
          <span className="text-red-400">● Error</span>
          <span className="text-yellow-400">● Warning</span>
          <span className="text-blue-400">● Running</span>
          <span className="text-purple-400">● Files</span>
          <span className="text-cyan-400">● Commands</span>
        </div>
      </div>

      {/* Files Modified */}
      {status === 'completed' && (
        <div className="card p-4">
          <h3 className="font-medium mb-3 flex items-center gap-2">
            <span>📁</span>
            Files Modified
          </h3>
          <div className="space-y-1 text-sm font-mono">
            <div className="text-[var(--success)]">• app/components/NewFeature.tsx (+34 lines)</div>
            <div className="text-[var(--warning)]">• lib/utils.ts (modified)</div>
            <div className="text-[var(--primary)]">• app/page.tsx (+5 lines)</div>
          </div>
        </div>
      )}

      {/* Actions */}
      <div className="flex items-center justify-between p-4 card">
        <button onClick={onBack} className="btn-secondary">
          ← Back to Ralph
        </button>
        <div className="flex items-center gap-3">
          <button
            className="btn-secondary"
            onClick={async () => {
              if (!projectPath) return
              try {
                const progressPath = `${projectPath}/scripts/ralph/progress.txt`
                const res = await fetch(`/api/files?path=${encodeURIComponent(progressPath)}`)
                if (res.ok) {
                  const data = await res.json()
                  // Open in new window
                  const win = window.open('', '_blank')
                  if (win) {
                    win.document.write(`<pre style="font-family:monospace;padding:20px;background:#1a1a24;color:#fafafa;">${data.content || 'No content'}</pre>`)
                    win.document.title = 'progress.txt'
                  }
                } else {
                  alert('Could not read progress.txt')
                }
              } catch (err) {
                alert('Error reading progress.txt: ' + err)
              }
            }}
          >
            View progress.txt
          </button>
          <button
            className="btn-primary"
            disabled={status !== 'completed'}
            onClick={() => {
              // Open project in file manager or show summary
              if (projectPath) {
                window.open(`vscode://file${projectPath}`, '_blank')
              }
            }}
          >
            Open in VS Code
          </button>
        </div>
      </div>
    </div>
  )
}