init
This commit is contained in:
commit
d295c7f139
1527 files changed
+270726
No files matched your search
@@ -0,0 +1,144 @@
|
||||
"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 };
|
||||
Reference in new issue
Block a user