Compare several runs of the same task side by side: status, summary, a changed-lines bar, time and cost on each card, a suggested run, and a native radio group to pick one and continue.
import { ParallelAttempts, type Attempt } from "@/components/ballmac/parallel-attempts"
const attempts: Attempt[] = [
{ id: "a", label: "Attempt A", model: "fast-small", status: "done", summary: "Adds the retry in the invoice view and leaves the shared helper alone. Two tests need updating.", added: 84, removed: 12, files: 4, duration: "1 min 12 s", cost: "$0.08" },
{ id: "b", label: "Attempt B", model: "balanced", status: "done", summary: "Moves the retry into the shared helper, adds a backoff and covers it with three new tests.", added: 142, removed: 38, files: 7, duration: "2 min 40 s", cost: "$0.31" },
{ id: "c", label: "Attempt C", model: "deep-reasoner", status: "running", summary: "Reading the payment client to see where the failure surfaces before changing anything.", progress: 58, duration: "3 min 5 s so far", cost: "$0.44" },
{ id: "d", label: "Attempt D", model: "fast-small", status: "failed", summary: "Stopped after the type check failed twice on the same file.", added: 20, removed: 4, files: 2, duration: "0 min 48 s", cost: "$0.04" },
]
export default function ParallelAttemptsDemo() {
return (
<div className="w-full max-w-4xl">
<ParallelAttempts attempts={attempts} recommended="b" defaultValue="b" />
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/parallel-attemptsInstall the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/i18nCopy the source into your project.
components/ballmac/parallel-attempts.tsx// Ballmac UI: Parallel Attempts. https://ui.ballmac.com/components/parallel-attempts "use client" import * as React from "react" import { Check, CircleAlert, CircleDashed, CircleStop, Clock, Coins, FileDiff, Loader2, Star, type LucideIcon } from "lucide-react" import { cn } from "@/lib/utils" import { useMessages, type Msg } from "@/lib/ballmac/i18n" type AttemptStatus = "running" | "done" | "failed" | "stopped" type Attempt = { id: string /** Name of the attempt, for example "Attempt A". */ label: string /** The model or setup that produced it. */ model?: string status: AttemptStatus /** What this attempt did or produced, in a sentence or two. */ summary: string /** Lines added. */ added?: number /** Lines removed. */ removed?: number /** Files changed. */ files?: number /** Time taken, already formatted, such as "2 min 4 s". */ duration?: string /** Cost, already formatted, such as "$0.31". */ cost?: string /** 0 to 100 while it is running. */ progress?: number } const STATUS: Record<AttemptStatus, { icon: LucideIcon; fallback: string; tone: string }> = { running: { icon: Loader2, fallback: "Running", tone: "text-foreground" }, done: { icon: Check, fallback: "Done", tone: "text-foreground" }, failed: { icon: CircleAlert, fallback: "Failed", tone: "text-destructive" }, stopped: { icon: CircleStop, fallback: "Stopped", tone: "text-muted-foreground" }, } function statusText(msg: Msg, status: AttemptStatus) { return msg(`parallel-attempts.status.${status}`, STATUS[status].fallback) } const num = (n: number) => n.toLocaleString("en-US") type ParallelAttemptsProps = Omit<React.ComponentProps<"div">, "onChange" | "defaultValue"> & { /** The attempts to compare. */ attempts: Attempt[] /** The chosen attempt id. Controlled. */ value?: string | null /** The chosen attempt id for uncontrolled use. */ defaultValue?: string | null /** Called when a finished attempt is picked. */ onValueChange?: (id: string) => void /** Marks one attempt as the suggested one. */ recommended?: string /** Text for the suggestion badge. */ recommendedLabel?: string /** Called when the person confirms the picked attempt. Adds the confirm button. */ onChoose?: (id: string) => void /** Text for the confirm button, which gets the attempt's name added. */ chooseLabel?: string /** Heading read by assistive technology. */ label?: string } /** * Several runs of the same task side by side, to compare and pick one. Each card is a native radio, so the arrow keys, Space and * the browser's own group behaviour work without extra code. Only finished attempts can be picked. */ function ParallelAttempts({ attempts, value: valueProp, defaultValue = null, onValueChange, recommended, recommendedLabel, onChoose, chooseLabel, label, className, ...props }: ParallelAttemptsProps) { const msg = useMessages() const name = React.useId() label ??= msg("parallel-attempts.label", "Attempts") recommendedLabel ??= msg("parallel-attempts.recommended", "Suggested") const [inner, setInner] = React.useState<string | null>(defaultValue) const value = valueProp !== undefined ? valueProp : inner const picked = attempts.find((a) => a.id === value && a.status === "done") function pick(id: string) { if (valueProp === undefined) setInner(id) onValueChange?.(id) } return ( <div data-slot="parallel-attempts" className={cn("grid w-full gap-4", className)} {...props}> <fieldset className="min-w-0"> <legend className="sr-only">{label}</legend> <div className="grid gap-3 [grid-template-columns:repeat(auto-fit,minmax(15rem,1fr))]"> {attempts.map((a) => { const s = STATUS[a.status] const Icon = s.icon const selectable = a.status === "done" const total = (a.added ?? 0) + (a.removed ?? 0) const addedShare = total > 0 ? ((a.added ?? 0) / total) * 100 : 0 return ( <label key={a.id} data-status={a.status} className={cn( "group/attempt relative flex min-w-0 flex-col gap-3 rounded-xl border bg-card p-4 text-card-foreground transition-[border-color,background-color,box-shadow] duration-150 motion-reduce:transition-none", selectable ? "cursor-pointer hover:bg-accent/40" : "cursor-default opacity-90", "has-[:checked]:border-foreground has-[:checked]:bg-accent/40 has-[:focus-visible]:ring-[3px] has-[:focus-visible]:ring-ring/50" )} > <input type="radio" name={name} value={a.id} checked={value === a.id} disabled={!selectable} onChange={() => pick(a.id)} className="peer sr-only" aria-describedby={`${name}-${a.id}`} /> <div className="flex items-start justify-between gap-3"> <div className="min-w-0"> <p className="flex items-center gap-2 text-sm font-semibold text-foreground"> <span className="truncate">{a.label}</span> {recommended === a.id && ( <span className="inline-flex shrink-0 items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium"> <Star aria-hidden="true" className="size-3" /> {recommendedLabel} </span> )} </p> {a.model && <p className="truncate font-mono text-[11px] text-muted-foreground">{a.model}</p>} </div> <span className={cn("inline-flex shrink-0 items-center gap-1.5 text-xs font-medium", s.tone)}> <Icon aria-hidden="true" className={cn("size-3.5", a.status === "running" && "motion-safe:animate-spin")} style={a.status === "running" ? { animationPlayState: "var(--bm-ambient-state, running)" } : undefined} /> {statusText(msg, a.status)} </span> </div> <p id={`${name}-${a.id}`} className="line-clamp-3 text-[13px] leading-relaxed text-muted-foreground"> {a.summary} </p> {a.status === "running" && typeof a.progress === "number" && ( <div role="progressbar" aria-label={msg("parallel-attempts.progress", "{label} progress", { label: a.label })} aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(a.progress)} className="h-1.5 overflow-hidden rounded-full bg-muted"> <div className="h-full rounded-full bg-foreground transition-[width] duration-500 motion-reduce:transition-none" style={{ width: `${Math.min(100, Math.max(0, a.progress))}%` }} /> </div> )} {total > 0 && ( <div className="grid gap-1.5"> <div aria-hidden="true" className="flex h-1.5 overflow-hidden rounded-full bg-muted"> <span className="bg-chart-2" style={{ width: `${addedShare}%` }} /> <span className="bg-destructive/70" style={{ width: `${100 - addedShare}%` }} /> </div> <p className="flex flex-wrap items-center gap-x-3 gap-y-0.5 font-mono text-xs tabular-nums"> <span>{msg("parallel-attempts.added", "{count} added", { count: `+${num(a.added ?? 0)}` })}</span> <span>{msg("parallel-attempts.removed", "{count} removed", { count: `−${num(a.removed ?? 0)}` })}</span> {typeof a.files === "number" && ( <span className="inline-flex items-center gap-1 text-muted-foreground"> <FileDiff aria-hidden="true" className="size-3" /> {msg("parallel-attempts.files", "{count} files", { count: num(a.files) })} </span> )} </p> </div> )} {(a.duration || a.cost) && ( <p className="mt-auto flex flex-wrap items-center gap-x-4 gap-y-0.5 border-t pt-3 text-xs text-muted-foreground tabular-nums"> {a.duration && ( <span className="inline-flex items-center gap-1.5"> <Clock aria-hidden="true" className="size-3.5" /> {a.duration} </span> )} {a.cost && ( <span className="inline-flex items-center gap-1.5"> <Coins aria-hidden="true" className="size-3.5" /> {a.cost} </span> )} </p> )} {value === a.id && selectable && ( <span aria-hidden="true" className="absolute end-3 bottom-3 flex size-5 items-center justify-center rounded-full bg-foreground text-background"> <Check className="size-3" /> </span> )} {!selectable && a.status !== "running" && <CircleDashed aria-hidden="true" className="sr-only" />} </label> ) })} </div> </fieldset> {onChoose && ( <div className="flex flex-wrap items-center justify-between gap-3"> <p aria-live="polite" className="text-[13px] text-muted-foreground"> {picked ? msg("parallel-attempts.picked", "{label} is selected.", { label: picked.label }) : msg("parallel-attempts.hint", "Pick a finished attempt to continue with.")} </p> <button type="button" disabled={!picked} onClick={() => picked && onChoose(picked.id)} className="inline-flex h-9 items-center rounded-full bg-primary px-4 text-sm font-medium text-primary-foreground outline-none transition-colors hover:bg-primary/90 focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50" > {picked ? (chooseLabel ?? msg("parallel-attempts.choose", "Continue with {label}", { label: picked.label })).replace("{label}", picked.label) : (chooseLabel ?? msg("parallel-attempts.chooseNone", "Continue"))} </button> </div> )} </div> ) } export { ParallelAttempts, type Attempt, type AttemptStatus, type ParallelAttemptsProps }Update the import paths to match your project setup.
Usage
import { ParallelAttempts } from "@/components/ballmac/parallel-attempts"The full example is in the Code tab above.
Examples
Pick and continue
Pick a finished attempt to continue with.
"use client"
import * as React from "react"
import { ParallelAttempts, type Attempt } from "@/components/ballmac/parallel-attempts"
const attempts: Attempt[] = [
{ id: "one", label: "Version 1", model: "balanced", status: "done", summary: "Rewrites the onboarding copy in a warmer tone and keeps every step.", added: 36, removed: 29, files: 2, duration: "40 s", cost: "$0.05" },
{ id: "two", label: "Version 2", model: "balanced", status: "done", summary: "Shortens the flow from five steps to three and moves the team invite to the end.", added: 64, removed: 71, files: 3, duration: "52 s", cost: "$0.06" },
]
export default function ParallelAttemptsChoose() {
const [done, setDone] = React.useState<string | null>(null)
return (
<div className="grid w-full max-w-2xl gap-4">
<ParallelAttempts attempts={attempts} onChoose={setDone} />
{done && (
<p role="status" className="rounded-lg border bg-card px-4 py-3 text-sm">
Continuing with <strong>{attempts.find((a) => a.id === done)?.label}</strong>.
</p>
)}
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
attempts*The attempts to compare. | Attempt[] | — |
valueThe chosen attempt id. Controlled. | string | null | — |
defaultValueThe chosen attempt id for uncontrolled use. | string | null | null |
onValueChangeCalled when a finished attempt is picked. | (id: string) => void | — |
recommendedMarks one attempt as the suggested one. | string | — |
recommendedLabelText for the suggestion badge. | string | — |
onChooseCalled when the person confirms the picked attempt. Adds the confirm button. | (id: string) => void | — |
chooseLabelText for the confirm button, which gets the attempt's name added. | string | — |
labelHeading read by assistive technology. | string | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Tab | Moves into the group once; the arrow keys move between finished attempts |
| ArrowRight / ArrowDown | Picks the next finished attempt (native radio behaviour, mirrored in right-to-left by the browser) |
| Space | Picks the focused attempt |
| Running, failed or stopped | Shown with an icon and words, and not selectable |
| Screen readers | A fieldset with a hidden legend; each radio is described by its summary |
Use with AI
attempts is [{ id, label, model?, status: running | done | failed | stopped, summary, added?, removed?, files?, duration?, cost?, progress? }]. value / defaultValue / onValueChange pick a finished attempt; recommended marks one; onChoose adds a confirm button. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Parallel Attempts (@ballmac/parallel-attempts) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Best-of-n runs where a person chooses the result
- Comparing two or more models on the same task
Not for
- Comparing prompts in a table; use the agent-4 block
- A single run's steps; use trace or agent-lanes
Registry JSON: https://ui.ballmac.com/r/parallel-attempts.json
Credits
Free to use in personal and commercial projects.
- npm
- lucide-react
- Registry
- @ballmac/i18nshadcn/utils
Pairs well with
Agent Lanes
A swimlane timeline for multi-agent runs: one lane per agent, steps coloured by what they are doing, dashed handoffs between lanes, a Now line and a detail panel, all reachable with the arrow keys.
Run Ledger
What an agent run cost, step by step: sortable time, tokens and money with each row's share, a total, the most expensive step marked, and an optional budget meter that warns in words.
Diff Viewer
A line-by-line text diff with change counts, line numbers, and accessible change labels.
Approval Card
A human-in-the-loop confirmation for agent actions: what will happen, risk level in words, the exact command, Approve, Deny and Always allow, a timeout countdown and a recorded decision.
Ballmac UI Pro
Need the whole screen, not one piece?
150 premium blocks (dashboards, settings, calendars, mail, shops, onboarding), a private shadcn registry and Figma tokens. Blocks that show data come with loading, empty and error states built in.
Not ready? Try three Pro blocks free
Leave your email and the blocks appear right here, ready to copy. Yours to use in any project.