fix(mcp): structural-diff write-back so agent edits don't jump the cursor (#152)
mutatePageContent wrote agent edits back by DELETING the whole Yjs fragment and
re-applying a fresh Y.Doc. Yjs is a CRDT — the editor anchors its selection to
node ids — so wiping every id made an open editor's cursor lose its anchor and
snap to the end of the document on every agent write. It was most visible on
comment anchoring (issue #152): a comment changes no text, yet the cursor jumped.
(Before commit 4201f0a3 the anchoring silently no-op'd, so the destructive write
never ran for comments — hence the regression.)
Fix: write via `updateYFragment` (y-prosemirror) — the same routine the editor
uses to sync its own edits into Yjs. It structurally diffs the new doc against
the live fragment and touches only changed nodes, preserving the ids of unchanged
ones, so the cursor stays put. This improves ALL agent write tools (text edits,
node ops, comments, replace) — minimal diff instead of full replace: less collab
noise, stable block-ids, other users' cursors no longer disrupted.
- collaboration.ts: new `applyDocToFragment` (sanitize -> PMNode.fromJSON against
a memoized docmost schema -> updateYFragment in one transact), keeping the
`findUnstorableAttr` encode diagnostic; swap the destructive write-back for it.
- package.json: `y-prosemirror` promoted to a direct dependency (was transitive).
- test: comment-cursor-stability.test.mjs — a Yjs RelativePosition (the cursor
anchor) survives both a sibling edit and a comment-mark anchoring (the old
full-replace tombstoned it -> null). 292 package tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -4,11 +4,18 @@ import * as Y from "yjs";
|
||||
import WebSocket from "ws";
|
||||
import { marked } from "marked";
|
||||
import { generateJSON } from "@tiptap/html";
|
||||
import { getSchema } from "@tiptap/core";
|
||||
import { Node as PMNode } from "@tiptap/pm/model";
|
||||
import { updateYFragment } from "y-prosemirror";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { docmostExtensions } from "./docmost-schema.js";
|
||||
import { withPageLock } from "./page-lock.js";
|
||||
import { sanitizeForYjs, findUnstorableAttr } from "./node-ops.js";
|
||||
import { summarizeChange } from "./diff.js";
|
||||
// The ProseMirror schema for the docmost editor, built once (mirrors diff.ts).
|
||||
// `updateYFragment` needs a real PM Node, so we re-hydrate the transformed JSON
|
||||
// against this schema before diffing it into the live Yjs fragment.
|
||||
const docmostSchema = getSchema(docmostExtensions);
|
||||
// Setup DOM environment for Tiptap HTML parsing in Node.js
|
||||
const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>");
|
||||
global.window = dom.window;
|
||||
@@ -450,6 +457,40 @@ export function buildYDoc(doc) {
|
||||
throw new Error(`Failed to encode document to Yjs (toYdoc): ${e instanceof Error ? e.message : String(e)}.${bad ? ` Offending attribute: ${bad}.` : " A node/mark attribute likely holds a value Yjs cannot store (e.g. undefined)."}`);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Write a new ProseMirror doc into the live Yjs fragment by STRUCTURAL DIFF,
|
||||
* preserving the Yjs identity of unchanged nodes (issue #152).
|
||||
*
|
||||
* The previous approach deleted the whole fragment and re-applied a fresh Y.Doc,
|
||||
* which discarded every Yjs node id. y-prosemirror anchors the editor selection
|
||||
* to those ids, so an open editor's cursor lost its anchor and snapped to the
|
||||
* end of the document on every agent write (most visibly on comment anchoring,
|
||||
* which changes no text at all). `updateYFragment` is exactly the routine the
|
||||
* editor itself uses to sync ProseMirror edits into Yjs: it diffs the new node
|
||||
* against the current fragment and touches only the changed children, so
|
||||
* unchanged nodes keep their ids and the live cursor stays put.
|
||||
*
|
||||
* Must run inside a single `transact` so the diff applies atomically (no remote
|
||||
* update interleaves). Keeps `buildYDoc`'s `findUnstorableAttr` diagnostic for
|
||||
* the opaque "Unexpected content type" encode failure.
|
||||
*/
|
||||
export function applyDocToFragment(ydoc, newDoc) {
|
||||
const safe = sanitizeForYjs(newDoc);
|
||||
const fragment = ydoc.getXmlFragment("default");
|
||||
try {
|
||||
const pmNode = PMNode.fromJSON(docmostSchema, safe);
|
||||
ydoc.transact(() => {
|
||||
updateYFragment(ydoc, fragment, pmNode, {
|
||||
mapping: new Map(),
|
||||
isOMark: new Map(),
|
||||
});
|
||||
});
|
||||
}
|
||||
catch (e) {
|
||||
const bad = findUnstorableAttr(safe);
|
||||
throw new Error(`Failed to encode document to Yjs (updateYFragment): ${e instanceof Error ? e.message : String(e)}.${bad ? ` Offending attribute: ${bad}.` : " A node/mark attribute likely holds a value Yjs cannot store (e.g. undefined)."}`);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Validate that a doc is Yjs-encodable by building (and discarding) a Y.Doc.
|
||||
* Throws the same descriptive error as the apply path when it is not. Used by
|
||||
@@ -649,16 +690,10 @@ export async function mutatePageContent(pageId, collabToken, baseUrl, transform)
|
||||
finish(null, mutationResult);
|
||||
return;
|
||||
}
|
||||
const tempDoc = buildYDoc(newDoc);
|
||||
// Fetch the fragment immediately before the transact that mutates
|
||||
// it, rather than reusing a handle grabbed across the transform.
|
||||
const fragment = ydoc.getXmlFragment("default");
|
||||
ydoc.transact(() => {
|
||||
if (fragment.length > 0) {
|
||||
fragment.delete(0, fragment.length);
|
||||
}
|
||||
Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(tempDoc));
|
||||
});
|
||||
// Structural diff into the live fragment (issue #152): preserves
|
||||
// the Yjs ids of unchanged nodes, so an open editor's cursor is not
|
||||
// yanked to the end of the document on every agent write.
|
||||
applyDocToFragment(ydoc, newDoc);
|
||||
}
|
||||
catch (e) {
|
||||
// Includes errors thrown by transform (e.g. "afterText not found",
|
||||
|
||||
Reference in New Issue
Block a user