# AI Review Panel

> 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.

- Category: review
- Job: Trust — Check results against sources, diffs and confidence, and make AI content and data handling visible.
- Slug: `ai-review-panel`
- HTML: https://ai-patterns.app/patterns/review/ai-review-panel
- JSON: https://ai-patterns.app/api/patterns/review/ai-review-panel
- Seen in: [datamaran](https://www.datamaran.com/)

## Summary
A 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.

## When to use
- Document extraction: contracts, regulatory notices, invoices, filings, medical or insurance forms, where each value must be traceable to the text.
- Reviews where the user is accountable for the result and needs to see *why* the model chose a value, not just the value.
- Long sources where finding the supporting passage by hand would take longer than checking it.

## When not to use
- Values with no single source passage (summaries, sentiment, scores). Show reasoning in a trace instead.
- Enrichment from the open web, where the user mostly fills empty fields. Use AI Fill Drawer.
- One or two fields. Inline suggestions next to the input are lighter than a panel.
- When values can be edited freely and checked later. Without an accountable review step, the per-field ceremony is overhead.

## Anatomy
- **Header** — ✦ title, "View source", close.
- **Status bar** — a segmented bar, one segment per status (dismissed, pending, accepted), each sized by its share of fields, with "N of M reviewed".
- **Filter tabs** — All, Pending, Accepted, Dismissed, each with a count badge tinted in its status color.
- **Search** — filters by field name or value.
- **Field card** — collapsed: status dot, field name, value preview, chevron. Expanded:
  - **Value** — the extracted value, marked ✦.
  - **Reasoning** — one short paragraph, collapsible.
  - **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.
  - **Actions** — Pending: Dismiss / Accept. Accepted: Undo. Dismissed: Restore.
- **Footer** — a full-width "Apply all pending (N)" button, which becomes "All fields reviewed" when nothing is pending.

## Behavior
- Every field starts pending unless the host already knows otherwise (e.g. accepted in an earlier session).
- One card is open at a time. Opening another closes the previous one.
- Accept and Dismiss change the dot, the tab counts and the status bar together; segments animate to their new widths.
- Restore and Undo both return a field to pending. Nothing is final until the host saves.
- Under a status tab, a field whose status changes leaves the list with a short exit animation, so the user sees where it went.
- "Apply all pending" accepts only pending fields. Dismissed fields stay dismissed.
- 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.
- 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.
- Search and the tab filter combine. An empty result says what was searched ("No fields match 'date'").

## Content guidelines
- Field names are the reviewer's vocabulary ("Degree of binding force"), sentence case.
- Values are short and normalized: dates in one format, enums from the field's allowed list.
- 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.
- Quotes are the shortest passage that supports the value — a clause, not a paragraph.
- Count, don't celebrate: "6 of 8 reviewed", "Apply all pending (3)".

## Accessibility
- Filter tabs are a `tablist` with roving focus; ←/→ move between them.
- 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.
- The status bar has a text alternative ("5 accepted, 2 pending, 1 dismissed").
- Each quote is a button labelled "View in source: <quote>". Focus triggers the same preview as hover.
- Status changes and "Apply all" are announced in a polite live region.
- With `prefers-reduced-motion`, cards fade instead of expanding and the bar jumps to its new widths.

## Related patterns
- **AI Fill Drawer** — Use it instead when values fill empty record fields from the web rather than coming from one document.
- **Inline Citation** — Use it instead when the source links belong inside generated prose rather than beside extracted values.
- **Confidence Indicator** — Use it as well next to a value when the model reports how sure it is.
- **Human in the Loop Question** — Use it instead when a single agent action needs approval rather than a set of values.

## Implementation

Reference implementation (React, TypeScript, Tailwind v4, Motion, Radix). Keep every rule from the spec above; if the host project has its own design system, re-express the markup and motion in it rather than copying these classes.

npm dependencies: `lucide-react`, `motion`

Local imports to provide: `@/lib/utils` (`cn` is the usual `clsx` + `tailwind-merge` helper).

```tsx title="component.tsx"
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ArrowUpRight, Check, ChevronDown, Eye, RotateCcw, Search, Sparkles, X, Zap } from "lucide-react";

import { cn } from "@/lib/utils";

export type ReviewStatus = "pending" | "accepted" | "dismissed";

export interface Evidence {
  id: string;
  /** The exact passage the value was extracted from. */
  quote: string;
}

export interface ReviewField {
  key: string;
  label: string;
  /** The value the model extracted. */
  value: string;
  /** Why the model chose this value — one short paragraph. */
  reasoning: string;
  evidence: Evidence[];
  /** Starting status. Defaults to "pending". */
  status?: ReviewStatus;
}

export interface AiReviewPanelProps {
  fields: ReviewField[];
  title?: string;
  /** Fires on every accept, dismiss, restore or undo — and once per field on "Apply all". */
  onStatusChange?: (key: string, status: ReviewStatus) => void;
  /** Hover / focus on a quote: highlight it in the source. `null` clears. */
  onEvidencePreview?: (evidence: Evidence | null) => void;
  /** Click on a quote: scroll the source to it. */
  onEvidenceOpen?: (evidence: Evidence) => void;
  onViewSource?: () => void;
  onClose?: () => void;
  className?: string;
}

type Filter = "all" | ReviewStatus;

const STATUS: Record<
  ReviewStatus,
  { label: string; dot: string; bar: string; badge: string }
> = {
  dismissed: {
    label: "Dismissed",
    dot: "bg-red-500",
    bar: "bg-red-500",
    badge: "bg-red-500/10 text-red-600 dark:text-red-400",
  },
  pending: {
    label: "Pending",
    dot: "bg-amber-500",
    bar: "bg-amber-500",
    badge: "bg-amber-500/15 text-amber-700 dark:text-amber-400",
  },
  accepted: {
    label: "Accepted",
    dot: "bg-emerald-500",
    bar: "bg-emerald-500",
    badge: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-400",
  },
};

const FILTERS: Filter[] = ["all", "pending", "accepted", "dismissed"];

export function AiReviewPanel({
  fields,
  title = "AI Review",
  onStatusChange,
  onEvidencePreview,
  onEvidenceOpen,
  onViewSource,
  onClose,
  className,
}: AiReviewPanelProps) {
  const reduceMotion = useReducedMotion();
  const [status, setStatus] = React.useState<Record<string, ReviewStatus>>(() =>
    Object.fromEntries(fields.map((f) => [f.key, f.status ?? "pending"]))
  );
  const [filter, setFilter] = React.useState<Filter>("all");
  const [query, setQuery] = React.useState("");
  const [expanded, setExpanded] = React.useState<string | null>(null);
  const [announcement, setAnnouncement] = React.useState("");
  const tabRefs = React.useRef<Record<string, HTMLButtonElement | null>>({});
  const listId = React.useId();

  const counts = React.useMemo(() => {
    const c = { all: fields.length, pending: 0, accepted: 0, dismissed: 0 };
    for (const f of fields) c[status[f.key]] += 1;
    return c;
  }, [fields, status]);

  const q = query.trim().toLowerCase();
  const visible = fields.filter(
    (f) =>
      (filter === "all" || status[f.key] === filter) &&
      (!q || f.label.toLowerCase().includes(q) || f.value.toLowerCase().includes(q))
  );

  function set(key: string, next: ReviewStatus) {
    setStatus((s) => ({ ...s, [key]: next }));
    onStatusChange?.(key, next);
    const label = fields.find((f) => f.key === key)?.label ?? key;
    setAnnouncement(`${label} ${next === "pending" ? "moved back to pending" : next}`);
  }

  function applyAll() {
    const pending = fields.filter((f) => status[f.key] === "pending");
    setStatus((s) => ({ ...s, ...Object.fromEntries(pending.map((f) => [f.key, "accepted" as const])) }));
    pending.forEach((f) => onStatusChange?.(f.key, "accepted"));
    setAnnouncement(`${pending.length} field${pending.length === 1 ? "" : "s"} accepted`);
  }

  function onTabKey(e: React.KeyboardEvent, i: number) {
    const dir = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0;
    if (!dir) return;
    e.preventDefault();
    const next = FILTERS[(i + dir + FILTERS.length) % FILTERS.length];
    setFilter(next);
    tabRefs.current[next]?.focus();
  }

  return (
    <aside
      aria-label={title}
      className={cn(
        "flex h-full w-full max-w-sm flex-col overflow-hidden rounded-xl border bg-background text-sm shadow-sm",
        className
      )}
    >
      <header className="flex items-center gap-2 px-4 pb-2 pt-3.5">
        <Sparkles className="size-4 text-violet-500" aria-hidden />
        <h2 className="flex-1 font-medium">{title}</h2>
        {onViewSource && (
          <button
            type="button"
            onClick={onViewSource}
            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"
          >
            <Eye className="size-3.5" aria-hidden />
            View source
          </button>
        )}
        {onClose && (
          <button
            type="button"
            onClick={onClose}
            aria-label="Close"
            className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
          >
            <X className="size-4" />
          </button>
        )}
      </header>

      {/* Progress: one segment per status, sized by share of fields */}
      <div className="px-4 pb-3">
        <div className="mb-1.5 flex items-baseline justify-between">
          <span className="text-xs font-medium">Status</span>
          <span className="text-xs tabular-nums text-muted-foreground">
            {counts.accepted + counts.dismissed} of {counts.all} reviewed
          </span>
        </div>
        <div
          role="img"
          aria-label={`${counts.accepted} accepted, ${counts.pending} pending, ${counts.dismissed} dismissed`}
          className="flex h-1.5 gap-0.5"
        >
          {(["dismissed", "pending", "accepted"] as const).map((s) => (
            <motion.span
              key={s}
              className={cn("h-full rounded-full", STATUS[s].bar)}
              initial={false}
              animate={{ flexGrow: counts[s], opacity: counts[s] ? 1 : 0 }}
              style={{ flexBasis: 0 }}
              transition={reduceMotion ? { duration: 0 } : { type: "spring", stiffness: 260, damping: 30 }}
            />
          ))}
        </div>
      </div>

      <div role="tablist" aria-label="Filter by status" className="flex border-b px-1.5">
        {FILTERS.map((f, i) => {
          const active = filter === f;
          return (
            <button
              key={f}
              ref={(el) => {
                tabRefs.current[f] = el;
              }}
              type="button"
              role="tab"
              aria-selected={active}
              aria-controls={listId}
              tabIndex={active ? 0 : -1}
              onClick={() => setFilter(f)}
              onKeyDown={(e) => onTabKey(e, i)}
              className={cn(
                "relative flex flex-1 items-center justify-center gap-1 whitespace-nowrap px-1 py-2 text-xs transition-colors",
                active ? "font-medium text-foreground" : "text-muted-foreground hover:text-foreground"
              )}
            >
              {f === "all" ? "All" : STATUS[f].label}
              <span
                className={cn(
                  "min-w-4.5 rounded-full px-1 py-px text-center text-[11px] tabular-nums",
                  f === "all" ? "bg-muted text-muted-foreground" : STATUS[f].badge
                )}
              >
                {counts[f]}
              </span>
              {active && (
                <motion.span
                  layoutId={`${listId}-tab`}
                  className="absolute inset-x-1 -bottom-px h-0.5 rounded-full bg-foreground"
                  transition={reduceMotion ? { duration: 0 } : { type: "spring", stiffness: 500, damping: 40 }}
                />
              )}
            </button>
          );
        })}
      </div>

      <div className="px-3 py-2.5">
        <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">
          <Search className="size-3.5 text-muted-foreground" aria-hidden />
          <input
            value={query}
            onChange={(e) => setQuery(e.target.value)}
            placeholder="Search fields…"
            aria-label="Search fields"
            className="min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
          />
        </label>
      </div>

      <div id={listId} role="tabpanel" className="flex-1 overflow-y-auto px-3 pb-3">
        <AnimatePresence initial={false} mode="popLayout">
          {visible.map((f) => (
            <motion.div
              key={f.key}
              layout={!reduceMotion}
              initial={{ opacity: 0, y: 6 }}
              animate={{ opacity: 1, y: 0 }}
              exit={{ opacity: 0, scale: 0.98 }}
              transition={{ duration: 0.18 }}
              className="mb-1.5"
            >
              <FieldCard
                field={f}
                status={status[f.key]}
                open={expanded === f.key}
                onToggle={() => setExpanded((k) => (k === f.key ? null : f.key))}
                onSet={(s) => set(f.key, s)}
                onEvidencePreview={onEvidencePreview}
                onEvidenceOpen={onEvidenceOpen}
              />
            </motion.div>
          ))}
        </AnimatePresence>
        {visible.length === 0 && (
          <p className="px-2 py-8 text-center text-xs text-muted-foreground">
            {q ? `No fields match “${query.trim()}”` : filter === "pending" ? "Nothing left to review" : "No fields here yet"}
          </p>
        )}
      </div>

      <footer className="border-t px-3 py-3">
        <button
          type="button"
          onClick={applyAll}
          disabled={counts.pending === 0}
          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"
        >
          {counts.pending > 0 ? (
            <>
              <Zap className="size-3.5" aria-hidden />
              Apply all pending ({counts.pending})
            </>
          ) : (
            <>
              <Check className="size-3.5" aria-hidden />
              All fields reviewed
            </>
          )}
        </button>
      </footer>

      <span className="sr-only" role="status" aria-live="polite">
        {announcement}
      </span>
    </aside>
  );
}

function FieldCard({
  field,
  status,
  open,
  onToggle,
  onSet,
  onEvidencePreview,
  onEvidenceOpen,
}: {
  field: ReviewField;
  status: ReviewStatus;
  open: boolean;
  onToggle: () => void;
  onSet: (s: ReviewStatus) => void;
  onEvidencePreview?: (e: Evidence | null) => void;
  onEvidenceOpen?: (e: Evidence) => void;
}) {
  const reduceMotion = useReducedMotion();
  const bodyId = React.useId();

  return (
    <div className={cn("overflow-hidden rounded-lg transition-colors", open ? "bg-muted/60" : "hover:bg-muted/40")}>
      <h3>
        <button
          type="button"
          onClick={onToggle}
          aria-expanded={open}
          aria-controls={bodyId}
          className="flex w-full items-center gap-2.5 px-3 py-2.5 text-left"
        >
          <span className={cn("size-2 shrink-0 rounded-full transition-colors", STATUS[status].dot)} aria-hidden />
          <span className="min-w-0 flex-1 truncate">{field.label}</span>
          <span className="sr-only">, {STATUS[status].label}</span>
          {!open && (
            <span className="max-w-[40%] truncate text-xs text-muted-foreground">{field.value}</span>
          )}
          <ChevronDown
            className={cn("size-4 shrink-0 text-muted-foreground transition-transform", open && "rotate-180")}
            aria-hidden
          />
        </button>
      </h3>

      <AnimatePresence initial={false}>
        {open && (
          <motion.div
            id={bodyId}
            initial={reduceMotion ? { opacity: 0 } : { height: 0, opacity: 0 }}
            animate={reduceMotion ? { opacity: 1 } : { height: "auto", opacity: 1 }}
            exit={reduceMotion ? { opacity: 0 } : { height: 0, opacity: 0 }}
            transition={{ duration: 0.2, ease: [0.2, 0, 0, 1] }}
          >
            <div className="flex flex-col gap-2 px-2 pb-2">
              <div className="flex items-center gap-2 rounded-md border bg-background px-3 py-2">
                <Sparkles className="size-3.5 shrink-0 text-violet-500" aria-hidden />
                <span className="font-medium">Value</span>
                <span className="min-w-0 flex-1 truncate text-right text-muted-foreground">{field.value}</span>
              </div>

              <Section title="Reasoning">
                <p className="leading-relaxed text-foreground/90">{field.reasoning}</p>
              </Section>

              {field.evidence.length > 0 && (
                <Section title="Evidence">
                  <ul className="flex flex-col gap-1.5">
                    {field.evidence.map((ev) => (
                      <li key={ev.id}>
                        <button
                          type="button"
                          onMouseEnter={() => onEvidencePreview?.(ev)}
                          onMouseLeave={() => onEvidencePreview?.(null)}
                          onFocus={() => onEvidencePreview?.(ev)}
                          onBlur={() => onEvidencePreview?.(null)}
                          onClick={() => onEvidenceOpen?.(ev)}
                          aria-label={`View in source: ${ev.quote}`}
                          className="group w-full rounded-md bg-muted/60 py-2 pl-2 pr-3 text-left transition-colors hover:bg-violet-500/10"
                        >
                          <span className="block border-l-2 border-violet-500 pl-3 leading-relaxed">“{ev.quote}”</span>
                          <span className="mt-1.5 flex items-center gap-1 pl-3.5 text-[11px] text-violet-600 dark:text-violet-400">
                            <ArrowUpRight className="size-3" aria-hidden />
                            Hover to preview · click to view in text
                          </span>
                        </button>
                      </li>
                    ))}
                  </ul>
                </Section>
              )}

              <div className="flex justify-end gap-1.5 pt-0.5">
                {status === "pending" ? (
                  <>
                    <ActionButton onClick={() => onSet("dismissed")}>
                      <X className="size-3.5" aria-hidden />
                      Dismiss
                    </ActionButton>
                    <ActionButton primary onClick={() => onSet("accepted")}>
                      <Check className="size-3.5" aria-hidden />
                      Accept
                    </ActionButton>
                  </>
                ) : (
                  <ActionButton onClick={() => onSet("pending")}>
                    <RotateCcw className="size-3.5" aria-hidden />
                    {status === "dismissed" ? "Restore" : "Undo"}
                  </ActionButton>
                )}
              </div>
            </div>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

function Section({ title, children }: { title: string; children: React.ReactNode }) {
  const [open, setOpen] = React.useState(true);
  const id = React.useId();
  return (
    <div className="rounded-md border bg-background">
      <button
        type="button"
        onClick={() => setOpen((o) => !o)}
        aria-expanded={open}
        aria-controls={id}
        className="flex w-full items-center gap-2 px-3 py-2 text-left"
      >
        <Sparkles className="size-3.5 shrink-0 text-violet-500" aria-hidden />
        <span className="flex-1 font-medium">{title}</span>
        <ChevronDown
          className={cn("size-4 text-muted-foreground transition-transform", !open && "-rotate-90")}
          aria-hidden
        />
      </button>
      {open && (
        <div id={id} className="px-3 pb-3">
          {children}
        </div>
      )}
    </div>
  );
}

function ActionButton({
  primary,
  onClick,
  children,
}: {
  primary?: boolean;
  onClick: () => void;
  children: React.ReactNode;
}) {
  return (
    <button
      type="button"
      onClick={onClick}
      className={cn(
        "flex h-8 items-center gap-1.5 rounded-md px-3 text-xs font-medium transition-colors",
        primary
          ? "bg-primary text-primary-foreground hover:opacity-90"
          : "border bg-background hover:bg-accent"
      )}
    >
      {children}
    </button>
  );
}
```
