@pilotiq/tiptap 3.4.0 → 3.6.0

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.
Files changed (39) hide show
  1. package/dist/extensions/AiInlineDiffExtension.d.ts +82 -0
  2. package/dist/extensions/AiInlineDiffExtension.d.ts.map +1 -0
  3. package/dist/extensions/AiInlineDiffExtension.js +205 -0
  4. package/dist/extensions/AiInlineDiffExtension.js.map +1 -0
  5. package/dist/extensions/AiSuggestionExtension.d.ts.map +1 -1
  6. package/dist/extensions/AiSuggestionExtension.js +108 -0
  7. package/dist/extensions/AiSuggestionExtension.js.map +1 -1
  8. package/dist/markdownExtension.js +0 -1
  9. package/dist/react/AiSuggestionBanner.d.ts +72 -0
  10. package/dist/react/AiSuggestionBanner.d.ts.map +1 -0
  11. package/dist/react/AiSuggestionBanner.js +72 -0
  12. package/dist/react/AiSuggestionBanner.js.map +1 -0
  13. package/dist/react/CollabTextRenderer.d.ts.map +1 -1
  14. package/dist/react/CollabTextRenderer.js +62 -27
  15. package/dist/react/CollabTextRenderer.js.map +1 -1
  16. package/dist/react/MarkdownEditor.d.ts.map +1 -1
  17. package/dist/react/MarkdownEditor.js +62 -7
  18. package/dist/react/MarkdownEditor.js.map +1 -1
  19. package/dist/react/TiptapEditor.d.ts.map +1 -1
  20. package/dist/react/TiptapEditor.js +47 -5
  21. package/dist/react/TiptapEditor.js.map +1 -1
  22. package/dist/react/useAiInlineDiff.d.ts +57 -0
  23. package/dist/react/useAiInlineDiff.d.ts.map +1 -0
  24. package/dist/react/useAiInlineDiff.js +121 -0
  25. package/dist/react/useAiInlineDiff.js.map +1 -0
  26. package/dist/react/useAiSuggestionBridge.d.ts +37 -1
  27. package/dist/react/useAiSuggestionBridge.d.ts.map +1 -1
  28. package/dist/react/useAiSuggestionBridge.js +63 -32
  29. package/dist/react/useAiSuggestionBridge.js.map +1 -1
  30. package/package.json +26 -24
  31. package/src/extensions/AiInlineDiffExtension.ts +263 -0
  32. package/src/extensions/AiSuggestionExtension.ts +107 -0
  33. package/src/react/AiSuggestionBanner.tsx +184 -0
  34. package/src/react/CollabTextRenderer.tsx +61 -27
  35. package/src/react/MarkdownEditor.tsx +62 -6
  36. package/src/react/TiptapEditor.tsx +48 -4
  37. package/src/react/useAiInlineDiff.ts +146 -0
  38. package/src/react/useAiSuggestionBridge.ts +105 -9
  39. package/dist/markdownExtension.js.map +0 -7
@@ -4,6 +4,7 @@ import type { AnyExtension } from '@tiptap/core'
4
4
  import StarterKit from '@tiptap/starter-kit'
5
5
  import Placeholder from '@tiptap/extension-placeholder'
6
6
  import Image from '@tiptap/extension-image'
7
+ import { DOMParser as ProseMirrorDOMParser } from '@tiptap/pm/model'
7
8
  // The `tiptap-markdown` chain (incl. CJS-only `markdown-it-task-lists`) is
8
9
  // pre-bundled into `dist/markdownExtension.js` at @pilotiq/tiptap build
9
10
  // time; importing the wrapper instead of `tiptap-markdown` directly
@@ -17,7 +18,10 @@ import {
17
18
  type MarkdownEditorProps,
18
19
  } from '@pilotiq/pilotiq/react'
19
20
  import { AiSuggestionExtension } from '../extensions/AiSuggestionExtension.js'
