@agent-native/toolkit 0.6.0 → 0.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 (173) hide show
  1. package/README.md +17 -1
  2. package/dist/chat-history/ChatHistoryList.d.ts +103 -0
  3. package/dist/chat-history/ChatHistoryList.d.ts.map +1 -0
  4. package/dist/chat-history/ChatHistoryList.js +109 -0
  5. package/dist/chat-history/ChatHistoryList.js.map +1 -0
  6. package/dist/chat-history/ChatHistoryList.spec.d.ts +2 -0
  7. package/dist/chat-history/ChatHistoryList.spec.d.ts.map +1 -0
  8. package/dist/chat-history/ChatHistoryList.spec.js +283 -0
  9. package/dist/chat-history/ChatHistoryList.spec.js.map +1 -0
  10. package/dist/chat-history/index.d.ts +2 -0
  11. package/dist/chat-history/index.d.ts.map +1 -0
  12. package/dist/chat-history/index.js +2 -0
  13. package/dist/chat-history/index.js.map +1 -0
  14. package/dist/chat-history.css +314 -0
  15. package/package.json +17 -1
  16. package/src/app-shell/header-actions.tsx +84 -0
  17. package/src/app-shell/index.ts +1 -0
  18. package/src/chat-history/ChatHistoryList.spec.tsx +453 -0
  19. package/src/chat-history/ChatHistoryList.tsx +469 -0
  20. package/src/chat-history/index.ts +7 -0
  21. package/src/chat-history.css +314 -0
  22. package/src/collab-ui/AgentPresenceChip.tsx +69 -0
  23. package/src/collab-ui/LiveCursorOverlay.tsx +241 -0
  24. package/src/collab-ui/PresenceBar.tsx +346 -0
  25. package/src/collab-ui/RecentEditHighlights.tsx +225 -0
  26. package/src/collab-ui/RemoteSelectionRings.tsx +227 -0
  27. package/src/collab-ui/agent-identity.ts +16 -0
  28. package/src/collab-ui/index.ts +39 -0
  29. package/src/collab-ui/lead-client.ts +32 -0
  30. package/src/collab-ui/types.spec.ts +18 -0
  31. package/src/collab-ui/types.ts +96 -0
  32. package/src/composer/AgentComposerFrame.tsx +59 -0
  33. package/src/composer/ComposerPlusMenu.tsx +983 -0
  34. package/src/composer/MentionPopover.tsx +412 -0
  35. package/src/composer/PastedTextChip.tsx +150 -0
  36. package/src/composer/PromptComposer.spec.ts +73 -0
  37. package/src/composer/PromptComposer.tsx +718 -0
  38. package/src/composer/RealtimeVoiceMode.spec.tsx +771 -0
  39. package/src/composer/RealtimeVoiceMode.tsx +856 -0
  40. package/src/composer/TiptapComposer.spec.ts +390 -0
  41. package/src/composer/TiptapComposer.tsx +2936 -0
  42. package/src/composer/VoiceButton.spec.ts +33 -0
  43. package/src/composer/VoiceButton.tsx +255 -0
  44. package/src/composer/asset-picker-url.spec.ts +56 -0
  45. package/src/composer/asset-picker-url.ts +80 -0
  46. package/src/composer/attachment-accept.spec.ts +22 -0
  47. package/src/composer/attachment-accept.ts +71 -0
  48. package/src/composer/draft-key.spec.ts +19 -0
  49. package/src/composer/draft-key.ts +7 -0
  50. package/src/composer/extensions/FileReference.tsx +55 -0
  51. package/src/composer/extensions/MentionReference.tsx +93 -0
  52. package/src/composer/extensions/SkillReference.tsx +56 -0
  53. package/src/composer/index.ts +79 -0
  54. package/src/composer/pasted-text.spec.ts +162 -0
  55. package/src/composer/pasted-text.ts +160 -0
  56. package/src/composer/prompt-attachments.spec.ts +80 -0
  57. package/src/composer/prompt-attachments.ts +93 -0
  58. package/src/composer/realtime-voice-audio-level.spec.ts +32 -0
  59. package/src/composer/realtime-voice-audio-level.ts +64 -0
  60. package/src/composer/realtime-voice-transcript.spec.ts +95 -0
  61. package/src/composer/realtime-voice-transcript.ts +134 -0
  62. package/src/composer/runtime-adapters.tsx +343 -0
  63. package/src/composer/types.ts +66 -0
  64. package/src/composer/use-file-search.ts +64 -0
  65. package/src/composer/use-mention-search.ts +90 -0
  66. package/src/composer/use-skills.ts +45 -0
  67. package/src/composer/useRealtimeVoiceMode.spec.ts +818 -0
  68. package/src/composer/useRealtimeVoiceMode.tsx +2117 -0
  69. package/src/composer/useVoiceDictation.spec.ts +45 -0
  70. package/src/composer/useVoiceDictation.ts +1191 -0
  71. package/src/context-ui/ContextMeter.tsx +101 -0
  72. package/src/context-ui/ContextSegmentRow.tsx +184 -0
  73. package/src/context-ui/ContextTreemap.tsx +125 -0
  74. package/src/context-ui/ContextXRayPanel.tsx +377 -0
  75. package/src/context-ui/format.ts +42 -0
  76. package/src/context-ui/index.ts +15 -0
  77. package/src/context-ui/types.ts +43 -0
  78. package/src/design-tweaks/index.ts +1 -0
  79. package/src/design-tweaks/visual-style-controls.tsx +1084 -0
  80. package/src/editor/BubbleToolbar.tsx +253 -0
  81. package/src/editor/CodeBlockNode.tsx +254 -0
  82. package/src/editor/DragHandle.spec.ts +588 -0
  83. package/src/editor/DragHandle.ts +1437 -0
  84. package/src/editor/ImageExtension.roundtrip.spec.ts +117 -0
  85. package/src/editor/ImageExtension.ts +306 -0
  86. package/src/editor/RegistryBlockContext.tsx +107 -0
  87. package/src/editor/RegistryBlockNode.spec.ts +279 -0
  88. package/src/editor/RegistryBlockNode.tsx +652 -0
  89. package/src/editor/RichMarkdownEditor.collab.spec.ts +79 -0
  90. package/src/editor/RichMarkdownEditor.roundtrip.spec.ts +226 -0
  91. package/src/editor/RichMarkdownEditor.tsx +81 -0
  92. package/src/editor/RunId.ts +61 -0
  93. package/src/editor/SharedRichEditor.tsx +321 -0
  94. package/src/editor/SlashCommandMenu.tsx +310 -0
  95. package/src/editor/disableHistory.spec.ts +85 -0
  96. package/src/editor/extensions.ts +402 -0
  97. package/src/editor/gfmDoc.spec.ts +230 -0
  98. package/src/editor/gfmDoc.ts +92 -0
  99. package/src/editor/index.ts +93 -0
  100. package/src/editor/registrySlashCommands.ts +131 -0
  101. package/src/editor/structuralReorderUndo.spec.ts +144 -0
  102. package/src/editor/surgical-apply.spec.ts +279 -0
  103. package/src/editor/surgical-apply.ts +192 -0
  104. package/src/editor/useCollabReconcile.concurrent.spec.ts +422 -0
  105. package/src/editor/useCollabReconcile.idempotent.spec.ts +324 -0
  106. package/src/editor/useCollabReconcile.ts +589 -0
  107. package/src/editor.css +510 -0
  108. package/src/hooks/index.ts +2 -0
  109. package/src/hooks/use-mobile.tsx +21 -0
  110. package/src/hooks/use-toast.ts +188 -0
  111. package/src/index.ts +8 -0
  112. package/src/onboarding/index.ts +1 -0
  113. package/src/provider.tsx +47 -0
  114. package/src/sharing/VisibilityBadge.spec.ts +96 -0
  115. package/src/sharing/VisibilityBadge.tsx +37 -0
  116. package/src/sharing/index.ts +4 -0
  117. package/src/styles.css +21 -0
  118. package/src/ui/accordion.tsx +56 -0
  119. package/src/ui/alert-dialog.tsx +141 -0
  120. package/src/ui/alert.tsx +59 -0
  121. package/src/ui/aspect-ratio.tsx +5 -0
  122. package/src/ui/avatar.tsx +48 -0
  123. package/src/ui/badge.tsx +37 -0
  124. package/src/ui/breadcrumb.tsx +115 -0
  125. package/src/ui/button.tsx +78 -0
  126. package/src/ui/calendar.tsx +214 -0
  127. package/src/ui/card.tsx +86 -0
  128. package/src/ui/carousel.tsx +260 -0
  129. package/src/ui/chart.tsx +375 -0
  130. package/src/ui/checkbox.tsx +28 -0
  131. package/src/ui/collapsible.tsx +9 -0
  132. package/src/ui/command.spec.tsx +51 -0
  133. package/src/ui/command.tsx +174 -0
  134. package/src/ui/context-menu.tsx +202 -0
  135. package/src/ui/date-picker.tsx +64 -0
  136. package/src/ui/dialog.spec.tsx +43 -0
  137. package/src/ui/dialog.tsx +146 -0
  138. package/src/ui/drawer.tsx +126 -0
  139. package/src/ui/dropdown-menu.tsx +218 -0
  140. package/src/ui/form.tsx +177 -0
  141. package/src/ui/hover-card.tsx +27 -0
  142. package/src/ui/index.ts +52 -0
  143. package/src/ui/input-otp.tsx +69 -0
  144. package/src/ui/input.tsx +22 -0
  145. package/src/ui/label.tsx +24 -0
  146. package/src/ui/logo.tsx +36 -0
  147. package/src/ui/menubar.tsx +235 -0
  148. package/src/ui/navigation-menu.tsx +128 -0
  149. package/src/ui/pagination.tsx +121 -0
  150. package/src/ui/popover.tsx +44 -0
  151. package/src/ui/progress.tsx +26 -0
  152. package/src/ui/radio-group.tsx +42 -0
  153. package/src/ui/resizable.tsx +43 -0
  154. package/src/ui/scroll-area.tsx +46 -0
  155. package/src/ui/select.tsx +158 -0
  156. package/src/ui/separator.tsx +29 -0
  157. package/src/ui/sheet.tsx +160 -0
  158. package/src/ui/sidebar.tsx +777 -0
  159. package/src/ui/skeleton.tsx +18 -0
  160. package/src/ui/slider.tsx +26 -0
  161. package/src/ui/sonner.tsx +58 -0
  162. package/src/ui/spinner.tsx +17 -0
  163. package/src/ui/switch.tsx +27 -0
  164. package/src/ui/table.tsx +117 -0
  165. package/src/ui/tabs.tsx +53 -0
  166. package/src/ui/textarea.tsx +23 -0
  167. package/src/ui/toast.tsx +127 -0
  168. package/src/ui/toaster.tsx +33 -0
  169. package/src/ui/toggle-group.tsx +59 -0
  170. package/src/ui/toggle.tsx +43 -0
  171. package/src/ui/tooltip.tsx +53 -0
  172. package/src/ui/use-toast.ts +3 -0
  173. package/src/utils.ts +8 -0
