{"slug":"ai-fill-drawer","category":"tables","job":"ask","added":"2026-11-03T12:17Z","title":"AI Fill Drawer","description":"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.","url":"https://ai-patterns.app/patterns/tables/ai-fill-drawer","markdown":"https://ai-patterns.app/patterns/tables/ai-fill-drawer.md","json":"https://ai-patterns.app/api/patterns/tables/ai-fill-drawer","seenIn":[{"team":"Notion","source":"https://www.notion.com/help/autofill"},{"team":"Linear","source":"https://linear.app/docs/triage-intelligence"},{"team":"coda","source":"https://help.coda.io/en/articles/7988177-coda-ai-features"}],"spec":"# AI Fill Drawer\n\n## Summary\nA 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.\n\n## When to use\n- Enrichment: CRM accounts, contacts, products, tickets or assets where many fields can be found from the web, documents or connected tools.\n- Records opened from a table row, where the drawer keeps the table in view for context.\n- Any form where most fields are guessable and the user's job is to check, not type.\n\n## When not to use\n- Fields whose value is a decision, not a fact: owner, stage, price, approval. Mark them not fillable.\n- Identifiers and legal or regulated data (tax IDs, contract terms) where a plausible wrong value is worse than an empty one.\n- Bulk enrichment of hundreds of rows. Run a background job and review the result in the table instead.\n- Long free-text fields. A drafted description or note belongs in an editor with Refinement Controls, not a one-line value cell.\n\n## Anatomy\n- **Header** — record title, a one-line subtitle (type, created date), close.\n- **Action bar** — \"Fill with AI\" (becomes \"Stop\" while running) and a status line (\"4 empty\", \"Filling 3 fields…\", \"All fields have values\").\n- **Field list** — a definition list: muted key on the left, value on the right. A row can be:\n  - **Set** — plain value, click to edit.\n  - **Empty** — muted \"Empty\"; on hover or focus, a per-field ✦ fill button.\n  - **Filling** — a pulsing skeleton bar in place of the value.\n  - **Suggested** — a tinted row, ✦ before the value, \"from <source>\" underneath, and accept ✓ / reject × buttons.\n  - **Not found** — italic \"Couldn't find a value\" with a retry button.\n- **Review footer** — slides up once filling ends and suggestions remain: \"3 AI values to review · Reject all · Accept all\".\n\n## Behavior\n- \"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.\n- Only empty fields are filled. A value the user set — or accepted — is never overwritten by a later fill.\n- Stop cancels the run: rows still filling go back to Empty, and late answers from that run are ignored. Suggestions that already arrived stay.\n- A suggestion is not the record's value until it is accepted. `onChange` fires only on accept or on an edit.\n- Accept turns the row into a plain set value. Reject empties it again and leaves it fillable.\n- 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.\n- When the model finds nothing, say so (\"Couldn't find a value\") instead of guessing. Retry is per field.\n- The review footer is hidden while filling, so Accept all can't accept a half-finished set.\n- Esc closes the drawer, unless focus is in a field editor.\n\n## Content guidelines\n- Keys are short nouns in the record's own vocabulary (\"Headquarters\", not \"Company HQ location\").\n- Suggested values use the same format the field would use if typed: ranges like \"1,001–5,000\", bare domains, no sentences.\n- 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.\n- The status line counts, never apologizes: \"2 empty\", not \"We couldn't fill 2 fields\".\n\n## Accessibility\n- Fields are a `<dl>` of `<dt>`/`<dd>` pairs, so screen readers announce key and value together.\n- Each value is a button labelled \"Edit <field>\"; the inline input is labelled by its key.\n- Accept, reject, fill and retry are icon buttons with full labels (\"Accept AI value for Industry\").\n- 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.\n- The status line is a polite live region, so progress (\"Filling 3 fields…\") and completion are announced.\n- The drawer takes focus when opened. With `prefers-reduced-motion`, it fades instead of sliding.\n\n## Related patterns\n- **Describe Filters** — Use it as well, since it's the table this drawer is usually opened from.\n- **Confidence Indicator** — Use it as well next to a suggestion when the model can report how sure it is.\n- **Source Trust Card** — Use it as well to expand a suggestion's source into a full preview.\n- **Background Tasks Drawer** — Use it instead when the drawer tracks long-running work rather than suggested values.\n","code":"\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { Check, RotateCcw, Sparkles, Square, X } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport interface FieldDef {\n  key: string;\n  label: string;\n  /** A value the user (or system) already set. `null` means empty. */\n  value: string | null;\n  /** Set false for fields the model must never fill (IDs, owners, legal names…). */\n  fillable?: boolean;\n}\n\nexport interface FillResult {\n  /** `null` when the model couldn't find a value it trusts. */\n  value: string | null;\n  /** Where the value came from — shown under the suggestion. */\n  source?: string;\n}\n\ntype FieldState =\n  | { status: \"set\"; value: string }\n  | { status: \"empty\" }\n  | { status: \"filling\" }\n  | { status: \"suggested\"; value: string; source?: string }\n  | { status: \"missing\" };\n\nexport interface AiFillDrawerProps {\n  open: boolean;\n  onClose: () => void;\n  title: string;\n  subtitle?: string;\n  fields: FieldDef[];\n  /** Fills a single field. Called once per empty, fillable field. */\n  onFill: (key: string) => Promise<FillResult>;\n  /** Fires whenever a value is committed — typed, or an accepted suggestion. */\n  onChange?: (key: string, value: string | null) => void;\n  className?: string;\n}\n\nconst initialState = (f: FieldDef): FieldState =>\n  f.value ? { status: \"set\", value: f.value } : { status: \"empty\" };\n\nexport function AiFillDrawer({\n  open,\n  onClose,\n  title,\n  subtitle,\n  fields,\n  onFill,\n  onChange,\n  className,\n}: AiFillDrawerProps) {\n  const reduceMotion = useReducedMotion();\n  const [state, setState] = React.useState<Record<string, FieldState>>(() =>\n    Object.fromEntries(fields.map((f) => [f.key, initialState(f)]))\n  );\n  const [editing, setEditing] = React.useState<string | null>(null);\n  /** Bumped by Stop — results from an older run are dropped. */\n  const run = React.useRef(0);\n  const panelRef = React.useRef<HTMLElement>(null);\n  const wasOpen = React.useRef(open);\n\n  React.useEffect(() => {\n    const justOpened = open && !wasOpen.current;\n    wasOpen.current = open;\n    if (!open) return;\n    if (justOpened) panelRef.current?.focus({ preventScroll: true });\n    function onKey(e: KeyboardEvent) {\n      if (e.key === \"Escape\" && !(e.target instanceof HTMLInputElement)) onClose();\n    }\n    window.addEventListener(\"keydown\", onKey);\n    return () => window.removeEventListener(\"keydown\", onKey);\n  }, [open, onClose]);\n\n  const patch = (key: string, next: FieldState) => setState((s) => ({ ...s, [key]: next }));\n\n  const isFillable = (f: FieldDef) => f.fillable !== false;\n  const fillableEmpty = fields.filter(\n    (f) => isFillable(f) && (state[f.key].status === \"empty\" || state[f.key].status === \"missing\")\n  );\n  const filling = fields.filter((f) => state[f.key].status === \"filling\");\n  const suggested = fields.filter((f) => state[f.key].status === \"suggested\");\n  const busy = filling.length > 0;\n\n  function fill(keys: string[]) {\n    const id = run.current;\n    for (const key of keys) {\n      patch(key, { status: \"filling\" });\n      onFill(key)\n        .then((r) => {\n          if (run.current !== id) return;\n          patch(key, r.value ? { status: \"suggested\", value: r.value, source: r.source } : { status: \"missing\" });\n        })\n        .catch(() => run.current === id && patch(key, { status: \"missing\" }));\n    }\n  }\n\n  function stop() {\n    run.current += 1;\n    setState((s) =>\n      Object.fromEntries(\n        Object.entries(s).map(([k, v]) => [k, v.status === \"filling\" ? { status: \"empty\" } : v])\n      )\n    );\n  }\n\n  function commit(key: string, value: string | null) {\n    patch(key, value ? { status: \"set\", value } : { status: \"empty\" });\n    onChange?.(key, value);\n  }\n\n  function accept(key: string) {\n    const s = state[key];\n    if (s.status === \"suggested\") commit(key, s.value);\n  }\n\n  function reject(key: string) {\n    patch(key, { status: \"empty\" });\n  }\n\n  return (\n    <AnimatePresence>\n      {open && (\n        <motion.aside\n          ref={panelRef}\n          tabIndex={-1}\n          aria-label={title}\n          initial={reduceMotion ? { opacity: 0 } : { x: \"100%\", opacity: 0.6 }}\n          animate={{ x: 0, opacity: 1 }}\n          exit={reduceMotion ? { opacity: 0 } : { x: \"100%\", opacity: 0.6 }}\n          transition={{ type: \"spring\", stiffness: 380, damping: 38 }}\n          className={cn(\n            \"flex h-full w-full max-w-sm flex-col overflow-hidden border-l bg-background text-sm shadow-xl outline-none\",\n            className\n          )}\n        >\n          <header className=\"flex items-start gap-2 px-5 pb-3 pt-4\">\n            <div className=\"min-w-0 flex-1\">\n              <h2 className=\"truncate font-medium\">{title}</h2>\n              {subtitle && <p className=\"truncate text-xs text-muted-foreground\">{subtitle}</p>}\n            </div>\n            <button\n              type=\"button\"\n              onClick={onClose}\n              aria-label=\"Close\"\n              className=\"flex size-7 shrink-0 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          </header>\n\n          <div className=\"flex items-center gap-2 border-b px-5 pb-3\">\n            {busy ? (\n              <button\n                type=\"button\"\n                onClick={stop}\n                className=\"flex h-8 items-center gap-1.5 rounded-full border px-3 font-medium transition-colors hover:bg-accent\"\n              >\n                <Square className=\"size-3 fill-current\" aria-hidden />\n                Stop\n              </button>\n            ) : (\n              <button\n                type=\"button\"\n                onClick={() => fill(fillableEmpty.map((f) => f.key))}\n                disabled={fillableEmpty.length === 0}\n                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\"\n              >\n                <Sparkles className=\"size-3.5\" aria-hidden />\n                Fill with AI\n              </button>\n            )}\n            <span className=\"text-xs text-muted-foreground\" role=\"status\" aria-live=\"polite\">\n              {busy\n                ? `Filling ${filling.length} field${filling.length === 1 ? \"\" : \"s\"}…`\n                : fillableEmpty.length > 0\n                  ? `${fillableEmpty.length} empty`\n                  : \"All fields have values\"}\n            </span>\n          </div>\n\n          <dl className=\"flex flex-1 flex-col gap-1.5 overflow-y-auto px-2 py-3\">\n            {fields.map((f) => (\n              <FieldRow\n                key={f.key}\n                field={f}\n                state={state[f.key]}\n                editing={editing === f.key}\n                onEdit={() => setEditing(f.key)}\n                onEditDone={(value) => {\n                  setEditing(null);\n                  if (value !== undefined) commit(f.key, value);\n                }}\n                onFill={() => fill([f.key])}\n                onAccept={() => accept(f.key)}\n                onReject={() => reject(f.key)}\n              />\n            ))}\n          </dl>\n\n          <AnimatePresence initial={false}>\n            {suggested.length > 0 && !busy && (\n              <motion.footer\n                initial={{ y: 12, opacity: 0 }}\n                animate={{ y: 0, opacity: 1 }}\n                exit={{ y: 12, opacity: 0 }}\n                transition={{ duration: 0.18 }}\n                className=\"flex items-center gap-2 border-t bg-muted/40 px-5 py-3\"\n              >\n                <span className=\"flex-1 text-xs text-muted-foreground\">\n                  {suggested.length} AI value{suggested.length === 1 ? \"\" : \"s\"} to review\n                </span>\n                <button\n                  type=\"button\"\n                  onClick={() => suggested.forEach((f) => reject(f.key))}\n                  className=\"h-8 rounded-full px-3 text-xs text-muted-foreground transition-colors hover:text-foreground\"\n                >\n                  Reject all\n                </button>\n                <button\n                  type=\"button\"\n                  onClick={() => suggested.forEach((f) => accept(f.key))}\n                  className=\"h-8 rounded-full bg-primary px-3 text-xs font-medium text-primary-foreground transition-opacity hover:opacity-90\"\n                >\n                  Accept all\n                </button>\n              </motion.footer>\n            )}\n          </AnimatePresence>\n        </motion.aside>\n      )}\n    </AnimatePresence>\n  );\n}\n\nfunction FieldRow({\n  field,\n  state,\n  editing,\n  onEdit,\n  onEditDone,\n  onFill,\n  onAccept,\n  onReject,\n}: {\n  field: FieldDef;\n  state: FieldState;\n  editing: boolean;\n  onEdit: () => void;\n  /** `undefined` = cancelled. */\n  onEditDone: (value?: string | null) => void;\n  onFill: () => void;\n  onAccept: () => void;\n  onReject: () => void;\n}) {\n  const labelId = React.useId();\n  // The input blurs as it unmounts after Enter/Esc; only the first exit counts.\n  const exited = React.useRef(false);\n  function exit(value?: string | null) {\n    if (exited.current) return;\n    exited.current = true;\n    onEditDone(value);\n  }\n  React.useEffect(() => {\n    if (editing) exited.current = false;\n  }, [editing]);\n  const suggested = state.status === \"suggested\";\n  const current = state.status === \"set\" || state.status === \"suggested\" ? state.value : \"\";\n\n  return (\n    <div\n      className={cn(\n        \"group grid grid-cols-[7.5rem_1fr] items-start gap-3 rounded-lg px-3 py-2 transition-colors\",\n        suggested && \"bg-muted/60\"\n      )}\n    >\n      <dt id={labelId} className=\"truncate pt-1.5 text-xs text-muted-foreground\">\n        {field.label}\n      </dt>\n      <dd className=\"min-w-0\">\n        {editing ? (\n          <input\n            autoFocus\n            defaultValue={current}\n            aria-labelledby={labelId}\n            onBlur={(e) => exit(e.target.value.trim() || null)}\n            onKeyDown={(e) => {\n              if (e.key === \"Enter\") exit(e.currentTarget.value.trim() || null);\n              if (e.key === \"Escape\") {\n                e.preventDefault();\n                exit(undefined);\n              }\n            }}\n            className=\"h-8 w-full rounded-md border bg-background px-2 outline-none focus:ring-2 focus:ring-ring/40\"\n          />\n        ) : (\n          <AnimatePresence mode=\"wait\" initial={false}>\n            <motion.div\n              key={state.status}\n              initial={{ opacity: 0, y: 3 }}\n              animate={{ opacity: 1, y: 0 }}\n              exit={{ opacity: 0 }}\n              transition={{ duration: 0.16 }}\n              className=\"flex min-h-8 items-start gap-1\"\n            >\n              {state.status === \"filling\" ? (\n                <span className=\"flex h-8 flex-1 items-center\" aria-label=\"Filling\">\n                  <span className=\"h-3 w-3/4 animate-pulse rounded-full bg-muted-foreground/15\" />\n                </span>\n              ) : (\n                <>\n                  <button\n                    type=\"button\"\n                    onClick={onEdit}\n                    aria-label={`Edit ${field.label}`}\n                    className=\"-mx-1.5 min-w-0 flex-1 rounded-md px-1.5 py-1.5 text-left transition-colors hover:bg-accent\"\n                  >\n                    {state.status === \"set\" || state.status === \"suggested\" ? (\n                      <span className=\"flex items-start gap-1.5\">\n                        {suggested && (\n                          <Sparkles className=\"mt-0.5 size-3.5 shrink-0 text-muted-foreground\" aria-label=\"AI suggestion\" />\n                        )}\n                        <span className=\"break-words\">{state.value}</span>\n                      </span>\n                    ) : state.status === \"missing\" ? (\n                      <span className=\"italic text-muted-foreground\">Couldn&apos;t find a value</span>\n                    ) : (\n                      <span className=\"text-muted-foreground/60\">Empty</span>\n                    )}\n                  </button>\n\n                  {suggested && (\n                    <span className=\"flex shrink-0 items-center\">\n                      <IconButton label={`Accept AI value for ${field.label}`} onClick={onAccept}>\n                        <Check className=\"size-3.5\" />\n                      </IconButton>\n                      <IconButton label={`Reject AI value for ${field.label}`} onClick={onReject}>\n                        <X className=\"size-3.5\" />\n                      </IconButton>\n                    </span>\n                  )}\n                  {(state.status === \"empty\" || state.status === \"missing\") && field.fillable !== false && (\n                    <IconButton\n                      label={`${state.status === \"missing\" ? \"Retry\" : \"Fill\"} ${field.label} with AI`}\n                      onClick={onFill}\n                      className=\"opacity-0 group-focus-within:opacity-100 group-hover:opacity-100\"\n                    >\n                      {state.status === \"missing\" ? <RotateCcw className=\"size-3.5\" /> : <Sparkles className=\"size-3.5\" />}\n                    </IconButton>\n                  )}\n                </>\n              )}\n            </motion.div>\n          </AnimatePresence>\n        )}\n        {suggested && state.source && !editing && (\n          <p className=\"truncate pt-0.5 text-xs text-muted-foreground\">from {state.source}</p>\n        )}\n      </dd>\n    </div>\n  );\n}\n\nfunction IconButton({\n  label,\n  onClick,\n  className,\n  children,\n}: {\n  label: string;\n  onClick: () => void;\n  className?: string;\n  children: React.ReactNode;\n}) {\n  return (\n    <button\n      type=\"button\"\n      onClick={onClick}\n      aria-label={label}\n      title={label}\n      className={cn(\n        \"flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground\",\n        className\n      )}\n    >\n      {children}\n    </button>\n  );\n}\n","dependencies":["lucide-react","motion"],"localImports":["@/lib/utils"]}