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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/task-queue.jsonPrompt
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).
Agent Plan
An agent's plan while it is still a draft — ordered steps with their rationale, risk chips on the dangerous ones, per-step include/exclude, inline rewriting, a live duration and cost estimate for the steps you kept, and a one-shot Approve or Request changes.
Agent Status
A compact agent state chip — a dot and a label morphing through idle, thinking, tool use, waiting-on-you and failure, with a drift-free phase clock and one polite announcement per phase.