@@ -0,0 +1,279 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { Editor } from "@tiptap/core";
4
+ import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
5
+ import { describe, expect, it } from "vitest";
6
+
7
+ import { createRichMarkdownExtensions } from "./RichMarkdownEditor.js";
8
+ import {
9
+ applyDocSurgically,
10
+ defaultParseValue,
11
+ diffTopLevel,
12
+ } from "./surgical-apply.js";
13
+
14
+ /**
15
+ * The surgical reconcile must replace ONLY the changed top-level run —
16
+ * unchanged siblings keep their identity (no NodeView teardown, minimal Yjs
17
+ * ops under Collaboration) — and must converge the editor to exactly the
18
+ * document a full setContent would have produced.
19
+ *
20
+ * NOTE: ProseMirror `Node.eq` relies on NodeType identity, which is
21
+ * per-Schema-instance — so target docs in these tests are parsed with the SAME
22
+ * editor's schema via `defaultParseValue`, mirroring production (where
23
+ * `parseValue` always uses the live editor).
24
+ */
25
+
26
+ function makeEditor(markdown: string): Editor {
27
+ const editor = new Editor({
28
+ extensions: createRichMarkdownExtensions(),
29
+ content: "",
30
+ });
31
+ if (markdown) editor.commands.setContent(markdown);
32
+ return editor;
33
+ }
34
+
35
+ function md(editor: Editor): string {
36
+ return (
37
+ (editor.storage as Record<string, any>).markdown?.getMarkdown?.() ?? ""
38
+ );
39
+ }
40
+
41
+ /** Parse markdown into a doc bound to THIS editor's schema. */
42
+ function parse(editor: Editor, markdown: string): ProseMirrorNode {
43
+ const doc = defaultParseValue(editor, markdown);
44
+ if (!doc) throw new Error("defaultParseValue returned null in test setup");
45
+ return doc;
46
+ }
47
+
48
+ describe("diffTopLevel", () => {
49
+ it("returns null for identical documents", () => {
50
+ const editor = makeEditor("# Title\n\nAlpha\n\nBravo");
51
+ try {
52
+ const same = parse(editor, "# Title\n\nAlpha\n\nBravo");
53
+ expect(diffTopLevel(editor.state.doc, same)).toBeNull();
54
+ } finally {
55
+ editor.destroy();
56
+ }
57
+ });
58
+
59
+ it("isolates a single changed middle node", () => {
60
+ const editor = makeEditor("# Title\n\nAlpha\n\nBravo\n\nCharlie");
61
+ try {
62
+ const target = parse(
63
+ editor,
64
+ "# Title\n\nAlpha CHANGED\n\nBravo\n\nCharlie",
65
+ );
66
+ const diff = diffTopLevel(editor.state.doc, target);
67
+ expect(diff).not.toBeNull();
68
+ expect(diff!.fromIndex).toBe(1);
69
+ expect(diff!.oldToIndex).toBe(2);
70
+ expect(diff!.newToIndex).toBe(2);
71
+ } finally {
72
+ editor.destroy();
73
+ }
74
+ });
75
+
76
+ it("handles insertion (empty old run)", () => {
77
+ const editor = makeEditor("Alpha\n\nCharlie");
78
+ try {
79
+ const target = parse(editor, "Alpha\n\nBravo\n\nCharlie");
80
+ const diff = diffTopLevel(editor.state.doc, target);
81
+ expect(diff).not.toBeNull();
82
+ expect(diff!.fromIndex).toBe(1);
83
+ expect(diff!.oldToIndex).toBe(1); // nothing removed
84
+ expect(diff!.newToIndex).toBe(2); // one node inserted
85
+ } finally {
86
+ editor.destroy();
87
+ }
88
+ });
89
+
90
+ it("handles deletion (empty new run)", () => {
91
+ const editor = makeEditor("Alpha\n\nBravo\n\nCharlie");
92
+ try {
93
+ const target = parse(editor, "Alpha\n\nCharlie");
94
+ const diff = diffTopLevel(editor.state.doc, target);
95
+ expect(diff).not.toBeNull();
96
+ expect(diff!.fromIndex).toBe(1);
97
+ expect(diff!.oldToIndex).toBe(2);
98
+ expect(diff!.newToIndex).toBe(1);
99
+ } finally {
100
+ editor.destroy();
101
+ }
102
+ });
103
+ });
104
+
105
+ describe("applyDocSurgically", () => {
106
+ it("converges the live doc to the target and leaves unchanged nodes intact", () => {
107
+ const editor = makeEditor("# Title\n\nAlpha\n\nBravo\n\nCharlie");
108
+ try {
109
+ const target = parse(
110
+ editor,
111
+ "# Title\n\nAlpha CHANGED\n\nBravo\n\nCharlie",
112
+ );
113
+ const before = editor.state.doc;
114
+ const untouched = [before.child(0), before.child(2), before.child(3)];
115
+
116
+ const result = applyDocSurgically(editor, target);
117
+ expect(result).toBe("applied");
118
+ expect(editor.state.doc.eq(target)).toBe(true);
119
+
120
+ // Structure-preserving: the untouched siblings are still node-equal
121
+ // (same content at the same top-level slots).
122
+ const after = editor.state.doc;
123
+ expect(after.child(0).eq(untouched[0])).toBe(true);
124
+ expect(after.child(2).eq(untouched[1])).toBe(true);
125
+ expect(after.child(3).eq(untouched[2])).toBe(true);
126
+ } finally {
127
+ editor.destroy();
128
+ }
129
+ });
130
+
131
+ it("returns noop for identical documents without dispatching", () => {
132
+ const editor = makeEditor("Alpha\n\nBravo");
133
+ try {
134
+ const same = parse(editor, "Alpha\n\nBravo");
135
+ const docBefore = editor.state.doc;
136
+ expect(applyDocSurgically(editor, same)).toBe("noop");
137
+ // Same state object — no transaction was dispatched.
138
+ expect(editor.state.doc).toBe(docBefore);
139
+ } finally {
140
+ editor.destroy();
141
+ }
142
+ });
143
+
144
+ it("handles full-document divergence (worst case degrades to full replace)", () => {
145
+ const editor = makeEditor("One\n\nTwo");
146
+ try {
147
+ const target = parse(editor, "# Totally\n\nDifferent\n\n- list");
148
+ expect(applyDocSurgically(editor, target)).toBe("applied");
149
+
150
+ // Serialization converges to what a full setContent would produce…
151
+ const reference = makeEditor("# Totally\n\nDifferent\n\n- list");
152
+ try {
153
+ expect(md(editor)).toBe(md(reference));
154
+ } finally {
155
+ reference.destroy();
156
+ }
157
+ // …and re-applying the same parsed value is a no-op (stable after one
158
+ // apply — trailing-cursor-paragraph preservation doesn't oscillate).
159
+ const again = parse(editor, "# Totally\n\nDifferent\n\n- list");
160
+ expect(applyDocSurgically(editor, again)).toBe("noop");
161
+ } finally {
162
+ editor.destroy();
163
+ }
164
+ });
165
+
166
+ it("preserves the user's trailing empty paragraph across reconciles", () => {
167
+ const editor = makeEditor("Alpha");
168
+ try {
169
+ // Give the user a trailing cursor line below the content.
170
+ editor.commands.focus("end");
171
+ editor.commands.insertContentAt(editor.state.doc.content.size, {
172
+ type: "paragraph",
173
+ });
174
+ const childCountBefore = editor.state.doc.childCount;
175
+
176
+ // Agent rewrites the first paragraph; markdown can't express the
177
+ // trailing empty paragraph, so the parsed doc lacks it.
178
+ const target = parse(editor, "Alpha CHANGED");
179
+ expect(applyDocSurgically(editor, target)).toBe("applied");
180
+
181
+ const doc = editor.state.doc;
182
+ expect(doc.childCount).toBe(childCountBefore);
183
+ const last = doc.child(doc.childCount - 1);
184
+ expect(last.type.name).toBe("paragraph");
185
+ expect(last.content.size).toBe(0);
186
+ expect(md(editor)).toContain("Alpha CHANGED");
187
+ } finally {
188
+ editor.destroy();
189
+ }
190
+ });
191
+
192
+ it("converges across a sequence of inserts, deletes, and rewrites", () => {
193
+ const editor = makeEditor("Alpha\n\nBravo\n\nCharlie");
194
+ const steps = [
195
+ "Alpha\n\nBravo\n\nInserted\n\nCharlie",
196
+ "Alpha\n\nCharlie",
197
+ "Prefix\n\nAlpha\n\nCharlie",
198
+ ];
199
+ try {
200
+ for (const step of steps) {
201
+ const target = parse(editor, step);
202
+ const result = applyDocSurgically(editor, target);
203
+ expect(result === "applied" || result === "noop").toBe(true);
204
+ expect(editor.state.doc.eq(target)).toBe(true);
205
+
206
+ // The serialized output matches what a full setContent would produce.
207
+ const reference = makeEditor(step);
208
+ try {
209
+ expect(md(editor)).toBe(md(reference));
210
+ } finally {
211
+ reference.destroy();
212
+ }
213
+ }
214
+ } finally {
215
+ editor.destroy();
216
+ }
217
+ });
218
+
219
+ it("fails (for setContent fallback) when the doc comes from a foreign schema", () => {
220
+ const editor = makeEditor("Alpha");
221
+ const foreign = makeEditor("Alpha CHANGED"); // separate Editor → separate Schema
222
+ try {
223
+ expect(applyDocSurgically(editor, foreign.state.doc)).toBe("failed");
224
+ expect(md(editor)).toBe("Alpha");
225
+ } finally {
226
+ editor.destroy();
227
+ foreign.destroy();
228
+ }
229
+ });
230
+
231
+ it("marks the transaction programmatic and history-free", () => {
232
+ const editor = makeEditor("Alpha");
233
+ try {
234
+ const target = parse(editor, "Alpha CHANGED");
235
+ let sawMeta = false;
236
+ let sawHistoryOff = false;
237
+ const origDispatch = editor.view.dispatch.bind(editor.view);
238
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
239
+ (editor.view as any).dispatch = (tr: any) => {
240
+ if (tr.getMeta("an-rich-md-programmatic-transaction")) sawMeta = true;
241
+ if (tr.getMeta("addToHistory") === false) sawHistoryOff = true;
242
+ origDispatch(tr);
243
+ };
244
+ applyDocSurgically(editor, target);
245
+ expect(sawMeta).toBe(true);
246
+ expect(sawHistoryOff).toBe(true);
247
+ } finally {
248
+ editor.destroy();
249
+ }
250
+ });
251
+ });
252
+
253
+ describe("defaultParseValue", () => {
254
+ it("parses markdown that applies to the same serialization as setContent", () => {
255
+ const editor = makeEditor("Alpha");
256
+ try {
257
+ const parsed = defaultParseValue(
258
+ editor,
259
+ "# Heading\n\nBody text\n\n- item",
260
+ );
261
+ expect(parsed).not.toBeNull();
262
+ expect(applyDocSurgically(editor, parsed!)).toBe("applied");
263
+
264
+ const reference = makeEditor("# Heading\n\nBody text\n\n- item");
265
+ try {
266
+ expect(md(editor)).toBe(md(reference));
267
+ } finally {
268
+ reference.destroy();
269
+ }
270
+ } finally {
271
+ editor.destroy();
272
+ }
273
+ });
274
+
275
+ it("returns null when the markdown storage is unavailable", () => {
276
+ const stub = { storage: {}, schema: null } as unknown as Editor;
277
+ expect(defaultParseValue(stub, "# x")).toBeNull();
278
+ });
279
+ });
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Surgical reconcile — apply an authoritative external document into the live
3
+ * editor by replacing only the changed top-level node run, instead of a
4
+ * whole-document `setContent`.
5
+ *
6
+ * Why: under the Collaboration extension, `setContent` routes through
7
+ * y-prosemirror and rewrites the ENTIRE `Y.XmlFragment`. Every block-level
8
+ * NodeView is torn down and recreated (each `ReactRenderer` constructor calls
9
+ * `flushSync`, firing inside a React lifecycle), remote carets jump, and the
10
+ * CRDT sees a delete-all + insert-all instead of a small edit. Diffing the
11
+ * top-level children and dispatching one `tr.replaceWith(from, to, changed)`
12
+ * leaves unchanged NodeViews untouched and produces minimal Yjs ops.
13
+ *
14
+ * This is the core mechanism behind re-enabling single-doc collab in the plan
15
+ * editor (see templates/plan/shared/plan-doc.collab-stability.spec.ts, "Option
16
+ * B") and removing agent-edit NodeView churn in content.
17
+ */
18
+
19
+ import { createNodeFromContent } from "@tiptap/core";
20
+ import type { Fragment, Node as ProseMirrorNode } from "@tiptap/pm/model";
21
+ import type { Editor } from "@tiptap/react";
22
+
23
+ /**
24
+ * Transaction meta marking programmatic (non-user) rich-markdown transactions.
25
+ * Declared here (not in useCollabReconcile) so the module dependency stays
26
+ * one-directional; useCollabReconcile re-exports it for consumers.
27
+ */
28
+ export const RICH_MARKDOWN_PROGRAMMATIC_TRANSACTION =
29
+ "an-rich-md-programmatic-transaction";
30
+
31
+ export interface TopLevelDiff {
32
+ /** Index of the first differing top-level child. */
33
+ fromIndex: number;
34
+ /** Exclusive end index of the differing run in the OLD doc. */
35
+ oldToIndex: number;
36
+ /** Exclusive end index of the differing run in the NEW doc. */
37
+ newToIndex: number;
38
+ /** Document position where the differing run starts. */
39
+ fromPos: number;
40
+ /** Document position where the differing run ends in the OLD doc. */
41
+ toPos: number;
42
+ }
43
+
44
+ /**
45
+ * Diff two documents at top-level-node granularity: trim the common prefix and
46
+ * suffix (node equality via ProseMirror's `Node.eq`, which is deep) and return
47
+ * the remaining changed run. Returns null when the documents are equal.
48
+ */
49
+ export function diffTopLevel(
50
+ oldDoc: ProseMirrorNode,
51
+ newDoc: ProseMirrorNode,
52
+ ): TopLevelDiff | null {
53
+ const oldCount = oldDoc.childCount;
54
+ const newCount = newDoc.childCount;
55
+
56
+ let prefix = 0;
57
+ const maxPrefix = Math.min(oldCount, newCount);
58
+ while (prefix < maxPrefix && oldDoc.child(prefix).eq(newDoc.child(prefix))) {
59
+ prefix++;
60
+ }
61
+
62
+ if (prefix === oldCount && prefix === newCount) return null; // identical
63
+
64
+ let suffix = 0;
65
+ const maxSuffix = Math.min(oldCount, newCount) - prefix;
66
+ while (
67
+ suffix < maxSuffix &&
68
+ oldDoc.child(oldCount - 1 - suffix).eq(newDoc.child(newCount - 1 - suffix))
69
+ ) {
70
+ suffix++;
71
+ }
72
+
73
+ const fromIndex = prefix;
74
+ const oldToIndex = oldCount - suffix;
75
+ const newToIndex = newCount - suffix;
76
+
77
+ // Positions: sum nodeSize of the preserved prefix, then of the changed run.
78
+ let fromPos = 0;
79
+ for (let i = 0; i < fromIndex; i++) fromPos += oldDoc.child(i).nodeSize;
80
+ let toPos = fromPos;
81
+ for (let i = fromIndex; i < oldToIndex; i++) {
82
+ toPos += oldDoc.child(i).nodeSize;
83
+ }
84
+
85
+ return { fromIndex, oldToIndex, newToIndex, fromPos, toPos };
86
+ }
87
+
88
+ function changedFragment(
89
+ newDoc: ProseMirrorNode,
90
+ diff: TopLevelDiff,
91
+ ): Fragment {
92
+ return newDoc.content.cut(
93
+ positionOfChild(newDoc, diff.fromIndex),
94
+ positionOfChild(newDoc, diff.newToIndex),
95
+ );
96
+ }
97
+
98
+ function positionOfChild(doc: ProseMirrorNode, index: number): number {
99
+ let pos = 0;
100
+ for (let i = 0; i < index; i++) pos += doc.child(i).nodeSize;
101
+ return pos;
102
+ }
103
+
104
+ function isEmptyParagraph(node: ProseMirrorNode | null): boolean {
105
+ return !!node && node.type.name === "paragraph" && node.content.size === 0;
106
+ }
107
+
108
+ /**
109
+ * Markdown can't represent a trailing empty paragraph (the cursor line users
110
+ * keep below a list/code block), so a parsed authoritative value never has
111
+ * one — but the live doc often does, and the legacy `setContent` path
112
+ * preserves it. Without this, every surgical reconcile whose old doc ends
113
+ * with an empty paragraph would DELETE the user's trailing cursor line.
114
+ */
115
+ function withPreservedTrailingParagraph(
116
+ oldDoc: ProseMirrorNode,
117
+ newDoc: ProseMirrorNode,
118
+ ): ProseMirrorNode {
119
+ const oldLast =
120
+ oldDoc.childCount > 0 ? oldDoc.child(oldDoc.childCount - 1) : null;
121
+ if (!isEmptyParagraph(oldLast)) return newDoc;
122
+ const newLast =
123
+ newDoc.childCount > 0 ? newDoc.child(newDoc.childCount - 1) : null;
124
+ if (isEmptyParagraph(newLast)) return newDoc;
125
+ return newDoc.copy(newDoc.content.addToEnd(oldLast!.type.create()));
126
+ }
127
+
128
+ /**
129
+ * Replace only the changed top-level run of the live document with the
130
+ * corresponding run from `newDoc`, in one programmatic, history-free
131
+ * transaction. Returns:
132
+ * - "applied" — a targeted replacement was dispatched
133
+ * - "noop" — the documents were already equal (nothing dispatched)
134
+ * - "failed" — the diff/transaction could not be applied (schema mismatch,
135
+ * invalid content, …); caller should fall back to setContent.
136
+ */
137
+ export function applyDocSurgically(
138
+ editor: Editor,
139
+ newDoc: ProseMirrorNode,
140
+ ): "applied" | "noop" | "failed" {
141
+ try {
142
+ const oldDoc = editor.state.doc;
143
+ // The parsed doc MUST come from this editor's schema — NodeType identity
144
+ // is per-Schema-instance, so a foreign-schema doc can never diff or apply.
145
+ if (newDoc.type.schema !== editor.schema || newDoc.type !== oldDoc.type) {
146
+ return "failed";
147
+ }
148
+
149
+ const target = withPreservedTrailingParagraph(oldDoc, newDoc);
150
+ const diff = diffTopLevel(oldDoc, target);
151
+ if (!diff) return "noop";
152
+
153
+ const fragment = changedFragment(target, diff);
154
+ const tr = editor.state.tr;
155
+ tr.replaceWith(diff.fromPos, diff.toPos, fragment);
156
+ tr.setMeta("addToHistory", false);
157
+ tr.setMeta(RICH_MARKDOWN_PROGRAMMATIC_TRANSACTION, true);
158
+ editor.view.dispatch(tr);
159
+ return "applied";
160
+ } catch {
161
+ return "failed";
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Best-effort default parser for the surgical path: use the tiptap-markdown
167
+ * storage parser (present when the shared editor's `features.markdown` is on)
168
+ * to turn the authoritative markdown into a ProseMirror document. Returns null
169
+ * when unavailable or when parsing fails — the caller falls back to the full
170
+ * `setContent` path.
171
+ *
172
+ * Apps with their own serializers (Content's NFM, Plan's blocks[] doc) should
173
+ * pass an explicit `parseValue` producing the exact doc their `setContent`
174
+ * would have written.
175
+ */
176
+ export function defaultParseValue(
177
+ editor: Editor,
178
+ value: string,
179
+ ): ProseMirrorNode | null {
180
+ try {
181
+ const storage = (editor.storage as Record<string, any>).markdown;
182
+ const parsed = storage?.parser?.parse?.(value, { inline: false });
183
+ if (typeof parsed !== "string" || !parsed) return null;
184
+ const node = createNodeFromContent(parsed, editor.schema, {
185
+ slice: false,
186
+ });
187
+ // createNodeFromContent with slice:false returns a Node (the doc).
188
+ return (node as ProseMirrorNode) ?? null;
189
+ } catch {
190
+ return null;
191
+ }
192
+ }