AI

Task Queue

An agent work queue — live progress and the current step for running tasks, drag-to-reprioritise queued ones with a keyboard path, one-shot cancel and retry per row, a concurrency budget in the header, and four data states.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  closestCenter,
  DndContext,
  DragOverlay,
  KeyboardSensor,
  PointerSensor,
  useSensor,
  useSensors,
  type Announcements,
  type DragEndEvent,
  type DragStartEvent,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/task-queue.json

Prompt

Build a React + TypeScript + Tailwind "TaskQueue" component with zod,
lucide-react and dnd-kit (@dnd-kit/core + @dnd-kit/sortable + @dnd-kit/utilities).

Contract
- A zod schema (`taskQueueItemSchema` in a sibling contract file) is the single
  source of truth for ONE task: { id, title, state, progress?: number,
  step?: string, agent?: string, error?: string, etaLabel?: string,
  attempts?: number }.
- The task state union is FIVE values: "queued" | "running" | "done" | "failed"
  | "cancelled". `cancelled` is a first-class outcome, NOT a failure: a human
  pulled the task and nothing broke. Collapsing the two would make the history
  lie. `queued` is the only state whose ORDER carries meaning.
- `progress` is a fraction 0..1 (not 0..100) and is optional: work that cannot
  report a percentage must be able to say so instead of reporting a fake 0.
- `etaLabel` is a pre-formatted STRING ("~40s left"), never a timestamp: the
  component owns no clock, so server and client can never disagree and a
  screenshot never goes stale mid-render.
- A separate envelope status — "loading" | "empty" | "error" | "ready" — is the
  panel's own render state, independent of any task's state. `status="error"`
  means the QUEUE could not be loaded; a task failing is `state: "failed"`, and
  the two read and recover differently.
- Props: items: Task[]; status; concurrency? (slots in the worker pool);
  onReorder?(nextItems) — the FULL already-reordered array; onCancelTask?(id);
  onRetryTask?(id); onRetryLoad?(); emptyState?; errorMessage?; skeletonRows?
  (4, clamped to >= 1); label? ("Task queue"); className plus the rest of the
  element props, ref forwarded to the <section>.
- The component NEVER sorts or mutates `items`. It renders the order it is
  handed, so "where a retried task rejoins the queue" stays the app's decision.

Behavior
- ONLY QUEUED TASKS ARE DRAGGABLE, and a drag permutes the queued SLOTS. Build
  the sortable set from the queued ids alone; on drop, apply the permutation by
  pouring the reordered ids back into the positions the queued tasks already
  occupied. A running or finished row sitting between two queued ones therefore
  never moves, and an interleaved list reorders correctly instead of being
  silently re-sorted.
- Grips render only when `onReorder` is passed AND there is more than one queued
  task. No handler and no second task mean nothing to reorder — an affordance
  that reorders nothing is a lie.
