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