AI

Batch Jobs

A batch inference jobs table — middle-truncated copyable job ids, a stacked done/failed/pending bar per job, spend so far, age and ETA derived from an injected clock, an expandable failed-request preview, a one-shot cancel, partial-result downloads, and four data states.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  AlertCircle,
  Ban,
  Check,
  ChevronRight,
  CircleAlert,
  CircleCheck,
  CircleDashed,
  CircleX,
  Copy,
  CopyX,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/batch-jobs.json

Prompt

Build a React + TypeScript + Tailwind "BatchJobs" component with zod and
lucide-react, plus a small copy-to-clipboard hook (any implementation that
returns { copied, copy, error } and clears its own reset timer on unmount).

Contract
- A zod schema (`batchJobsItemSchema` in a sibling contract file) is the single
  source of truth for ONE job: { id, status, total, done, failed, costUsd,
  createdAt, eta?, model?, endpoint?, error?, failures?: { customId, code?,
  httpStatus?, message }[] }.
- The job status union is SEVEN values, not three: "validating" | "running" |
  "cancelling" | "completed" | "cancelled" | "failed" | "expired". The three most
  tables drop are the three that decide what the reader does next:
  `cancelling` is not `cancelled` (the stop was accepted, in-flight requests are
  still landing, and the cancel affordance must already be gone); `expired` is not
  `failed` (the completion window elapsed, nothing broke, and the partial output
  is still downloadable and still billed); `cancelled` and `expired` therefore
  both carry PARTIAL RESULTS, and collapsing them into "failed" tells people to
  re-run work they already paid for.
- `failures` is a SAMPLE of failed requests, never the error file. `failed` is the
  true count, and the panel always says "Showing 3 of 118".
- `costUsd` is money spent SO FAR, not a forecast — it keeps climbing on a running
  job and survives on a cancelled one.
- `createdAt` / `eta` are INSTANTS (ISO string, epoch ms or Date), never
  pre-baked "2h ago" strings, so the wording is recomputed against the clock you
  inject rather than going stale mid-render.
- A separate envelope status — "loading" | "empty" | "error" | "ready" — is the
  table's own render state, independent of any job's status. `status="error"`
  means the batch API did not answer; a JOB failing is `job.status === "failed"`,
  and the two read differently on screen.
- Props: items: Job[]; status; now? (injected clock); defaultOpenIds?;
  onCancel?(jobId); onDownload?(jobId); onRetry?; failureSampleLimit? (3);
  idHeadChars? (14); idTailChars? (6); formatCost?; formatDuration?;
  formatAbsolute?; emptyState?; errorMessage?; showSummary? (true); label?
  ("Batch jobs"); className plus the rest of the element props, ref forwarded to
  the <section>.
- Every numeric prop is clamped (failureSampleLimit >= 1, idHeadChars >= 4,
  idTailChars >= 3) so a 0 or a NaN can never render an empty panel or an id
  that is only an ellipsis.

Behavior
- THE DENOMINATOR IS DERIVED, NOT TRUSTED. Per row compute done/failed as
  clamped non-negative integers (NaN, -1 and 3.7 all become >= 0), then
  total = max(declaredTotal, done + failed) and pending = total - done - failed.
  A provider that reports 2,807 processed requests for a 2,800-request file
  (retries counted twice) widens the denominator instead of painting a bar
  segment past the end of its track — an overflowing bar is a rendering bug the
  reader blames on their own data. total === 0 must not divide by zero. A widened
  denominator then EXPLAINS itself, in the figures ("of 2,807 (2,800 submitted)")
  and in the bar's aria-valuetext: a total nobody submitted, shown without a word,
  reads as a bug in the table.
- CANCEL IS ONE-SHOT. The handler fires at most once per job per status no matter
  how fast the button is clicked, because the guard is a ref keyed `id -> status`
  that is read and written synchronously inside the click handler: several clicks
  dispatched in one task all read the same stale state, so a state-only guard
  would let the extras through. The pending marker is keyed to the status it
  fired FROM, which makes it expire by itself — the moment the consumer moves the
  job on (running -> cancelling), the marker stops matching, the spinner lifts and
  a genuinely new request is answerable again. No effect, no timer, no reset
  callback. Document the trade this buys: if YOUR cancel call fails and you leave
  the job in the same status, the button stays locked — re-arm it by moving the
  job to a distinct status or by remounting the row, because "the reader already
  asked for this exact thing" is the state the lock is modelling.
