AI Patterns

tables

AI Fill Drawer

A record drawer of key/value fields where the model fills the empty values — each arrives as a sourced suggestion to accept, edit or reject.

CompanyIndustry
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, RotateCcw, Sparkles, Square, X } from "lucide-react";

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

export interface FieldDef {
  key: string;
  label: string;
  /** A value the user (or system) already set. `null` means empty. */
  value: string | null;
  /** Set false for fields the model must never fill (IDs, owners, legal names…). */
  fillable?: boolean;
}

export interface FillResult {
  /** `null` when the model couldn't find a value it trusts. */
  value: string | null;
  /** Where the value came from — shown under the suggestion. */
  source?: string;
}

type FieldState =
  | { status: "set"; value: string }
  | { status: "empty" }
  | { status: "filling" }
  | { status: "suggested"; value: string; source?: string }
  | { status: "missing" };

export interface AiFillDrawerProps {
  open: boolean;
  onClose: () => void;
  title: string;
  subtitle?: string;
  fields: FieldDef[];
  /** Fills a single field. Called once per empty, fillable field. */
  onFill: (key: string) => Promise<FillResult>;
  /** Fires whenever a value is committed — typed, or an accepted suggestion. */
  onChange?: (key: string, value: string | null) => void;
  className?: string;
}

const initialState = (f: FieldDef): FieldState =>
  f.value ? { status: "set", value: f.value } : { status: "empty" };

