Skip to content

Commit edbeefb

Browse files
committed
fix: AI operations on collaborative documents
`AIExtension.invokeAI` forks the Y.Doc before a request starts, so AI changes don't reach other collaborators until they're accepted. Forking swaps the `ySync` plugin, which reconfigures the ProseMirror state — and ProseMirror carries over the state of plugins that share a key rather than re-initializing them. The new plugin's `binding` still ends up on the forked fragment (it's set from the plugin's view, via a transaction), but `type` and `doc` keep pointing at the fragment the editor was bound to before. That left the plugin state split across two Y.Docs. `RelativePositionMappingExtension` resolved tracked positions with the stale `doc` and the new `binding.type`, so the decoded type was never part of the bound fragment and every lookup returned `null`. The `update` tool tracks the selection across the LLM round-trip, so any AI request on a selection in a collaborative document failed with "Position not found, cannot track positions". Re-point the plugin state's `type`/`doc` at the newly bound fragment on both fork and merge, and resolve relative positions against the doc that owns the bound type.
1 parent 4998d23 commit edbeefb

6 files changed

Lines changed: 213 additions & 4 deletions

File tree

packages/core/src/yjs/extensions/ForkYDoc.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { afterEach, describe, expect, it } from "vite-plus/test";
2+
import { trackPosition } from "../../api/positionMapping.js";
23
import * as Y from "yjs";
34
import { Awareness } from "y-protocols/awareness";
45
import { BlockNoteEditor } from "../../index.js";
@@ -213,4 +214,40 @@ describe("ForkYDocExtension", () => {
213214
forkYDoc.merge({ keepChanges: true });
214215
expect(getEditorText(ctx.editor)).toContain("Forked modification");
215216
});
217+
218+
// https://github.com/TypeCellOS/BlockNote/issues/2946
219+
it("can track positions while forked", () => {
220+
ctx = createCollabEditor();
221+
setEditorText(ctx.editor, "Hello World");
222+
223+
const forkYDoc = ctx.editor.getExtension(ForkYDocExtension)!;
224+
forkYDoc.fork();
225+
226+
// Store position at "Hello| World"
227+
const getCursorPos = trackPosition(ctx.editor, 8);
228+
expect(getCursorPos()).toBe(8);
229+
230+
// Insert text at the beginning of "|Hello World"
231+
ctx.editor._tiptapEditor.commands.insertContentAt(3, "Test ");
232+
expect(getCursorPos()).toBe(13);
233+
});
234+
235+
// https://github.com/TypeCellOS/BlockNote/issues/2946
236+
it("can track positions across fork and merge", () => {
237+
ctx = createCollabEditor();
238+
setEditorText(ctx.editor, "Hello World");
239+
240+
// Store position at "Hello| World"
241+
const getCursorPos = trackPosition(ctx.editor, 8);
242+
243+
const forkYDoc = ctx.editor.getExtension(ForkYDocExtension)!;
244+
forkYDoc.fork();
245+
expect(getCursorPos()).toBe(8);
246+
247+
ctx.editor._tiptapEditor.commands.insertContentAt(3, "Test ");
248+
expect(getCursorPos()).toBe(13);
249+
250+
forkYDoc.merge({ keepChanges: true });
251+
expect(getCursorPos()).toBe(13);
252+
});
216253
});