- The cancel button uses aria-disabled, never the native `disabled` attribute:
  `disabled` blurs the button the instant it flips, dropping focus to <body> in
  the middle of the action, and removes it from the tab order.
- ACTIONS ARE DERIVED FROM STATUS, never toggled by the click. Cancel is offered
  only for "validating" / "running" and only when onCancel is passed; a
  "cancelling" row shows a non-interactive "Stopping" marker instead. Download is
  offered for any terminal status with done > 0 — labelled "Partial" for
  cancelled / expired / failed, because a partial file is exactly what those
  states leave behind — and is hidden entirely when nothing finished. A button
  that calls nothing is worse than no button.
- The failure panel is a real disclosure: a <button aria-expanded> per row whose
  panel row is UNMOUNTED when closed (a zero-height collapse leaves the panel's
  controls reachable by Tab — an invisible keyboard trap), with aria-controls set
  only while open so it never points at an id that is not in the document. The
  chevron exists only when there is something to open (failed > 0, a sample, or a
  job-level error); other rows render an equal-width spacer so the ids stay
  aligned. Nothing auto-expands — a table that opens every failing row is a table
  you must collapse before you can scan it — but `defaultOpenIds` pins chosen rows
  open on mount and is deliberately read ONCE, so a poll that re-sends the same
  list cannot slam a panel shut while it is being read.
- Truncation is always announced. The panel draws failureSampleLimit samples and
  then states "Showing 3 of 118 failed requests — the error file has the rest";
  when failed > 0 but no samples came with the job it says so instead of
  rendering an empty panel. Per-request messages and the job-level error are the
  one thing never capped: no max-height, no line clamp, wrapped.
- THE TABLE OWNS NO CLOCK. With `now` injected, createdAt renders as an age
  ("3h 12m ago") and eta as a remaining time ("ETA 22m"), both recomputed from the
  instants every render and never accumulated, so a paused tab, a re-render or a
  replayed poll cannot drift. Without `now`, both fall back to absolute UTC
  instants — formatted with an explicit timeZone, because formatting in the
  ambient zone renders one string on the server and another in the browser. An
  ETA already in the past reads "ETA any moment", never a negative or a fake
  "0s"; a terminal job's ETA is dropped entirely; an unparseable instant collapses
  to "—" once, so no downstream branch has to think about NaN.
- Job ids are MIDDLE-truncated in JS, never with a CSS ellipsis: provider ids
  share a long prefix and differ in the tail, so cutting the end turns every row
  into the same string. The id itself is the copy button — the accessible name
  carries the value in full, the click writes the full value, and a blocked
  clipboard flips to a destructive icon instead of failing silently.
- The table never sorts, filters or paginates: it renders `items` in the order you
  hand it over, and `status="ready"` with an empty array renders the empty branch
  rather than a headed table with no rows.
- The horizontal scroll container becomes a labelled, tabbable region ONLY while
  it is actually overflowing, measured through a ResizeObserver (subscribed via
  useSyncExternalStore, so there is no effect-then-setState round trip) that
  watches both the container and the table and is disconnected on unmount. A
  scroll box a keyboard cannot reach is a WCAG failure; a permanent tab stop on a
  table that fits is noise.

Rendering & styling
- Semantic tokens only: bg-card / border / text-muted-foreground for chrome,
  bg-primary for the done segment and the Download button (with
  text-primary-foreground), bg-destructive for the failed segment,
  bg-destructive/5 + border-destructive/40 + text-destructive for failure blocks,
  bg-muted for the bar track and skeletons, bg-muted/30 for the open panel row.
  No hard-coded colours anywhere; the only inline styles are the two bar widths.
- Seven statuses are seven SHAPES plus a written word — dashed circle, spinner,
  ban, check, x-circle, alert, hourglass — so they survive a monochrome theme and
  a colour-blind reader, which is exactly where cancelled / failed / expired would
  otherwise blur together.