export function AiFillDrawer({
  open,
  onClose,
  title,
  subtitle,
  fields,
  onFill,
  onChange,
  className,
}: AiFillDrawerProps) {
  const reduceMotion = useReducedMotion();
  const [state, setState] = React.useState<Record<string, FieldState>>(() =>
    Object.fromEntries(fields.map((f) => [f.key, initialState(f)]))
  );
  const [editing, setEditing] = React.useState<string | null>(null);
  /** Bumped by Stop — results from an older run are dropped. */
  const run = React.useRef(0);
  const panelRef = React.useRef<HTMLElement>(null);
  const wasOpen = React.useRef(open);

  React.useEffect(() => {
    const justOpened = open && !wasOpen.current;
    wasOpen.current = open;
    if (!open) return;
    if (justOpened) panelRef.current?.focus({ preventScroll: true });
    function onKey(e: KeyboardEvent) {
      if (e.key === "Escape" && !(e.target instanceof HTMLInputElement)) onClose();
    }
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [open, onClose]);

  const patch = (key: string, next: FieldState) => setState((s) => ({ ...s, [key]: next }));

  const isFillable = (f: FieldDef) => f.fillable !== false;
  const fillableEmpty = fields.filter(
    (f) => isFillable(f) && (state[f.key].status === "empty" || state[f.key].status === "missing")
  );
  const filling = fields.filter((f) => state[f.key].status === "filling");
  const suggested = fields.filter((f) => state[f.key].status === "suggested");
  const busy = filling.length > 0;

  function fill(keys: string[]) {
    const id = run.current;
    for (const key of keys) {
      patch(key, { status: "filling" });
      onFill(key)
        .then((r) => {
          if (run.current !== id) return;
          patch(key, r.value ? { status: "suggested", value: r.value, source: r.source } : { status: "missing" });
        })
        .catch(() => run.current === id && patch(key, { status: "missing" }));
    }
  }

  function stop() {
    run.current += 1;
    setState((s) =>
      Object.fromEntries(
        Object.entries(s).map(([k, v]) => [k, v.status === "filling" ? { status: "empty" } : v])
      )
    );
  }

  function commit(key: string, value: string | null) {
    patch(key, value ? { status: "set", value } : { status: "empty" });
    onChange?.(key, value);
  }

  function accept(key: string) {
    const s = state[key];
    if (s.status === "suggested") commit(key, s.value);
  }

  function reject(key: string) {
    patch(key, { status: "empty" });
  }

  return (
    <AnimatePresence>
      {open && (
        <motion.aside
          ref={panelRef}
          tabIndex={-1}
          aria-label={title}
          initial={reduceMotion ? { opacity: 0 } : { x: "100%", opacity: 0.6 }}
          animate={{ x: 0, opacity: 1 }}
          exit={reduceMotion ? { opacity: 0 } : { x: "100%", opacity: 0.6 }}
          transition={{ type: "spring", stiffness: 380, damping: 38 }}
          className={cn(
            "flex h-full w-full max-w-sm flex-col overflow-hidden border-l bg-background text-sm shadow-xl outline-none",
            className
          )}
        >
          <header className="flex items-start gap-2 px-5 pb-3 pt-4">
            <div className="min-w-0 flex-1">
              <h2 className="truncate font-medium">{title}</h2>
              {subtitle && <p className="truncate text-xs text-muted-foreground">{subtitle}</p>}
            </div>
            <button
              type="button"
              onClick={onClose}
              aria-label="Close"
              className="flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
            >
              <X className="size-4" />
            </button>
          </header>

          <div className="flex items-center gap-2 border-b px-5 pb-3">
            {busy ? (
              <button
                type="button"
                onClick={stop}
                className="flex h-8 items-center gap-1.5 rounded-full border px-3 font-medium transition-colors hover:bg-accent"
              >
                <Square className="size-3 fill-current" aria-hidden />
                Stop
              </button>
            ) : (
              <button
                type="button"
                onClick={() => fill(fillableEmpty.map((f) => f.key))}
                disabled={fillableEmpty.length === 0}
                className="flex h-8 items-center gap-1.5 rounded-full border px-3 font-medium transition-colors hover:bg-accent disabled:opacity-40 disabled:hover:bg-transparent"
              >
                <Sparkles className="size-3.5" aria-hidden />
                Fill with AI
              </button>
            )}
            <span className="text-xs text-muted-foreground" role="status" aria-live="polite">
              {busy
                ? `Filling ${filling.length} field${filling.length === 1 ? "" : "s"}…`
                : fillableEmpty.length > 0
                  ? `${fillableEmpty.length} empty`
                  : "All fields have values"}
            </span>
          </div>

          <dl className="flex flex-1 flex-col gap-1.5 overflow-y-auto px-2 py-3">
            {fields.map((f) => (
              <FieldRow
                key={f.key}
                field={f}
                state={state[f.key]}
                editing={editing === f.key}
                onEdit={() => setEditing(f.key)}
                onEditDone={(value) => {
                  setEditing(null);
                  if (value !== undefined) commit(f.key, value);
                }}
                onFill={() => fill([f.key])}
                onAccept={() => accept(f.key)}
                onReject={() => reject(f.key)}
              />
            ))}
          </dl>

          <AnimatePresence initial={false}>
            {suggested.length > 0 && !busy && (
              <motion.footer
                initial={{ y: 12, opacity: 0 }}
                animate={{ y: 0, opacity: 1 }}
                exit={{ y: 12, opacity: 0 }}
                transition={{ duration: 0.18 }}
                className="flex items-center gap-2 border-t bg-muted/40 px-5 py-3"
              >
                <span className="flex-1 text-xs text-muted-foreground">
                  {suggested.length} AI value{suggested.length === 1 ? "" : "s"} to review
                </span>
                <button
                  type="button"
                  onClick={() => suggested.forEach((f) => reject(f.key))}
                  className="h-8 rounded-full px-3 text-xs text-muted-foreground transition-colors hover:text-foreground"
                >
                  Reject all
                </button>
                <button
                  type="button"
                  onClick={() => suggested.forEach((f) => accept(f.key))}
                  className="h-8 rounded-full bg-primary px-3 text-xs font-medium text-primary-foreground transition-opacity hover:opacity-90"
                >
                  Accept all
                </button>
              </motion.footer>
            )}
          </AnimatePresence>
        </motion.aside>
      )}
    </AnimatePresence>
  );
}

function FieldRow({
  field,
  state,
  editing,
  onEdit,
  onEditDone,
  onFill,
  onAccept,
  onReject,
}: {
  field: FieldDef;
  state: FieldState;
  editing: boolean;
  onEdit: () => void;
  /** `undefined` = cancelled. */
  onEditDone: (value?: string | null) => void;
  onFill: () => void;
  onAccept: () => void;
  onReject: () => void;
}) {
  const labelId = React.useId();
  // The input blurs as it unmounts after Enter/Esc; only the first exit counts.
  const exited = React.useRef(false);
  function exit(value?: string | null) {
    if (exited.current) return;
    exited.current = true;
    onEditDone(value);
  }
  React.useEffect(() => {
    if (editing) exited.current = false;
  }, [editing]);
  const suggested = state.status === "suggested";
  const current = state.status === "set" || state.status === "suggested" ? state.value : "";

  return (
    <div
      className={cn(
        "group grid grid-cols-[7.5rem_1fr] items-start gap-3 rounded-lg px-3 py-2 transition-colors",
        suggested && "bg-muted/60"
      )}
    >
      <dt id={labelId} className="truncate pt-1.5 text-xs text-muted-foreground">
        {field.label}
      </dt>
      <dd className="min-w-0">
        {editing ? (
          <input
            autoFocus
            defaultValue={current}
            aria-labelledby={labelId}
            onBlur={(e) => exit(e.target.value.trim() || null)}
            onKeyDown={(e) => {
              if (e.key === "Enter") exit(e.currentTarget.value.trim() || null);
              if (e.key === "Escape") {
                e.preventDefault();
                exit(undefined);
              }
            }}
            className="h-8 w-full rounded-md border bg-background px-2 outline-none focus:ring-2 focus:ring-ring/40"
          />
        ) : (
          <AnimatePresence mode="wait" initial={false}>
            <motion.div
              key={state.status}
              initial={{ opacity: 0, y: 3 }}
              animate={{ opacity: 1, y: 0 }}
              exit={{ opacity: 0 }}
              transition={{ duration: 0.16 }}
              className="flex min-h-8 items-start gap-1"
            >
              {state.status === "filling" ? (
                <span className="flex h-8 flex-1 items-center" aria-label="Filling">
                  <span className="h-3 w-3/4 animate-pulse rounded-full bg-muted-foreground/15" />
                </span>
              ) : (
                <>
                  <button
                    type="button"
                    onClick={onEdit}
                    aria-label={`Edit ${field.label}`}
                    className="-mx-1.5 min-w-0 flex-1 rounded-md px-1.5 py-1.5 text-left transition-colors hover:bg-accent"
                  >
                    {state.status === "set" || state.status === "suggested" ? (
                      <span className="flex items-start gap-1.5">
                        {suggested && (
                          <Sparkles className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" aria-label="AI suggestion" />
                        )}
                        <span className="break-words">{state.value}</span>
                      </span>
                    ) : state.status === "missing" ? (
                      <span className="italic text-muted-foreground">Couldn&apos;t find a value</span>
                    ) : (
                      <span className="text-muted-foreground/60">Empty</span>
                    )}
                  </button>

                  {suggested && (
                    <span className="flex shrink-0 items-center">
                      <IconButton label={`Accept AI value for ${field.label}`} onClick={onAccept}>
                        <Check className="size-3.5" />
                      </IconButton>
                      <IconButton label={`Reject AI value for ${field.label}`} onClick={onReject}>
                        <X className="size-3.5" />
                      </IconButton>
                    </span>
                  )}
                  {(state.status === "empty" || state.status === "missing") && field.fillable !== false && (
                    <IconButton
                      label={`${state.status === "missing" ? "Retry" : "Fill"} ${field.label} with AI`}
                      onClick={onFill}
                      className="opacity-0 group-focus-within:opacity-100 group-hover:opacity-100"
                    >
                      {state.status === "missing" ? <RotateCcw className="size-3.5" /> : <Sparkles className="size-3.5" />}
                    </IconButton>
                  )}
                </>
              )}
            </motion.div>
          </AnimatePresence>
        )}
        {suggested && state.source && !editing && (
          <p className="truncate pt-0.5 text-xs text-muted-foreground">from {state.source}</p>
        )}
      </dd>
    </div>
  );
}

function IconButton({
  label,
  onClick,
  className,
  children,
}: {
  label: string;
  onClick: () => void;
  className?: string;
  children: React.ReactNode;
}) {
  return (
    <button
      type="button"
      onClick={onClick}
      aria-label={label}
      title={label}
      className={cn(
        "flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground",
        className
      )}
    >
      {children}
    </button>
  );
}

A UX spec for this pattern — written for agents implementing or reusing it, not the code.

# AI Fill Drawer

## Summary
A record-details drawer that lists a record's fields as key/value rows and lets the model fill the empty values. "Fill with AI" fills every empty field at once. Each value arrives on its own as a marked suggestion, with the source it came from, and only becomes the record's real value once the user accepts or edits it. Values the user already set are never touched.

## When to use
- Enrichment: CRM accounts, contacts, products, tickets or assets where many fields can be found from the web, documents or connected tools.
- Records opened from a table row, where the drawer keeps the table in view for context.
- Any form where most fields are guessable and the user's job is to check, not type.

## When not to use
- Fields whose value is a decision, not a fact: owner, stage, price, approval. Mark them not fillable.
- Identifiers and legal or regulated data (tax IDs, contract terms) where a plausible wrong value is worse than an empty one.
- Bulk enrichment of hundreds of rows. Run a background job and review the result in the table instead.
- Long free-text fields. A drafted description or note belongs in an editor with Refinement Controls, not a one-line value cell.

## Anatomy
- **Header** — record title, a one-line subtitle (type, created date), close.
- **Action bar** — "Fill with AI" (becomes "Stop" while running) and a status line ("4 empty", "Filling 3 fields…", "All fields have values").
- **Field list** — a definition list: muted key on the left, value on the right. A row can be:
  - **Set** — plain value, click to edit.
  - **Empty** — muted "Empty"; on hover or focus, a per-field ✦ fill button.
  - **Filling** — a pulsing skeleton bar in place of the value.
  - **Suggested** — a tinted row, ✦ before the value, "from <source>" underneath, and accept ✓ / reject × buttons.
  - **Not found** — italic "Couldn't find a value" with a retry button.
- **Review footer** — slides up once filling ends and suggestions remain: "3 AI values to review · Reject all · Accept all".

## Behavior
- "Fill with AI" requests every empty, fillable field in parallel. Rows resolve independently, in whatever order the answers arrive. Don't hold all of them back until the slowest finishes.
- Only empty fields are filled. A value the user set — or accepted — is never overwritten by a later fill.
- Stop cancels the run: rows still filling go back to Empty, and late answers from that run are ignored. Suggestions that already arrived stay.
- A suggestion is not the record's value until it is accepted. `onChange` fires only on accept or on an edit.
- Accept turns the row into a plain set value. Reject empties it again and leaves it fillable.
- Clicking any value, including a suggestion, edits it inline. Enter or blur saves, Esc cancels. Saving an edited suggestion counts as accepting the user's value.
- When the model finds nothing, say so ("Couldn't find a value") instead of guessing. Retry is per field.
- The review footer is hidden while filling, so Accept all can't accept a half-finished set.
- Esc closes the drawer, unless focus is in a field editor.

## Content guidelines
- Keys are short nouns in the record's own vocabulary ("Headquarters", not "Company HQ location").
- Suggested values use the same format the field would use if typed: ranges like "1,001–5,000", bare domains, no sentences.
- Always show a source when the model has one — a page, a document, a tool. "Web search" is acceptable; no source at all should be rare.
- The status line counts, never apologizes: "2 empty", not "We couldn't fill 2 fields".

## Accessibility
- Fields are a `<dl>` of `<dt>`/`<dd>` pairs, so screen readers announce key and value together.
- Each value is a button labelled "Edit <field>"; the inline input is labelled by its key.
- Accept, reject, fill and retry are icon buttons with full labels ("Accept AI value for Industry").
- The ✦ marker has an accessible label ("AI suggestion"), and the tinted row is never the only signal — the accept/reject buttons and the source line also mark it.
- The status line is a polite live region, so progress ("Filling 3 fields…") and completion are announced.
- The drawer takes focus when opened. With `prefers-reduced-motion`, it fades instead of sliding.

## Related patterns
- Describe Filters — the table this drawer is usually opened from.
- Confidence Indicator — add next to a suggestion when the model can report how sure it is.
- Source Trust Card — expand "from <source>" into a full source preview.
- Background Tasks Drawer — the same drawer shell, for long-running work.