@pilotiq/tiptap 3.5.0 → 3.7.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 (38) hide show
  1. package/dist/extensions/AiInlineDiffExtension.d.ts +96 -0
  2. package/dist/extensions/AiInlineDiffExtension.d.ts.map +1 -0
  3. package/dist/extensions/AiInlineDiffExtension.js +216 -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 +51 -0
  7. package/dist/extensions/AiSuggestionExtension.js.map +1 -1
  8. package/dist/index.d.ts +2 -0
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +2 -0
  11. package/dist/index.js.map +1 -1
  12. package/dist/react/AiSuggestionBanner.d.ts +72 -0
  13. package/dist/react/AiSuggestionBanner.d.ts.map +1 -0
  14. package/dist/react/AiSuggestionBanner.js +72 -0
  15. package/dist/react/AiSuggestionBanner.js.map +1 -0
  16. package/dist/react/MarkdownEditor.d.ts.map +1 -1
  17. package/dist/react/MarkdownEditor.js +58 -20
  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 +43 -18
  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 +218 -0
  25. package/dist/react/useAiInlineDiff.js.map +1 -0
  26. package/dist/surgicalOps.d.ts +72 -0
  27. package/dist/surgicalOps.d.ts.map +1 -0
  28. package/dist/surgicalOps.js +160 -0
  29. package/dist/surgicalOps.js.map +1 -0
  30. package/package.json +24 -22
  31. package/src/extensions/AiInlineDiffExtension.ts +286 -0
  32. package/src/extensions/AiSuggestionExtension.ts +51 -0
  33. package/src/index.ts +15 -0
  34. package/src/react/AiSuggestionBanner.tsx +184 -0
  35. package/src/react/MarkdownEditor.tsx +58 -19
  36. package/src/react/TiptapEditor.tsx +44 -16
  37. package/src/react/useAiInlineDiff.ts +267 -0
  38. package/src/surgicalOps.ts +186 -0
