AI

Labeling Queue

A one-item-at-a-time human labeling queue — number-key label buttons, skip and flag escapes, a resumable "34 / 200" progress bar, undo for the last call, an all-done screen, and a one-shot lock per item so a double press can never double-label.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { CircleAlert, CircleCheckBig, Flag, Inbox, ListChecks, RefreshCcw, SkipForward, Undo2 } from "lucide-react"

import { cn } from "@/lib/utils"
import type {
  LabelingQueueDecision,
  LabelingQueueDecisionKind,
  LabelingQueueItem,
  LabelingQueueLabel,
  LabelingQueueStatus,
  LabelingQueueSummary,
  LabelingQueueTone,

Installation

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

Prompt

Build a React + TypeScript + Tailwind "LabelingQueue" component with zod and
lucide-react. It shows ONE item at a time out of a batch and turns a human's
judgement into a stream of decisions.

Contract
- A zod schema in a sibling contract file is the single source of truth.
  Item:      { id, content, context?: string[], source?, label? }
  Label:     { id, name, hint?, tone?: "neutral" | "positive" | "negative" }
  Decision:  { itemId, kind: "label" | "skip" | "flag", labelId?, via:
               "pointer" | "keyboard" }   // labelId present iff kind === "label"
  Summary:   { total, labeled, skipped, flagged, decided, remaining }
  Envelope:  status: "loading" | "empty" | "error" | "ready"
- THREE outcomes, not one: `label` is the answer, `skip` is "not me / not now",
  `flag` is "this row is broken, do not train on it". A queue that only offers
  labels forces a guess, and a guessed label is the one thing a labelled dataset
  cannot recover from later.
- `item.label` is the RESUME channel: an item that arrives carrying a label id is
  already decided, which is what makes a fresh mount read "34 / 200" instead of
  "0 / 200". Those items are never re-asked and never enter the session undo
  stack, so undo cannot roll back last week's work by someone else.
- Label ORDER is meaningful: position 1..9 binds the digit shortcut. Labels past
  the ninth render without a digit and stay clickable.
- Props: status; items; labels; heading? ("Labeling queue", used as the visible
  title and the region's accessible name); onDecide?(decision, item);
  onUndo?(decision, item | undefined); onComplete?(summary); onRetry?;
  shortcutScope? ("card" | "page" | "off", default "card"); skipKey? ("s");
  flagKey? ("f"); undoKey? ("u"); allowSkip?/allowFlag?/allowUndo? (true);
  density? ("comfortable" | "compact"); contentPreviewChars? (700, clamped to
  >= 80, Infinity means never cap); errorMessage?; emptyState?; doneState?;
  className plus the rest of the element props, ref forwarded to the <section>.

Behavior
- THE CURSOR IS DERIVED, NEVER STORED: the current item is "the first item with
  no decision". An index in state would point at the wrong row the moment the
  parent appends a page, re-orders the batch, or an undo re-opens an earlier
  item — and it would silently drift out of sync with the counters.
- ONE-SHOT PER ITEM. The commit path first checks a ref holding the id of the
  item it last accepted; a second press on the same item is dropped, so
  `onDecide` fires at most once per item however fast the click. The lock must
  live in a ref, not in state: five clicks dispatched inside a single task all
  read the same stale state, and because the queue advances on every decision
  those four extra events would land on the NEXT four items — the worst possible
  failure, because the data looks labelled. The lock expires by itself (the next
  item has a different id), and undo explicitly RELEASES it, otherwise the item
  it just restored could never be decided again. The deliberate trade-off: a
  press that lands before the re-render is DROPPED rather than applied to the
  next row — a lost keystroke is recoverable, a mislabelled row is not.
- AUTO-REPEAT IS NOT A DECISION: ignore any key event with `repeat === true`.
  Without it, a finger resting on "1" labels the whole queue in under a second.
  Also ignore events with alt/ctrl/meta held (never shadow ⌘Z or Ctrl+F) and any
  event whose target sits inside an input, textarea, select or contenteditable.
- Key precedence is explicit: undo, then skip, then flag, then digits — so a
  consumer who binds skipKey="1" gets skip, not a label. Handled keys call
  preventDefault ONLY when they actually did something.
- `shortcutScope="card"` handles keys that bubble out of the card (armed as soon
  as focus is anywhere inside it — which it is, right after any click), while
  "page" attaches a single document listener for a full-screen labelling tool and
  removes it on unmount / on scope change. Both scopes run the SAME handler,
  read through a ref so it never goes stale and the listener is not re-subscribed
  on every render. "off" leaves the buttons as the only path.
- Undo pops the last SESSION decision: drop it from the map, pop the history
  stack, release the one-shot lock, call onUndo. The item becomes current again
  because the cursor is derived. If that row has left `items` in the meantime,
  the decision is still dropped (the counters must not lie) and onUndo receives
  `undefined` for the item.
- onComplete fires ONCE when the last undecided item is decided, from an effect
  guarded by a ref, with the callback itself read through a second ref so an
  inline arrow in the parent cannot re-fire it. It re-arms only if an undo
  re-opens the queue.
- The all-done screen is a state of `ready`, not `empty`: "you cleared 200 rows"
  and "nothing was assigned to you" are different sentences. When the last item
  goes, the label row unmounts and focus would fall to <body>; the done panel is
  focused instead — but only when THIS session emptied the queue and only if
  nothing else took focus in the same tick, so a batch that arrives already
  cleared never steals focus (or scroll) on mount.
- Long content is capped with an ANNOUNCED number ("Showing 400 of 2,183
  characters") plus a Show all toggle, and the disclosure resets when the item
  changes (adjust state during render, no effect) so item 2 is never read through
  item 1's expansion.
- labels=[] is a degraded state, not a crash: say the taxonomy is missing and
  keep skip and flag alive. A labeller who can still flag a broken row is doing
  useful work.
- Counters are derived from items + session on every render — never incremented —
  so a dropped callback, a re-mount or a replayed batch can't desync the bar from
  the list.

Rendering & styling
- Semantic tokens only: bg-card / border / text-muted-foreground for chrome,
  bg-primary for the progress fill, bg-primary/5 + border-primary/40 for a
  positive label, bg-destructive/5 + text-destructive for a negative one and for
  Flag, bg-muted for skeleton blocks. No hard-coded colours anywhere.
- Tone is a MEANING, not decoration: only taxonomies whose extremes carry a
  judgement (safe vs unsafe) get colour, and the word always stays the primary
  signal — the tint is a redundant second channel, never the only one.
- Root <section aria-label={heading}> holds ONE persistent sr-only role="status"
  that reads the last action plus the new position and the first ~160 characters
  of the new item ("Item 34 labeled as Billing. Now on item 35 of 200: …"), so a
  screen-reader user hears what changed without hunting for it.
- The progress bar is a real role="progressbar" with aria-valuemin/max/now and an
  aria-valuetext in words. Each label button carries aria-keyshortcuts and shows
  its digit in a <kbd>. The undo control uses aria-disabled, never the native
  `disabled` attribute, which would blur the button the instant history empties
  and drop focus to <body> right after an undo.
- The item panel is keyed by item id so its enter animation replays per item; the
  label row is deliberately NOT keyed, so focus survives the queue advancing
  under your fingers. Every animation and transition is motion-reduce-guarded.
- Long ids and long content use `wrap-anywhere` + `min-w-0` (not `break-words`):
  only overflow-wrap:anywhere lowers the element's min-content width, which is
  what actually stops a 90-character id from widening the card.
- cn() merges the consumer's className into the root, which spreads the remaining
  element props and forwards its ref; the consumer's onKeyDown is composed with
  the shortcut handler rather than replaced.

Customization levers
- Taxonomy: `labels` is data — swap five intent buckets for three judgement
  buckets and the hotkeys re-bind by position. Give `tone` only to choices that
  mean something; leave a flat taxonomy monochrome.
- Density: `density="compact"` drops the per-label hints for an experienced
  labeller doing 200 an hour; "comfortable" keeps the guideline under each name
  for a new one.
- Which escapes exist: `allowSkip` / `allowFlag` / `allowUndo` remove the actions
  you don't want to model. Turning undo off hides the control, not the history.
- Keys: `skipKey` / `flagKey` / `undoKey` are single strings; `shortcutScope`
  decides whether they arm with focus in the card, for the whole page, or never.
- Reading budget: `contentPreviewChars` sets how much of an item is drawn before
  the counted reveal (Infinity for short rows that should never be cut).
- Slots: `emptyState` for "nothing assigned", `doneState` for the end-of-shift
  screen (drop a "load the next batch" button in it), `errorMessage` + `onRetry`
  for the envelope failure, `heading` for the shift name.
- Persistence is yours: `onDecide` is the write path (POST it, queue it offline),
  and the batch you feed back in — with `item.label` filled — is what resumes the
  progress bar.

Concepts

  • Derived cursor — "the current item" is not a stored index, it is the first item with no decision. Append a page, re-order the batch or undo three calls and the cursor lands where it should, because there is no second copy of the truth to drift: the counters, the bar and the card all read the same derivation.
  • One-shot lock per item — the guard is a ref read and written synchronously inside the handler. Five clicks dispatched in one task all see the same stale state, and in a queue that advances on every decision a state-only guard wouldn't just double-label one row — the extra events would land on the next rows, which is worse, because the result looks like real work.
  • Auto-repeat is not a decision — a held-down "1" arrives as a stream of key events with repeat: true. Ignoring them is the difference between a hotkey and a shredder; the same handler also steps aside for modifier chords and for anything typed inside a text field.
  • Resume channel vs session history — labels that arrive on the items are somebody's finished work: counted in "34 / 200", never re-asked, never undoable. Only decisions made in this session go on the undo stack, so U can't reach back into last week.
  • Skip and flag are outcomes, not escape hatches — "not me / not now" and "this row is broken" are answers a dataset needs. Force a label and you get a guess, and a guessed label is indistinguishable from a real one once it is in the training set.
  • Announced caps and rescued focus — a long ticket says exactly how much of it is on screen and resets its disclosure with the next item; when the last item leaves, the buttons unmount and focus would fall to <body>, so the done panel takes it — but only when this session emptied the queue, never on a batch that arrives already cleared.

On This Page