Files
career-ops-vbleroy/web/src/components/jobs/worker-card.tsx
T
redlineo d295c7f139
Release Please / release-please (push) Failing after 4m2s
Web CI / web typecheck + build (push) Successful in 3m39s
CodeQL Analysis / Analyze (javascript-typescript) (push) Failing after 1h1m49s
CodeQL Analysis / Analyze (go) (push) Failing after 1h1m49s
init
2026-07-13 10:14:00 +03:00

145 lines
5.9 KiB
TypeScript

"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<string, string> = {
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 (
<div className={cn(inline && "rounded-xl border border-border bg-surface/60 p-2.5")}>
<div className="flex items-center gap-2">
{job.status === "running" ? (
<Loader2 className="size-3 shrink-0 animate-spin text-brand" />
) : job.status === "error" ? (
<AlertTriangle className={cn("size-3 shrink-0", tone.icon)} />
) : (
<Check className={cn("size-3 shrink-0", tone.icon)} />
)}
<span className={cn("truncate font-medium", inline ? "text-sm" : "text-xs")}>{job.title}</span>
{hasScore && (
<span
className={cn(
"ml-auto shrink-0 rounded px-1 py-0.5 font-semibold tabular-nums",
inline ? "text-xs" : "text-[10px]",
tone.chip,
)}
>
{job.result!.score}
</span>
)}
{trailing != null && (
<span className={cn("shrink-0", hasScore ? "ml-1" : "ml-auto")}>{trailing}</span>
)}
</div>
<div className={cn("mt-1.5 w-full overflow-hidden rounded-full bg-surface-hover", inline ? "h-1.5" : "h-1")}>
{job.status === "running" ? (
<div className="job-indeterminate h-full w-full" />
) : (
<div className={cn("h-full w-full rounded-full", tone.bar)} />
)}
</div>
{(bottom || running) && (
<div className={cn("mt-1 truncate text-faint", inline ? "text-xs" : "text-[10px]")}>
{running ? `${last ?? "Working"} · ${fmtElapsed(elapsed)}` : bottom}
</div>
)}
{authError && (
<div className={cn("mt-1 text-amber-700 dark:text-amber-400", inline ? "text-xs" : "text-[10px]")}>
Sign your CLI in from Config, then re-run.
</div>
)}
{tokens > 0 && (
<div className={cn("mt-1 text-faint tabular-nums", inline ? "text-xs" : "text-[10px]")}>
{fmtTokens(tokens)} tokens{job.cost?.usd != null ? ` · $${job.cost.usd.toFixed(2)}` : ""}
</div>
)}
</div>
);
}
// Re-exported icon used by callers that compose their own trailing affordances.
export { X as DismissIcon };