57 / 122 · Structured output
Long job
Work measured in minutes: weighted stages, an ETA, and a way out.
Verify the fix on CIabout 4 min
cloneinstallbuildtest
Installation
npx shadcn@latest add "@assistant-ui/elements-job-progress"
Usage
import { JobProgress } from "@/components/elements/job-progress";
<JobProgress
title="Verify the fix on CI"
stages={stages}
stageIndex={1}
stageProgress={0.6}
eta="about 3 min"
/>Props
title*stringWhat the job is doing.
stages*JobStage[]Stages with relative weights, so a long install does not read the same as a fast clone.
stageIndex*numberWhich stage is running. Passing stages.length marks the job finished.
stageProgress*numberProgress inside the current stage, 0 to 1.
eta*stringPre-formatted estimate. Replaced by done once finished.
onCancel() => voidCalled to stop the job. Hidden once it finishes.
classNamestringExtra classes merged onto the root.
Source
job-progress.tsx"use client";
import { CheckIcon, Loader2Icon, XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { ghostButton, mono, paper } from "./surfaces";
export interface JobStage {
name: string;
weight: number;
}
export function JobProgress({
title,
stages,
stageIndex,
stageProgress,
eta,
onCancel,
className,
}: {
title: string;
stages: readonly JobStage[];
stageIndex: number;
stageProgress: number;
eta: string;
onCancel?: () => void;
className?: string;
}) {
const totalWeight = stages.reduce((sum, stage) => sum + stage.weight, 0) || 1;
const completed = stages
.slice(0, stageIndex)
.reduce((sum, stage) => sum + stage.weight, 0);
const current = stages[stageIndex];
const overall =
((completed + (current ? current.weight * stageProgress : 0)) /
totalWeight) *
100;
const finished = stageIndex >= stages.length;
return (
<div
className={cn(
paper,
"flex w-full max-w-sm flex-col gap-3 rounded-2xl p-4",
className,
)}
>
<div className="flex items-center gap-2.5">
{finished ? (
<CheckIcon className="size-3.5 shrink-0 text-emerald-500" />
) : (
<Loader2Icon className="text-foreground/35 size-3.5 shrink-0 animate-spin motion-reduce:animate-none" />
)}
<span className="min-w-0 flex-1 truncate text-[13.5px] font-medium">
{title}
</span>
<span className={cn(mono, "text-foreground/35 shrink-0 tabular-nums")}>
{finished ? "done" : eta}
</span>
{!finished && (
<button
type="button"
aria-label="Cancel the job"
onClick={onCancel}
className={cn(ghostButton, "size-6 shrink-0")}
>
<XIcon className="size-3.5" />
</button>
)}
</div>
<span className="bg-foreground/[0.06] h-1 w-full overflow-hidden rounded-full">
<span
className={cn(
"block h-full rounded-full transition-[width] duration-500 ease-out motion-reduce:transition-none",
finished ? "bg-emerald-500" : "bg-blue-500 dark:bg-blue-400",
)}
style={{ width: `${Math.min(100, overall)}%` }}
/>
</span>
<div className="flex flex-wrap gap-x-3 gap-y-1">
{stages.map((stage, i) => (
<span
key={stage.name}
className={cn(
mono,
i < stageIndex
? "text-foreground/35"
: i === stageIndex
? "text-foreground/90"
: "text-foreground/20",
)}
>
{stage.name}
</span>
))}
</div>
</div>
);
}