AI

Knowledge Picker

A grounding-scope selector for collections, files and links — tri-state collections that cascade only over what is actually selectable, per-source document and chunk counts with index freshness, an accounting bar that prices the selection in chunks, and four data states.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  AlertCircle,
  Check,
  ChevronRight,
  Database,
  FileText,
  Globe,
  Library,
  LoaderCircle,
  Minus,
  Plus,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/knowledge-picker.json

Prompt

Build a React + TypeScript + Tailwind "KnowledgePicker" component with zod and
lucide-react — the grounding-scope selector a user fills in BEFORE a retrieval
run: which collections, files and links the retriever may read.

Contract
- A recursive zod schema (`knowledgeSourceSchema` in a sibling contract file,
  `z.lazy` + an explicit `z.ZodType<KnowledgeSource>` annotation) is the single
  source of truth for one node: { id, name, kind: "collection" | "file" | "url",
  count (documents), chunks?: number, updatedAt: string|number|Date, children?:
  KnowledgeSource[], description?, indexState?: "indexed" | "indexing" |
  "failed", disabled?, disabledReason? }.
- `children` is what makes a node a BRANCH. A collection that lists its members
  gets a derived tri-state; a collection that hides them (children omitted or
  empty) stays one atomic unit you take or leave. One shape covers both, so a
  backend that starts exposing members later needs no new contract.
- `count` and `chunks` are two different numbers on purpose: documents are what
  the human recognises, chunks are what the retriever ranks and what fills the
  model's window. `chunks` is OPTIONAL because plenty of stores only know it
  after the first query — model "unknown" as unknown, never as 0.
- A separate envelope status — "loading" | "empty" | "error" | "ready" — is the
  panel's own fetch state, independent of any source's `indexState`. The list can
  be ready while one collection inside it is still embedding.
- Props: status; sources; recentIds?; value? / defaultValue? / onValueChange?
  (controlled or uncontrolled); now? (injected clock); locale? ("en-US");
  staleAfterDays? (30); maxSelected?; searchable? (true); defaultExpandedIds?;
  maxHeight?; skeletonRows? (4); emptyState?; errorMessage?; onRetry?;
  onConnect?; connectLabel?; label? ("Knowledge sources"); className plus the
  remaining element props, ref forwarded to the <section>.
- `value` holds ATOMIC UNIT ids. A caller that stores one collection id meaning
  "everything in here" is understood — every read normalises `value` to the leaf
  ids it stands for, and the first toggle rewrites it in that canonical form.
  Ids the picker cannot resolve (another page of the workspace, a deleted source)
  are carried over untouched and counted separately, never silently dropped.

Behavior
- ONE INDEX PASS builds parent links, ancestor paths, inherited locks and, per
  node, its leaf bucket plus derived totals (documents, chunks, how many members
  have no chunk count). A branch row therefore can never disagree with what
  selecting it actually adds.
- Branch state is DERIVED, never stored: checked when every *selectable* leaf
  below it is in, mixed when only some are, unchecked at zero. Deliberately
  unlike a generic tri-state tree, which counts locked leaves too and leaves a
  collection holding one still-indexing file stuck on "mixed" forever, looking
  broken. A locked leaf that came back selected from the server still counts in
  the totals, so the accounting stays honest.
- Cascade flips only what it MAY flip. Selecting a collection adds its selectable
  members and skips the ones that are indexing, failed or locked; UNCHECKING
  removes everything the node owns, locked members included — a leftover
  selected-but-locked leaf is exactly the invisible scope that makes a run read a
  document the user thought they had removed.
- A node with nothing selectable under it is INERT, and says why on the row
  (`disabledReason`, or wording derived from `indexState`). It is aria-disabled,
  never natively `disabled`: the native attribute drops focus to <body> mid-action
  and removes the row from the tab order.
- `maxSelected` refuses WITH the arithmetic — 'Engineering wiki' needs 3 slots,
  0 slots left of 4 — in a role="status" line under the summary, instead of a
  dead checkbox that swallows the click. The notice clears on the next successful
  change; it is never a timer. Unchecking always works, even at the cap.
- The filter keeps hits, their ancestors (for context) and the whole subtree of a
  matched BRANCH (you asked for the bucket, you see what is in it), auto-expands
  the path down to every hit, and highlights the matched substring. "Select
  matches" adds only the matches — never the ancestors that are merely on screen
  because a member matched. Escape clears the query and keeps focus in the field.
- Manual disclosure survives the filter: collapses made while filtering live in
  their own set, so clearing the query restores exactly the tree the reader had
  built. That set resets when the query changes (a different result set), which
  is done by adjusting state during render — no effect, so there is never a frame
  where the previous query's collapses apply to different rows.
- The recently-used row is a shortcut, not a duplicate list: a chip toggles the
  same node and, when it lives inside a closed collection, EXPANDS the path first
  so the reader sees what their click did. Unknown recent ids are skipped rather
  than faked. The row is hidden while filtering — a filter is already a jump.
- The panel owns NO CLOCK. Freshness is `now - updatedAt` with `now` injected;
  omit it and every row falls back to its UTC calendar date, computed WITHOUT
  Intl (an `Intl.DateTimeFormat` with no explicit timeZone reads the runtime's
  zone, so a UTC server and a UTC+8 browser render different strings and React
  logs a hydration mismatch). Anything older than `staleAfterDays` gets a `stale`
  mark, and the summary counts how many selected sources are stale.
