@pilotiq/tiptap 3.0.0 → 3.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,415 @@
1
+ import { Extension, type Editor } from '@tiptap/core'
2
+ import { Plugin, PluginKey, type EditorState, type Transaction } from '@tiptap/pm/state'
3
+ import { Decoration, DecorationSet } from '@tiptap/pm/view'
4
+
5
+ /**
6
+ * One AI-suggested replacement of `[from, to)` with `replacement`.
7
+ *
8
+ * `from === to` represents a pure insertion at that position. v1 carries
9
+ * plain-text replacement only — marks and structure round-trip through the
10
+ * editor as-is when the suggestion is approved (the original range's marks
11
+ * are preserved on the inserted text node by ProseMirror).
12
+ */
13
+ export interface AiSuggestion {
14
+ /** Stable id; consumer-provided. Re-adding with the same id replaces the prior entry. */
15
+ id: string
16
+ /** Inclusive document position the original range starts at. */
17
+ from: number
18
+ /** Exclusive document position the original range ends at. */
19
+ to: number
20
+ /** Plain-text replacement. Empty string = pure deletion. */
21
+ replacement: string
22
+ /** Optional attribution surfaced on the chip widget. */
23
+ source?: {
24
+ agentSlug?: string
25
+ agentLabel?: string
26
+ }
27
+ }
28
+
29
+ export interface AiSuggestionExtensionOptions {
30
+ /**
31
+ * Class prefix for both decoration spans and chip widgets. The package
32
+ * stays CSS-free — consumers ship the matching styles. Default
33
+ * `'pilotiq-ai-suggestion'` produces classes:
34
+ * - `pilotiq-ai-suggestion-original` (strikethrough on the original range)
35
+ * - `pilotiq-ai-suggestion-chip` (root of the inline widget)
36
+ * - `pilotiq-ai-suggestion-replacement` (the suggested-text preview span)
37
+ * - `pilotiq-ai-suggestion-accept` (Approve button)
38
+ * - `pilotiq-ai-suggestion-reject` (Reject button)
39
+ */
40
+ classPrefix: string
41
+ /**
42
+ * Fired whenever the suggestion list changes — after `add*`, `approve*`,
43
+ * `reject*`, `clear*`, or after a doc edit collapses a range. Lets the host
44
+ * mirror state into a React context (e.g. `PendingSuggestionsApi`).
45
+ */
46
+ onChange?: (suggestions: AiSuggestion[]) => void
47
+ }
48
+
49
+ declare module '@tiptap/core' {
50
+ interface Commands<ReturnType> {
51
+ aiSuggestion: {
52
+ /** Add or replace a suggestion (matched by id). */
53
+ addAiSuggestion: (suggestion: AiSuggestion) => ReturnType
54
+ /** Add or replace many suggestions in one transaction. */
55
+ addAiSuggestions: (suggestions: AiSuggestion[]) => ReturnType
56
+ /** Apply the replacement to the doc and drop the suggestion. */
57
+ approveAiSuggestion: (id: string) => ReturnType
58
+ /** Drop the suggestion without touching the doc. */
59
+ rejectAiSuggestion: (id: string) => ReturnType
60
+ /** Apply every replacement in highest-`from`-first order. */
61
+ approveAllAiSuggestions: () => ReturnType
62
+ /** Drop every suggestion. */
63
+ rejectAllAiSuggestions: () => ReturnType
64
+ /** Alias for `rejectAllAiSuggestions`. */
65
+ clearAiSuggestions: () => ReturnType
66
+ }
67
+ }
68
+ }
69
+
70
+ interface PluginState {
71
+ suggestions: readonly AiSuggestion[]
72
+ }
73
+
74
+ interface SetMeta {
75
+ type: 'set'
76
+ next: readonly AiSuggestion[]
77
+ }
78
+
79
+ export const aiSuggestionPluginKey = new PluginKey<PluginState>('pilotiqAiSuggestion')
80
+
81
+ /**
82
+ * Append or replace by id. Pure — exported for tests and so the same dedupe
83
+ * shape can drive consumer-side mirror state.
84
+ */
85
+ export function upsertSuggestion(
86
+ current: readonly AiSuggestion[],
87
+ next: AiSuggestion,
88
+ ): AiSuggestion[] {
89
+ const idx = current.findIndex(s => s.id === next.id)
90
+ if (idx === -1) return [...current, next]
91
+ const copy = current.slice()
92
+ copy[idx] = next
93
+ return copy
94
+ }
95
+
96
+ /** Append or replace many — semantically equivalent to a fold over `upsertSuggestion`. */
97
+ export function upsertSuggestions(
98
+ current: readonly AiSuggestion[],
99
+ nexts: readonly AiSuggestion[],
100
+ ): AiSuggestion[] {
101
+ let acc: AiSuggestion[] = current.slice()
102
+ for (const n of nexts) acc = upsertSuggestion(acc, n)
103
+ return acc
104
+ }
105
+
106
+ /** Remove by id. */
107
+ export function removeSuggestion(
108
+ current: readonly AiSuggestion[],
109
+ id: string,
110
+ ): AiSuggestion[] {
111
+ return current.filter(s => s.id !== id)
112
+ }
113
+
114
+ /**
115
+ * Remap survivors through a PM mapping; drop ranges that collapsed past
116
+ * each other (`to < from` after remap). Pure — exported for tests.
117
+ */
118
+ export function remapSuggestions(
119
+ suggestions: readonly AiSuggestion[],
120
+ map: (pos: number, side: -1 | 1) => number,
121
+ ): AiSuggestion[] {
122
+ const out: AiSuggestion[] = []
123
+ for (const s of suggestions) {
124
+ const from = map(s.from, -1)
125
+ const to = map(s.to, 1)
126
+ if (to < from) continue
127
+ out.push({ ...s, from, to })
128
+ }
129
+ return out
130
+ }
131
+
132
+ /**
133
+ * Order suggestions for `approveAll` so the highest-`from` runs first;
134
+ * earlier-in-doc replacements then can't shift positions of later ones.
135
+ * Pure — exported for tests.
136
+ */
137
+ export function sortForApproveAll(
138
+ suggestions: readonly AiSuggestion[],
139
+ ): AiSuggestion[] {
140
+ return suggestions.slice().sort((a, b) => b.from - a.from)
141
+ }
142
+
143
+ /**
144
+ * Editor extension that tracks AI-suggested edits as inline decorations with
145
+ * per-hunk Approve/Reject chips. The package is CSS-free — consumers wire
146
+ * styles against the documented class names.
147
+ *
148
+ * Usage:
149
+ * ```ts
150
+ * editor.commands.addAiSuggestion({
151
+ * id: 'seo-1',
152
+ * from: 12,
153
+ * to: 18,
154
+ * replacement: 'better',
155
+ * source: { agentLabel: 'SEO' },
156
+ * })
157
+ * // …user clicks ✓ on the chip, or:
158
+ * editor.commands.approveAiSuggestion('seo-1')
159
+ * ```
160
+ *
161
+ * Mounted by default inside `TiptapEditor`; consumer code reaches it through
162
+ * the editor's command surface.
163
+ */
164
+ export const AiSuggestionExtension = Extension.create<AiSuggestionExtensionOptions>({
165
+ name: 'pilotiqAiSuggestion',
166
+
167
+ addOptions() {
168
+ return {
169
+ classPrefix: 'pilotiq-ai-suggestion',
170
+ }
171
+ },
172
+
173
+ addCommands() {
174
+ return {
175
+ addAiSuggestion: (suggestion) => ({ tr, state, dispatch }) => {
176
+ const current = aiSuggestionPluginKey.getState(state)?.suggestions ?? []
177
+ const next = upsertSuggestion(current, suggestion)
178
+ if (dispatch) {
179
+ tr.setMeta(aiSuggestionPluginKey, { type: 'set', next } satisfies SetMeta)
180
+ dispatch(tr)
181
+ }
182
+ return true
183
+ },
184
+
185
+ addAiSuggestions: (suggestions) => ({ tr, state, dispatch }) => {
186
+ const current = aiSuggestionPluginKey.getState(state)?.suggestions ?? []
187
+ const next = upsertSuggestions(current, suggestions)
188
+ if (dispatch) {
189
+ tr.setMeta(aiSuggestionPluginKey, { type: 'set', next } satisfies SetMeta)
190
+ dispatch(tr)
191
+ }
192
+ return true
193
+ },
194
+
195
+ approveAiSuggestion: (id) => ({ tr, state, dispatch }) => {
196
+ const current = aiSuggestionPluginKey.getState(state)?.suggestions ?? []
197
+ const target = current.find(s => s.id === id)
198
+ if (!target) return false
199
+ if (dispatch) {
200
+ applyApprove(tr, state, target)
201
+ tr.setMeta(aiSuggestionPluginKey, {
202
+ type: 'set',
203
+ next: removeSuggestion(current, id),
204
+ } satisfies SetMeta)
205
+ dispatch(tr)
206
+ }
207
+ return true
208
+ },
209
+
210
+ rejectAiSuggestion: (id) => ({ tr, state, dispatch }) => {
211
+ const current = aiSuggestionPluginKey.getState(state)?.suggestions ?? []
212
+ const target = current.find(s => s.id === id)
213
+ if (!target) return false
214
+ if (dispatch) {
215
+ tr.setMeta(aiSuggestionPluginKey, {
216
+ type: 'set',
217
+ next: removeSuggestion(current, id),
218
+ } satisfies SetMeta)
219
+ dispatch(tr)
220
+ }
221
+ return true
222
+ },
223
+
224
+ approveAllAiSuggestions: () => ({ tr, state, dispatch }) => {
225
+ const current = aiSuggestionPluginKey.getState(state)?.suggestions ?? []
226
+ if (current.length === 0) return false
227
+ if (dispatch) {
228
+ for (const s of sortForApproveAll(current)) applyApprove(tr, state, s)
229
+ tr.setMeta(aiSuggestionPluginKey, {
230
+ type: 'set',
231
+ next: [],
232
+ } satisfies SetMeta)
233
+ dispatch(tr)
234
+ }
235
+ return true
236
+ },
237
+
238
+ rejectAllAiSuggestions: () => ({ tr, state, dispatch }) => {
239
+ const current = aiSuggestionPluginKey.getState(state)?.suggestions ?? []
240
+ if (current.length === 0) return false
241
+ if (dispatch) {
242
+ tr.setMeta(aiSuggestionPluginKey, {
243
+ type: 'set',
244
+ next: [],
245
+ } satisfies SetMeta)
246
+ dispatch(tr)
247
+ }
248
+ return true
249
+ },
250
+
251
+ clearAiSuggestions: () => ({ tr, state, dispatch }) => {
252
+ const current = aiSuggestionPluginKey.getState(state)?.suggestions ?? []
253
+ if (current.length === 0) return false
254
+ if (dispatch) {
255
+ tr.setMeta(aiSuggestionPluginKey, {
256
+ type: 'set',
257
+ next: [],
258
+ } satisfies SetMeta)
259
+ dispatch(tr)
260
+ }
261
+ return true
262
+ },
263
+ }
264
+ },
265
+
266
+ addProseMirrorPlugins() {
267
+ const ext = this
268
+ return [
269
+ new Plugin<PluginState>({
270
+ key: aiSuggestionPluginKey,
271
+ state: {
272
+ init: (): PluginState => ({ suggestions: [] }),
273
+ apply(tr, prev): PluginState {
274
+ const meta = tr.getMeta(aiSuggestionPluginKey) as SetMeta | undefined
275
+ const base = meta?.type === 'set' ? meta.next : prev.suggestions
276
+ if (!tr.docChanged) return { suggestions: base }
277
+ return {
278
+ suggestions: remapSuggestions(base, (pos, side) =>
279
+ tr.mapping.map(pos, side)),
280
+ }
281
+ },
282
+ },
283
+ props: {
284
+ decorations(state) {
285
+ const ps = aiSuggestionPluginKey.getState(state)
286
+ if (!ps || ps.suggestions.length === 0) return DecorationSet.empty
287
+ return buildDecorations(state, ps.suggestions, ext.options.classPrefix, ext.editor)
288
+ },
289
+ },
290
+ view(view) {
291
+ let last = aiSuggestionPluginKey.getState(view.state)?.suggestions
292
+ return {
293
+ update(updated) {
294
+ const next = aiSuggestionPluginKey.getState(updated.state)?.suggestions
295
+ if (next === last) return
296
+ last = next
297
+ const cb = ext.options.onChange
298
+ if (cb) cb(next ? [...next] : [])
299
+ },
300
+ destroy() {},
301
+ }
302
+ },
303
+ }),
304
+ ]
305
+ },
306
+ })
307
+
308
+ function applyApprove(tr: Transaction, state: EditorState, target: AiSuggestion): void {
309
+ const docSize = state.doc.content.size
310
+ const from = clampPos(target.from, docSize)
311
+ const to = clampPos(target.to, docSize)
312
+ if (from > to) return
313
+ if (target.replacement.length > 0) {
314
+ tr.replaceWith(from, to, state.schema.text(target.replacement))
315
+ } else if (from < to) {
316
+ tr.delete(from, to)
317
+ }
318
+ }
319
+
320
+ function buildDecorations(
321
+ state: EditorState,
322
+ suggestions: readonly AiSuggestion[],
323
+ prefix: string,
324
+ editor: Editor,
325
+ ): DecorationSet {
326
+ const docSize = state.doc.content.size
327
+ const decos: Decoration[] = []
328
+
329
+ for (const s of suggestions) {
330
+ const from = clampPos(s.from, docSize)
331
+ const to = clampPos(s.to, docSize)
332
+ if (from > to) continue
333
+
334
+ if (from < to) {
335
+ decos.push(
336
+ Decoration.inline(from, to, {
337
+ class: `${prefix}-original`,
338
+ 'data-pilotiq-ai-suggestion-id': s.id,
339
+ }),
340
+ )
341
+ }
342
+
343
+ decos.push(
344
+ Decoration.widget(to, () => buildChip(s, prefix, editor), {
345
+ side: 1,
346
+ ignoreSelection: true,
347
+ key: `pilotiq-ai-suggestion:${s.id}`,
348
+ }),
349
+ )
350
+ }
351
+
352
+ return DecorationSet.create(state.doc, decos)
353
+ }
354
+
355
+ /** Bound `pos` into `[0, max]`; non-finite or negative input collapses to 0. */
356
+ export function clampPos(pos: number, max: number): number {
357
+ if (!Number.isFinite(pos)) return 0
358
+ if (pos < 0) return 0
359
+ if (pos > max) return max
360
+ return Math.trunc(pos)
361
+ }
362
+
363
+ function buildChip(s: AiSuggestion, prefix: string, editor: Editor): HTMLElement {
364
+ const root = document.createElement('span')
365
+ root.className = `${prefix}-chip`
366
+ root.setAttribute('data-pilotiq-ai-suggestion-id', s.id)
367
+ root.contentEditable = 'false'
368
+
369
+ if (s.replacement.length > 0) {
370
+ const insert = document.createElement('span')
371
+ insert.className = `${prefix}-replacement`
372
+ insert.textContent = s.replacement
373
+ root.appendChild(insert)
374
+ }
375
+
376
+ if (s.source?.agentLabel) {
377
+ root.setAttribute('data-pilotiq-ai-suggestion-source', s.source.agentLabel)
378
+ }
379
+ if (s.source?.agentSlug) {
380
+ root.setAttribute('data-pilotiq-ai-suggestion-source-slug', s.source.agentSlug)
381
+ }
382
+
383
+ root.appendChild(buildButton(prefix, 'accept', '✓', 'Accept suggestion', () => {
384
+ editor.chain().focus().approveAiSuggestion(s.id).run()
385
+ }))
386
+ root.appendChild(buildButton(prefix, 'reject', '✕', 'Reject suggestion', () => {
387
+ editor.chain().focus().rejectAiSuggestion(s.id).run()
388
+ }))
389
+
390
+ return root
391
+ }
392
+
393
+ function buildButton(
394
+ prefix: string,
395
+ variant: 'accept' | 'reject',
396
+ glyph: string,
397
+ title: string,
398
+ onClick: () => void,
399
+ ): HTMLButtonElement {
400
+ const btn = document.createElement('button')
401
+ btn.type = 'button'
402
+ btn.className = `${prefix}-${variant}`
403
+ btn.title = title
404
+ btn.textContent = glyph
405
+ // Don't steal the editor selection on press — the click handler runs on
406
+ // mouseup, but mousedown is what flips focus to the button. Cancelling it
407
+ // keeps the cursor in the editor so `editor.chain().focus()` lands cleanly.
408
+ btn.addEventListener('mousedown', (e) => e.preventDefault())
409
+ btn.addEventListener('click', (e) => {
410
+ e.preventDefault()
411
+ e.stopPropagation()
412
+ onClick()
413
+ })
414
+ return btn
415
+ }
package/src/index.ts CHANGED
@@ -19,6 +19,19 @@ export {
19
19
  export { registerTiptap } from './register.js'
20
20
  export { tiptap } from './plugin.js'
21
21
  export { TiptapEditor } from './react/TiptapEditor.js'
22
+ export {
23
+ AiSuggestionExtension,
24
+ aiSuggestionPluginKey,
25
+ upsertSuggestion,
26
+ upsertSuggestions,
27
+ removeSuggestion,
28
+ remapSuggestions,
29
+ sortForApproveAll,
30
+ clampPos,
31
+ type AiSuggestion,
32
+ type AiSuggestionExtensionOptions,
33
+ } from './extensions/AiSuggestionExtension.js'
34
+ export { useAiSuggestionBridge } from './react/useAiSuggestionBridge.js'
22
35
  export {
23
36
  renderRichTextToHtml,
24
37
  isRichTextValue,
@@ -14,6 +14,7 @@ import { Details, DetailsSummary, DetailsContent } from '@tiptap/extension-detai
14
14
  import { Grid, GridColumn } from '../extensions/GridExtension.js'
15
15
  import { Popover } from '@base-ui/react/popover'
16
16
  import type { FieldRendererProps } from '@pilotiq/pilotiq/react'
17
+ import { useAiSuggestionBridge } from './useAiSuggestionBridge.js'
17
18
  import type { BlockMeta } from '../Block.js'
18
19
  import type { ToolbarGroups, RichTextStorage, ColorSwatch } from '../RichTextField.js'
19
20
  import { BlockNodeExtension } from '../extensions/BlockNodeExtension.js'
@@ -24,6 +25,7 @@ import {
24
25
  import { DragHandleExtension } from '../extensions/DragHandleExtension.js'
25
26
  import { MergeTagExtension } from '../extensions/MergeTagExtension.js'
26
27
  import { LeadMarkExtension, SmallMarkExtension } from '../extensions/TextSizeMarks.js'
28
+ import { AiSuggestionExtension } from '../extensions/AiSuggestionExtension.js'
27
29
  import {
28
30
  MentionExtension,
29
31
  type MentionState,
@@ -246,6 +248,10 @@ function ClientEditor(props: FieldRendererProps) {
246
248
  fieldName: name,
247
249
  })] : [MentionExtension]),
248
250
  DragHandleExtension,
251
+ // AI suggestions — always-on extension that tracks suggested edits as
252
+ // inline strikethrough + Approve/Reject chip widgets. Idle until the
253
+ // host calls `editor.commands.addAiSuggestion(...)`.
254
+ AiSuggestionExtension,
249
255
  ],