21
+ import { AiInlineDiffExtension, aiInlineDiffPluginKey } from '../extensions/AiInlineDiffExtension.js'
20
22
  import { useAiSuggestionBridge } from './useAiSuggestionBridge.js'
23
+ import { useAiInlineDiff, useIsAiInlineDiffActive } from './useAiInlineDiff.js'
24
+ import { AiSuggestionBanner } from './AiSuggestionBanner.js'
21
25
 
22
26
  // Inline lucide.dev SVGs — same posture as `toolbarButtons.tsx` so this
23
27
  // package doesn't pull `lucide-react` as a peer dep. Keep stroke / size
@@ -214,12 +218,14 @@ export function MarkdownEditor({
214
218
  }),
215
219
  Image.configure({ inline: false, allowBase64: false }),
216
220
  Placeholder.configure({ placeholder: placeholder ?? 'Write in markdown…' }),
217
- // AI suggestions — always-on extension that tracks suggested edits as
218
- // inline strikethrough + Approve/Reject chip widgets. Idle until the
219
- // host calls `editor.commands.addAiSuggestion(...)` via the bridge below.
220
- // Matches the `TiptapEditor` wiring so suggestion mode works uniformly
221
- // across RichTextField / MarkdownField / TextField+TextareaField.
221
+ // AI suggestions — chip widget for surgical (range-anchored) edits.
222
222
  AiSuggestionExtension,
223
+ // AI inline diff — Tiptap-Pro-style visualization for whole-field
224
+ // suggestions (prosemirror-changeset under the hood). Decorations
225
+ // show green-background inserts inline + red-strikethrough widgets
226
+ // for deleted text. Host's `<AiSuggestionBanner>` drives Accept /
227
+ // Reject via the extension's commands.
228
+ AiInlineDiffExtension,
223
229
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
224
230
  ...(collabExtensions as any[]),
225
231
  ],