packages/core/src/yjs/extensions/ForkYDoc.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { yUndoPluginKey } from "y-prosemirror";
1+
import { ySyncPluginKey, yUndoPluginKey } from "y-prosemirror";
22
import * as Y from "yjs";
3+
import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
34
import {
45
createExtension,
56
createStore,
@@ -11,6 +12,26 @@ import { YSyncExtension } from "./YSync.js";
1112
import { YUndoExtension } from "./YUndo.js";
1213
import { findTypeInOtherYdoc } from "../utils.js";
1314

15+
/**
16+
* Point the `ySync` plugin state at `fragment`.
17+
*
18+
* Swapping the `ySync` plugin reconfigures the ProseMirror state, and
19+
* ProseMirror carries over the state of plugins that share a key instead of
20+
* re-initializing them. So the new plugin's `binding` (which is set from its
21+
* view, via a transaction) ends up on the new fragment, while `type` and `doc`
22+
* still point at the fragment the editor was bound to before. Anything reading
23+
* those (e.g. `RelativePositionMappingExtension`) would then mix up the two
24+
* Y.Docs, so we set them explicitly here.
25+
*/
26+
function bindYSyncPluginStateTo(
27+
editor: BlockNoteEditor<any, any, any>,
28+
fragment: Y.XmlFragment,
29+
) {
30+
editor.transact((tr) =>
31+
tr.setMeta(ySyncPluginKey, { type: fragment, doc: fragment.doc }),
32+
);
33+
}
34+
1435
export const ForkYDocExtension = createExtension(
1536
({ editor, options }: ExtensionOptions<CollaborationOptions>) => {
1637
let forkedState:
@@ -84,6 +105,8 @@ export const ForkYDocExtension = createExtension(
84105
],
85106
);
86107

108+
bindYSyncPluginStateTo(editor, forkedFragment);
109+
87110
// Tell the store that the editor is now forked
88111
store.setState({ isForked: true });
89112
},
@@ -110,6 +133,8 @@ export const ForkYDocExtension = createExtension(
110133
],
111134
);
112135