- Keyboard is a first-class path: dnd-kit's KeyboardSensor with
  sortableKeyboardCoordinates (Space to pick up, arrows to move, Space to drop),
  and custom announcements ("Picked up X, queued position 2 of 5", "Moved X to
  queued position 4 of 5") pushed into dnd-kit's live region. Compute the
  announcement from the SAME pure resolver as the mutation so the two can never
  drift apart. Stay silent on the first onDragOver (the row is over its own
  slot) instead of overwriting the pick-up message.
- Edge case: the dragged task may leave the queued set mid-drag (a slot frees up
  and it starts running). The resolver then returns null and the drop is a
  no-op — never a reorder computed against a stale list.
- CANCEL / RETRY ARE ONE-SHOT AND OPTIMISTIC. Keep a Map<id, {kind, sinceState}>
  in BOTH a ref and state: the ref is read and written synchronously inside the
  click handler, because five clicks dispatched in one task all read the same
  stale state and a state-only guard would cancel the same job five times. The
  entry is keyed to the state it was fired FROM, so it expires by itself the
  moment the consumer flips the task's state — no effect, no timer, no reset.
  A genuine second request after a real transition (failed → running → failed)
  is a different key and goes through, so the lock never leaves a dead button.
  While the entry is live the row shows "Cancelling…" / "Requeueing…".
- Affordances follow the state machine: Cancel/Stop on queued and running rows
  only, Retry on failed and cancelled ones only, and each button renders only if
  its handler was passed. Buttons use 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 removing the row's only control from
  the tab order.
- Progress is determinate or honestly indeterminate: with `progress` the bar is
  clamped to 0..1 (a NaN or a 1.8 from a flaky worker can never overflow it) and
  the percentage is printed; without it the bar pulses, prints "—", and carries
  no aria-valuenow. A failed or cancelled task that carries progress keeps a
  frozen bar in its own tone — how far it got is evidence.
- The header states the concurrency budget: capacity pips plus "2/3 running · 5
  queued", with finished counts (done / failed / cancelled) on the trailing
  edge. If more tasks are running than there are slots, say "over capacity" in
  the destructive tone instead of clipping the extra — an over-subscribed pool
  is exactly what you opened the panel to find. Skip the pips above ~12 slots.
- A failure's text is never capped: no max-height, no line clamp, wrapped,
  monospace. `step` doubles as "Stopped at …" on a failed row, which is usually
  the first thing you need.
- A `ready` envelope carrying an empty array renders the EMPTY branch, not a
  headed panel with nothing under it.
- One persistent sr-only role="status" line, mounted for the whole ready branch,
  spells out the counts, so a task finishing or a slot freeing up is announced
  from an element the screen reader is already watching.

Rendering & styling
- Semantic tokens only: bg-card / border / text-muted-foreground for chrome,
  bg-primary for the running bar and the filled capacity pips,
  bg-muted-foreground/30 for empty pips, text-destructive with
  border-destructive/40 and bg-destructive/5 for failures, bg-muted/30 for
  finished rows. No hard-coded colours anywhere.
- Five states are five SHAPES plus a written word — dashed circle / spinner /
  check / alert / ban — so "cancelled" and "failed" stay distinguishable in a
  monochrome theme and for a colour-blind reader. The spinner and the
  indeterminate pulse stop under prefers-reduced-motion while the row keeps
  saying what it is doing in words; the sortable transition and the drop
  animation are disabled there too (read via useSyncExternalStore so the server
  snapshot is false and hydration stays clean).
- Long titles and unbroken URLs 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 stops a 170-character title from making the
  panel wider than the sidebar it sits in.
- Structure: <section aria-labelledby> → <header> with the heading and counts →
  sr-only status → <ol> of rows. The list is ordered because the queue is.
- Pass a stable `id` to DndContext (React.useId): dnd-kit's internal aria ids
  come from a global counter that drifts between server and client render.
- cn() merges the consumer's className into the root, which also spreads the
  remaining element props and forwards its ref.

Customization levers
- Scope of the queue: `concurrency` turns the header into a budget reading
  ("2/3 running") — omit it and it just counts. Raise the pip cut-off, or drop
  the pips entirely, for a pool with dozens of slots.
- Affordances: pass only the handlers you actually implement. `onReorder` alone
  gives a read-only-but-sortable board; no handlers at all gives a pure status
  panel with no grips and no buttons.
- Row density: the meta line (state word · position · agent · ETA · attempt) is
  the first thing to trim for a compact sidebar; drop `agent` and `etaLabel`
  from the data and it collapses on its own. The step line and the progress bar
  are the two blocks worth keeping.
- Ordering policy stays yours: the component renders `items` as given, so
  "requeue at the back", "always float failures to the top" or "hide finished
  work after 10 rows" are one filter/sort in your container.
- Copy: `label` names the panel, `emptyState` replaces the empty body,
  `errorMessage` + `onRetryLoad` own the envelope failure, and the button words
  ("Stop" for running, "Cancel" for queued) are the place to speak your domain.
- Motion: everything decorative is already motion-reduce-guarded; to go further,
  drop the pulse on the indeterminate bar and keep the word "Running".

Concepts

  • Only the queue has an order — running work has already started and finished work is history, so neither can be reprioritised. Dragging permutes the queued slots: the reordered ids are poured back into the positions the queued tasks already held, which is why a running row wedged between two queued ones never gets pushed around and an interleaved list survives a drop intact.
  • A lock that expires by itself — the cancel/retry guard is a ref read and written synchronously in the click handler (five clicks in one task all observe the same stale state), keyed to the state it fired from. When the consumer flips the task's state the key stops matching, so the optimistic "Cancelling…" and the lock lift together, and a genuine second attempt after a real transition still goes through.
  • Optimistic, not fake — the row says "Cancelling…" the instant you click, but nothing about the task changes until the data layer says so. The queue is fully controlled: it never mutates items, never sorts them, and never invents a state it wasn't given.
  • Indeterminate is a state, not a zero — a task that cannot report a fraction draws a pulsing bar and prints "—" instead of a 0 % that reads as "stuck". A failed task keeps its frozen bar because how far it got is evidence, and under reduced motion the pulse stops while the words carry on.
  • The concurrency budget is the headline — "2/3 running · 5 queued" answers "why is nothing happening" before you open a single row, and an over-subscribed pool is stated in words rather than clipped to fit the pips.
  • Cancelled is not failed — one is a decision, the other is a breakage, and they need different shapes, different tones and different affordances (retry offers to requeue either, but only the failure carries a trace).

On This Page