@@ -247,7 +253,47 @@ export function MarkdownEditor({
247
253
  // Cross-package suggestion bridge — sync the host's
248
254
  // `<PendingSuggestionsContext>` queue with the editor's `AiSuggestion`
249
255
  // extension. No-op when no provider is mounted (default no-op context).
250
- useAiSuggestionBridge(editor ?? null, name)
256
+ //
257
+ // Whole-field handling: NO chip widget here. The chip's `textContent`
258
+ // renderer surfaces raw markdown (`## Heading\n- item`) as literal text
259
+ // inside the green pill — visually unparseable for multi-paragraph
260
+ // rewrites. Instead, `<AiSuggestionBanner>` mounts above the editor
261
+ // (see render below). Producer-supplied range suggestions still ride
262
+ // the inline chip path — those have a precise anchor worth showing
263
+ // in context.
264
+ const applyWholeField = (value: string): void => {
265
+ if (!editor || editor.isDestroyed) return
266
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
267
+ ;(editor.commands as any).setContent(value)
268
+ }
269
+ useAiSuggestionBridge(editor ?? null, name, {
270
+ onApplyWholeField: applyWholeField,
271
+ })
272
+
273
+ // Inline diff for whole-field suggestions — replaces the editor doc with
274
+ // the proposed markdown so the user sees the structural diff (inserted
275
+ // headings / list items / etc.) before approving. Pipeline:
276
+ // 1. tiptap-markdown's parser turns the source into HTML
277
+ // (`editor.storage.markdown.parser.parse(value)` returns a string).
278
+ // 2. ProseMirror's `DOMParser.fromSchema(schema).parseSlice(...)` turns
279
+ // that HTML into a Slice against THIS editor's schema — same path
280
+ // the editor's own clipboard-paste uses, so the slice is guaranteed
281
+ // schema-valid.
282
+ useAiInlineDiff(editor ?? null, name, {
283
+ parseSuggestion: (ed, value) => {
284
+ try {
285
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
286
+ const parser = (ed.storage as any).markdown?.parser
287
+ if (!parser || typeof parser.parse !== 'function') return null
288
+ const html = parser.parse(value)
289
+ if (typeof html !== 'string') return null
290
+ const container = document.createElement('div')
291
+ container.innerHTML = html
292
+ return ProseMirrorDOMParser.fromSchema(ed.schema).parseSlice(container)
293
+ } catch { return null }
294
+ },
295
+ })
296
+ const isDiffActive = useIsAiInlineDiffActive(editor ?? null)
251
297
 
252
298
  // First-load seed for collab. Collaboration starts the editor empty
253
299
  // regardless of `content`; once the provider syncs from the server we
@@ -417,6 +463,16 @@ export function MarkdownEditor({
417
463
 
418
464
  return (
419
465
  <div className="flex flex-col rounded-md border bg-background">
466
+ <AiSuggestionBanner
467
+ fieldName={name}
468
+ onApplyWholeField={applyWholeField}
469
+ {...(isDiffActive && editor
470
+ ? {
471
+ onAcceptViaEditor: () => editor.commands.acceptAiInlineDiff(),
472
+ onRejectViaEditor: () => editor.commands.rejectAiInlineDiff(),
473
+ }
474
+ : {})}
475
+ />
420
476
  {canAttach && (
421
477
  <input
422
478
  ref={fileInputRef}
@@ -20,6 +20,9 @@ import type {
20
20
  } from '@pilotiq/pilotiq/react'
21
21
  import { useCollabRoom, getCollabExtensions, onProviderSynced } from '@pilotiq/pilotiq/react'
22
22
  import { useAiSuggestionBridge } from './useAiSuggestionBridge.js'
23
+ import { useAiInlineDiff, useIsAiInlineDiffActive } from './useAiInlineDiff.js'
24
+ import { AiSuggestionBanner } from './AiSuggestionBanner.js'
25
+ import { DOMParser as ProseMirrorDOMParser } from '@tiptap/pm/model'
23
26
  import type { BlockMeta } from '../Block.js'
24
27
  import type { ToolbarGroups, RichTextStorage, ColorSwatch } from '../RichTextField.js'
25
28
  import { BlockNodeExtension } from '../extensions/BlockNodeExtension.js'
@@ -31,6 +34,7 @@ import { DragHandleExtension } from '../extensions/DragHandleExtension.js'
31
34
  import { MergeTagExtension } from '../extensions/MergeTagExtension.js'
32
35
  import { LeadMarkExtension, SmallMarkExtension } from '../extensions/TextSizeMarks.js'
33
36
  import { AiSuggestionExtension } from '../extensions/AiSuggestionExtension.js'
37
+ import { AiInlineDiffExtension } from '../extensions/AiInlineDiffExtension.js'
34
38
  import {
35
39
  MentionExtension,
36
40
  type MentionState,
@@ -315,10 +319,11 @@ function ClientEditor(props: ClientEditorProps) {
315
319
  fieldName: name,
316
320
  })] : [MentionExtension]),
317
321
  DragHandleExtension,
318
- // AI suggestions — always-on extension that tracks suggested edits as
319
- // inline strikethrough + Approve/Reject chip widgets. Idle until the
320
- // host calls `editor.commands.addAiSuggestion(...)`.
322
+ // AI suggestions — chip widget for surgical (range-anchored) edits.
321
323
  AiSuggestionExtension,
324
+ // AI inline diff — Tiptap-Pro-style visualization for whole-field
325
+ // suggestions via prosemirror-changeset. See AiInlineDiffExtension.
326
+ AiInlineDiffExtension,
322
327
  // Realtime-collab extensions (Yjs `Collaboration` + cursor) — empty
323
328
  // when no `<RecordCollabRoom>` is mounted up-tree, or when no plugin
324
329
  // registered a factory via `registerCollabExtensions`.
@@ -463,7 +468,36 @@ function ClientEditor(props: ClientEditorProps) {
463
468
  // Cross-package suggestion bridge — sync the host's
464
469
  // `<PendingSuggestionsContext>` queue with the editor's `AiSuggestion`
465
470
  // extension. No-op when no provider is mounted (default no-op context).
466
- useAiSuggestionBridge(editor ?? null, name)
471
+ //
472
+ // Whole-field handling: NO chip widget here. The chip's `textContent`
473
+ // renderer would surface raw HTML tags as literal text inside the
474
+ // green pill — unparseable on multi-paragraph rewrites. Instead,
475
+ // `<AiSuggestionBanner>` mounts above the editor (see render below).
476
+ // Producer-supplied range suggestions still ride the inline chip —
477
+ // those have a precise anchor worth visualizing in context.
478
+ const applyWholeField = (value: string): void => {
479
+ if (!editor || editor.isDestroyed) return
480
+ editor.commands.setContent(value)
481
+ }
482
+ useAiSuggestionBridge(editor ?? null, name, {
483
+ onApplyWholeField: applyWholeField,
484
+ })
485
+
486
+ // Inline diff for whole-field suggestions. Pipeline mirrors MarkdownEditor:
487
+ // HTML → ProseMirror Slice via the schema's DOMParser. Suggested values
488
+ // on a RichTextField are typically HTML (or marked-up JSON that the
489
+ // schema's DOMParser also handles via its serialized round-trip). For
490
+ // JSON suggestions, the schema may reject — falls back to banner-only.
491
+ useAiInlineDiff(editor ?? null, name, {
492
+ parseSuggestion: (ed, value) => {
493
+ try {
494
+ const container = document.createElement('div')
495
+ container.innerHTML = value
496
+ return ProseMirrorDOMParser.fromSchema(ed.schema).parseSlice(container)
497
+ } catch { return null }
498
+ },
499
+ })
500
+ const isDiffActive = useIsAiInlineDiffActive(editor ?? null)
467
501
 
468
502
  // Re-render the toolbar when the selection / marks change so active-state
469
503
  // booleans stay fresh.
@@ -472,6 +506,16 @@ function ClientEditor(props: ClientEditorProps) {
472
506
  return (
473
507
  <div className="relative flex flex-col">
474
508
  <input type="hidden" name={name} value={serialized} />
509
+ <AiSuggestionBanner
510
+ fieldName={name}
511
+ onApplyWholeField={applyWholeField}
512
+ {...(isDiffActive && editor
513
+ ? {
514
+ onAcceptViaEditor: () => editor.commands.acceptAiInlineDiff(),
515
+ onRejectViaEditor: () => editor.commands.rejectAiInlineDiff(),
516
+ }
517
+ : {})}
518
+ />
475
519
  {editor && toolbarGroups && toolbarGroups.length > 0 && (
476
520
  <Toolbar
477
521
  editor={editor}
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Bridge between the host's `<PendingSuggestionsContext>` queue and the
3
+ * editor's `AiInlineDiffExtension`. When a whole-field suggestion arrives
4
+ * for the field, the hook:
5
+ *
6
+ * 1. Parses the suggested value into a ProseMirror `Slice` via the
7
+ * renderer-supplied parser. Each Tiptap surface owns its own
8
+ * content shape — markdown source for `MarkdownEditor`, HTML / JSON
9
+ * for `TiptapEditor`, plain text for `CollabTextRenderer`.
10
+ * 2. Calls `editor.commands.startAiInlineDiff(id, slice)` — the
11
+ * extension snapshots the current doc as the baseline, replaces
12
+ * the doc content with the proposed slice, and starts a
13
+ * `prosemirror-changeset` tracking the diff.
14
+ * 3. Registers an applier on the cross-tree pending-suggestion
15
+ * registry so the host's `<AiSuggestionBanner>` Accept button (and
16
+ * any other surface calling `pendingSuggestions.approve(id)`) runs
17
+ * `acceptAiInlineDiff()` instead of the legacy `onApplyWholeField`
18
+ * callback. The current doc IS the accepted state — no extra
19
+ * content swap needed.
20
+ *
21
+ * Reject handling: not registered on the applier (the registry only
22
+ * tracks Approve). Renderers wire Reject through the banner's
23
+ * `onRejectWithEditor` prop, which calls `rejectAiInlineDiff()` to revert
24
+ * the doc to the baseline before dismissing the suggestion.
25
+ *
26
+ * Defensive: only one inline diff active at a time per editor. If a new
27
+ * synthesized suggestion arrives while one is still pending review, the
28
+ * hook drops it (the producer should have waited). This matches
29
+ * `AiSuggestionExtension`'s chip path which also allows only one
30
+ * suggestion at a time per id.
31
+ */
32
+
33
+ import { useEffect, useRef } from 'react'
34
+ import type { Editor } from '@tiptap/core'
35
+ import type { Slice } from '@tiptap/pm/model'
36
+ import {
37
+ registerPendingSuggestionApplier,
38
+ usePendingSuggestionsForField,
39
+ type PendingSuggestion,
40
+ type PendingSuggestionApplier,
41
+ } from '@pilotiq/pilotiq/react'
42
+ import { aiInlineDiffPluginKey } from '../extensions/AiInlineDiffExtension.js'
43
+
44
+ export interface UseAiInlineDiffOptions {
45
+ /**
46
+ * Parse the suggested string value into a ProseMirror Slice that's
47
+ * compatible with this editor's schema. Returns `null` to skip (e.g.
48
+ * malformed content, unsupported markup) — the suggestion stays in
49
+ * the queue but no diff renders, and the host's fallback path (banner
50
+ * with `onApplyWholeField`) takes over.
51
+ *
52
+ * Renderers implement this per content shape:
53
+ * - plain text → wrap each line in a `paragraph` node
54
+ * - markdown → run through the Markdown extension's parseMarkdown
55
+ * - HTML → DOMParser + ProseMirror's DOMParser.parse
56
+ */
57
+ parseSuggestion: (editor: Editor, value: string) => Slice | null
58
+ }
59
+
60
+ /**
61
+ * Returns whether a diff is currently active in the editor. Hosts use
62
+ * this to gate the banner's UI between the legacy `onApplyWholeField`
63
+ * mode and the diff-aware mode (Reject routes through
64
+ * `rejectAiInlineDiff` to revert the doc).
65
+ */
66
+ export function useIsAiInlineDiffActive(editor: Editor | null): boolean {
67
+ // Re-render on every editor transaction so the hook tracks state
68
+ // changes (start / accept / reject). useEditorState would be the
69
+ // idiomatic way; we read directly here to keep the dep surface tiny.
70
+ const [, force] = useReducerForceUpdate()
71
+ useEffect(() => {
72
+ if (!editor) return
73
+ const handler = () => force()
74
+ editor.on('transaction', handler)
75
+ return () => { editor.off('transaction', handler) }
76
+ }, [editor, force])
77
+ if (!editor) return false
78
+ return aiInlineDiffPluginKey.getState(editor.state) !== null
79
+ }
80
+
81
+ export function useAiInlineDiff(
82
+ editor: Editor | null,
83
+ fieldName: string,
84
+ options: UseAiInlineDiffOptions,
85
+ ): void {
86
+ const { list } = usePendingSuggestionsForField(fieldName)
87
+
88
+ const parseRef = useRef(options.parseSuggestion)
89
+ useEffect(() => { parseRef.current = options.parseSuggestion }, [options.parseSuggestion])
90
+
91
+ // Track which ids we've handed off to the editor's diff extension
92
+ // so we don't re-start the diff every render or for already-active
93
+ // suggestions.
94
+ const startedRef = useRef<Set<string>>(new Set())
95
+
96
+ // Context → editor: start the diff for each new whole-field suggestion.
97
+ useEffect(() => {
98
+ if (!editor) return
99
+ const wholeField = list.filter(s => !hasEditorRange(s))
100
+ for (const s of wholeField) {
101
+ if (startedRef.current.has(s.id)) continue
102
+ if (typeof s.suggestedValue !== 'string') continue
103
+ // Bail when a different diff is already showing — one at a time.
104
+ // Producer should serialize calls; if not, the second suggestion
105
+ // sits in the queue until the first is approved/rejected.
106
+ if (aiInlineDiffPluginKey.getState(editor.state) !== null) continue
107
+ const slice = parseRef.current(editor, s.suggestedValue)
108
+ if (!slice) continue
109
+ editor.commands.startAiInlineDiff(s.id, slice)
110
+ startedRef.current.add(s.id)
111
+ }
112
+ // Cleanup: when a suggestion leaves the context AND we previously
113
+ // started a diff for it, the editor should drop the diff state too.
114
+ // Approve dismisses via context → here we drop from startedRef.
115
+ const contextIds = new Set(list.map(s => s.id))
116
+ for (const id of Array.from(startedRef.current)) {
117
+ if (!contextIds.has(id)) startedRef.current.delete(id)
118
+ }
119
+ }, [editor, list])
120
+
121
+ // Cross-tree applier — when the banner / chat-sidebar pill calls
122
+ // `pendingSuggestions.approve(id)` for one of our tracked suggestions,
123
+ // accept the diff. Editor is the source of truth for the new doc.
124
+ useEffect(() => {
125
+ if (!editor) return
126
+ const applier: PendingSuggestionApplier = (suggestion) => {
127
+ if (!startedRef.current.has(suggestion.id)) return
128
+ editor.commands.acceptAiInlineDiff()
129
+ }
130
+ return registerPendingSuggestionApplier(undefined, fieldName, applier)
131
+ }, [editor, fieldName])
132
+ }
133
+
134
+ function hasEditorRange(s: PendingSuggestion): boolean {
135
+ const meta = (s.meta ?? {}) as Record<string, unknown>
136
+ const range = meta['editorRange'] as { from?: unknown; to?: unknown } | undefined
137
+ return !!(range && typeof range.from === 'number' && typeof range.to === 'number')
138
+ }
139
+
140
+ // useReducer + dispatch is the smallest-API force-update primitive React
141
+ // ships. Hoisted into a helper so the call site stays one line.
142
+ import { useReducer } from 'react'
143
+ function useReducerForceUpdate(): [number, () => void] {
144
+ const [n, inc] = useReducer((x: number) => (x + 1) | 0, 0)
145
+ return [n, () => inc()]
146
+ }
@@ -29,8 +29,50 @@ import { aiSuggestionPluginKey } from '../extensions/AiSuggestionExtension.js'
29
29
  * hook had previously pushed (so an id added directly by host code via
30
30
  * `editor.commands.addAiSuggestion(...)` doesn't get reflected back through
31
31
  * a context that never knew about it).
32
+ *
33
+ * **Whole-field fallback** (chat-driven suggestions). Producers like
34
+ * `@pilotiq-pro/ai`'s `update_form_state` tool push suggestions that target
35
+ * the whole field — no `meta.editorRange`, just `suggestedValue` as a string.
36
+ * Without `editorRange` the bridge can't render the inline-diff chip widget
37
+ * (it has nowhere to anchor), so the host renderer passes an
38
+ * `onApplyWholeField(value)` callback. When the chat-sidebar Approve fires
39
+ * for a non-bridge-pushed id, the registered applier calls this callback
40
+ * instead of no-op'ing — letting each renderer apply the suggestion the
41
+ * right way for its shape (plain text → `plainTextToDoc`, markdown → set
42
+ * markdown source, richtext → setContent with HTML/JSON). The host is also
43
+ * responsible for the Approve UI — FieldShell hides its legacy overlay
44
+ * whenever a Tiptap renderer is mounted (richtext / markdown / collab text).
32
45
  */
33
- export function useAiSuggestionBridge(editor: Editor | null, fieldName: string): void {
46
+ export interface UseAiSuggestionBridgeOptions {
47
+ /**
48
+ * Apply a whole-field suggestion that lacks `meta.editorRange`. Each
49
+ * Tiptap renderer passes its own implementation (different content
50
+ * shapes — plain text, markdown source, HTML/JSON). Omit for editors
51
+ * that should ignore whole-field suggestions entirely.
52
+ */
53
+ onApplyWholeField?: (suggestedValue: string) => void
54
+
55
+ /**
56
+ * Synthesize a `{ from, to }` range for whole-field suggestions so the
57
+ * inline-diff chip widget can render BEFORE the user approves. The
58
+ * extension's `applyApprove` inserts a plain text node spanning the
59
+ * synthesized range — safe only for editors whose schema accepts a
60
+ * text-node replacement covering the whole doc (CollabTextRenderer's
61
+ * plain-text schema fits; richtext / markdown lose formatting if
62
+ * approved that way). Return `undefined` to skip synthesis and fall
63
+ * through to the legacy `onApplyWholeField` callback (silent swap).
64
+ */
65
+ synthesizeWholeFieldRange?: (
66
+ editor: Editor,
67
+ suggestion: PendingSuggestion,
68
+ ) => { from: number; to: number } | undefined
69
+ }
70
+
71
+ export function useAiSuggestionBridge(
72
+ editor: Editor | null,
73
+ fieldName: string,
74
+ options: UseAiSuggestionBridgeOptions = {},
75
+ ): void {
34
76
  const { list, dismiss } = usePendingSuggestionsForField(fieldName)
35
77
 
36
78
  // Hold the latest `dismiss` in a ref so the editor-side listener — which
@@ -38,8 +80,24 @@ export function useAiSuggestionBridge(editor: Editor | null, fieldName: string):
38
80
  const dismissRef = useRef(dismiss)
39
81
  useEffect(() => { dismissRef.current = dismiss }, [dismiss])
40
82
 
83
+ // Same ref pattern for the whole-field applier — captured here so the
84
+ // applier closure registered below stays stable across re-renders without
85
+ // re-registering on every option change.
86
+ const onApplyWholeFieldRef = useRef(options.onApplyWholeField)
87
+ useEffect(() => { onApplyWholeFieldRef.current = options.onApplyWholeField }, [options.onApplyWholeField])
88
+ const synthesizeRangeRef = useRef(options.synthesizeWholeFieldRange)
89
+ useEffect(() => { synthesizeRangeRef.current = options.synthesizeWholeFieldRange }, [options.synthesizeWholeFieldRange])
90
+
41
91
  // Set of ids this hook pushed; used by both directions for cycle control.
42
92
  const pushedRef = useRef<Set<string>>(new Set())
93
+ // Subset of `pushedRef` whose range was synthesized (no producer-supplied
94
+ // `meta.editorRange`). Approving these must NOT route through the chip's
95
+ // plain-text replace — that would clobber HTML / markdown formatting on
96
+ // rich editors. Instead the applier delegates to `onApplyWholeField`,
97
+ // which sets content via the renderer's content-shape-aware command
98
+ // (`setContent(plainTextToDoc(...))` / `setContent(markdownSrc)` /
99
+ // `setContent(html)`), then clears the chip without a doc edit.
100
+ const synthesizedRef = useRef<Set<string>>(new Set())
43
101
 
44
102
  // Context → editor.
45
103
  useEffect(() => {
@@ -49,8 +107,23 @@ export function useAiSuggestionBridge(editor: Editor | null, fieldName: string):
49
107
  for (const s of list) {
50
108
  if (pushedRef.current.has(s.id)) continue
51
109
  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
110
+ const rawRange = meta['editorRange'] as { from?: unknown; to?: unknown } | undefined
111
+ let range: { from: number; to: number } | undefined
112
+ let isSynthesized = false
113
+ if (rawRange && typeof rawRange.from === 'number' && typeof rawRange.to === 'number') {
114
+ range = { from: rawRange.from, to: rawRange.to }
115
+ } else {
116
+ // Producer didn't supply a range — let the renderer synthesize one
117
+ // so the inline-diff chip widget can still render. Renderers that
118
+ // can't safely round-trip a plain-text replace (richtext/markdown
119
+ // losing formatting on approve) STILL benefit by synthesizing for
120
+ // visualization — the applier below routes Approve through
121
+ // `onApplyWholeField` for synthesized ids instead of the chip's
122
+ // text-node replace.
123
+ range = synthesizeRangeRef.current?.(editor, s)
124
+ if (!range) continue
125
+ isSynthesized = true
126
+ }
54
127
  const replacement = typeof s.suggestedValue === 'string' ? s.suggestedValue : ''
55
128
  editor.commands.addAiSuggestion({
56
129
  id: s.id,
@@ -60,6 +133,7 @@ export function useAiSuggestionBridge(editor: Editor | null, fieldName: string):
60
133
  ...(s.source ? { source: s.source } : {}),
61
134
  })
62
135
  pushedRef.current.add(s.id)
136
+ if (isSynthesized) synthesizedRef.current.add(s.id)
63
137
  }
64
138
 
65
139
  for (const id of Array.from(pushedRef.current)) {
@@ -68,6 +142,7 @@ export function useAiSuggestionBridge(editor: Editor | null, fieldName: string):
68
142
  // mutating the doc (rejectAiSuggestion drops state only).
69
143
  editor.commands.rejectAiSuggestion(id)
70
144
  pushedRef.current.delete(id)
145
+ synthesizedRef.current.delete(id)
71
146
  }
72
147
  }, [editor, list])
73
148
 
@@ -98,12 +173,33 @@ export function useAiSuggestionBridge(editor: Editor | null, fieldName: string):
98
173
  useEffect(() => {
99
174
  if (!editor) return
100
175
  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.
176
+ const apply = onApplyWholeFieldRef.current
177
+ const hasSynthesized = synthesizedRef.current.has(suggestion.id)
178
+ const hasPushed = pushedRef.current.has(suggestion.id)
179
+
180
+ // Synthesized whole-field range the chip rendered for visualization,
181
+ // but routing Approve through the editor's `approveAiSuggestion` would
182
+ // do a plain-text replace and clobber HTML / markdown formatting.
183
+ // Delegate to the renderer-supplied applier (content-shape-aware)
184
+ // and clear the chip state without a doc edit.
185
+ if (hasSynthesized && apply && typeof suggestion.suggestedValue === 'string') {
186
+ apply(suggestion.suggestedValue)
187
+ editor.commands.rejectAiSuggestion(suggestion.id)
188
+ return
189
+ }
190
+ // Producer-supplied editor range — surgical edit. Forward Approve to
191
+ // the editor command; the transaction listener above mirrors the
192
+ // dismiss back into context.
193
+ if (hasPushed) {
194
+ editor.chain().focus().approveAiSuggestion(suggestion.id).run()
195
+ return
196
+ }
197
+ // Whole-field path WITHOUT visualization — producer skipped the range
198
+ // and the renderer didn't synthesize. Same applier as above, no chip
199
+ // to clear. Context's `approve()` dismisses the queue entry.
200
+ if (apply && typeof suggestion.suggestedValue === 'string') {
201
+ apply(suggestion.suggestedValue)
202
+ }
107
203
  }
108
204
  // Editor renderers don't currently have access to a `formId` here;
109
205
  // pass `undefined` so the wildcard form scope resolves. Phase 8.5+