@@ -0,0 +1,286 @@
1
+ /**
2
+ * Inline-diff visualization for whole-field AI suggestions.
3
+ *
4
+ * Sibling to `AiSuggestionExtension`, which handles producer-supplied
5
+ * range suggestions (surgical edits with `meta.editorRange`) via the
6
+ * inline chip widget. This extension handles the *whole-field* case:
7
+ * the AI proposes a new document and the user reviews the structural
8
+ * delta (added paragraphs, deleted text, mark changes, etc.) before
9
+ * accepting or rejecting via the host-mounted `<AiSuggestionBanner>`.
10
+ *
11
+ * Architecture:
12
+ * 1. `startAiInlineDiff(id, newDoc)` captures the current doc as the
13
+ * baseline, replaces the doc body with `newDoc`'s content (so the
14
+ * editor surface IS the proposed state), and initializes a
15
+ * `prosemirror-changeset` tracking the original-to-current
16
+ * transition.
17
+ * 2. The plugin appendTransaction hook keeps the changeset in sync
18
+ * with any further transactions while the diff is pending — e.g.
19
+ * y-prosemirror remote edits arriving during review. (Rare on
20
+ * whole-field flows but free.)
21
+ * 3. A decorations spec walks `ChangeSet.changes` and emits:
22
+ * - inline green-background decorations on inserted ranges
23
+ * - widget decorations rendering the *deleted* text struck
24
+ * through next to the insert point (the deleted content
25
+ * isn't in the current doc, so a widget is the only way to
26
+ * surface it)
27
+ * 4. `acceptAiInlineDiff()` clears the plugin state — the current
28
+ * doc is the accepted state.
29
+ * 5. `rejectAiInlineDiff()` replaces the doc back to the baseline
30
+ * via a single transaction and clears state.
31
+ *
32
+ * For Tiptap Pro parity. See `[[project_pilotiq_text_field_tiptap_rules]]`.
33
+ */
34
+
35
+ import { Extension } from '@tiptap/core'
36
+ import { Plugin, PluginKey } from '@tiptap/pm/state'
37
+ import type { EditorState, Transaction } from '@tiptap/pm/state'
38
+ import { Decoration, DecorationSet } from '@tiptap/pm/view'
39
+ import type { Node as ProseMirrorNode, Slice } from '@tiptap/pm/model'
40
+ import { ChangeSet } from 'prosemirror-changeset'
41
+
42
+ declare module '@tiptap/core' {
43
+ interface Commands<ReturnType> {
44
+ aiInlineDiff: {
45
+ /**
46
+ * Start the inline-diff review session. Snapshots the current
47
+ * doc as the baseline, replaces the doc with `newDocSlice`'s
48
+ * content, and shows the diff overlay.
49
+ *
50
+ * `id` is the host-side `PendingSuggestion.id` — used so the
51
+ * banner / approve handlers can correlate the editor state with
52
+ * the queue entry.
53
+ */
54
+ startAiInlineDiff: (id: string, newDocSlice: Slice) => ReturnType
55
+ /**
56
+ * Start the inline-diff review session for a surgical edit.
57
+ * Snapshots the current doc as the baseline, then runs
58
+ * `applyFn(tr)` to mutate the transaction with a precise change
59
+ * (e.g. replace one block, insert before a position, set a mark
60
+ * on a range). The plugin folds the resulting steps into the
61
+ * changeset, so decorations land exactly on the modified ranges
62
+ * — no whole-doc replacement.
63
+ *
64
+ * Use this for `replace_block` / `insert_block_before` /
65
+ * `delete_block` / `update_block_mark` AI ops. Returns false (no
66
+ * dispatch) when `applyFn` produced no doc change.
67
+ */
68
+ applySurgicalAiInlineDiff: (id: string, applyFn: (tr: Transaction) => void) => ReturnType
69
+ /** Clear diff state. Current doc IS the accepted state. */
70
+ acceptAiInlineDiff: () => ReturnType
71
+ /** Revert doc to the captured baseline and clear diff state. */
72
+ rejectAiInlineDiff: () => ReturnType
73
+ }
74
+ }
75
+ }
76
+
77
+ interface DiffState {
78
+ id: string
79
+ /** Original doc captured at `startAiInlineDiff` time — used for revert. */
80
+ baseline: ProseMirrorNode
81
+ /** ChangeSet accumulating diffs since baseline. */
82
+ changeset: ChangeSet
83
+ }
84
+
85
+ export const aiInlineDiffPluginKey = new PluginKey<DiffState | null>('pilotiqAiInlineDiff')
86
+
87
+ /** Read the active diff state, if any. Public for hosts that want to
88
+ * branch their banner UI on "diff active" vs "diff inactive". */
89
+ export function getAiInlineDiffState(state: EditorState): DiffState | null {
90
+ return aiInlineDiffPluginKey.getState(state) ?? null
91
+ }
92
+
93
+ interface StartMeta { type: 'start'; id: string; baseline: ProseMirrorNode }
94
+ interface ClearMeta { type: 'clear' }
95
+ type DiffMeta = StartMeta | ClearMeta
96
+
97
+ export interface AiInlineDiffExtensionOptions {
98
+ /**
99
+ * Class prefix for inline-diff decorations. Defaults to
100
+ * `'pilotiq-ai-diff'`, producing:
101
+ * - `pilotiq-ai-diff-inserted` (green-background span on new ranges)
102
+ * - `pilotiq-ai-diff-deleted` (widget DOM root for deleted text)
103
+ * - `pilotiq-ai-diff-deleted-text` (the strikethrough span inside)
104
+ */
105
+ classPrefix?: string
106
+ }
107
+
108
+ export const AiInlineDiffExtension = Extension.create<AiInlineDiffExtensionOptions>({
109
+ name: 'pilotiqAiInlineDiff',
110
+
111
+ addOptions() {
112
+ return { classPrefix: 'pilotiq-ai-diff' }
113
+ },
114
+
115
+ onCreate() {
116
+ // Mirror the chip CSS injection pattern. Idempotent via sentinel.
117
+ if (typeof document === 'undefined') return
118
+ const SENTINEL = 'data-pilotiq-ai-diff-styles'
119
+ if (document.head.querySelector(`style[${SENTINEL}]`)) return
120
+ const prefix = this.options.classPrefix
121
+ const style = document.createElement('style')
122
+ style.setAttribute(SENTINEL, '')
123
+ style.textContent = `
124
+ .${prefix}-inserted {
125
+ background-color: rgba(187, 247, 208, 0.55);
126
+ color: rgb(20, 83, 45);
127
+ text-decoration: none;
128
+ }
129
+ .${prefix}-deleted {
130
+ display: inline;
131
+ margin-right: 0.125em;
132
+ }
133
+ .${prefix}-deleted-text {
134
+ text-decoration: line-through;
135
+ text-decoration-color: rgba(220, 38, 38, 0.7);
136
+ background-color: rgba(254, 226, 226, 0.55);
137
+ color: rgb(153, 27, 27);
138
+ padding: 0 0.125em;
139
+ }
140
+ `
141
+ document.head.appendChild(style)
142
+ },
143
+
144
+ addCommands() {
145
+ return {
146
+ startAiInlineDiff: (id, newDocSlice) => ({ tr, state, dispatch }) => {
147
+ const baseline = state.doc
148
+ const docEnd = state.doc.content.size
149
+ // Replace the whole doc body with the proposed content. The
150
+ // schema enforces validity — if the slice doesn't fit, ProseMirror
151
+ // throws (callers should pre-validate via `editor.schema`).
152
+ tr.replaceRange(0, docEnd, newDocSlice)
153
+ const meta: StartMeta = { type: 'start', id, baseline }
154
+ tr.setMeta(aiInlineDiffPluginKey, meta)
155
+ if (dispatch) dispatch(tr)
156
+ return true
157
+ },
158
+ applySurgicalAiInlineDiff: (id, applyFn) => ({ tr, state, dispatch }) => {
159
+ const baseline = state.doc
160
+ applyFn(tr)
161
+ if (!tr.docChanged) return false
162
+ const meta: StartMeta = { type: 'start', id, baseline }
163
+ tr.setMeta(aiInlineDiffPluginKey, meta)
164
+ if (dispatch) dispatch(tr)
165
+ return true
166
+ },
167
+ acceptAiInlineDiff: () => ({ tr, dispatch }) => {
168
+ const meta: ClearMeta = { type: 'clear' }
169
+ tr.setMeta(aiInlineDiffPluginKey, meta)
170
+ if (dispatch) dispatch(tr)
171
+ return true
172
+ },
173
+ rejectAiInlineDiff: () => ({ tr, state, dispatch }) => {
174
+ const ds = aiInlineDiffPluginKey.getState(state)
175
+ if (!ds) return false
176
+ const docEnd = state.doc.content.size
177
+ // Replace whole body with the baseline's content via a slice that
178
+ // spans the baseline's open boundaries (always 0 for a top-level
179
+ // doc replace).
180
+ tr.replaceWith(0, docEnd, ds.baseline.content)
181
+ const meta: ClearMeta = { type: 'clear' }
182
+ tr.setMeta(aiInlineDiffPluginKey, meta)
183
+ if (dispatch) dispatch(tr)
184
+ return true
185
+ },
186
+ }
187
+ },
188
+
189
+ addProseMirrorPlugins() {
190
+ const ext = this
191
+ return [
192
+ new Plugin<DiffState | null>({
193
+ key: aiInlineDiffPluginKey,
194
+ state: {
195
+ init() { return null },
196
+ apply(tr, value) {
197
+ const meta = tr.getMeta(aiInlineDiffPluginKey) as DiffMeta | undefined
198
+ if (meta?.type === 'start') {
199
+ // Baseline captured BEFORE the replaceRange step in this
200
+ // same transaction. The changeset's `addSteps` consumes
201
+ // the transaction's step list to compute the diff between
202
+ // the baseline doc and the post-transaction doc.
203
+ const cs = ChangeSet.create(meta.baseline).addSteps(tr.doc, tr.mapping.maps, null)
204
+ return { id: meta.id, baseline: meta.baseline, changeset: cs }
205
+ }
206
+ if (meta?.type === 'clear') return null
207
+ if (!value) return value
208
+ // No explicit meta — a regular transaction landed while the
209
+ // diff was active. Fold its steps into the changeset so any
210
+ // further edits (e.g. y-prosemirror remote ops) are reflected.
211
+ if (tr.docChanged) {
212
+ const cs = value.changeset.addSteps(tr.doc, tr.mapping.maps, null)
213
+ return { ...value, changeset: cs }
214
+ }
215
+ return value
216
+ },
217
+ },
218
+ props: {
219
+ decorations(state) {
220
+ const ds = aiInlineDiffPluginKey.getState(state)
221
+ if (!ds) return DecorationSet.empty
222
+ return buildDiffDecorations(state, ds, ext.options.classPrefix ?? 'pilotiq-ai-diff')
223
+ },
224
+ },
225
+ }),
226
+ ]
227
+ },
228
+ })
229
+
230
+ function buildDiffDecorations(
231
+ state: EditorState,
232
+ ds: DiffState,
233
+ prefix: string,
234
+ ): DecorationSet {
235
+ const decos: Decoration[] = []
236
+ const docSize = state.doc.content.size
237
+
238
+ for (const change of ds.changeset.changes) {
239
+ // `fromB..toB` is the range in the CURRENT doc that holds the
240
+ // inserted content. `fromA..toA` is the range in the BASELINE doc
241
+ // that was removed. `inserted` / `deleted` are Span[] arrays whose
242
+ // `length` sums to (toB - fromB) / (toA - fromA) respectively.
243
+ const fromB = Math.max(0, Math.min(change.fromB, docSize))
244
+ const toB = Math.max(fromB, Math.min(change.toB, docSize))
245
+
246
+ if (toB > fromB) {
247
+ decos.push(
248
+ Decoration.inline(fromB, toB, {
249
+ class: `${prefix}-inserted`,
250
+ 'data-pilotiq-ai-diff-id': ds.id,
251
+ }),
252
+ )
253
+ }
254
+
255
+ // Deleted text — pull from the baseline using the `fromA..toA` range.
256
+ // Render via a widget at the change's insert-point (or end of insert)
257
+ // so the deleted text appears immediately before / after the new run.
258
+ // Empty deletions (pure inserts) skip the widget.
259
+ if (change.toA > change.fromA) {
260
+ const deletedText = ds.baseline.textBetween(change.fromA, change.toA, '\n', ' ')
261
+ if (deletedText.length > 0) {
262
+ decos.push(
263
+ Decoration.widget(fromB, () => buildDeletedWidget(deletedText, prefix, ds.id), {
264
+ side: -1,
265
+ ignoreSelection: true,
266
+ key: `pilotiq-ai-diff:deleted:${change.fromA}:${change.toA}`,
267
+ }),
268
+ )
269
+ }
270
+ }
271
+ }
272
+
273
+ return DecorationSet.create(state.doc, decos)
274
+ }
275
+
276
+ function buildDeletedWidget(text: string, prefix: string, id: string): HTMLElement {
277
+ const root = document.createElement('span')
278
+ root.className = `${prefix}-deleted`
279
+ root.setAttribute('data-pilotiq-ai-diff-id', id)
280
+ root.contentEditable = 'false'
281
+ const inner = document.createElement('span')
282
+ inner.className = `${prefix}-deleted-text`
283
+ inner.textContent = text
284
+ root.appendChild(inner)
285
+ return root
286
+ }
@@ -222,6 +222,57 @@ export const AiSuggestionExtension = Extension.create<AiSuggestionExtensionOptio
222
222
  }
