Pilotiq
DocsGitHub

AI Suggestions

Pilotiq core ships a queue-based seam — PendingSuggestionsContext — for any external mutator that wants to propose a field-value change to the user instead of applying it directly. The seam is generic: AI agents are the headline producer (@pilotiq-pro/ai mounts the queue inside <AiUiProvider>), but the same primitives serve form-recovery flows, undo stacks, bulk imports, or any other "would you like to apply this?" UX an ecosystem author needs.

This guide is for plugin authors building custom field renderers, alternate overlay UIs, or non-AI producers on top of the seam. Typical pilotiq users don't reach for these APIs — the chat sidebar, FieldShell overlay, and Tiptap chip all wire themselves automatically when @pilotiq-pro/ai is installed.

Looking for the user-facing toggle? Pilotiq.aiSuggestionsMode('auto' \| 'review') and the field overlay UX it produces are documented in Pro › @pilotiq-pro/ai › Consent & approval. This page covers the underlying queue + applier seam that mode rides on top of.

#Anatomy

producer  →  PendingSuggestionsContext  →  applier  →  field state
            (queue)                        (registered per field)

Three roles:

  • Producer — pushes a PendingSuggestion onto the queue via usePendingSuggestions().push(...). Doesn't need to know how to apply the value; just tags it with fieldName (+ optional formId, meta.editorRange for richtext, etc.).
  • QueuePendingSuggestionsContext holds the list across the panel tree. Subscribers filter by field. Multi-form pages discriminate via formId.
  • Applier — registered by the field renderer (or an editor adapter) via registerPendingSuggestionApplier(formId, fieldName, fn). Aggregate consumers (e.g. a chat-sidebar pill that lives outside the form's React tree) call approve(id) on the queue, which looks up the applier and runs it.

Pilotiq core's <FieldShell> registers a generic applier for every non-richtext field automatically — it uses useFieldState.setValue for controlled (live) forms and a DOM fallback for uncontrolled ones. The @pilotiq/tiptap adapter registers an editor-command applier for richtext fields. So the typical pilotiq install already has appliers registered on every field; producers can call approve(id) and trust something will run.

#The PendingSuggestion shape

import type { PendingSuggestion } from '@pilotiq/pilotiq/react'

interface PendingSuggestion {
  /** Stable id; producer-supplied. Re-pushing with the same id replaces. */
  id:              string
  /** Field name this suggestion targets. Matched verbatim by readers. */
  fieldName:       string
  /** Form scope. Multi-form pages stamp the form's id; readers in form A
   *  drop suggestions meant for form B. Optional — when both producer and
   *  consumer omit it, suggestions are global. */
  formId?:         string
  /** Snapshot of the field's value at production time. */
  currentValue:    unknown
  /** Proposed replacement. */
  suggestedValue:  unknown
  /** Optional attribution surfaced on the diff overlay / inline chip. */
  source?:         { agentSlug?: string; agentLabel?: string }
  /** Wallclock ms; producers fill on push. */
  createdAt:       number
  /** Field-type-specific extras. Sparse. The Tiptap bridge looks for
   *  `meta.editorRange = { from, to }` on richtext suggestions to drive
   *  the inline diff range. */
  meta?:           Record<string, unknown>
}

#Reading the queue

import {
  usePendingSuggestions,
  usePendingSuggestionsForField,
} from '@pilotiq/pilotiq/react'

// Aggregate consumer (chat sidebar pill, bulk-action menu, etc.)
const { list, push, approve, dismiss, approveAll, dismissAll } =
  usePendingSuggestions()

// Field-renderer subscriber
const { list, dismiss } = usePendingSuggestionsForField(fieldName, formId)

usePendingSuggestionsForField filters automatically:

  • s.fieldName === fieldName (verbatim)
  • if both formId args are non-undefined, they must match; otherwise the entry passes (so global suggestions reach scoped readers and vice-versa)

Outside a <PendingSuggestionsProvider> (the typical case in headless tests, marketing-site previews, or panels without @pilotiq-pro/ai installed), the context resolves to a frozen no-op API: list is empty, methods do nothing. Producers can call freely without a guard.

#Producing — push(s)

const { push } = usePendingSuggestions()

push({
  id:             'seo-1',
  fieldName:      'metaDescription',
  currentValue:   'Old description',
  suggestedValue: 'A snappier description with keywords',
  source:         { agentSlug: 'seo', agentLabel: 'SEO' },
})

push is idempotent on id — re-pushing with the same id replaces the prior entry. Useful for producers that re-emit (e.g. an AI tool that hadn't finished but is now retrying); consumers see one stable queue position.

If id is omitted, the provider generates one and returns it.

#Approving — applier registry

The cross-tree apply path:

import {
  registerPendingSuggestionApplier,
  type PendingSuggestionApplier,
} from '@pilotiq/pilotiq/react'

// Field renderer (typically inside FieldShell or its equivalent):
useEffect(() => {
  const apply: PendingSuggestionApplier = (suggestion) => {
    // Mutate THIS field with suggestion.suggestedValue.
    // Any scheme works — React state, DOM mutation, editor command, …
  }
  return registerPendingSuggestionApplier(formId, fieldName, apply)
}, [formId, fieldName])

The registry is a module-level Map<string, applier> keyed by (formId, fieldName). Form-scoped registrations always win over the wildcard for the same field name. Re-registering with the same key replaces the prior entry — most-recently-mounted renderer wins (typical in multi-instance form scenarios where an old form unmounts after a new one mounts during navigation).

registerPendingSuggestionApplier returns an unregister function for useEffect cleanup.

When a queue consumer calls approve(id):

  1. Provider finds the suggestion by id.
  2. Looks up an applier for (suggestion.formId, suggestion.fieldName).
  3. Calls the applier. Failures are swallowed with a console.warn so a busted applier doesn't strand entries.
  4. Drops the suggestion from the queue.

If no applier is registered (e.g. the target form isn't currently mounted), approve falls through to a plain dismiss — the queue still clears.

approveAll(filter?) runs the same resolution per matching entry.

#Rendering — overlay registry

Pilotiq core's <FieldShell> reserves a slot below every non-richtext input for a registered overlay component. Plugins fill the slot via registerPendingSuggestionOverlay(C):

import {
  registerPendingSuggestionOverlay,
  type PendingSuggestionOverlayProps,
} from '@pilotiq/pilotiq/react'

function MyDiffOverlay({ suggestion, onApprove, onReject }: PendingSuggestionOverlayProps) {
  return (
    <div>
      <span>{String(suggestion.currentValue)}</span>
      <span>→</span>
      <span>{String(suggestion.suggestedValue)}</span>
      <button onClick={onApprove}>Approve</button>
      <button onClick={onReject}>Reject</button>
    </div>
  )
}

// Once at boot — typically from a plugin's `register(panel)` step.
registerPendingSuggestionOverlay(MyDiffOverlay)

Only one overlay component can be registered at a time — the registry is a single slot, not a list. The last register* call wins. Most apps install only @pilotiq-pro/ai's overlay (<PendingSuggestionOverlay>); register your own only if you want to replace it.

<FieldShell> mounts the overlay below the input whenever a matching suggestion exists in the queue. Richtext fields are skipped — they render the diff inline via the Tiptap chip instead.

The overlay is responsible for applying the value on Approve. If your overlay reads useFieldState, it can call setValue directly; otherwise mirror the DOM-fallback pattern in pilotiq core's FieldShell (Object.getOwnPropertyDescriptor(proto, 'value')?.set to mutate the input in a way that triggers React's onChange handlers).

#Worked example: a custom file-upload field with AI-suggested filenames

A custom field renderer that participates in the queue end-to-end:

import { useEffect, useState } from 'react'
import { useFieldState } from '@pilotiq/pilotiq/react'
import {
  usePendingSuggestionsForField,
  registerPendingSuggestionApplier,
} from '@pilotiq/pilotiq/react'

function CustomFilenameInput({ name, formId, defaultValue }: {
  name:         string
  formId?:      string
  defaultValue: string
}) {
  const [value, setValue] = useState(defaultValue)
  const fieldState = useFieldState(name)

  // Register an applier so cross-tree approve (chat pill, bulk menu)
  // can mutate this input.
  useEffect(() => {
    return registerPendingSuggestionApplier(formId, name, (s) => {
      const next = String(s.suggestedValue)
      setValue(next)
      if (fieldState.controlled) fieldState.setValue(next)
    })
  }, [formId, name, fieldState])

  // Mount an inline overlay when a suggestion targets this field
  // (instead of using FieldShell's slot — useful when your renderer
  // wants tight UI control).
  const { list, dismiss } = usePendingSuggestionsForField(name, formId)
  const suggestion = list[0]

  return (
    <div>
      <input
        name={name}
        value={value}
        onChange={(e) => setValue(e.target.value)}
      />
      {suggestion && (
        <div className="rounded border bg-primary/5 p-2 text-sm">
          <span>Suggested: {String(suggestion.suggestedValue)}</span>
          <button onClick={() => {
            setValue(String(suggestion.suggestedValue))
            dismiss(suggestion.id)
          }}>
            Apply
          </button>
          <button onClick={() => dismiss(suggestion.id)}>
            Dismiss
          </button>
        </div>
      )}
    </div>
  )
}

The renderer:

  1. Subscribes to its slice of the queue with usePendingSuggestionsForField.
  2. Renders an inline overlay when a suggestion arrives.
  3. Registers an applier so out-of-tree consumers (chat pill, bulk approve) can also mutate this input.
  4. Falls back to useFieldState.setValue when the form is controlled.

Producers — anywhere in the panel tree — push suggestions:

const { push } = usePendingSuggestions()
push({
  id:             `filename:${recordId}`,
  fieldName:      'filename',
  currentValue:   currentName,
  suggestedValue: 'better-keyword-rich-name.jpg',
  source:         { agentLabel: 'SEO' },
})

#When to skip the seam

The queue is the right call when:

  • multiple users / agents may compete to update the same field,
  • the user should see a diff before applying, or
  • you want a single Approve/Reject UI surface across multiple producers.

Skip it for:

  • direct user input (you want immediate updates, not a queue),
  • batch backend processes that don't need user approval (write to the source of truth — DB, Y.Doc — and let the form re-fetch).

#Reference

  • PendingSuggestionsContext, usePendingSuggestions, usePendingSuggestionsForField, PendingSuggestion, PendingSuggestionsApi — all exported from @pilotiq/pilotiq/react.
  • registerPendingSuggestionOverlay / getPendingSuggestionOverlay — overlay registry.
  • registerPendingSuggestionApplier / getPendingSuggestionApplier — applier registry.

For the AI consumer side (chat pill, FieldShell overlay UI, Tiptap chip), see Pro › @pilotiq-pro/ai. For the Tiptap extension that powers richtext inline diffs, see Packages › @pilotiq/tiptap.