@agent-native/toolkit 0.6.0 → 0.8.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.
- package/README.md +17 -1
- package/agent-native.eject.json +411 -0
- package/dist/chat-history/ChatHistoryList.d.ts +103 -0
- package/dist/chat-history/ChatHistoryList.d.ts.map +1 -0
- package/dist/chat-history/ChatHistoryList.js +109 -0
- package/dist/chat-history/ChatHistoryList.js.map +1 -0
- package/dist/chat-history/ChatHistoryList.spec.d.ts +2 -0
- package/dist/chat-history/ChatHistoryList.spec.d.ts.map +1 -0
- package/dist/chat-history/ChatHistoryList.spec.js +283 -0
- package/dist/chat-history/ChatHistoryList.spec.js.map +1 -0
- package/dist/chat-history/index.d.ts +2 -0
- package/dist/chat-history/index.d.ts.map +1 -0
- package/dist/chat-history/index.js +2 -0
- package/dist/chat-history/index.js.map +1 -0
- package/dist/chat-history.css +314 -0
- package/package.json +20 -1
- package/src/app-shell/header-actions.tsx +84 -0
- package/src/app-shell/index.ts +1 -0
- package/src/chat-history/ChatHistoryList.spec.tsx +453 -0
- package/src/chat-history/ChatHistoryList.tsx +469 -0
- package/src/chat-history/index.ts +7 -0
- package/src/chat-history.css +314 -0
- package/src/collab-ui/AgentPresenceChip.tsx +69 -0
- package/src/collab-ui/LiveCursorOverlay.tsx +241 -0
- package/src/collab-ui/PresenceBar.tsx +346 -0
- package/src/collab-ui/RecentEditHighlights.tsx +225 -0
- package/src/collab-ui/RemoteSelectionRings.tsx +227 -0
- package/src/collab-ui/agent-identity.ts +16 -0
- package/src/collab-ui/index.ts +39 -0
- package/src/collab-ui/lead-client.ts +32 -0
- package/src/collab-ui/types.spec.ts +18 -0
- package/src/collab-ui/types.ts +96 -0
- package/src/composer/AgentComposerFrame.tsx +59 -0
- package/src/composer/ComposerPlusMenu.tsx +983 -0
- package/src/composer/MentionPopover.tsx +412 -0
- package/src/composer/PastedTextChip.tsx +150 -0
- package/src/composer/PromptComposer.spec.ts +73 -0
- package/src/composer/PromptComposer.tsx +718 -0
- package/src/composer/RealtimeVoiceMode.spec.tsx +771 -0
- package/src/composer/RealtimeVoiceMode.tsx +856 -0
- package/src/composer/TiptapComposer.spec.ts +390 -0
- package/src/composer/TiptapComposer.tsx +2936 -0
- package/src/composer/VoiceButton.spec.ts +33 -0
- package/src/composer/VoiceButton.tsx +255 -0
- package/src/composer/asset-picker-url.spec.ts +56 -0
- package/src/composer/asset-picker-url.ts +80 -0
- package/src/composer/attachment-accept.spec.ts +22 -0
- package/src/composer/attachment-accept.ts +71 -0
- package/src/composer/draft-key.spec.ts +19 -0
- package/src/composer/draft-key.ts +7 -0
- package/src/composer/extensions/FileReference.tsx +55 -0
- package/src/composer/extensions/MentionReference.tsx +93 -0
- package/src/composer/extensions/SkillReference.tsx +56 -0
- package/src/composer/index.ts +79 -0
- package/src/composer/pasted-text.spec.ts +162 -0
- package/src/composer/pasted-text.ts +160 -0
- package/src/composer/prompt-attachments.spec.ts +80 -0
- package/src/composer/prompt-attachments.ts +93 -0
- package/src/composer/realtime-voice-audio-level.spec.ts +32 -0
- package/src/composer/realtime-voice-audio-level.ts +64 -0
- package/src/composer/realtime-voice-transcript.spec.ts +95 -0
- package/src/composer/realtime-voice-transcript.ts +134 -0
- package/src/composer/runtime-adapters.tsx +343 -0
- package/src/composer/types.ts +66 -0
- package/src/composer/use-file-search.ts +64 -0
- package/src/composer/use-mention-search.ts +90 -0
- package/src/composer/use-skills.ts +45 -0
- package/src/composer/useRealtimeVoiceMode.spec.ts +818 -0
- package/src/composer/useRealtimeVoiceMode.tsx +2117 -0
- package/src/composer/useVoiceDictation.spec.ts +45 -0
- package/src/composer/useVoiceDictation.ts +1191 -0
- package/src/context-ui/ContextMeter.tsx +101 -0
- package/src/context-ui/ContextSegmentRow.tsx +184 -0
- package/src/context-ui/ContextTreemap.tsx +125 -0
- package/src/context-ui/ContextXRayPanel.tsx +377 -0
- package/src/context-ui/format.ts +42 -0
- package/src/context-ui/index.ts +15 -0
- package/src/context-ui/types.ts +43 -0
- package/src/design-tweaks/index.ts +1 -0
- package/src/design-tweaks/visual-style-controls.tsx +1084 -0
- package/src/editor/BubbleToolbar.tsx +253 -0
- package/src/editor/CodeBlockNode.tsx +254 -0
- package/src/editor/DragHandle.spec.ts +588 -0
- package/src/editor/DragHandle.ts +1437 -0
- package/src/editor/ImageExtension.roundtrip.spec.ts +117 -0
- package/src/editor/ImageExtension.ts +306 -0
- package/src/editor/RegistryBlockContext.tsx +107 -0
- package/src/editor/RegistryBlockNode.spec.ts +279 -0
- package/src/editor/RegistryBlockNode.tsx +652 -0
- package/src/editor/RichMarkdownEditor.collab.spec.ts +79 -0
- package/src/editor/RichMarkdownEditor.roundtrip.spec.ts +226 -0
- package/src/editor/RichMarkdownEditor.tsx +81 -0
- package/src/editor/RunId.ts +61 -0
- package/src/editor/SharedRichEditor.tsx +321 -0
- package/src/editor/SlashCommandMenu.tsx +310 -0
- package/src/editor/disableHistory.spec.ts +85 -0
- package/src/editor/extensions.ts +402 -0
- package/src/editor/gfmDoc.spec.ts +230 -0
- package/src/editor/gfmDoc.ts +92 -0
- package/src/editor/index.ts +93 -0
- package/src/editor/registrySlashCommands.ts +131 -0
- package/src/editor/structuralReorderUndo.spec.ts +144 -0
- package/src/editor/surgical-apply.spec.ts +279 -0
- package/src/editor/surgical-apply.ts +192 -0
- package/src/editor/useCollabReconcile.concurrent.spec.ts +422 -0
- package/src/editor/useCollabReconcile.idempotent.spec.ts +324 -0
- package/src/editor/useCollabReconcile.ts +589 -0
- package/src/editor.css +510 -0
- package/src/hooks/index.ts +2 -0
- package/src/hooks/use-mobile.tsx +21 -0
- package/src/hooks/use-toast.ts +188 -0
- package/src/index.ts +8 -0
- package/src/onboarding/index.ts +1 -0
- package/src/provider.tsx +47 -0
- package/src/sharing/VisibilityBadge.spec.ts +96 -0
- package/src/sharing/VisibilityBadge.tsx +37 -0
- package/src/sharing/index.ts +4 -0
- package/src/styles.css +21 -0
- package/src/ui/accordion.tsx +56 -0
- package/src/ui/alert-dialog.tsx +141 -0
- package/src/ui/alert.tsx +59 -0
- package/src/ui/aspect-ratio.tsx +5 -0
- package/src/ui/avatar.tsx +48 -0
- package/src/ui/badge.tsx +37 -0
- package/src/ui/breadcrumb.tsx +115 -0
- package/src/ui/button.tsx +78 -0
- package/src/ui/calendar.tsx +214 -0
- package/src/ui/card.tsx +86 -0
- package/src/ui/carousel.tsx +260 -0
- package/src/ui/chart.tsx +375 -0
- package/src/ui/checkbox.tsx +28 -0
- package/src/ui/collapsible.tsx +9 -0
- package/src/ui/command.spec.tsx +51 -0
- package/src/ui/command.tsx +174 -0
- package/src/ui/context-menu.tsx +202 -0
- package/src/ui/date-picker.tsx +64 -0
- package/src/ui/dialog.spec.tsx +43 -0
- package/src/ui/dialog.tsx +146 -0
- package/src/ui/drawer.tsx +126 -0
- package/src/ui/dropdown-menu.tsx +218 -0
- package/src/ui/form.tsx +177 -0
- package/src/ui/hover-card.tsx +27 -0
- package/src/ui/index.ts +52 -0
- package/src/ui/input-otp.tsx +69 -0
- package/src/ui/input.tsx +22 -0
- package/src/ui/label.tsx +24 -0
- package/src/ui/logo.tsx +36 -0
- package/src/ui/menubar.tsx +235 -0
- package/src/ui/navigation-menu.tsx +128 -0
- package/src/ui/pagination.tsx +121 -0
- package/src/ui/popover.tsx +44 -0
- package/src/ui/progress.tsx +26 -0
- package/src/ui/radio-group.tsx +42 -0
- package/src/ui/resizable.tsx +43 -0
- package/src/ui/scroll-area.tsx +46 -0
- package/src/ui/select.tsx +158 -0
- package/src/ui/separator.tsx +29 -0
- package/src/ui/sheet.tsx +160 -0
- package/src/ui/sidebar.tsx +777 -0
- package/src/ui/skeleton.tsx +18 -0
- package/src/ui/slider.tsx +26 -0
- package/src/ui/sonner.tsx +58 -0
- package/src/ui/spinner.tsx +17 -0
- package/src/ui/switch.tsx +27 -0
- package/src/ui/table.tsx +117 -0
- package/src/ui/tabs.tsx +53 -0
- package/src/ui/textarea.tsx +23 -0
- package/src/ui/toast.tsx +127 -0
- package/src/ui/toaster.tsx +33 -0
- package/src/ui/toggle-group.tsx +59 -0
- package/src/ui/toggle.tsx +43 -0
- package/src/ui/tooltip.tsx +53 -0
- package/src/ui/use-toast.ts +3 -0
- 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
|
+
}
|