"use client"; import { useEffect, useState } from "react"; import { Check, X, Loader2, AlertTriangle } from "lucide-react"; import type { Job } from "@/components/jobs/job-store"; import { cn } from "@/lib/cn"; // Humanize raw agent tool names into what the user actually cares about, so a // multi-minute evaluation reads as progress instead of a cryptic tool dump (#8). const STEP_LABELS: Record = { WebFetch: "Reading the posting", WebSearch: "Searching the web", Read: "Reading your CV & profile", Glob: "Looking through your files", Grep: "Looking through your files", Write: "Writing the report", Edit: "Updating the report", NotebookEdit: "Updating the report", Bash: "Saving to your tracker", TodoWrite: "Planning the steps", Task: "Working", }; const humanizeStep = (label: string): string => STEP_LABELS[label] ?? label; // Auth/sign-in failures are the most common real error — detect them so we can give // a concrete next step instead of a dead end (#8). function isAuthError(job: Job): boolean { if (job.status !== "error") return false; const hay = `${job.steps[job.steps.length - 1]?.label ?? ""} ${job.text}`.toLowerCase(); return /auth|login|sign[ -]?in|credential|api[ -]?key|unauthorized|not authenticated|installed and authenticated/.test(hay); } const fmtElapsed = (ms: number): string => { const s = Math.max(0, Math.floor(ms / 1000)); return s < 60 ? `${s}s` : `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; }; const fmtTokens = (n: number): string => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`); // Tick once a second WHILE running so a long evaluation visibly counts up (never // looks frozen). Stops re-rendering as soon as the job settles. function useElapsed(running: boolean, startedAt: number): number { const [now, setNow] = useState(startedAt); useEffect(() => { if (!running) return; setNow(Date.now()); const t = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(t); }, [running, startedAt]); return Math.max(0, now - startedAt); } // The ONE worker card — a pure function of a Job. Rendered in three surfaces: // the sidebar tray (variant="tray", inside WorkerPills' Link), inline in the // assistant chat (variant="inline"), and conceptually the /jobs/[id] timeline. // Keeping it single is what guarantees the human UI and the agentic UI stay // visually identical. TONE + pillTone live here (the canonical source). export const TONE = { good: { bar: "bg-emerald-500/70", chip: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-400", icon: "text-emerald-500" }, warn: { bar: "bg-amber-500/70", chip: "bg-amber-500/15 text-amber-700 dark:text-amber-400", icon: "text-amber-500" }, bad: { bar: "bg-red-400/70", chip: "bg-red-500/15 text-red-700 dark:text-red-400", icon: "text-red-400" }, muted: { bar: "bg-zinc-400/50", chip: "bg-surface-hover text-muted", icon: "text-zinc-400" }, } as const; export function pillTone(j: Job): keyof typeof TONE { if (j.status === "error") return "bad"; if (j.status === "done") return j.result?.tone ?? "muted"; return "muted"; } export function WorkerCard({ job, variant = "tray", trailing, }: { job: Job; variant?: "tray" | "inline"; trailing?: React.ReactNode; }) { const tone = TONE[pillTone(job)]; const running = job.status === "running"; const elapsed = useElapsed(running, job.startedAt); const rawLast = job.steps[job.steps.length - 1]?.label; const last = rawLast ? humanizeStep(rawLast) : undefined; const bottom = job.status === "done" && job.result?.summary ? job.result.summary : last; const inline = variant === "inline"; const hasScore = job.result?.score != null; const authError = isAuthError(job); const tokens = job.status === "done" ? job.cost?.tokens ?? 0 : 0; return (
{job.status === "running" ? ( ) : job.status === "error" ? ( ) : ( )} {job.title} {hasScore && ( {job.result!.score} )} {trailing != null && ( {trailing} )}
{job.status === "running" ? (
) : (
)}
{(bottom || running) && (
{running ? `${last ?? "Working"} · ${fmtElapsed(elapsed)}` : bottom}
)} {authError && (
Sign your CLI in from Config, then re-run.
)} {tokens > 0 && (
{fmtTokens(tokens)} tokens{job.cost?.usd != null ? ` · $${job.cost.usd.toFixed(2)}` : ""}
)}
); } // Re-exported icon used by callers that compose their own trailing affordances. export { X as DismissIcon };