AI

AI Form Assist

Model-filled form fields — a sparkle per row, ghost-rendered values with a confidence tint, source popovers, a staggered fill-all wave, and per-field accept, dismiss and undo.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  ArrowRight,
  Check,
  CheckCheck,
  CornerDownLeft,
  ExternalLink,
  FileText,
  Loader2,
  Sparkles,
  TriangleAlert,
  Undo2,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/ai-form-assist.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "AiFormAssist" component: a form whose
fields can be filled by a model, one field at a time or all at once, where a
person always presses Accept before anything is written. Dependencies:
lucide-react for icons and the Radix Popover primitive (package "radix-ui") for
the source hint. No form library.

Contract
- forwardRef<AiFormAssistHandle, AiFormAssistProps>; props extend
  React.HTMLAttributes<HTMLDivElement> minus children / defaultValue / onChange,
  and the rest spreads onto the root div.
- fields: AiFormAssistField[] — { name (also the control's name attribute),
  label, hint?, placeholder?, type? (native input type), multiline?, rows? = 3,
  required?, autoComplete?, inputMode?, assist? = true, skipFill? = false }.
  assist:false renders a plain row with no assistant affordance at all (card
  number, PIN); skipFill keeps the row's own button but excludes it from the
  wave.
- onSuggest(field, { signal, values, trigger }) => Suggestion | string | null |
  Promise<…>. Suggestion = { value, confidence? (0..1), source?, note? }.
  Source = { label, detail?, excerpt?, href? }. trigger is "field" | "fill-all".
  A bare string is shorthand for { value }; null (or an empty value) means
  "nothing to offer"; a rejection becomes a retryable per-field error.
- values? / defaultValues?: Record<string, string> — controlled or uncontrolled.
- onValuesChange(next, { names, cause }) with cause "input" | "accept" |
  "accept-all" | "undo"; `names` lists what moved.
- onAccept / onReject / onUndo receive the field object plus the value, the
  previous value, confidence, source and a `bulk` flag.
- heading?, description?, fillMode? = "empty" | "all", staggerMs? = 120,
  bulkActionsFrom? = 2, confidenceThresholds? = { high: 0.8, medium: 0.5 },
  showConfidence? = true, disabled?, announce? = "status" | "off", plus label
  overrides (fillAllLabel, cancelLabel, acceptLabel, rejectLabel, undoLabel,
  retryLabel).
- The ref is an imperative handle: suggest(name) => boolean, fillAll(), stop(),
  accept(name), dismiss(name), focus(name) — so a toolbar, a keyboard shortcut
  or a wizard step elsewhere can drive the same rows.

Behavior — one machine per row
- Each field owns: idle → requesting → suggested → (accepted | refused), plus an
  error branch with Retry. Keep the states in a Record<name, FieldState>; keep
  in-flight runs in a ref'd Map<name, { controller, runId, fillId }>.
- One run per field: pressing the button again while a request is in flight is a
  no-op, not a second request the user pays for.
- Wrap the callback in `new Promise(resolve => resolve(onSuggest(...)))` — not
  Promise.resolve(fn()) — so a synchronous throw becomes a rejection instead of
  escaping and pinning the row at "requesting" forever.
- On settle, claim the result: it is only applied if the signal is not aborted
  AND the map still holds this field with this runId. A per-field id, not one
  global counter: several rows are legitimately in flight at once, and a global
  counter would make every answer except the last look stale.
- Record the field's value at request time as the suggestion's `baseline`. If
  the user edits the field afterwards, keep the suggestion and flag it stale
  ("you edited this field after asking — accepting replaces what is in it now"),
  never silently re-target it or throw it away.
- Reading values inside a late callback (staggered timer, arriving answer) must
  go through a ref that is refreshed on commit — a render closure captured at
  click time would compute the baseline from text the user has since changed.

Behavior — fill-all wave
- fillAll() picks the eligible rows (assist, not skipFill, not in flight, no
  pending suggestion, and blank unless fillMode="all"), then fires one request
  every staggerMs via setTimeout, keeping every timer id in a Set.
- Track the wave as { id, total, answered }; each settled row increments
  answered and the wave clears itself at total. A refused start must also count,
  or the wave never reports done.
- Stop bumps the wave id FIRST (so a timer firing mid-teardown finds a dead id),
  clears every pending timer, aborts only the runs tagged with that wave id, and
  returns those rows to idle. Values are untouched.
- Zero eligible rows is not an error: say why ("every blank field is already
  answered — switch fillMode to all to redo the filled ones").

Behavior — accepting
- Accept applies the pending value, records a per-field undo entry { previous,
  applied } and fires onAccept once. A one-shot lock keyed by suggestion id
  guards it: two clicks in the same task share one closure and would otherwise
  push a duplicate undo entry and fire the callback twice.
- If the suggestion equals what is already in the field, render "matches your
  value" with no Accept button — there is nothing to apply.
- Accept all commits ONE record containing every accepted field. N sequential
  writes would each be built from the same base in a controlled parent that has
  not re-rendered yet, and all but the last would be lost.
- Undo restores the previous value and disappears when the user edits the field
  (or when a new run touches it). No timer: an undo that expires on its own is
  an undo the user cannot rely on.
- Escape cancels an in-flight run or refuses a pending suggestion; Enter accepts
  it. Claim those keys ONLY when there is something of yours to close, so an
  Escape inside a dialog still reaches the dialog and Enter still submits the
  form the rest of the time. Skip Enter while event.nativeEvent.isComposing is
  true (an IME candidate is being committed), and require Cmd/Ctrl+Enter in a
  textarea, where a bare Enter is a newline.
- Focus: buttons that vanish with the panel (Accept, Dismiss, Undo) hand focus
  back to the field with the caret at the end; Retry hands it to the row's
  trigger, which is exactly the stop control for the run it just started. Do it
  in a layout effect keyed on a tick — the node only exists after the panel has
  been removed. Guard setSelectionRange: it throws InvalidStateError on email /
  number / date inputs.

Behavior — cleanup
- One unmount effect aborts every controller in the Map and clears every timer
  in the Set. Close over the Map/Set objects captured in the effect body, never
  `ref.current` inside the cleanup.

Rendering & styling
- Semantic tokens only: bg-background / bg-card / bg-muted / bg-popover,
  text-foreground / text-muted-foreground / text-popover-foreground,
  text-destructive for errors, border / border-input / border-primary, ring /
  ring-ring, and primary tints (bg-primary/5, border-primary/30, bg-primary/10)
  for the assistant's own marks. No hex, no rgb(), no oklch().
- Row = label + hint, then a bordered wrapper holding the control and the
  sparkle button, then the suggestion panel, then one message line.
- Empty field → paint the suggested value as ghost text: an absolutely
  positioned, aria-hidden, pointer-events-none span with the control's exact
  padding and type size (truncate for an input, whitespace-pre-wrap for a
  textarea), and blank the placeholder while it shows or the two read as one
  smear. Filled field → do NOT overlay; render `<del>old</del> → <ins>new</ins>`
  inside the panel instead. The panel also carries an sr-only copy of the value,
  because the ghost is hidden from assistive tech.
- Confidence buckets tint the wrapper and the panel: high = primary border and
  a primary-tinted panel, medium = neutral, low = dashed frame. Draw the number
  as a tiny track + percentage with an sr-only "low confidence," prefix. No
  confidence reported = no meter, neutral tint.
- The source chip opens a portalled Popover (not a tooltip: the excerpt is worth
  selecting, the link is worth clicking, and it must work on touch). Run the
  href through an allow-list — http(s) and mailto only, protocol-relative
  rejected — because the link arrives from the same pipeline as the value.
- The trigger is ONE button that swaps its children between sparkle and spinner;
  swapping two buttons would remount the node and drop focus exactly when a run
  starts. Same trick for Fill with AI / Stop.
- Motion: the panel and ghost use a 160–180ms rise, the in-flight row a slow
  sweep gradient. Ship the keyframes in a React 19 hoisted <style href
  precedence>. Every animation is off under prefers-reduced-motion
  (motion-reduce:[animation:none] / motion-reduce:hidden) — the stagger is NOT
  motion, it is request pacing, so it stays.
- A11y: <label htmlFor> per row; aria-describedby chains hint + suggestion group
  + message; the panel is role="group" with aria-label "Suggestion for <label>";
  the row carries aria-busy while requesting; one polite role="status" region at
  the bottom summarises the form ("3 suggestions waiting for review"), and
  announce="off" renders the same sentence inert for forms that already own a
  live region. `disabled` is aria-disabled + readOnly, never the native
  attribute, so the values stay readable and selectable.

Customization levers
- staggerMs: 0 fires the whole wave at once (use it when your backend batches),
  200–300 reads as a visible cascade and is gentler on a rate limit.
- fillMode: "empty" protects what the user typed; "all" is the "re-extract
  everything" button for a re-uploaded document.
- confidenceThresholds: raise `high` if your model is over-confident; drop
  showConfidence entirely when the number would only add noise.
- bulkActionsFrom: set it to 1 to always show Accept all, or to Infinity to
  force row-by-row review on a compliance form.
- Panel contents: the meter, the source chip, the note and the stale line are
  independent blocks — delete any of them without touching the machine.
- Ghost vs panel: keeping only the panel (never overlaying the field) is a
  one-line change and suits dense forms; keeping only the ghost suits a wizard
  with one field per screen.
- Labels: every button string is a prop, so the component localises without a
  fork.
- Density: the row gap (gap-3), control padding (px-3 py-2) and the
  [0.6875rem] micro-copy size are the only spacing knobs; override them through
  className.
- Per-field policy: `assist: false` for values a model must never guess,
  `skipFill: true` for values it may guess but only on request.

Concepts

  • Per-field claim — every request carries its own AbortController and run id, and an answer is only allowed to land if the field's slot still belongs to it; several rows are legitimately in flight at once, so a single global "latest request wins" counter would throw away every answer but the last.
  • Baseline, not current value — a suggestion remembers the text its field held when it was asked for, so editing mid-flight neither re-targets the answer nor deletes it: the row is flagged stale and says what accepting would actually overwrite.
  • Ghost vs replace — an empty field can wear the suggested value as ghost text (autofill-like, nothing hidden under it); a field that already holds something gets a struck-through / marked pair instead, because overlaying real text would read as one smear.
  • Staggered wave — fill-all is a schedule, not a burst: one request leaves every staggerMs, each row resolves on its own clock, and stopping drops the timers that have not fired while aborting the requests that have. It paces the backend, so it survives prefers-reduced-motion untouched.
  • One-shot accept — the lock is keyed on the suggestion's id, so a double click, a stray Enter and Accept all can all hit the same row without duplicating the undo entry or firing onAccept twice.
  • Undo without a clock — accepting leaves a per-field Undo that lives until the user edits that field; nothing expires on a timer, because an undo that vanishes on its own is one nobody trusts.

On This Page