- The accounting bar states selected sources, documents and chunks. When some
  selected sources have not reported a chunk count, the total is rendered as a
  FLOOR ("1.2K+") with a line saying how many are still counting — and when none
  of them has a count yet it says "chunk count pending" rather than printing 0.
- Arrow keys move between rows without a roving tabindex (this is a form control,
  not a file tree, and every row belongs in the natural tab order); the DOM query
  runs at event time, so collapsed children — which are UNMOUNTED, never
  zero-height — are simply not there to be stepped into.
- Four envelopes are first-class branches: skeleton rows with aria-busy for
  loading; a zero-state that says what an ungrounded answer means, with a CTA
  that only renders when `onConnect` is passed; a role="alert" failure that reads
  differently from a source's index failing and offers a retry; the picker
  itself for ready. `status="ready"` with an empty array renders the zero-state.

Rendering & styling
- Semantic tokens only: bg-card / border / text-muted-foreground for chrome,
  border-primary + bg-primary + text-primary-foreground for a checked box,
  bg-primary/20 for the filter highlight, bg-primary/10 for a selected chip,
  text-destructive for a failed index and for the cap notice, bg-muted for
  skeletons and hovers. No hard-coded colours, no arbitrary radii.
- State is carried by SHAPE as well as fill — check, minus, spinner, alert
  triangle — plus a word ("indexing", "index failed", "stale"), so it survives a
  monochrome theme and a colour-blind reader. The indexing spinner stops under
  prefers-reduced-motion and still reads as in-progress; every transition is
  motion-reduce-guarded.
- The root is a <section aria-label> holding ONE persistent sr-only role="status"
  line with the running total ("2 sources selected, 273 chunks in scope"), so a
  CHANGE is announced from an element the screen reader is already watching. The
  cap notice obeys the same rule: its <p role="status"> is mounted for the whole
  ready branch and merely sr-only while empty — a region that appears together
  with its first message is one nobody was watching, and a refusal announced by
  nothing is a click that seems to do nothing.
  Rows are <button role="checkbox" aria-checked={true|false|"mixed"}>; the
  disclosure chevron is a SIBLING button (never nested inside the row button)
  with aria-expanded; a child row carries an sr-only "inside <parent>".
- Long names use `wrap-anywhere` plus `min-w-0`, not `break-words`: only
  overflow-wrap:anywhere lowers the min-content width, which is what stops a
  70-character file name from pushing the picker wider than the sidebar.
- Depth is one small inline padding step, not a nested layout — the flat list
  keeps arrow-key order identical to reading order.
- cn() merges the consumer's className into the root, which also spreads the
  remaining element props and forwards its ref.

Customization levers
- Scope policy: `maxSelected` caps the selection (drop it for unlimited);
  `staleAfterDays` decides what counts as stale — 7 for a news corpus, 365 for
  contracts; `disabled` + `disabledReason` express entitlements the index knows
  nothing about (legal hold, wrong workspace).
- Density and chrome: `searchable={false}` removes the filter for a short list,
  `recentIds` omitted removes the shortcut row, `maxHeight` turns the list into a
  focusable scroll region for a sidebar, `defaultExpandedIds` decides how much of
  the tree greets the reader.
- Wording: `label` names the region, `connectLabel` / `onConnect` own the empty
  CTA, `errorMessage` / `onRetry` own the envelope failure, `emptyState` replaces
  the zero-state body wholesale, `locale` moves every number and every relative
  time to your language.
- Accounting: keep chunks as the headline number for a RAG surface; swap the
  footer to documents (or add cost per 1K chunks) when your users think in files
  or in money. Keep whatever you show a FLOOR when any count is missing.
- Selection model: pass `value` / `onValueChange` to keep the scope in a form or
  a URL, or leave both out and let the picker own it with `defaultValue`.

Concepts

  • Grounding scope is chosen before the run, not explained after it — this panel answers "what may the model read", and every number on it exists to make that decision honest: how big the corpus is, how fresh, and which parts are unusable right now. The post-run question ("what came back and why") belongs to a retrieval inspector, not here.
  • Selectable-only cascade — a collection's tri-state is derived from the members a click may actually flip, so a bucket holding one still-indexing file lands on checked instead of freezing on mixed. The generic tree rule (count every child) produces a checkbox that looks permanently broken, and users respond by selecting the whole workspace.
  • Refusal with the arithmetic — at a cap, the picker names what the click needed and what was left ("needs 3 slots, 0 left of 4"). A checkbox that silently swallows a click teaches people that the control is broken; a number teaches them what to deselect.
  • Chunk accounting, not file counting — files are what the human recognises, chunks are what fills the window. Four innocuous-looking sources can be a hundred thousand chunks, so the footer prices the scope in the retriever's unit — and prints a floor with a + whenever a source has not reported its count, because an under-reported budget is worse than an unknown one.
  • Freshness is scope quality — an index is a photograph of a document, and a stale photograph is how a grounded answer confidently quotes last year's price. Every row carries its own age against an injected clock, and the summary says how many selected sources are past the staleness line.
  • The filter never steals the disclosure — matching auto-opens the path to each hit, but collapses the reader makes while filtering are stored separately and reinstated when the query clears, so searching does not silently rearrange the tree they had built.

On This Page