{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "parallel-attempts",
  "title": "Parallel Attempts",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "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.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/parallel-attempts.tsx",
      "content": "// Ballmac UI: Parallel Attempts. https://ui.ballmac.com/components/parallel-attempts\n\"use client\"\n\nimport * as React from \"react\"\nimport { Check, CircleAlert, CircleDashed, CircleStop, Clock, Coins, FileDiff, Loader2, Star, type LucideIcon } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useMessages, type Msg } from \"@/lib/ballmac/i18n\"\n\ntype AttemptStatus = \"running\" | \"done\" | \"failed\" | \"stopped\"\n\ntype Attempt = {\n  id: string\n  /** Name of the attempt, for example \"Attempt A\". */\n  label: string\n  /** The model or setup that produced it. */\n  model?: string\n  status: AttemptStatus\n  /** What this attempt did or produced, in a sentence or two. */\n  summary: string\n  /** Lines added. */\n  added?: number\n  /** Lines removed. */\n  removed?: number\n  /** Files changed. */\n  files?: number\n  /** Time taken, already formatted, such as \"2 min 4 s\". */\n  duration?: string\n  /** Cost, already formatted, such as \"$0.31\". */\n  cost?: string\n  /** 0 to 100 while it is running. */\n  progress?: number\n}\n\nconst STATUS: Record<AttemptStatus, { icon: LucideIcon; fallback: string; tone: string }> = {\n  running: { icon: Loader2, fallback: \"Running\", tone: \"text-foreground\" },\n  done: { icon: Check, fallback: \"Done\", tone: \"text-foreground\" },\n  failed: { icon: CircleAlert, fallback: \"Failed\", tone: \"text-destructive\" },\n  stopped: { icon: CircleStop, fallback: \"Stopped\", tone: \"text-muted-foreground\" },\n}\n\nfunction statusText(msg: Msg, status: AttemptStatus) {\n  return msg(`parallel-attempts.status.${status}`, STATUS[status].fallback)\n}\n\nconst num = (n: number) => n.toLocaleString(\"en-US\")\n\ntype ParallelAttemptsProps = Omit<React.ComponentProps<\"div\">, \"onChange\" | \"defaultValue\"> & {\n  /** The attempts to compare. */\n  attempts: Attempt[]\n  /** The chosen attempt id. Controlled. */\n  value?: string | null\n  /** The chosen attempt id for uncontrolled use. */\n  defaultValue?: string | null\n  /** Called when a finished attempt is picked. */\n  onValueChange?: (id: string) => void\n  /** Marks one attempt as the suggested one. */\n  recommended?: string\n  /** Text for the suggestion badge. */\n  recommendedLabel?: string\n  /** Called when the person confirms the picked attempt. Adds the confirm button. */\n  onChoose?: (id: string) => void\n  /** Text for the confirm button, which gets the attempt's name added. */\n  chooseLabel?: string\n  /** Heading read by assistive technology. */\n  label?: string\n}\n\n/**\n * 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\n * the browser's own group behaviour work without extra code. Only finished attempts can be picked.\n */\nfunction ParallelAttempts({\n  attempts,\n  value: valueProp,\n  defaultValue = null,\n  onValueChange,\n  recommended,\n  recommendedLabel,\n  onChoose,\n  chooseLabel,\n  label,\n  className,\n  ...props\n}: ParallelAttemptsProps) {\n  const msg = useMessages()\n  const name = React.useId()\n  label ??= msg(\"parallel-attempts.label\", \"Attempts\")\n  recommendedLabel ??= msg(\"parallel-attempts.recommended\", \"Suggested\")\n  const [inner, setInner] = React.useState<string | null>(defaultValue)\n  const value = valueProp !== undefined ? valueProp : inner\n  const picked = attempts.find((a) => a.id === value && a.status === \"done\")\n\n  function pick(id: string) {\n    if (valueProp === undefined) setInner(id)\n    onValueChange?.(id)\n  }\n\n  return (\n    <div data-slot=\"parallel-attempts\" className={cn(\"grid w-full gap-4\", className)} {...props}>\n      <fieldset className=\"min-w-0\">\n        <legend className=\"sr-only\">{label}</legend>\n        <div className=\"grid gap-3 [grid-template-columns:repeat(auto-fit,minmax(15rem,1fr))]\">\n          {attempts.map((a) => {\n            const s = STATUS[a.status]\n            const Icon = s.icon\n            const selectable = a.status === \"done\"\n            const total = (a.added ?? 0) + (a.removed ?? 0)\n            const addedShare = total > 0 ? ((a.added ?? 0) / total) * 100 : 0\n            return (\n              <label\n                key={a.id}\n                data-status={a.status}\n                className={cn(\n                  \"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\",\n                  selectable ? \"cursor-pointer hover:bg-accent/40\" : \"cursor-default opacity-90\",\n                  \"has-[:checked]:border-foreground has-[:checked]:bg-accent/40 has-[:focus-visible]:ring-[3px] has-[:focus-visible]:ring-ring/50\"\n                )}\n              >\n                <input\n                  type=\"radio\"\n                  name={name}\n                  value={a.id}\n                  checked={value === a.id}\n                  disabled={!selectable}\n                  onChange={() => pick(a.id)}\n                  className=\"peer sr-only\"\n                  aria-describedby={`${name}-${a.id}`}\n                />\n                <div className=\"flex items-start justify-between gap-3\">\n                  <div className=\"min-w-0\">\n                    <p className=\"flex items-center gap-2 text-sm font-semibold text-foreground\">\n                      <span className=\"truncate\">{a.label}</span>\n                      {recommended === a.id && (\n                        <span className=\"inline-flex shrink-0 items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium\">\n                          <Star aria-hidden=\"true\" className=\"size-3\" /> {recommendedLabel}\n                        </span>\n                      )}\n                    </p>\n                    {a.model && <p className=\"truncate font-mono text-[11px] text-muted-foreground\">{a.model}</p>}\n                  </div>\n                  <span className={cn(\"inline-flex shrink-0 items-center gap-1.5 text-xs font-medium\", s.tone)}>\n                    <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} />\n                    {statusText(msg, a.status)}\n                  </span>\n                </div>\n\n                <p id={`${name}-${a.id}`} className=\"line-clamp-3 text-[13px] leading-relaxed text-muted-foreground\">\n                  {a.summary}\n                </p>\n\n                {a.status === \"running\" && typeof a.progress === \"number\" && (\n                  <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\">\n                    <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))}%` }} />\n                  </div>\n                )}\n\n                {total > 0 && (\n                  <div className=\"grid gap-1.5\">\n                    <div aria-hidden=\"true\" className=\"flex h-1.5 overflow-hidden rounded-full bg-muted\">\n                      <span className=\"bg-chart-2\" style={{ width: `${addedShare}%` }} />\n                      <span className=\"bg-destructive/70\" style={{ width: `${100 - addedShare}%` }} />\n                    </div>\n                    <p className=\"flex flex-wrap items-center gap-x-3 gap-y-0.5 font-mono text-xs tabular-nums\">\n                      <span>{msg(\"parallel-attempts.added\", \"{count} added\", { count: `+${num(a.added ?? 0)}` })}</span>\n                      <span>{msg(\"parallel-attempts.removed\", \"{count} removed\", { count: `−${num(a.removed ?? 0)}` })}</span>\n                      {typeof a.files === \"number\" && (\n                        <span className=\"inline-flex items-center gap-1 text-muted-foreground\">\n                          <FileDiff aria-hidden=\"true\" className=\"size-3\" />\n                          {msg(\"parallel-attempts.files\", \"{count} files\", { count: num(a.files) })}\n                        </span>\n                      )}\n                    </p>\n                  </div>\n                )}\n\n                {(a.duration || a.cost) && (\n                  <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\">\n                    {a.duration && (\n                      <span className=\"inline-flex items-center gap-1.5\">\n                        <Clock aria-hidden=\"true\" className=\"size-3.5\" />\n                        {a.duration}\n                      </span>\n                    )}\n                    {a.cost && (\n                      <span className=\"inline-flex items-center gap-1.5\">\n                        <Coins aria-hidden=\"true\" className=\"size-3.5\" />\n                        {a.cost}\n                      </span>\n                    )}\n                  </p>\n                )}\n\n                {value === a.id && selectable && (\n                  <span aria-hidden=\"true\" className=\"absolute end-3 bottom-3 flex size-5 items-center justify-center rounded-full bg-foreground text-background\">\n                    <Check className=\"size-3\" />\n                  </span>\n                )}\n                {!selectable && a.status !== \"running\" && <CircleDashed aria-hidden=\"true\" className=\"sr-only\" />}\n              </label>\n            )\n          })}\n        </div>\n      </fieldset>\n\n      {onChoose && (\n        <div className=\"flex flex-wrap items-center justify-between gap-3\">\n          <p aria-live=\"polite\" className=\"text-[13px] text-muted-foreground\">\n            {picked ? msg(\"parallel-attempts.picked\", \"{label} is selected.\", { label: picked.label }) : msg(\"parallel-attempts.hint\", \"Pick a finished attempt to continue with.\")}\n          </p>\n          <button\n            type=\"button\"\n            disabled={!picked}\n            onClick={() => picked && onChoose(picked.id)}\n            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\"\n          >\n            {picked ? (chooseLabel ?? msg(\"parallel-attempts.choose\", \"Continue with {label}\", { label: picked.label })).replace(\"{label}\", picked.label) : (chooseLabel ?? msg(\"parallel-attempts.chooseNone\", \"Continue\"))}\n          </button>\n        </div>\n      )}\n    </div>\n  )\n}\n\nexport { ParallelAttempts, type Attempt, type AttemptStatus, type ParallelAttemptsProps }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/parallel-attempts.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "ai",
      "agents",
      "compare",
      "attempts",
      "best-of-n",
      "diff",
      "choose"
    ],
    "version": "1.0.0",
    "updated": "2026-10-24",
    "tests": true,
    "ai": {
      "summary": "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.",
      "whenToUse": [
        "Best-of-n runs where a person chooses the result",
        "Comparing two or more models on the same task"
      ],
      "whenNotToUse": [
        "Comparing prompts in a table; use the agent-4 block",
        "A single run's steps; use trace or agent-lanes"
      ],
      "composesWith": [
        "agent-lanes",
        "run-ledger",
        "diff-viewer",
        "approval-card"
      ],
      "a11y": [
        {
          "keys": "Tab",
          "action": "Moves into the group once; the arrow keys move between finished attempts"
        },
        {
          "keys": "ArrowRight / ArrowDown",
          "action": "Picks the next finished attempt (native radio behaviour, mirrored in right-to-left by the browser)"
        },
        {
          "keys": "Space",
          "action": "Picks the focused attempt"
        },
        {
          "keys": "Running, failed or stopped",
          "action": "Shown with an icon and words, and not selectable"
        },
        {
          "keys": "Screen readers",
          "action": "A fieldset with a hidden legend; each radio is described by its summary"
        }
      ],
      "customization": [
        "recommended and recommendedLabel",
        "onChoose and chooseLabel",
        "Cards fill the width in columns of at least 15rem"
      ]
    },
    "statesNone": "A presentational comparison: you pass in the attempts and their statuses, so it has nothing of its own to load or submit.",
    "examples": [
      "parallel-attempts-demo",
      "parallel-attempts-choose"
    ],
    "url": "https://ui.ballmac.com/components/parallel-attempts"
  },
  "categories": [
    "ai"
  ],
  "type": "registry:ui"
}