{"slug":"ai-review-panel","category":"review","job":"trust","added":"2026-11-04T16:18Z","title":"AI Review Panel","description":"A side panel for checking extracted values one by one — each field shows its value, the model's reasoning and verbatim evidence that highlights in the source, with accept, dismiss and apply-all-pending.","url":"https://ai-patterns.app/patterns/review/ai-review-panel","markdown":"https://ai-patterns.app/patterns/review/ai-review-panel.md","json":"https://ai-patterns.app/api/patterns/review/ai-review-panel","seenIn":[{"team":"datamaran","source":"https://www.datamaran.com/"}],"spec":"# AI Review Panel\n\n## Summary\nA side panel for checking values the model extracted from a source document. Each field is a collapsible card showing the extracted value, the model's reasoning, and the exact passages it relied on. Hovering a passage highlights it in the source; clicking scrolls to it. The user accepts or dismisses each field, or applies every pending one at once, and a segmented status bar and filter tabs show how much is left to review.\n\n## When to use\n- Document extraction: contracts, regulatory notices, invoices, filings, medical or insurance forms, where each value must be traceable to the text.\n- Reviews where the user is accountable for the result and needs to see *why* the model chose a value, not just the value.\n- Long sources where finding the supporting passage by hand would take longer than checking it.\n\n## When not to use\n- Values with no single source passage (summaries, sentiment, scores). Show reasoning in a trace instead.\n- Enrichment from the open web, where the user mostly fills empty fields. Use AI Fill Drawer.\n- One or two fields. Inline suggestions next to the input are lighter than a panel.\n- When values can be edited freely and checked later. Without an accountable review step, the per-field ceremony is overhead.\n\n## Anatomy\n- **Header** — ✦ title, \"View source\", close.\n- **Status bar** — a segmented bar, one segment per status (dismissed, pending, accepted), each sized by its share of fields, with \"N of M reviewed\".\n- **Filter tabs** — All, Pending, Accepted, Dismissed, each with a count badge tinted in its status color.\n- **Search** — filters by field name or value.\n- **Field card** — collapsed: status dot, field name, value preview, chevron. Expanded:\n  - **Value** — the extracted value, marked ✦.\n  - **Reasoning** — one short paragraph, collapsible.\n  - **Evidence** — one or more verbatim quotes with an accent rule on the left and the hint \"Hover to preview · click to view in text\", collapsible.\n  - **Actions** — Pending: Dismiss / Accept. Accepted: Undo. Dismissed: Restore.\n- **Footer** — a full-width \"Apply all pending (N)\" button, which becomes \"All fields reviewed\" when nothing is pending.\n\n## Behavior\n- Every field starts pending unless the host already knows otherwise (e.g. accepted in an earlier session).\n- One card is open at a time. Opening another closes the previous one.\n- Accept and Dismiss change the dot, the tab counts and the status bar together; segments animate to their new widths.\n- Restore and Undo both return a field to pending. Nothing is final until the host saves.\n- Under a status tab, a field whose status changes leaves the list with a short exit animation, so the user sees where it went.\n- \"Apply all pending\" accepts only pending fields. Dismissed fields stay dismissed.\n- Hovering or focusing a quote previews it: the matching passage is highlighted in the source. Leaving clears the preview. Clicking pins the highlight and scrolls the source to it.\n- Evidence must be a verbatim substring of the source. Drop any quote the host can't find in the source before passing it in, rather than highlighting the wrong passage.\n- Search and the tab filter combine. An empty result says what was searched (\"No fields match 'date'\").\n\n## Content guidelines\n- Field names are the reviewer's vocabulary (\"Degree of binding force\"), sentence case.\n- Values are short and normalized: dates in one format, enums from the field's allowed list.\n- Reasoning is one to three sentences, cites what in the text decided it, and states doubt plainly (\"It may not count as a consultation deadline\") rather than hedging every value.\n- Quotes are the shortest passage that supports the value — a clause, not a paragraph.\n- Count, don't celebrate: \"6 of 8 reviewed\", \"Apply all pending (3)\".\n\n## Accessibility\n- Filter tabs are a `tablist` with roving focus; ←/→ move between them.\n- Each card header is a button inside a heading, with `aria-expanded` and `aria-controls`; the status is in its accessible name, so color is never the only signal.\n- The status bar has a text alternative (\"5 accepted, 2 pending, 1 dismissed\").\n- Each quote is a button labelled \"View in source: <quote>\". Focus triggers the same preview as hover.\n- Status changes and \"Apply all\" are announced in a polite live region.\n- With `prefers-reduced-motion`, cards fade instead of expanding and the bar jumps to its new widths.\n\n## Related patterns\n- **AI Fill Drawer** — Use it instead when values fill empty record fields from the web rather than coming from one document.\n- **Inline Citation** — Use it instead when the source links belong inside generated prose rather than beside extracted values.\n- **Confidence Indicator** — Use it as well next to a value when the model reports how sure it is.\n- **Human in the Loop Question** — Use it instead when a single agent action needs approval rather than a set of values.\n","code":"\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { ArrowUpRight, Check, ChevronDown, Eye, RotateCcw, Search, Sparkles, X, Zap } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport type ReviewStatus = \"pending\" | \"accepted\" | \"dismissed\";\n\nexport interface Evidence {\n  id: string;\n  /** The exact passage the value was extracted from. */\n  quote: string;\n}\n\nexport interface ReviewField {\n  key: string;\n  label: string;\n  /** The value the model extracted. */\n  value: string;\n  /** Why the model chose this value — one short paragraph. */\n  reasoning: string;\n  evidence: Evidence[];\n  /** Starting status. Defaults to \"pending\". */\n  status?: ReviewStatus;\n}\n\nexport interface AiReviewPanelProps {\n  fields: ReviewField[];\n  title?: string;\n  /** Fires on every accept, dismiss, restore or undo — and once per field on \"Apply all\". */\n  onStatusChange?: (key: string, status: ReviewStatus) => void;\n  /** Hover / focus on a quote: highlight it in the source. `null` clears. */\n  onEvidencePreview?: (evidence: Evidence | null) => void;\n  /** Click on a quote: scroll the source to it. */\n  onEvidenceOpen?: (evidence: Evidence) => void;\n  onViewSource?: () => void;\n  onClose?: () => void;\n  className?: string;\n}\n\ntype Filter = \"all\" | ReviewStatus;\n\nconst STATUS: Record<\n  ReviewStatus,\n  { label: string; dot: string; bar: string; badge: string }\n> = {\n  dismissed: {\n    label: \"Dismissed\",\n    dot: \"bg-red-500\",\n    bar: \"bg-red-500\",\n    badge: \"bg-red-500/10 text-red-600 dark:text-red-400\",\n  },\n  pending: {\n    label: \"Pending\",\n    dot: \"bg-amber-500\",\n    bar: \"bg-amber-500\",\n    badge: \"bg-amber-500/15 text-amber-700 dark:text-amber-400\",\n  },\n  accepted: {\n    label: \"Accepted\",\n    dot: \"bg-emerald-500\",\n    bar: \"bg-emerald-500\",\n    badge: \"bg-emerald-500/15 text-emerald-700 dark:text-emerald-400\",\n  },\n};\n\nconst FILTERS: Filter[] = [\"all\", \"pending\", \"accepted\", \"dismissed\"];\n\nexport function AiReviewPanel({\n  fields,\n  title = \"AI Review\",\n  onStatusChange,\n  onEvidencePreview,\n  onEvidenceOpen,\n  onViewSource,\n  onClose,\n  className,\n}: AiReviewPanelProps) {\n  const reduceMotion = useReducedMotion();\n  const [status, setStatus] = React.useState<Record<string, ReviewStatus>>(() =>\n    Object.fromEntries(fields.map((f) => [f.key, f.status ?? \"pending\"]))\n  );\n  const [filter, setFilter] = React.useState<Filter>(\"all\");\n  const [query, setQuery] = React.useState(\"\");\n  const [expanded, setExpanded] = React.useState<string | null>(null);\n  const [announcement, setAnnouncement] = React.useState(\"\");\n  const tabRefs = React.useRef<Record<string, HTMLButtonElement | null>>({});\n  const listId = React.useId();\n\n  const counts = React.useMemo(() => {\n    const c = { all: fields.length, pending: 0, accepted: 0, dismissed: 0 };\n    for (const f of fields) c[status[f.key]] += 1;\n    return c;\n  }, [fields, status]);\n\n  const q = query.trim().toLowerCase();\n  const visible = fields.filter(\n    (f) =>\n      (filter === \"all\" || status[f.key] === filter) &&\n      (!q || f.label.toLowerCase().includes(q) || f.value.toLowerCase().includes(q))\n  );\n\n  function set(key: string, next: ReviewStatus) {\n    setStatus((s) => ({ ...s, [key]: next }));\n    onStatusChange?.(key, next);\n    const label = fields.find((f) => f.key === key)?.label ?? key;\n    setAnnouncement(`${label} ${next === \"pending\" ? \"moved back to pending\" : next}`);\n  }\n\n  function applyAll() {\n    const pending = fields.filter((f) => status[f.key] === \"pending\");\n    setStatus((s) => ({ ...s, ...Object.fromEntries(pending.map((f) => [f.key, \"accepted\" as const])) }));\n    pending.forEach((f) => onStatusChange?.(f.key, \"accepted\"));\n    setAnnouncement(`${pending.length} field${pending.length === 1 ? \"\" : \"s\"} accepted`);\n  }\n\n  function onTabKey(e: React.KeyboardEvent, i: number) {\n    const dir = e.key === \"ArrowRight\" ? 1 : e.key === \"ArrowLeft\" ? -1 : 0;\n    if (!dir) return;\n    e.preventDefault();\n    const next = FILTERS[(i + dir + FILTERS.length) % FILTERS.length];\n    setFilter(next);\n    tabRefs.current[next]?.focus();\n  }\n\n  return (\n    <aside\n      aria-label={title}\n      className={cn(\n        \"flex h-full w-full max-w-sm flex-col overflow-hidden rounded-xl border bg-background text-sm shadow-sm\",\n        className\n      )}\n    >\n      <header className=\"flex items-center gap-2 px-4 pb-2 pt-3.5\">\n        <Sparkles className=\"size-4 text-violet-500\" aria-hidden />\n        <h2 className=\"flex-1 font-medium\">{title}</h2>\n        {onViewSource && (\n          <button\n            type=\"button\"\n            onClick={onViewSource}\n            className=\"flex h-7 items-center gap-1.5 rounded-md px-2 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground\"\n          >\n            <Eye className=\"size-3.5\" aria-hidden />\n            View source\n          </button>\n        )}\n        {onClose && (\n          <button\n            type=\"button\"\n            onClick={onClose}\n            aria-label=\"Close\"\n            className=\"flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground\"\n          >\n            <X className=\"size-4\" />\n          </button>\n        )}\n      </header>\n\n      {/* Progress: one segment per status, sized by share of fields */}\n      <div className=\"px-4 pb-3\">\n        <div className=\"mb-1.5 flex items-baseline justify-between\">\n          <span className=\"text-xs font-medium\">Status</span>\n          <span className=\"text-xs tabular-nums text-muted-foreground\">\n            {counts.accepted + counts.dismissed} of {counts.all} reviewed\n          </span>\n        </div>\n        <div\n          role=\"img\"\n          aria-label={`${counts.accepted} accepted, ${counts.pending} pending, ${counts.dismissed} dismissed`}\n          className=\"flex h-1.5 gap-0.5\"\n        >\n          {([\"dismissed\", \"pending\", \"accepted\"] as const).map((s) => (\n            <motion.span\n              key={s}\n              className={cn(\"h-full rounded-full\", STATUS[s].bar)}\n              initial={false}\n              animate={{ flexGrow: counts[s], opacity: counts[s] ? 1 : 0 }}\n              style={{ flexBasis: 0 }}\n              transition={reduceMotion ? { duration: 0 } : { type: \"spring\", stiffness: 260, damping: 30 }}\n            />\n          ))}\n        </div>\n      </div>\n\n      <div role=\"tablist\" aria-label=\"Filter by status\" className=\"flex border-b px-1.5\">\n        {FILTERS.map((f, i) => {\n          const active = filter === f;\n          return (\n            <button\n              key={f}\n              ref={(el) => {\n                tabRefs.current[f] = el;\n              }}\n              type=\"button\"\n              role=\"tab\"\n              aria-selected={active}\n              aria-controls={listId}\n              tabIndex={active ? 0 : -1}\n              onClick={() => setFilter(f)}\n              onKeyDown={(e) => onTabKey(e, i)}\n              className={cn(\n                \"relative flex flex-1 items-center justify-center gap-1 whitespace-nowrap px-1 py-2 text-xs transition-colors\",\n                active ? \"font-medium text-foreground\" : \"text-muted-foreground hover:text-foreground\"\n              )}\n            >\n              {f === \"all\" ? \"All\" : STATUS[f].label}\n              <span\n                className={cn(\n                  \"min-w-4.5 rounded-full px-1 py-px text-center text-[11px] tabular-nums\",\n                  f === \"all\" ? \"bg-muted text-muted-foreground\" : STATUS[f].badge\n                )}\n              >\n                {counts[f]}\n              </span>\n              {active && (\n                <motion.span\n                  layoutId={`${listId}-tab`}\n                  className=\"absolute inset-x-1 -bottom-px h-0.5 rounded-full bg-foreground\"\n                  transition={reduceMotion ? { duration: 0 } : { type: \"spring\", stiffness: 500, damping: 40 }}\n                />\n              )}\n            </button>\n          );\n        })}\n      </div>\n\n      <div className=\"px-3 py-2.5\">\n        <label className=\"flex h-8 items-center gap-2 rounded-md border bg-background px-2.5 focus-within:ring-2 focus-within:ring-ring/40\">\n          <Search className=\"size-3.5 text-muted-foreground\" aria-hidden />\n          <input\n            value={query}\n            onChange={(e) => setQuery(e.target.value)}\n            placeholder=\"Search fields…\"\n            aria-label=\"Search fields\"\n            className=\"min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground\"\n          />\n        </label>\n      </div>\n\n      <div id={listId} role=\"tabpanel\" className=\"flex-1 overflow-y-auto px-3 pb-3\">\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          {visible.map((f) => (\n            <motion.div\n              key={f.key}\n              layout={!reduceMotion}\n              initial={{ opacity: 0, y: 6 }}\n              animate={{ opacity: 1, y: 0 }}\n              exit={{ opacity: 0, scale: 0.98 }}\n              transition={{ duration: 0.18 }}\n              className=\"mb-1.5\"\n            >\n              <FieldCard\n                field={f}\n                status={status[f.key]}\n                open={expanded === f.key}\n                onToggle={() => setExpanded((k) => (k === f.key ? null : f.key))}\n                onSet={(s) => set(f.key, s)}\n                onEvidencePreview={onEvidencePreview}\n                onEvidenceOpen={onEvidenceOpen}\n              />\n            </motion.div>\n          ))}\n        </AnimatePresence>\n        {visible.length === 0 && (\n          <p className=\"px-2 py-8 text-center text-xs text-muted-foreground\">\n            {q ? `No fields match “${query.trim()}”` : filter === \"pending\" ? \"Nothing left to review\" : \"No fields here yet\"}\n          </p>\n        )}\n      </div>\n\n      <footer className=\"border-t px-3 py-3\">\n        <button\n          type=\"button\"\n          onClick={applyAll}\n          disabled={counts.pending === 0}\n          className=\"flex h-9 w-full items-center justify-center gap-1.5 rounded-md bg-primary text-xs font-medium text-primary-foreground transition-opacity hover:opacity-90 disabled:opacity-40\"\n        >\n          {counts.pending > 0 ? (\n            <>\n              <Zap className=\"size-3.5\" aria-hidden />\n              Apply all pending ({counts.pending})\n            </>\n          ) : (\n            <>\n              <Check className=\"size-3.5\" aria-hidden />\n              All fields reviewed\n            </>\n          )}\n        </button>\n      </footer>\n\n      <span className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n        {announcement}\n      </span>\n    </aside>\n  );\n}\n\nfunction FieldCard({\n  field,\n  status,\n  open,\n  onToggle,\n  onSet,\n  onEvidencePreview,\n  onEvidenceOpen,\n}: {\n  field: ReviewField;\n  status: ReviewStatus;\n  open: boolean;\n  onToggle: () => void;\n  onSet: (s: ReviewStatus) => void;\n  onEvidencePreview?: (e: Evidence | null) => void;\n  onEvidenceOpen?: (e: Evidence) => void;\n}) {\n  const reduceMotion = useReducedMotion();\n  const bodyId = React.useId();\n\n  return (\n    <div className={cn(\"overflow-hidden rounded-lg transition-colors\", open ? \"bg-muted/60\" : \"hover:bg-muted/40\")}>\n      <h3>\n        <button\n          type=\"button\"\n          onClick={onToggle}\n          aria-expanded={open}\n          aria-controls={bodyId}\n          className=\"flex w-full items-center gap-2.5 px-3 py-2.5 text-left\"\n        >\n          <span className={cn(\"size-2 shrink-0 rounded-full transition-colors\", STATUS[status].dot)} aria-hidden />\n          <span className=\"min-w-0 flex-1 truncate\">{field.label}</span>\n          <span className=\"sr-only\">, {STATUS[status].label}</span>\n          {!open && (\n            <span className=\"max-w-[40%] truncate text-xs text-muted-foreground\">{field.value}</span>\n          )}\n          <ChevronDown\n            className={cn(\"size-4 shrink-0 text-muted-foreground transition-transform\", open && \"rotate-180\")}\n            aria-hidden\n          />\n        </button>\n      </h3>\n\n      <AnimatePresence initial={false}>\n        {open && (\n          <motion.div\n            id={bodyId}\n            initial={reduceMotion ? { opacity: 0 } : { height: 0, opacity: 0 }}\n            animate={reduceMotion ? { opacity: 1 } : { height: \"auto\", opacity: 1 }}\n            exit={reduceMotion ? { opacity: 0 } : { height: 0, opacity: 0 }}\n            transition={{ duration: 0.2, ease: [0.2, 0, 0, 1] }}\n          >\n            <div className=\"flex flex-col gap-2 px-2 pb-2\">\n              <div className=\"flex items-center gap-2 rounded-md border bg-background px-3 py-2\">\n                <Sparkles className=\"size-3.5 shrink-0 text-violet-500\" aria-hidden />\n                <span className=\"font-medium\">Value</span>\n                <span className=\"min-w-0 flex-1 truncate text-right text-muted-foreground\">{field.value}</span>\n              </div>\n\n              <Section title=\"Reasoning\">\n                <p className=\"leading-relaxed text-foreground/90\">{field.reasoning}</p>\n              </Section>\n\n              {field.evidence.length > 0 && (\n                <Section title=\"Evidence\">\n                  <ul className=\"flex flex-col gap-1.5\">\n                    {field.evidence.map((ev) => (\n                      <li key={ev.id}>\n                        <button\n                          type=\"button\"\n                          onMouseEnter={() => onEvidencePreview?.(ev)}\n                          onMouseLeave={() => onEvidencePreview?.(null)}\n                          onFocus={() => onEvidencePreview?.(ev)}\n                          onBlur={() => onEvidencePreview?.(null)}\n                          onClick={() => onEvidenceOpen?.(ev)}\n                          aria-label={`View in source: ${ev.quote}`}\n                          className=\"group w-full rounded-md bg-muted/60 py-2 pl-2 pr-3 text-left transition-colors hover:bg-violet-500/10\"\n                        >\n                          <span className=\"block border-l-2 border-violet-500 pl-3 leading-relaxed\">“{ev.quote}”</span>\n                          <span className=\"mt-1.5 flex items-center gap-1 pl-3.5 text-[11px] text-violet-600 dark:text-violet-400\">\n                            <ArrowUpRight className=\"size-3\" aria-hidden />\n                            Hover to preview · click to view in text\n                          </span>\n                        </button>\n                      </li>\n                    ))}\n                  </ul>\n                </Section>\n              )}\n\n              <div className=\"flex justify-end gap-1.5 pt-0.5\">\n                {status === \"pending\" ? (\n                  <>\n                    <ActionButton onClick={() => onSet(\"dismissed\")}>\n                      <X className=\"size-3.5\" aria-hidden />\n                      Dismiss\n                    </ActionButton>\n                    <ActionButton primary onClick={() => onSet(\"accepted\")}>\n                      <Check className=\"size-3.5\" aria-hidden />\n                      Accept\n                    </ActionButton>\n                  </>\n                ) : (\n                  <ActionButton onClick={() => onSet(\"pending\")}>\n                    <RotateCcw className=\"size-3.5\" aria-hidden />\n                    {status === \"dismissed\" ? \"Restore\" : \"Undo\"}\n                  </ActionButton>\n                )}\n              </div>\n            </div>\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </div>\n  );\n}\n\nfunction Section({ title, children }: { title: string; children: React.ReactNode }) {\n  const [open, setOpen] = React.useState(true);\n  const id = React.useId();\n  return (\n    <div className=\"rounded-md border bg-background\">\n      <button\n        type=\"button\"\n        onClick={() => setOpen((o) => !o)}\n        aria-expanded={open}\n        aria-controls={id}\n        className=\"flex w-full items-center gap-2 px-3 py-2 text-left\"\n      >\n        <Sparkles className=\"size-3.5 shrink-0 text-violet-500\" aria-hidden />\n        <span className=\"flex-1 font-medium\">{title}</span>\n        <ChevronDown\n          className={cn(\"size-4 text-muted-foreground transition-transform\", !open && \"-rotate-90\")}\n          aria-hidden\n        />\n      </button>\n      {open && (\n        <div id={id} className=\"px-3 pb-3\">\n          {children}\n        </div>\n      )}\n    </div>\n  );\n}\n\nfunction ActionButton({\n  primary,\n  onClick,\n  children,\n}: {\n  primary?: boolean;\n  onClick: () => void;\n  children: React.ReactNode;\n}) {\n  return (\n    <button\n      type=\"button\"\n      onClick={onClick}\n      className={cn(\n        \"flex h-8 items-center gap-1.5 rounded-md px-3 text-xs font-medium transition-colors\",\n        primary\n          ? \"bg-primary text-primary-foreground hover:opacity-90\"\n          : \"border bg-background hover:bg-accent\"\n      )}\n    >\n      {children}\n    </button>\n  );\n}\n","dependencies":["lucide-react","motion"],"localImports":["@/lib/utils"]}