← back to Yolo Agent
ledger-app/src/App.tsx
695 lines
import { useEffect, useState, useRef, useCallback } from 'react'
import {
Loader2,
CheckCircle2,
XCircle,
Clock,
ListOrdered,
Activity,
Terminal,
ChevronDown,
ChevronUp,
Play,
Pause,
Square,
Skull,
Trash2,
Send,
Cpu,
} from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { ScrollArea } from '@/components/ui/scroll-area'
import { xfetch } from '@/lib/xfetch'
// ── Types ──────────────────────────────────────────────────────────────────
interface YoloStatus {
running: boolean
paused: boolean
currentTask: string | null
startedAt: string | null
totalRuns: number
totalSuccess: number
totalFailed: number
totalSkipped: number
cooldownSec: number
mode: string
queueLength: number
lastRunAt: string | null
loopStartedAt: string | null
observations: Array<{ ts: string; msg: string }>
currentProcess: { pid: number } | null
}
interface QueuedTask {
name: string
path: string
size: number
created: string
}
interface HistoryItem {
task: string
status: string
duration: string
durationSec?: number
exitCode?: number
completedAt: string
stdout?: string
stderr?: string
logFile?: string
}
interface LogLine {
ts: string
text: string
source?: string
}
interface BackgroundProc {
pid: string
cpu: string
mem: string
cmd: string
lastLine: string
type?: string
}
// ── Helpers ────────────────────────────────────────────────────────────────
function formatElapsed(startedAt: string): string {
const ms = Date.now() - new Date(startedAt).getTime()
const sec = Math.floor(ms / 1000)
if (sec < 60) return `${sec}s`
const min = Math.floor(sec / 60)
const remainSec = sec % 60
if (min < 60) return `${min}m ${remainSec}s`
const hr = Math.floor(min / 60)
const remainMin = min % 60
return `${hr}h ${remainMin}m`
}
function formatTime(iso: string): string {
try {
return new Date(iso).toLocaleTimeString('en-US', {
timeZone: 'America/Los_Angeles',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
} catch {
return iso
}
}
function formatDateTime(iso: string): string {
try {
return new Date(iso).toLocaleString('en-US', {
timeZone: 'America/Los_Angeles',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
} catch {
return iso
}
}
// ── App ────────────────────────────────────────────────────────────────────
export default function App() {
const [status, setStatus] = useState<YoloStatus | null>(null)
const [queue, setQueue] = useState<QueuedTask[]>([])
const [history, setHistory] = useState<HistoryItem[]>([])
const [liveLog, setLiveLog] = useState<LogLine[]>([])
const [background, setBackground] = useState<BackgroundProc[]>([])
const [sessionLog, setSessionLog] = useState<LogLine[]>([])
const [sessionOpen, setSessionOpen] = useState(false)
const [elapsed, setElapsed] = useState('')
const [taskInput, setTaskInput] = useState('')
const [error, setError] = useState<string | null>(null)
const terminalRef = useRef<HTMLDivElement>(null)
const sessionLogRef = useRef<LogLine[]>([])
// ── Data fetchers ──────────────────────────────────────────────────────
const fetchStatus = useCallback(async () => {
try {
const res = await xfetch('/api/status')
if (res.ok) {
const data = await res.json()
setStatus(data)
setError(null)
}
} catch (e) {
setError('Failed to connect to YOLO API')
}
}, [])
const fetchQueue = useCallback(async () => {
try {
const res = await xfetch('/api/queue')
if (res.ok) setQueue(await res.json())
} catch {}
}, [])
const fetchHistory = useCallback(async () => {
try {
const res = await xfetch('/api/history')
if (res.ok) setHistory(await res.json())
} catch {}
}, [])
const fetchLiveLog = useCallback(async () => {
try {
const res = await xfetch('/api/live-log')
if (res.ok) {
const lines: LogLine[] = await res.json()
setLiveLog(lines)
// Append to session log (dedup by text content)
const existing = new Set(sessionLogRef.current.map(l => l.text))
const newLines = lines.filter(l => !existing.has(l.text))
if (newLines.length > 0) {
sessionLogRef.current = [...sessionLogRef.current, ...newLines].slice(-500)
setSessionLog([...sessionLogRef.current])
}
}
} catch {}
}, [])
const fetchBackground = useCallback(async () => {
try {
const res = await xfetch('/api/background')
if (res.ok) {
const data = await res.json()
setBackground(data.processes || [])
}
} catch {}
}, [])
// ── Polling intervals ──────────────────────────────────────────────────
useEffect(() => {
fetchStatus()
fetchQueue()
fetchHistory()
fetchLiveLog()
fetchBackground()
const statusInterval = setInterval(() => {
fetchStatus()
fetchQueue()
fetchHistory()
fetchBackground()
}, 5000)
const logInterval = setInterval(fetchLiveLog, 3000)
return () => {
clearInterval(statusInterval)
clearInterval(logInterval)
}
}, [fetchStatus, fetchQueue, fetchHistory, fetchLiveLog, fetchBackground])
// ── Elapsed timer ──────────────────────────────────────────────────────
useEffect(() => {
if (!status?.startedAt || !status?.currentTask) {
setElapsed('')
return
}
const tick = () => setElapsed(formatElapsed(status.startedAt!))
tick()
const id = setInterval(tick, 1000)
return () => clearInterval(id)
}, [status?.startedAt, status?.currentTask])
// ── Auto-scroll terminal ──────────────────────────────────────────────
useEffect(() => {
if (terminalRef.current) {
terminalRef.current.scrollTop = terminalRef.current.scrollHeight
}
}, [liveLog])
// ── API actions ────────────────────────────────────────────────────────
const apiAction = async (action: string) => {
try {
const res = await xfetch(`/api/${action}`, { method: 'POST' })
const data = await res.json()
if (data.msg) {
// Brief visual feedback
setError(null)
}
fetchStatus()
fetchQueue()
} catch (e) {
setError(`Action "${action}" failed`)
}
}
const submitTask = async () => {
const prompt = taskInput.trim()
if (!prompt) return
try {
await xfetch('/api/run', { method: 'POST', body: { prompt } })
setTaskInput('')
fetchStatus()
fetchQueue()
} catch {
setError('Failed to submit task')
}
}
// ── Derived state ──────────────────────────────────────────────────────
const statusText = status
? status.running
? status.paused
? 'PAUSED'
: 'RUNNING'
: 'IDLE'
: 'LOADING'
const statusVariant = status
? status.running
? status.paused
? ('warning' as const)
: ('success' as const)
: ('secondary' as const)
: ('secondary' as const)
// ── Render ─────────────────────────────────────────────────────────────
return (
<div className="min-h-screen bg-background p-4 md:p-6 max-w-7xl mx-auto">
{/* Header */}
<header className="flex flex-wrap items-center gap-4 mb-6 pb-4 border-b border-border">
<div className="flex items-center gap-3">
<Activity className="w-6 h-6 text-primary" />
<h1 className="text-xl md:text-2xl font-bold text-primary">
YOLO Task Ledger
</h1>
</div>
<Badge variant={statusVariant} className="text-sm px-3 py-1">
{statusText}
</Badge>
{status && (
<div className="flex gap-4 text-xs text-muted-foreground ml-auto">
<span>
<CheckCircle2 className="w-3 h-3 inline mr-1 text-success" />
{status.totalSuccess}
</span>
<span>
<XCircle className="w-3 h-3 inline mr-1 text-destructive" />
{status.totalFailed}
</span>
<span>
<ListOrdered className="w-3 h-3 inline mr-1 text-muted-foreground" />
{status.queueLength}
</span>
<span className="text-muted-foreground/60">
Mode: {status.mode} | CD: {status.cooldownSec}s
</span>
</div>
)}
</header>
{error && (
<div className="mb-4 p-3 rounded-lg bg-destructive/10 border border-destructive/30 text-destructive text-sm">
{error}
</div>
)}
{/* Controls */}
<div className="flex flex-wrap gap-2 mb-6">
<button
onClick={() => apiAction('start')}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-success/20 text-success text-xs font-semibold hover:bg-success/30 transition-colors"
>
<Play className="w-3 h-3" /> Start
</button>
<button
onClick={() => apiAction('pause')}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-warning/20 text-warning text-xs font-semibold hover:bg-warning/30 transition-colors"
>
<Pause className="w-3 h-3" /> Pause
</button>
<button
onClick={() => apiAction('stop')}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-destructive/20 text-destructive text-xs font-semibold hover:bg-destructive/30 transition-colors"
>
<Square className="w-3 h-3" /> Stop
</button>
<button
onClick={() => apiAction('kill')}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-destructive/20 text-destructive text-xs font-semibold hover:bg-destructive/30 transition-colors"
>
<Skull className="w-3 h-3" /> Kill Task
</button>
<button
onClick={() => apiAction('clear-queue')}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-secondary text-muted-foreground text-xs font-semibold hover:bg-secondary/80 transition-colors"
>
<Trash2 className="w-3 h-3" /> Clear Queue
</button>
</div>
{/* Running Task */}
{status?.currentTask && (
<Card className="mb-6 border-success/50 pulse-glow">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-success text-sm">
<Loader2 className="w-4 h-4 animate-spin" />
Running Task
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap items-center gap-4">
<span className="text-foreground font-semibold text-base">
{status.currentTask}
</span>
{elapsed && (
<Badge variant="outline" className="text-xs">
<Clock className="w-3 h-3 mr-1" />
{elapsed}
</Badge>
)}
{status.currentProcess?.pid && (
<span className="text-xs text-muted-foreground">
PID: {status.currentProcess.pid}
</span>
)}
</div>
</CardContent>
</Card>
)}
{/* Live Output Terminal */}
<Card className="mb-6">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm">
<Terminal className="w-4 h-4 text-primary" />
Live Output
{liveLog.length > 0 && (
<span className="text-xs text-muted-foreground ml-auto">
{liveLog.length} lines
</span>
)}
</CardTitle>
</CardHeader>
<CardContent>
<div
ref={terminalRef}
className="bg-background rounded-lg border border-border p-3 h-48 overflow-y-auto terminal-output"
>
{liveLog.length === 0 ? (
<span className="text-muted-foreground/50 text-xs">
No output -- waiting for task...
</span>
) : (
liveLog.map((line, i) => (
<div key={i} className="text-foreground/90 hover:bg-secondary/30">
<span className="text-muted-foreground/40 mr-2 select-none">
{String(i + 1).padStart(3)}
</span>
{line.text}
</div>
))
)}
</div>
</CardContent>
</Card>
{/* Quick Task Input */}
<Card className="mb-6">
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<Send className="w-4 h-4 text-primary" />
Quick Task
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex gap-2">
<textarea
value={taskInput}
onChange={(e) => setTaskInput(e.target.value)}
placeholder="Enter a prompt for Claude to execute autonomously..."
className="flex-1 bg-background border border-input rounded-md px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-ring resize-vertical min-h-[60px]"
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
submitTask()
}
}}
/>
<button
onClick={submitTask}
className="self-end px-4 py-2 rounded-md bg-primary text-primary-foreground text-xs font-semibold hover:bg-primary/90 transition-colors"
>
Submit
</button>
</div>
</CardContent>
</Card>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
{/* Queued Tasks */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<ListOrdered className="w-4 h-4 text-primary" />
Queued Tasks
<Badge variant="secondary" className="ml-auto text-xs">
{queue.length}
</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea className="h-48">
{queue.length === 0 ? (
<p className="text-muted-foreground/50 text-xs py-2">
No tasks queued
</p>
) : (
<div className="space-y-2">
{queue.map((task, i) => (
<div
key={task.name}
className="flex items-center gap-3 p-2 rounded-md bg-secondary/50 border border-border/50"
>
<span className="text-xs font-bold text-primary w-6 text-right">
#{i + 1}
</span>
<span className="text-sm text-foreground flex-1 truncate">
{task.name}
</span>
<span className="text-xs text-muted-foreground">
{(task.size / 1024).toFixed(1)}KB
</span>
</div>
))}
</div>
)}
</ScrollArea>
</CardContent>
</Card>
{/* Background Processes */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<Cpu className="w-4 h-4 text-warning" />
Background Processes
<Badge variant="warning" className="ml-auto text-xs">
{background.length}
</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea className="h-48">
{background.length === 0 ? (
<p className="text-muted-foreground/50 text-xs py-2">
No background processes
</p>
) : (
<div className="space-y-2">
{background.map((proc) => (
<div
key={proc.pid}
className="p-2 rounded-md bg-warning/5 border border-warning/20"
>
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-mono text-warning">
PID {proc.pid}
</span>
<span className="text-xs text-muted-foreground">
CPU: {proc.cpu}% | MEM: {proc.mem}
</span>
</div>
<div className="text-xs text-foreground/80 truncate">
{proc.cmd}
</div>
{proc.lastLine && (
<div className="text-xs text-muted-foreground mt-1 truncate italic">
{proc.lastLine}
</div>
)}
</div>
))}
</div>
)}
</ScrollArea>
</CardContent>
</Card>
</div>
{/* Completed Tasks (History) */}
<Card className="mb-6">
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 text-primary" />
Completed Tasks
<Badge variant="secondary" className="ml-auto text-xs">
{history.length}
</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea className="h-64">
{history.length === 0 ? (
<p className="text-muted-foreground/50 text-xs py-2">
No completed tasks
</p>
) : (
<table className="w-full">
<thead>
<tr className="border-b border-border text-xs text-muted-foreground">
<th className="text-left py-2 pr-4">Task</th>
<th className="text-left py-2 pr-4">Status</th>
<th className="text-left py-2 pr-4">Duration</th>
<th className="text-left py-2">Completed</th>
</tr>
</thead>
<tbody>
{history.map((item, i) => (
<tr
key={i}
className="border-b border-border/30 text-xs hover:bg-secondary/30 transition-colors"
>
<td className="py-2 pr-4 text-foreground max-w-[200px] truncate">
{item.task || '?'}
</td>
<td className="py-2 pr-4">
{item.status === 'success' ? (
<Badge variant="success" className="text-[10px]">
<CheckCircle2 className="w-3 h-3 mr-1" />
success
</Badge>
) : (
<Badge variant="destructive" className="text-[10px]">
<XCircle className="w-3 h-3 mr-1" />
{item.status || 'failed'}
</Badge>
)}
</td>
<td className="py-2 pr-4 text-muted-foreground">
{item.duration || '?'}
</td>
<td className="py-2 text-muted-foreground">
{item.completedAt
? formatDateTime(item.completedAt)
: '?'}
</td>
</tr>
))}
</tbody>
</table>
)}
</ScrollArea>
</CardContent>
</Card>
{/* Session Log (collapsible) */}
<Card className="mb-6">
<CardHeader
className="pb-2 cursor-pointer select-none hover:bg-secondary/30 transition-colors rounded-t-xl"
onClick={() => setSessionOpen(!sessionOpen)}
>
<CardTitle className="text-sm flex items-center gap-2">
<Terminal className="w-4 h-4 text-muted-foreground" />
Session Log
<span className="text-xs text-muted-foreground ml-1">
({sessionLog.length} lines)
</span>
<span className="ml-auto">
{sessionOpen ? (
<ChevronUp className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronDown className="w-4 h-4 text-muted-foreground" />
)}
</span>
</CardTitle>
</CardHeader>
{sessionOpen && (
<CardContent>
<div className="bg-background rounded-lg border border-border p-3 h-64 overflow-y-auto terminal-output">
{sessionLog.length === 0 ? (
<span className="text-muted-foreground/50 text-xs">
Session log empty -- output accumulates here across tasks
</span>
) : (
sessionLog.map((line, i) => (
<div key={i} className="text-foreground/80">
<span className="text-muted-foreground/30 mr-2 select-none text-[10px]">
{formatTime(line.ts)}
</span>
{line.text}
</div>
))
)}
</div>
</CardContent>
)}
</Card>
{/* Observations */}
{status && status.observations.length > 0 && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<Activity className="w-4 h-4 text-muted-foreground" />
Observations
</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea className="h-40">
<div className="space-y-1">
{status.observations.slice(0, 30).map((obs, i) => (
<div
key={i}
className="text-xs py-1 border-b border-border/20 text-muted-foreground"
>
<span className="text-muted-foreground/40 mr-2">
{formatTime(obs.ts)}
</span>
{obs.msg}
</div>
))}
</div>
</ScrollArea>
</CardContent>
</Card>
)}
{/* Footer */}
<footer className="mt-8 pt-4 border-t border-border text-center text-xs text-muted-foreground/40">
YOLO Agent (Yuri) | Port 9670 | Auto-refresh: status 5s, log 3s
</footer>
</div>
)
}