PYAE1994's picture
feat(phase4): Persistent Workflow OS v4.0.0 — queue + checkpoint + workspace memory
cf47145 verified
'use client';
import { useEffect, useState } from 'react';
import { ListTodo, Send, Loader2, RefreshCw, Clock, CheckCircle2, XCircle, Zap, AlertTriangle } from 'lucide-react';
import { api, type QueueJob, type QueueStatus } from '@/lib/api';
const PRIORITY_LABELS = ['CRITICAL', 'HIGH', 'NORMAL', 'LOW'];
const PRIORITY_COLORS = [
'bg-red-500/20 text-red-300 border-red-500/30',
'bg-orange-500/20 text-orange-300 border-orange-500/30',
'bg-blue-500/20 text-blue-300 border-blue-500/30',
'bg-slate-500/20 text-slate-300 border-slate-500/30',
];
function PriorityBadge({ priority }: { priority: number }) {
const cls = PRIORITY_COLORS[priority] ?? PRIORITY_COLORS[2];
return (
<span className={`inline-flex items-center rounded border px-2 py-0.5 text-[10px] font-bold ${cls}`}>
{PRIORITY_LABELS[priority] ?? 'NORMAL'}
</span>
);
}
function JobStatusIcon({ status }: { status: string }) {
if (status === 'COMPLETE') return <CheckCircle2 className="h-4 w-4 text-emerald-400" />;
if (status === 'FAILED') return <XCircle className="h-4 w-4 text-red-400" />;
if (status === 'RUNNING') return <Loader2 className="h-4 w-4 text-blue-400 animate-spin" />;
if (status === 'CANCELLED') return <XCircle className="h-4 w-4 text-orange-400" />;
return <Clock className="h-4 w-4 text-slate-400" />;
}
export default function QueuePanel() {
const [status, setStatus] = useState<QueueStatus | null>(null);
const [jobs, setJobs] = useState<QueueJob[]>([]);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState<string | null>(null);
// Submit form
const [prompt, setPrompt] = useState('');
const [priority, setPriority] = useState(2);
const [submitting, setSubmitting] = useState(false);
const reload = async () => {
setLoading(true);
try {
const [st, jb] = await Promise.all([api.queueStatus(), api.listQueueJobs()]);
setStatus(st);
setJobs(jb.jobs);
setErr(null);
} catch (e: any) {
setErr(e.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
reload();
const t = setInterval(reload, 3000);
return () => clearInterval(t);
}, []);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!prompt.trim()) return;
setSubmitting(true);
try {
const title = prompt.split('\n')[0].slice(0, 80);
await api.queueSubmit(title, prompt, priority);
setPrompt('');
await reload();
} catch (e: any) {
setErr(e.message);
} finally {
setSubmitting(false);
}
};
return (
<div className="space-y-6">
{/* Stats row */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
<div className="rounded-xl border border-white/10 bg-slate-900/60 p-4">
<p className="text-xs text-slate-400 uppercase tracking-wide mb-1">Queue Depth</p>
<p className="text-3xl font-bold">{status?.queue_depth ?? '—'}</p>
</div>
<div className="rounded-xl border border-white/10 bg-slate-900/60 p-4">
<p className="text-xs text-slate-400 uppercase tracking-wide mb-1">Running</p>
<p className="text-3xl font-bold text-blue-300">{status?.running_jobs ?? '—'}</p>
</div>
<div className="rounded-xl border border-white/10 bg-slate-900/60 p-4">
<p className="text-xs text-slate-400 uppercase tracking-wide mb-1">Backend</p>
<div className="flex items-center gap-2 mt-1">
<Zap className="h-4 w-4 text-brand-400" />
<span className="font-semibold capitalize">{status?.backend ?? '—'}</span>
</div>
</div>
</div>
{/* Submit to queue */}
<section className="rounded-xl border border-white/10 bg-slate-900/60 p-5">
<h2 className="flex items-center gap-2 font-semibold mb-4">
<ListTodo className="h-4 w-4 text-brand-400" />
Submit to Queue
</h2>
<form onSubmit={submit} className="space-y-3">
<textarea
value={prompt}
onChange={e => setPrompt(e.target.value)}
rows={3}
placeholder="Describe the task to queue..."
className="w-full rounded-lg border border-white/10 bg-slate-800/60 p-3 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
/>
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-2">
<label className="text-xs text-slate-400">Priority:</label>
<select
value={priority}
onChange={e => setPriority(Number(e.target.value))}
className="text-xs bg-slate-700 border border-slate-600 text-slate-200 rounded px-2 py-1 focus:outline-none"
>
<option value={0}>0 — CRITICAL</option>
<option value={1}>1 — HIGH</option>
<option value={2}>2 — NORMAL</option>
<option value={3}>3 — LOW</option>
</select>
</div>
<button
type="submit"
disabled={submitting || !prompt.trim()}
className="flex items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium hover:bg-brand-700 disabled:opacity-50 ml-auto"
>
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
Queue Task
</button>
</div>
</form>
</section>
{/* Error */}
{err && (
<div className="flex items-center gap-2 rounded-lg bg-red-500/10 border border-red-500/20 p-3 text-sm text-red-300">
<AlertTriangle className="h-4 w-4 shrink-0" /> {err}
</div>
)}
{/* Queued jobs */}
<section className="rounded-xl border border-white/10 bg-slate-900/60 p-5">
<header className="flex items-center justify-between mb-4">
<h2 className="flex items-center gap-2 font-semibold">
<ListTodo className="h-4 w-4 text-slate-400" />
Queued Jobs
{jobs.length > 0 && (
<span className="ml-1 text-xs bg-slate-700 text-slate-300 px-2 py-0.5 rounded-full">{jobs.length}</span>
)}
</h2>
<button
onClick={reload}
className="flex items-center gap-1 text-xs text-slate-400 hover:text-slate-200 border border-white/10 rounded px-2 py-1 hover:bg-white/5"
>
<RefreshCw className={`h-3 w-3 ${loading ? 'animate-spin' : ''}`} />
Refresh
</button>
</header>
{jobs.length === 0 ? (
<p className="text-sm text-slate-500 italic">No jobs in queue.</p>
) : (
<div className="space-y-2">
{jobs.map(job => (
<div key={job.job_id} className="flex items-start gap-3 rounded-lg border border-white/5 bg-slate-800/40 p-3">
<JobStatusIcon status={job.status} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mono text-xs text-slate-500">#{job.task_id}</span>
<PriorityBadge priority={job.priority} />
<span className="text-xs text-slate-400 truncate">{job.prompt.slice(0, 60)}</span>
</div>
<p className="text-[10px] text-slate-600 mt-0.5">
job: {job.job_id} · status: {job.status}
{job.retry_count > 0 && ` · retries: ${job.retry_count}`}
</p>
</div>
</div>
))}
</div>
)}
</section>
</div>
);
}