AI

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  AlertCircle,
  Check,
  ChevronRight,
  CircleSlash,
  Copy,
  CopyX,
  CornerDownRight,
  Database,
  Download,
  Files,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/dataset-browser.json

Prompt

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.

On This Page