250
256
  content: initialContent ?? '',
251
257
  onUpdate: ({ editor: ed }) => {
@@ -332,6 +338,11 @@ function ClientEditor(props: FieldRendererProps) {
332
338
  // scratch on every keystroke.
333
339
  useEffect(() => { editorRef.current = editor ?? null }, [editor])
334
340
 
341
+ // Cross-package suggestion bridge — sync the host's
342
+ // `<PendingSuggestionsContext>` queue with the editor's `AiSuggestion`
343
+ // extension. No-op when no provider is mounted (default no-op context).
344
+ useAiSuggestionBridge(editor ?? null, name)
345
+
335
346
  // Re-render the toolbar when the selection / marks change so active-state
336
347
  // booleans stay fresh.
337
348
  const tick = useEditorTick(editor)
@@ -0,0 +1,119 @@
1
+ import { useEffect, useRef } from 'react'
2
+ import type { Editor } from '@tiptap/core'
3
+ import {
4
+ registerPendingSuggestionApplier,
5
+ usePendingSuggestionsForField,
6
+ type PendingSuggestion,
7
+ type PendingSuggestionApplier,
8
+ } from '@pilotiq/pilotiq/react'
9
+ import { aiSuggestionPluginKey } from '../extensions/AiSuggestionExtension.js'
10
+
11
+ /**
12
+ * Two-way sync between the cross-package `<PendingSuggestionsContext>`
13
+ * queue and this editor's `AiSuggestionExtension` state.
14
+ *
15
+ * - **Context → editor**: every entry whose `meta.editorRange = { from, to }`
16
+ * is present and whose `suggestedValue` is a string gets pushed into the
17
+ * editor as an inline-diff hunk via `addAiSuggestion`. Entries leaving the
18
+ * queue are removed from the editor via `rejectAiSuggestion` (no doc edit).
19
+ *
20
+ * - **Editor → context**: when a chip's Approve / Reject button removes a
21
+ * hunk from the editor's plugin state, the matching id is dismissed from
22
+ * the queue (`dismiss(id)`) so other surfaces (e.g. the chat-sidebar pill,
23
+ * a future FieldShell overlay) clear in lock-step. The doc mutation
24
+ * itself happens inside the editor — context is just a notification.
25
+ *
26
+ * Cycle protection: the hook tracks which ids it has personally pushed to
27
+ * the editor (`pushed`). The Context→editor pass never re-pushes an id that's
28
+ * already there, and the Editor→context pass only dismisses ids that this
29
+ * hook had previously pushed (so an id added directly by host code via
30
+ * `editor.commands.addAiSuggestion(...)` doesn't get reflected back through
31
+ * a context that never knew about it).
32
+ */
33
+ export function useAiSuggestionBridge(editor: Editor | null, fieldName: string): void {
34
+ const { list, dismiss } = usePendingSuggestionsForField(fieldName)
35
+
36
+ // Hold the latest `dismiss` in a ref so the editor-side listener — which
37
+ // installs once per editor — always reaches the up-to-date context API.
38
+ const dismissRef = useRef(dismiss)
39
+ useEffect(() => { dismissRef.current = dismiss }, [dismiss])
40
+
41
+ // Set of ids this hook pushed; used by both directions for cycle control.
42
+ const pushedRef = useRef<Set<string>>(new Set())
43
+
44
+ // Context → editor.
45
+ useEffect(() => {
46
+ if (!editor) return
47
+ const contextIds = new Set(list.map(s => s.id))
48
+
49
+ for (const s of list) {
50
+ if (pushedRef.current.has(s.id)) continue
51
+ const meta = (s.meta ?? {}) as Record<string, unknown>
52
+ const range = meta['editorRange'] as { from?: unknown; to?: unknown } | undefined
53
+ if (!range || typeof range.from !== 'number' || typeof range.to !== 'number') continue
54
+ const replacement = typeof s.suggestedValue === 'string' ? s.suggestedValue : ''
55
+ editor.commands.addAiSuggestion({
56
+ id: s.id,
57
+ from: range.from,
58
+ to: range.to,
59
+ replacement,
60
+ ...(s.source ? { source: s.source } : {}),
61
+ })
62
+ pushedRef.current.add(s.id)
63
+ }
64
+
65
+ for (const id of Array.from(pushedRef.current)) {
66
+ if (contextIds.has(id)) continue
67
+ // Context dropped the suggestion — remove from editor without
68
+ // mutating the doc (rejectAiSuggestion drops state only).
69
+ editor.commands.rejectAiSuggestion(id)
70
+ pushedRef.current.delete(id)
71
+ }
72
+ }, [editor, list])
73
+
74
+ // Editor → context.
75
+ useEffect(() => {
76
+ if (!editor) return
77
+ const handler = () => {
78
+ const ps = aiSuggestionPluginKey.getState(editor.state)
79
+ if (!ps) return
80
+ const editorIds = new Set(ps.suggestions.map((s: { id: string }) => s.id))
81
+ for (const id of Array.from(pushedRef.current)) {
82
+ if (editorIds.has(id)) continue
83
+ // Chip removed the suggestion (Approve mutated the doc, Reject did
84
+ // not — either way it's gone from editor state). Mirror to context.
85
+ pushedRef.current.delete(id)
86
+ dismissRef.current(id)
87
+ }
88
+ }
89
+ editor.on('transaction', handler)
90
+ return () => { editor.off('transaction', handler) }
91
+ }, [editor])
92
+
93
+ // Cross-tree applier (Phase 8.5). When an aggregate consumer (e.g. a
94
+ // chat-sidebar pending-pill) calls `pendingSuggestions.approve(id)`,
95
+ // the pro provider looks up the applier registered for this
96
+ // `(formId, fieldName)` and invokes it. We translate that into the
97
+ // editor's own approve command — same path the inline chip click takes.
98
+ useEffect(() => {
99
+ if (!editor) return
100
+ const applier: PendingSuggestionApplier = (suggestion) => {
101
+ // Bail when the suggestion isn't one of ours (no editor range or
102
+ // bridge-pushed entry). Pro provider falls back to plain dismiss.
103
+ if (!pushedRef.current.has(suggestion.id)) return
104
+ editor.chain().focus().approveAiSuggestion(suggestion.id).run()
105
+ // The transaction listener above sees the editor state drop the id
106
+ // and calls `dismiss(id)` on its own — no manual mirror needed.
107
+ }
108
+ // Editor renderers don't currently have access to a `formId` here;
109
+ // pass `undefined` so the wildcard form scope resolves. Phase 8.5+
110
+ // can thread `formId` via the bridge call site if a future multi-
111
+ // form richtext consumer needs it.
112
+ return registerPendingSuggestionApplier(undefined, fieldName, applier)
113
+ }, [editor, fieldName])
114
+ }
115
+
116
+ // Re-export the pending-suggestion type for consumers that import the hook
117
+ // from this module directly — saves them a separate `@pilotiq/pilotiq/react`
118
+ // import when wiring an external producer.
119
+ export type { PendingSuggestion }