- The stacked bar is one track: done and failed are drawn as widths over a muted
  remainder. It is a role="progressbar" named by an aria-label ("Requests" — a
  progressbar with no accessible name is announced as a bare "progress bar")
  whose aria-valuetext carries the prose ("1,842 of 2,800 requests done, 12
  failed, 946 pending"), while the visible figures next to it are aria-hidden —
  "1,842 · 12 · 2,800" is a riddle when read aloud.
- Markup is a real <table> with a scoped <thead>, an sr-only <caption> repeating
  the aggregate line, and one <tbody> row per job plus an optional detail row that
  spans every column. The root is a <section aria-label> holding ONE persistent
  sr-only role="status" line, so a copy or a cancel is announced from an element
  the screen reader is already watching.
- Long ids and long provider messages use `wrap-anywhere` (overflow-wrap:
  anywhere) plus `min-w-0`, NOT `break-words`: only the former lowers the
  element's min-content width, which is what actually stops a 64-character id
  from widening the whole table.
- Every animation is decorative and guarded: the running spinner, the cancelling
  pulse, the skeleton shimmer and the bar's width transition all stop under
  prefers-reduced-motion while the words keep saying what is happening.
- cn() merges the consumer's className into the root, which also spreads the
  remaining element props and forwards its ref.

Customization levers
- Columns: `showSummary` drops the aggregate header line; deleting the Cost or
  the Created cell (with its <th>) is a two-line change — keep the colSpan of the
  detail row in step with the column count.
- Units: `formatCost` swaps the currency or the precision (a credits balance, a
  per-1M-token figure); the default spells sub-cent spend as "<$0.01" instead of
  the free-looking "$0.00". `formatDuration` swaps the age/ETA wording (the
  default is deliberately coarse — batches live for hours, not milliseconds), and
  `formatAbsolute` owns the no-clock fallback.
- Live time: pass a `now` you tick once per second in your app — one interval for
  the whole page instead of one per row.
- Density: `idHeadChars` / `idTailChars` decide how much of an id survives
  truncation; `failureSampleLimit` decides how much of the error file is previewed
  before the "of N" note. Raise them in an ops console, lower them in a customer
  dashboard — but never make either silent.
- Disclosure: `defaultOpenIds` pins rows open on first paint (the failing one you
  linked someone to). Wire `onCancel` / `onDownload` to your API — omit either and
  the matching affordance disappears rather than becoming decorative.
- Chrome: `label` names the region and titles the header, `emptyState` replaces
  the empty body, `errorMessage` + `onRetry` own the envelope failure, and
  `model` / `endpoint` are your own per-row scan aids.

Concepts

  • Partial results are the whole point — a cancelled or expired batch is not a failed one: it left a real output file behind and it was really billed. The row's action is derived from done > 0 rather than from "did it say completed", so the work you already paid for stays one click away instead of being re-run.
  • A self-expiring one-shot lock — the cancel guard is a ref read and written synchronously inside the click handler, keyed to the status it fired from. Several clicks dispatched in one task all observe the same stale state, so a state-only guard would send several cancels; keying it to the status means the lock lifts by itself the moment the consumer moves the job on, instead of leaving a dead button behind.
  • The denominator is derived, not trusted — counts arrive from a worker that retries, double-counts and occasionally sends NaN. Clamping each count and taking max(total, done + failed) means the bar can never overflow its track, and a zero-request job can never divide by zero; the widened total then names the figure it grew from, so the reader sees odd numbers that explain themselves rather than a broken widget.
  • Truncation is announced, in both directions — a failure preview says "Showing 3 of 118" so a sample is never mistaken for the total, and a job id is truncated in the MIDDLE because provider ids share a prefix and differ in the tail. A CSS ellipsis would render every row as the same string.
  • No clock inside the table — ages and ETAs are recomputed from createdAt / eta against an injected now, never incremented by a timer the component owns. A paused tab, a re-render or a replayed poll therefore cannot drift, the server and the browser agree, and dropping now degrades to absolute instants rather than to a lie.
  • The scroll region earns its tab stop — a horizontally scrollable box that a keyboard cannot reach is a WCAG failure, so the region role and the tab stop appear only while the table actually overflows, measured by a ResizeObserver subscribed as an external store and disconnected on unmount.

On This Page