Dataset Browser
A prompt/completion dataset row by row — one-line pair previews, quality-flag chips with the rule behind each one, the full pair one click away, OR'd flag filters, and a selection that survives filtering feeding a bulk export/remove bar.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/dataset-browser.jsonPrompt
Build a React + TypeScript + Tailwind "DatasetBrowser" component with zod and
lucide-react, reusing a shadcn Badge, a shadcn Button and a copy-to-clipboard hook.
Contract
- A zod schema (`datasetBrowserItemSchema` in a sibling contract file) is the
single source of truth for ONE example: { id, prompt, completion, flags:
Flag[], tokens, split?: "train"|"validation"|"test" }.
- Flag is a CLOSED enum of five: "duplicate" | "empty-completion" | "too-long" |
"pii" | "truncated". Each flag owns a label, an icon, a one-sentence rule
description and a severity of "block" (the trainer rejects this row, or it
leaks) or "warn" (it still trains but degrades the mix). A free-form string
would render as an unstyled pill nobody can act on; map your linter's rule
names onto these instead.
- `prompt` and `completion` are the RAW strings. Never pre-truncate them for the
browser: the preview is derived, the audit needs the original. `completion`
MUST be allowed to be "" — that blank is precisely what "empty-completion"
exists to surface, so the contract has to carry it rather than force the data
layer to drop the row.
- `tokens` comes from YOUR tokenizer. The component only sums and formats it; it
never counts, so the server and the browser can't disagree.
- An envelope status — "loading" | "empty" | "error" | "ready" — is the browser's
own render state, independent of any row. Alongside it: `items` (a PAGE, not
necessarily the corpus) and `total?` (the corpus size, which unlocks the
"10 of 12,480 loaded" note).
- Props: items; status; total?; filters? / defaultFilters? / onFiltersChange?;
selectedIds? / defaultSelectedIds? / onSelectedIdsChange?; defaultExpandedIds?;
selectable? (true); onExport?(rows); onRemove?(ids); onRetry?; previewChars?
(96); skeletonRows? (4); confirmTimeoutMs? (4000); flagMeta? (per-flag label /
description / severity overrides); emptyState?; errorMessage?; label?
("Dataset"); className plus the rest of the element props, ref forwarded to the
<section>.
- Filters and selection are each controlled-when-passed and uncontrolled
otherwise; the change callback fires in both modes. Expansion is always owned by
the component. Every numeric prop is clamped (previewChars >= 24, skeletonRows
1…12, confirmTimeoutMs >= 1000) so a 0 or a NaN can never blank a row.
Behavior
- A row is ONE line of prompt and ONE line of completion. The preview is produced
in JS — whitespace collapsed to single spaces, then sliced to previewChars — and
only then clipped with CSS. Slicing in JS is what makes the DOM hold exactly what
is on screen, so the server render, a snapshot and a screenshot all agree.
- An absent side of a pair is never a blank line: an empty prompt or completion
renders the words "empty prompt" / "empty completion" in the destructive token,
and the expanded panel says "Empty string — this side of the pair has no content
at all." A blank line reads as a rendering bug; here the absence IS the finding.
- The flag census is computed over the whole PAGE, not over the filtered view, so a
chip's count never changes because another chip is pressed. A row carrying the
same flag twice counts once.
- Chips are OR-ed multi-select, plus a "Clean" chip meaning "zero flags" (the
complement of all of them, not a flag) and an "All" chip that clears them. A chip
renders only when its count is non-zero — a control that can only ever return
zero rows is dead — with ONE exception: a chip that is currently ACTIVE always
renders, because a filter you cannot see is a filter you cannot switch off.
- ZERO ROWS AFTER FILTERING IS NOT THE EMPTY STATE. `status="empty"` invites you to
upload data; a filtered-out view states how many examples are loaded and offers a
"Clear filters" button. Confusing the two sends people hunting for data they
already have.
- Row numbers are positions in the PAGE, not in the filtered view, so a number you
noted stays valid after you toggle a chip.
- Expanding is a real <button aria-expanded> whose panel is UNMOUNTED when closed
(a zero-height collapse leaves every copy button reachable by Tab, invisible),
and aria-controls is only set while open so it never points at a missing id. The
expanded panel is UNCAPPED: no max-height, no line clamp. Expanding a row is the
audit — an example you can only read half of is an example you cannot judge. Rows
expand independently, so several can be open at once for comparison.
- The panel also explains itself: every flag on the row prints its rule in one
sentence, and the pair copies three ways — prompt only, completion only, or as
one JSONL line, the format it will actually ship in.
- SELECTION SURVIVES FILTERING. "Select all" means all VISIBLE rows and leaves
selections hidden by the current filter exactly where they were; narrowing a
filter must never silently unselect rows the reader already decided about. The
bulk bar therefore reads "3 selected · 1 hidden by the current filter", and Export
hands back the hidden ones too — you should know that before you act, not
discover it in the output file.
- The header checkbox is tri-state over the VISIBLE rows: checked when all of them
are selected, indeterminate when only some are. `indeterminate` is a DOM
property, so it is set from a ref callback in the commit phase, never as an
attribute.
- REMOVE IS ARM-THEN-CONFIRM AND ONE-SHOT. The first click arms the same button
(its label becomes "Confirm removing 3"), the second fires onRemove exactly once.
It is the same element in both states, so arming never yanks focus out from under
the pointer or the keyboard. The lock lives in a REF read and written
synchronously inside the handler: several clicks dispatched in one task all
observe the same stale state, so a state-only guard would delete twice. An armed
button disarms itself after confirmTimeoutMs — an armed destructive control that
stays armed forever is a landmine for the next click near it — and the timer is
cleared on unmount.
- Changing WHAT is about to be removed cancels the confirmation, including when the
change arrives from outside through a controlled `selectedIds`. That reset is an
adjust-state-during-render comparison against the previous selection key, not an
effect, so there is never a painted frame where an armed button points at a
different set of rows.
- Export and Remove render only when their handler exists. A destructive button
that destroys nothing is worse than no button at all.
- Memo keys are derived from a joined STRING of the ids / filters, never from the
arrays themselves: an inline `selectedIds={[...]}` literal changes identity every
render, and busting these memos would recompute the census and the filter on
every unrelated keystroke on the page.
Rendering & styling
- Semantic tokens only: bg-card / border / text-muted-foreground for chrome,
bg-primary + text-primary-foreground for a pressed filter chip, bg-primary/5 for
a selected row, bg-muted/40 for the bulk bar and the code blocks, text-destructive
(with border-destructive/40 + bg-destructive/5) for blocking flags and missing
text, accent-primary for the native checkboxes. No hard-coded colours anywhere.
- Severity is carried by SHAPE as well as colour: each flag has its own lucide icon
(files / circle-slash / ruler / shield-alert / scissors), so the two piles survive
a monochrome theme and a colour-blind reader.
- Long prompts use `wrap-anywhere` plus `min-w-0` in the expanded panel and
`truncate` in the preview line; the JS slice keeps every row exactly one line tall
per side no matter how wide the container is.
- The root is a <section aria-label="Dataset"> holding ONE persistent sr-only
role="status" line — "7 of 10 examples shown, 3 selected" — mounted for the whole
ready branch, so filtering, selecting and arming are announced from an element the
screen reader is already watching.
- Every control is a real button or a real checkbox with a focus-visible ring:
filter chips carry aria-pressed, row headers aria-expanded, the bulk bar is a
role="group". The only animations are a chevron rotation and the loading pulse,
both motion-reduce-guarded; nothing about the component depends on them.
- cn() merges the consumer's className into the root, which also spreads the
remaining element props and forwards its ref.
Customization levers
- Vocabulary: `flagMeta` overrides any flag's label, one-sentence rule and severity
— rename "Over budget" to your trainer's wording, or demote PII to a warning if
your pipeline redacts downstream. Severity is what decides the destructive
treatment and the "N blocking" figure in the summary line.
- Density: `previewChars` trades preview length against row height (96 is
comfortable around 700px wide; drop to 40 in a narrow side panel). `selectable`
false removes the checkbox column and the bulk bar entirely, turning the component
into a read-only reviewer.
- Ownership: pass `filters` / `selectedIds` to hoist the state into a URL or a
parent toolbar (the callbacks fire either way); pass only `defaultFilters` /
`defaultSelectedIds` to land the reader on a pre-triaged view.
- Actions: `onExport` receives the row OBJECTS so you can serialise JSONL, CSV or a
POST body yourself; `onRemove` receives ids only. Drop either handler to hide its
button. `confirmTimeoutMs` widens or narrows the confirmation window.
- Paging: `total` turns the summary into "N of M loaded". The component never
guesses a corpus size and never paginates for you — the page you hand it is the
page it draws, in full.
- Copy: `emptyState`, `errorMessage` + `onRetry` and `label` own the envelope
branches and the region's accessible name.Concepts
- The census is page-wide, the view is filtered — chip counts are computed over every loaded row, so pressing one chip never rewrites the numbers on the others. A count that moves while you are deciding what to press is a count you stop trusting.
- Filtered-out is not empty — the empty state invites you to upload data; a filter that matches nothing has to say how many examples ARE loaded and hand you a way back. Collapsing the two sends people hunting for data they already have.
- Selection survives filtering, and says so — narrowing a chip must not silently unselect rows you already judged, so hidden picks stay picked and the bulk bar spells out how many are off screen. Export takes them along, and you learn that before you act rather than from the output file.
- The preview is derived, the pair is not — whitespace is collapsed and the string is sliced in JS so the DOM holds exactly what is drawn, while expanding a row shows the original with no max-height and no clamp. Truncating the audit view would defeat the only reason to open it.
- Arm-then-confirm with a self-expiring, one-shot lock — the same button becomes its own confirmation so focus never moves, a ref taken synchronously in the handler makes a double click impossible, the arm times out by itself, and changing the selection cancels it, because the thing you were about to delete is no longer the thing on screen.
- Absence is written out, never left blank — an empty completion renders the words instead of an empty line, because that blank is the whole reason the row is flagged, and a missing string looks exactly like a broken renderer.
Prompt Arena
Pairwise prompt results as a scoreboard you can act on — an A/tie/B split bar, a verdict gated by a Wilson interval instead of a raw lead, sampled matchups with their own vote splits, the two outputs for one input on demand, and a rerun that fires exactly once.
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.