136+
bindYSyncPluginStateTo(editor, originalFragment);
137+
113138
// Reset the undo stack to the original undo stack
114139
yUndoPluginKey.getState(
115140
editor.prosemirrorState,

packages/core/src/yjs/extensions/RelativePositionMapping.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,15 @@ export const RelativePositionMappingExtension = createExtension(
5151
const curYSyncPluginState = ySyncPluginKey.getState(
5252
editor.prosemirrorState,
5353
) as typeof ySyncPluginState;
54+
// Resolve against the doc that owns the currently bound type, and not
55+
// against `curYSyncPluginState.doc`. Those can point at different
56+
// Y.Docs (e.g. right after forking the doc, see `ForkYDocExtension`),
57+
// in which case the resolved type wouldn't be part of the bound
58+
// fragment and the position would be reported as "not found".
59+
const boundType = curYSyncPluginState.binding.type;
5460
const pos = relativePositionToAbsolutePosition(
55-
curYSyncPluginState.doc,
56-
curYSyncPluginState.binding.type,
61+
boundType.doc,
62+
boundType,
5763
relativePosition,
5864
curYSyncPluginState.binding.mapping,
5965
);

packages/xl-ai/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,8 @@
114114
"typescript": "^5.9.3",
115115
"undici": "^6.22.0",
116116
"vite-plugin-externalize-deps": "^0.10.0",
117-
"vite-plus": "catalog:"
117+
"vite-plus": "catalog:",
118+
"yjs": "^13.6.27"
118119
},
119120
"peerDependencies": {
120121
"react": "^18.0 || ^19.0 || >= 19.0.0-rc",
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
/**
2+
* Regression test for https://github.com/TypeCellOS/BlockNote/issues/2946
3+
*
4+
* Runs the `update` stream tool against a Yjs-collaborative editor, fully
5+
* offline (no LLM call): the tool call is fed straight into the executor.
6+
*
7+
* `AIExtension.invokeAI` forks the Y.Doc before the request starts, so the
8+
* fork is part of the setup here.
9+
*/
10+
import {
11+
BlockNoteEditor,
12+
expandPMRangeToWords,
13+
getBlockInfo,
14+
getNodeById,
15+
} from "@blocknote/core";
16+
import type { ForkYDocExtension } from "@blocknote/core/yjs";
17+
import { withCollaboration } from "@blocknote/core/yjs";
18+
import { TextSelection } from "prosemirror-state";
19+
import { describe, expect, it } from "vite-plus/test";
20+
import * as Y from "yjs";
21+
22+
import { AIExtension } from "../../../AIExtension.js";
23+
import { StreamToolExecutor } from "../../../streamTool/StreamToolExecutor.js";
24+
import { StreamTool } from "../../../streamTool/streamTool.js";
25+
import { tools } from "./tools/index.js";
26+
27+
function createLocalEditor(text: string) {
28+
const editor = BlockNoteEditor.create({
29+
initialContent: [{ type: "paragraph", content: text }],
30+
trailingBlock: false,
31+
extensions: [AIExtension()],
32+
});
33+
editor.mount(document.createElement("div"));
34+
35+
return { editor, fork: () => undefined };
36+
}
37+
38+
function createCollabEditor(text: string) {
39+
const ydoc = new Y.Doc();
40+
const editor = BlockNoteEditor.create(
41+
withCollaboration({
42+
collaboration: {
43+
fragment: ydoc.getXmlFragment("doc"),
44+
user: { color: "#ff0000", name: "Local User" },
45+
provider: undefined,
46+
},
47+
trailingBlock: false,
48+
extensions: [AIExtension()],
49+
}),
50+
);
51+
editor.mount(document.createElement("div"));
52+
53+
editor.replaceBlocks(editor.document, [{ type: "paragraph", content: text }]);
54+
55+
return {
56+
editor,
57+
fork: () =>
58+
editor.getExtension<typeof ForkYDocExtension>("yForkDoc")?.fork(),
59+
};
60+
}
61+
62+
/**
63+
* Selects the full content of the first block, mirroring what the AI menu does
64+
* (`buildAIRequest` -> `expandPMRangeToWords`)
65+
*/
66+
function selectWholeFirstBlock(editor: BlockNoteEditor<any, any, any>) {
67+
const id = editor.document[0].id;
68+
const info = getBlockInfo(getNodeById(id, editor.prosemirrorState.doc)!);
69+
if (!info.isBlockContainer) {
70+
throw new Error("not a block container");
71+
}
72+
const from = info.blockContent.beforePos + 1;
73+
const to = info.blockContent.afterPos - 1;
74+
75+
editor.transact((tr) => {
76+
tr.setSelection(TextSelection.create(tr.doc, from, to));
77+
});
78+
79+
return expandPMRangeToWords(editor.prosemirrorState.doc, {
80+
$from: editor.prosemirrorState.doc.resolve(from),
81+
$to: editor.prosemirrorState.doc.resolve(to),
82+
});
83+
}
84+
85+
async function runUpdate(
86+
editor: BlockNoteEditor<any, any, any>,
87+
id: string,
88+
html: string,
89+
selection?: { from: number; to: number },
90+
) {
91+
const streamTools = [
92+
tools.update(editor, {
93+
idsSuffixed: false,
94+
withDelays: false,
95+
updateSelection: selection,
96+
}),
97+
] as StreamTool<any>[];
98+
99+
await new StreamToolExecutor(streamTools).execute(
100+
(async function* () {
101+
yield {
102+
operation: { type: "update" as const, id, block: html },
103+
isUpdateToPreviousOperation: false,
104+
isPossiblyPartial: false,
105+
metadata: undefined,
106+
};
107+
})(),
108+
);
109+
}
110+
111+
describe.each([
112+
["local", createLocalEditor],
113+
["collaborative", createCollabEditor],
114+
])("update tool (%s)", (_name, createEditor) => {
115+
it("updates a selected paragraph", async () => {
116+
const { editor, fork } = createEditor("Bonjour le monde");
117+
fork();
118+
const id = editor.document[0].id;
119+
const selection = selectWholeFirstBlock(editor);
120+
121+
await runUpdate(editor, id, "<p>Bonjour à tous</p>", selection);
122+
123+
editor.getExtension(AIExtension)?.acceptChanges();
124+
expect((editor.document[0] as any).content[0].text).toBe("Bonjour à tous");
125+
});
126+
127+
it("updates a paragraph without a selection", async () => {
128+
const { editor, fork } = createEditor("Bonjour le monde");
129+
fork();
130+
const id = editor.document[0].id;
131+
132+
await runUpdate(editor, id, "<p>Bonjour à tous</p>");
133+
134+
editor.getExtension(AIExtension)?.acceptChanges();
135+
expect((editor.document[0] as any).content[0].text).toBe("Bonjour à tous");
136+
});
137+
});

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)