← 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>
)
}