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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/ai-form-assist.jsonPrompt
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 survivesprefers-reduced-motionuntouched. - 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
onAccepttwice. - 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.
Chat Import
A staged importer for assistant exports — reads ChatGPT, Claude and JSON-Lines files, previews the conversations it recovered with per-item checkboxes, and ends on a summary that names every skipped item and why.
Translation Panel
A document beside its machine translation, aligned segment by segment — hover or focus a pair and both halves light up, with per-segment translated / waiting / failed states, a swappable language header, a formality control and progress over the whole job.