proteinea / src /app /chat /_components /JobStatusCard.tsx
Mahmoud Eljendy
feat: Antibody Studio — AI-native antibody design workspace by Proteinea
30cc31a
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { getPhyloJobStatus } from "@/lib/api";
type JobStatus = "queued" | "running" | "completed" | "failed" | "unknown";
interface JobArtifact {
name: string;
url: string;
mime?: string;
bytes?: number;
}
interface Props {
jobId: string;
toolName?: string;
/** Called when job completes with output artifacts. */
onComplete?: (artifacts: JobArtifact[]) => void;
}
const STATUS_COLORS: Record<JobStatus, { bg: string; text: string; ring: string }> = {
queued: { bg: "bg-amber-50", text: "text-amber-700", ring: "ring-amber-200" },
running: { bg: "bg-blue-50", text: "text-blue-700", ring: "ring-blue-200" },
completed: { bg: "bg-emerald-50", text: "text-emerald-700", ring: "ring-emerald-200" },
failed: { bg: "bg-red-50", text: "text-red-700", ring: "ring-red-200" },
unknown: { bg: "bg-gray-50", text: "text-gray-500", ring: "ring-gray-200" },
};
const STATUS_LABELS: Record<JobStatus, string> = {
queued: "Queued",
running: "Running",
completed: "Completed",
failed: "Failed",
unknown: "Unknown",
};
function normalizeStatus(raw: string | undefined): JobStatus {
if (!raw) return "unknown";
const s = raw.toLowerCase();
if (s === "completed" || s === "success" || s === "done") return "completed";
if (s === "failed" || s === "error" || s === "cancelled") return "failed";
if (s === "running" || s === "processing" || s === "in_progress") return "running";
if (s === "queued" || s === "pending" || s === "submitted") return "queued";
return "unknown";
}
function formatElapsed(ms: number): string {
const sec = Math.floor(ms / 1000);
if (sec < 60) return `${sec}s`;
const min = Math.floor(sec / 60);
const rem = sec % 60;
return `${min}m ${rem}s`;
}
export default function JobStatusCard({ jobId, toolName, onComplete }: Props) {
const [status, setStatus] = useState<JobStatus>("queued");
const [error, setError] = useState<string | null>(null);
const [artifacts, setArtifacts] = useState<JobArtifact[]>([]);
const [costEstimate, setCostEstimate] = useState<number | null>(null);
const [elapsed, setElapsed] = useState(0);
const [expanded, setExpanded] = useState(false);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const startTimeRef = useRef(Date.now());
const completedRef = useRef(false);
const stopPolling = useCallback(() => {
if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}, []);
useEffect(() => {
if (!jobId) return;
// Start elapsed timer
startTimeRef.current = Date.now();
timerRef.current = setInterval(() => {
setElapsed(Date.now() - startTimeRef.current);
}, 1000);
// Start polling
const poll = async () => {
try {
const data = await getPhyloJobStatus(jobId);
const hubStatus = data.hub?.status;
const normalized = normalizeStatus(hubStatus as string | undefined);
setStatus(normalized);
if (data.hub?.cost_estimate != null) {
setCostEstimate(data.hub.cost_estimate as number);
}
if (normalized === "completed" && !completedRef.current) {
completedRef.current = true;
stopPolling();
const arts = (data.artifacts ?? []) as JobArtifact[];
setArtifacts(arts);
onComplete?.(arts);
} else if (normalized === "failed") {
stopPolling();
const errMsg =
(data.hub?.error as string) ??
(data.hub?.message as string) ??
"Job failed";
setError(String(errMsg));
}
} catch {
// Transient error, keep polling
}
};
// Poll immediately, then every 5s
poll();
pollRef.current = setInterval(poll, 5000);
return stopPolling;
}, [jobId, onComplete, stopPolling]);
const isActive = status === "queued" || status === "running";
const colors = STATUS_COLORS[status];
return (
<div
data-testid="job-status-card"
className="my-2 rounded-xl border border-border bg-white overflow-hidden shadow-sm animate-fade-in"
>
{/* Header row */}
<button
onClick={() => setExpanded(!expanded)}
className="w-full flex items-center gap-3 px-4 py-3 text-left hover:bg-muted/20 transition-colors"
>
{/* Status indicator */}
{isActive ? (
<div className="w-5 h-5 rounded-full border-2 border-accent border-t-transparent animate-spin shrink-0" />
) : status === "completed" ? (
<div className="w-5 h-5 rounded-full bg-emerald-100 flex items-center justify-center shrink-0">
<svg
className="w-3 h-3 text-emerald-600"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={3}
>
<polyline points="20 6 9 17 4 12" />
</svg>
</div>
) : (
<div className="w-5 h-5 rounded-full bg-red-100 flex items-center justify-center shrink-0">
<svg
className="w-3 h-3 text-red-600"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={3}
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</div>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-foreground">
{toolName ? toolName.replace(/_/g, " ") : "Job"}{" "}
</span>
<span
className={`inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium ring-1 ${colors.bg} ${colors.text} ${colors.ring}`}
>
{STATUS_LABELS[status]}
</span>
</div>
<span className="text-[10px] text-muted-fg font-mono">{jobId}</span>
</div>
{/* Right side: elapsed + cost */}
<div className="text-right shrink-0">
<p className="text-[11px] text-foreground tabular-nums">
{formatElapsed(elapsed)}
</p>
{costEstimate != null && (
<p className="text-[10px] text-muted-fg">
~${costEstimate.toFixed(2)}
</p>
)}
</div>
<svg
className={`w-3.5 h-3.5 text-muted-fg transition-transform shrink-0 ${
expanded ? "rotate-180" : ""
}`}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
{/* Expanded detail */}
{expanded && (
<div className="border-t border-border-subtle px-4 py-3 space-y-2">
<div className="grid grid-cols-2 gap-2 text-[11px]">
<div>
<p className="text-[10px] text-muted-fg uppercase tracking-wider font-semibold">
Status
</p>
<p className="text-foreground">{STATUS_LABELS[status]}</p>
</div>
<div>
<p className="text-[10px] text-muted-fg uppercase tracking-wider font-semibold">
Elapsed
</p>
<p className="text-foreground tabular-nums">
{formatElapsed(elapsed)}
</p>
</div>
{costEstimate != null && (
<div>
<p className="text-[10px] text-muted-fg uppercase tracking-wider font-semibold">
Cost
</p>
<p className="text-foreground">${costEstimate.toFixed(2)}</p>
</div>
)}
{toolName && (
<div>
<p className="text-[10px] text-muted-fg uppercase tracking-wider font-semibold">
Tool
</p>
<p className="text-foreground">{toolName}</p>
</div>
)}
</div>
</div>
)}
{/* Completed: artifacts */}
{status === "completed" && artifacts.length > 0 && (
<div className="border-t border-border-subtle px-4 py-2">
<p className="text-[10px] text-muted-fg uppercase tracking-wider font-semibold mb-1">
Results
</p>
<ul className="space-y-1">
{artifacts.map((a, i) => (
<li key={i} className="flex items-center gap-2">
<svg
className="w-3 h-3 text-muted-fg shrink-0"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
<a
href={a.url}
target="_blank"
rel="noopener noreferrer"
className="text-[11px] text-accent hover:text-accent-hover hover:underline truncate"
>
{a.name}
</a>
{a.bytes != null && a.bytes > 0 && (
<span className="text-[10px] text-muted-fg shrink-0">
{(a.bytes / 1024).toFixed(1)}KB
</span>
)}
</li>
))}
</ul>
</div>
)}
{/* Failed: error message */}
{error && (
<div className="border-t border-red-200 bg-red-50 px-4 py-2">
<p className="text-[11px] text-red-700">{error}</p>
</div>
)}
</div>
);
}