223
223
  .${prefix}-accept:hover { color: rgb(21, 128, 61); }
224
224
  .${prefix}-reject:hover { color: rgb(185, 28, 28); }
225
+
226
+ /* Banner — top-of-editor strip for whole-field suggestions on rich
227
+ surfaces (markdown / richtext). Sibling to the chip styles above;
228
+ lives here so both ship via the same extension-mount sentinel.
229
+ Class names live under \`pilotiq-ai-banner-*\` (not \`-suggestion-\`)
230
+ since the banner is a host-mounted React component, not a PM
231
+ decoration. */
232
+ .pilotiq-ai-banner {
233
+ display: flex;
234
+ align-items: center;
235
+ gap: 0.5rem;
236
+ padding: 0.375rem 0.625rem;
237
+ margin-bottom: 0.375rem;
238
+ border-radius: 0.375rem;
239
+ background-color: rgba(254, 252, 232, 0.9);
240
+ border: 1px solid rgba(234, 179, 8, 0.4);
241
+ color: rgb(113, 63, 18);
242
+ font-size: 0.875rem;
243
+ line-height: 1.4;
244
+ }
245
+ .pilotiq-ai-banner-icon { flex: 0 0 auto; }
246
+ .pilotiq-ai-banner-label { flex: 1 1 auto; }
247
+ .pilotiq-ai-banner-actions {
248
+ display: inline-flex;
249
+ gap: 0.375rem;
250
+ flex: 0 0 auto;
251
+ }
252
+ .pilotiq-ai-banner-reject,
253
+ .pilotiq-ai-banner-accept {
254
+ appearance: none;
255
+ cursor: pointer;
256
+ font-size: 0.8125rem;
257
+ font-weight: 500;
258
+ line-height: 1;
259
+ padding: 0.25rem 0.625rem;
260
+ border-radius: 0.25rem;
261
+ border: 1px solid transparent;
262
+ }
263
+ .pilotiq-ai-banner-reject {
264
+ background-color: transparent;
265
+ color: rgb(120, 53, 15);
266
+ border-color: rgba(180, 83, 9, 0.4);
267
+ }
268
+ .pilotiq-ai-banner-reject:hover {
269
+ background-color: rgba(254, 215, 170, 0.4);
270
+ }
271
+ .pilotiq-ai-banner-accept {
272
+ background-color: rgb(22, 101, 52);
273
+ color: white;
274
+ }
275
+ .pilotiq-ai-banner-accept:hover { background-color: rgb(21, 128, 61); }
225
276
  `
226
277
  document.head.appendChild(style)
227
278
  },
package/src/index.ts CHANGED
@@ -38,6 +38,21 @@ export {
38
38
  type AiSuggestionExtensionOptions,
39
39
  } from './extensions/AiSuggestionExtension.js'
40
40
  export { useAiSuggestionBridge } from './react/useAiSuggestionBridge.js'
41
+ export {
42
+ AiInlineDiffExtension,
43
+ aiInlineDiffPluginKey,
44
+ getAiInlineDiffState,
45
+ type AiInlineDiffExtensionOptions,
46
+ } from './extensions/AiInlineDiffExtension.js'
47
+ export {
48
+ planReplaceBlock,
49
+ planInsertBlockBefore,
50
+ planDeleteBlock,
51
+ planUpdateBlockMark,
52
+ summarizeBlockStructure,
53
+ type BlockMarkRange,
54
+ type TransactionModifier,
55
+ } from './surgicalOps.js'
41
56
  export {
42
57
  renderRichTextToHtml,
43
58
  isRichTextValue,
@@ -0,0 +1,184 @@
1
+ import { useMemo } from 'react'
2
+ import {
3
+ usePendingSuggestionsForField,
4
+ usePendingSuggestions,
5
+ type PendingSuggestion,
6
+ } from '@pilotiq/pilotiq/react'
7
+
8
+ /**
9
+ * Top-of-editor banner UI for whole-field AI suggestions on Tiptap surfaces
10
+ * whose content shape can't survive the inline chip widget's plain-text
11
+ * replace (richtext, markdown). The chip path renders the replacement via
12
+ * `Element.textContent = replacement` which surfaces raw HTML / markdown
13
+ * as literal text — fine for plain `TextField`, ugly for the others.
14
+ *
15
+ * Visible only when at least one pending suggestion targets this field
16
+ * AND lacks `meta.editorRange` (i.e. a whole-field replacement from
17
+ * `update_form_state`'s `set_value` op). Range-anchored suggestions stay
18
+ * on the editor-side chip widget path — those have a precise location
19
+ * the user wants to see in context.
20
+ *
21
+ * Phase 1 ships banner-only ("Changes suggested — Accept / Reject"); no
22
+ * inline diff visualization yet. Phase 2 will replace the banner-only
23
+ * UX with a `prosemirror-changeset`-driven inline diff on the editor's
24
+ * doc itself, with the banner staying as the global Accept-all / Reject
25
+ * control bar. See `[[project_pilotiq_text_field_tiptap_rules]]`.
26
+ *
27
+ * Approve runs the renderer-supplied `onApplyWholeField(value)` callback
28
+ * AND dismisses the suggestion from the queue. Reject just dismisses
29
+ * (no doc mutation). Multiple pending whole-field suggestions on the
30
+ * same field stack — Accept all / Reject all collapse the queue in one
31
+ * pass.
32
+ */
33
+ export interface AiSuggestionBannerProps {
34
+ /** Field name, matches the suggestion's `fieldName`. */
35
+ fieldName: string
36
+ /**
37
+ * Apply a whole-field suggestion to the underlying editor. Receives the
38
+ * raw `suggestedValue` string from the suggestion. The renderer wires
39
+ * its own content-shape-aware `setContent` here (markdown source for
40
+ * MarkdownEditor, HTML / JSON for TiptapEditor).
41
+ *
42
+ * Skipped when `onAcceptViaEditor` is supplied — that path means the
43
+ * editor already holds the proposed state via `AiInlineDiffExtension`,
44
+ * and Accept routes through `acceptAiInlineDiff()` instead. The host
45
+ * still calls `pendingSuggestions.approve(id)` afterwards to dismiss
46
+ * the queue entry.
47
+ */
48
+ onApplyWholeField: (suggestedValue: string) => void
49
+ /**
50
+ * Diff-aware Accept hook. When supplied, the banner calls this first
51
+ * (so the editor commits its diff state) and then dismisses via the
52
+ * context. `onApplyWholeField` is NOT called in this mode — the
53
+ * editor's current doc is already the accepted state.
54
+ *
55
+ * Sparse so the simple banner path (Phase 1, no diff) keeps its
56
+ * existing semantics.
57
+ */
58
+ onAcceptViaEditor?: () => void
59
+ /**
60
+ * Diff-aware Reject hook. When supplied, the banner calls this first
61
+ * (so the editor reverts to the baseline) and then dismisses via the
62
+ * context. Sparse — see `onAcceptViaEditor`.
63
+ */
64
+ onRejectViaEditor?: () => void
65
+ /** Optional class on the outer banner element. Defaults to a minimal styled chrome. */
66
+ className?: string
67
+ }
68
+
69
+ /**
70
+ * Hook variant — returns banner state without rendering, for renderers
71
+ * that want to compose their own chrome. Renderer-agnostic.
72
+ */
73
+ export function useAiSuggestionBanner(fieldName: string): {
74
+ pending: readonly PendingSuggestion[]
75
+ approveAll: (apply: (value: string) => void) => void
76
+ rejectAll: () => void
77
+ } {
78
+ const { list, dismiss } = usePendingSuggestionsForField(fieldName)
79
+
80
+ // Only whole-field suggestions land in the banner. Range-anchored ones
81
+ // ride the editor chip widget.
82
+ const pending = useMemo(
83
+ () => list.filter(s => !hasEditorRange(s)),
84
+ [list],
85
+ )
86
+
87
+ const approveAll = (apply: (value: string) => void): void => {
88
+ for (const s of pending) {
89
+ if (typeof s.suggestedValue === 'string') apply(s.suggestedValue)
90
+ dismiss(s.id)
91
+ }
92
+ }
93
+
94
+ const rejectAll = (): void => {
95
+ for (const s of pending) dismiss(s.id)
96
+ }
97
+
98
+ return { pending, approveAll, rejectAll }
99
+ }
100
+
101
+ function hasEditorRange(s: PendingSuggestion): boolean {
102
+ const meta = (s.meta ?? {}) as Record<string, unknown>
103
+ const range = meta['editorRange'] as { from?: unknown; to?: unknown } | undefined
104
+ return !!(range && typeof range.from === 'number' && typeof range.to === 'number')
105
+ }
106
+
107
+ export function AiSuggestionBanner({
108
+ fieldName,
109
+ onApplyWholeField,
110
+ onAcceptViaEditor,
111
+ onRejectViaEditor,
112
+ className,
113
+ }: AiSuggestionBannerProps): React.ReactElement | null {
114
+ const { pending, approveAll, rejectAll } = useAiSuggestionBanner(fieldName)
115
+ const { dismiss } = usePendingSuggestions()
116
+
117
+ if (pending.length === 0) return null
118
+
119
+ // First (and usually only) pending suggestion drives the agent-label
120
+ // display. Multiple-at-once is rare in practice — the banner shows the
121
+ // most recent producer to keep the chrome compact.
122
+ const head = pending[0]!
123
+ const sourceLabel = head.source?.agentLabel ?? null
124
+
125
+ const handleAccept = (): void => {
126
+ // Diff-active path — editor's current doc IS the accepted state.
127
+ // Commit via the editor command, then drop the queue entries.
128
+ if (onAcceptViaEditor) {
129
+ onAcceptViaEditor()
130
+ for (const s of pending) dismiss(s.id)
131
+ return
132
+ }
133
+ approveAll(onApplyWholeField)
134
+ }
135
+
136
+ const handleReject = (): void => {
137
+ // Diff-active path — editor still holds the proposed state; revert
138
+ // to the captured baseline before dismissing.
139
+ if (onRejectViaEditor) {
140
+ onRejectViaEditor()
141
+ for (const s of pending) dismiss(s.id)
142
+ return
143
+ }
144
+ rejectAll()
145
+ }
146
+
147
+ // Per-suggestion controls when there's more than one — keeps the UX
148
+ // discoverable. Single suggestion: Accept / Reject only.
149
+ const single = pending.length === 1
150
+
151
+ return (
152
+ <div
153
+ role="region"
154
+ aria-label="AI suggested changes"
155
+ data-pilotiq-ai-banner=""
156
+ className={className ?? 'pilotiq-ai-banner'}
157
+ >
158
+ <span className="pilotiq-ai-banner-icon" aria-hidden="true">💡</span>
159
+ <span className="pilotiq-ai-banner-label">
160
+ {single
161
+ ? sourceLabel
162
+ ? `Changes suggested by ${sourceLabel}`
163
+ : 'Changes suggested'
164
+ : `${pending.length} changes suggested`}
165
+ </span>
166
+ <div className="pilotiq-ai-banner-actions">
167
+ <button
168
+ type="button"
169
+ className="pilotiq-ai-banner-reject"
170
+ onClick={handleReject}
171
+ >
172
+ {single ? 'Reject' : 'Reject all'}
173
+ </button>
174
+ <button
175
+ type="button"
176
+ className="pilotiq-ai-banner-accept"
177
+ onClick={handleAccept}
178
+ >
179
+ {single ? 'Accept' : 'Accept all'}
180
+ </button>
181
+ </div>
182
+ </div>
183
+ )
184
+ }
@@ -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
  ],
@@ -248,23 +254,46 @@ export function MarkdownEditor({
248
254
  // `<PendingSuggestionsContext>` queue with the editor's `AiSuggestion`
249
255
  // extension. No-op when no provider is mounted (default no-op context).
250
256
  //
251
- // Whole-field handling for chat-driven suggestions (e.g.
252
- // `update_form_state`). The chip widget renders inline for visualization
253
- // (synthesized range over the whole doc), but Approve routes through
254
- // `onApplyWholeField` so the new markdown source parses correctly via
255
- // the Markdown extension the chip's plain-text replace would lose
256
- // headings, lists, formatting.
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
+ }
257
269
  useAiSuggestionBridge(editor ?? null, name, {
258
- synthesizeWholeFieldRange: (ed) => ({
259
- from: 0,
260
- to: ed.state.doc.content.size,
261
- }),
262
- onApplyWholeField: (value) => {
263
- if (!editor || editor.isDestroyed) return
264
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
265
- ;(editor.commands as any).setContent(value)
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 }
266
294
  },
267
295
  })
296
+ const isDiffActive = useIsAiInlineDiffActive(editor ?? null)
268
297
 
269
298
  // First-load seed for collab. Collaboration starts the editor empty
270
299
  // regardless of `content`; once the provider syncs from the server we
@@ -434,6 +463,16 @@ export function MarkdownEditor({
434
463
 
435
464
  return (
436
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
+ />
437
476
  {canAttach && (
438
477
  <input
439
478
  ref={fileInputRef}