[bug][ai-chat] Комментарии агента сбрасывают курсор в конец статьи (регрессия) #152

Closed
opened 2026-06-24 05:24:37 +03:00 by claude_code · 0 comments
Collaborator

Симптом

Когда агент (AI-чат / MCP) оставляет комментарий к статье, курсор в открытом редакторе прыгает в самый конец документа — на последний символ. Раньше такого не было.

Воспроизведение:

  1. Открыть страницу в редакторе, поставить курсор/выделение где-нибудь в середине.
  2. Попросить агента в AI-чате оставить инлайн-комментарий к фрагменту текста.
  3. Как только агент создаёт комментарий — курсор улетает в конец статьи.

Причина

Привязка подсветки комментария делается в пакете @docmost/mcp: createComment (packages/mcp/src/client.ts) вызывает mutatePageContent(...), которая открывает «живой» Yjs-документ (тот же, что открыт в браузере), накладывает mark comment через applyAnchorInDoc и пишет результат обратно.

Запись обратно в packages/mcp/src/lib/collaboration.ts сделана как полная замена документа — весь фрагмент удаляется и пересобирается заново:

const tempDoc = buildYDoc(newDoc);
const fragment = ydoc.getXmlFragment("default");
ydoc.transact(() => {
  if (fragment.length > 0) {
    fragment.delete(0, fragment.length);   // удаляются ВСЕ Yjs-элементы
  }
  Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(tempDoc)); // вставляются совершенно новые элементы
});

Yjs — это CRDT: у каждого куска текста свой идентификатор, и курсор в редакторе «приклеен» к этим идентификаторам через y-prosemirror. При полном удалении + пересборке все старые идентификаторы исчезают. Удалённое обновление прилетает в открытый редактор, y-prosemirror пытается восстановить позицию курсора по старому id — а его больше нет, позиция не резолвится, выделение схлопывается в дефолт (конец документа). Отсюда прыжок на последний символ.

Этот разрушительный write-back — общий путь записи для всех инструментов агента (правка текста, вставка/удаление узлов и т.д.), но именно на комментариях это особенно заметно: комментарий ничего не меняет в тексте, а курсор всё равно улетает.

Почему это регрессия

До коммита 4201f0a3 («feat(comments): make AI comments inline-only with robust anchoring») шаг привязки комментария молча проваливался — mark по факту не накладывался, реальной мутации живого документа не происходило, курсор не трогался. После него привязка стала реально срабатывать, и теперь каждый комментарий проходит через ту самую полную замену, которая дёргает курсор. Сам по себе фикс привязки корректен — проблема в разрушительном способе записи в документ.

Предлагаемое решение

Заменить полную замену в mutatePageContent на updateYFragment из y-prosemirror — это ровно тот механизм, которым редактор синхронит свои правки в Yjs. Он делает структурный диф нового документа против текущего фрагмента и меняет только реально изменившиеся узлы, сохраняя Yjs-идентификаторы неизменных узлов. Курсор в открытом редакторе остаётся на месте.

Эскиз (packages/mcp/src/lib/collaboration.ts):

import { updateYFragment } from "y-prosemirror";
import { getSchema } from "@tiptap/core";
import { Node as PMNode } from "@tiptap/pm/model";

// внутри onSynced, вместо delete(0,len) + applyUpdate:
const safe = sanitizeForYjs(newDoc);                 // тот же guard, что в buildYDoc
const pmNode = PMNode.fromJSON(schema, safe);        // schema = getSchema(docmostExtensions)
const fragment = ydoc.getXmlFragment("default");
ydoc.transact(() => {
  updateYFragment(ydoc, fragment, pmNode, { mapping: new Map(), isOMark: new Map() });
});

Поведение updateYFragment проверено по исходнику (y-prosemirror@1.3.7): при пустом mapping он всё равно дифит против заполненного фрагмента — ищет совпадающий префикс/суффикс детей, у текстовых узлов применяет дельта-формат (updateYText), у элементов рекурсивно сравнивает и трогает только изменённое. Для комментария это сводится к наложению mark comment на нужный диапазон через format(), без удаления остального.

Затронутые файлы

  • packages/mcp/src/lib/collaboration.ts — заменить write-back в mutatePageContent; добавить импорты и memoized-schema (по образцу packages/mcp/src/lib/diff.ts); сохранить диагностику findUnstorableAttr на ошибке кодирования.
  • packages/mcp/package.json — добавить y-prosemirror в прямые зависимости (сейчас транзитивная).
  • После правки — пересобрать пакет: pnpm --filter @docmost/mcp build (сервер грузит @docmost/mcp из build/).

Зона влияния / риски

  • Меняется путь записи для всех write-инструментов агента, проходящих через mutatePageContent (правка текста, узлы, привязка комментариев, replacePageContent/update_page*). Это строгое улучшение: минимальный диф вместо полной замены — меньше шума в коллаборации, сохраняются block-id, не сбрасываются чужие курсоры.
  • updateYFragment — это штатная функция привязки самого редактора (prosemirrorToYXmlFragment использует её так же), так что использование поддерживаемое.
  • Бонус: при последующих правках агента инлайн-комментарии лучше «переживают» изменения, т.к. узлы с marks не пересоздаются целиком.

Тестирование

  • Прогнать packages/mcp/test/mock/create-comment.test.mjs и юнит-тесты пакета.
  • Добавить проверку, что после привязки комментария идентичность неизменных Yjs-узлов сохраняется (нет полного пересоздания фрагмента) и что mark comment реально наложен на диапазон.
  • Ручная проверка сценария из «Воспроизведение»: курсор остаётся на месте после комментария агента.
## Симптом Когда агент (AI-чат / MCP) оставляет **комментарий** к статье, курсор в открытом редакторе прыгает в самый конец документа — на последний символ. Раньше такого не было. **Воспроизведение:** 1. Открыть страницу в редакторе, поставить курсор/выделение где-нибудь в середине. 2. Попросить агента в AI-чате оставить инлайн-комментарий к фрагменту текста. 3. Как только агент создаёт комментарий — курсор улетает в конец статьи. ## Причина Привязка подсветки комментария делается в пакете `@docmost/mcp`: `createComment` ([`packages/mcp/src/client.ts`](packages/mcp/src/client.ts)) вызывает `mutatePageContent(...)`, которая открывает «живой» Yjs-документ (тот же, что открыт в браузере), накладывает mark `comment` через `applyAnchorInDoc` и пишет результат обратно. Запись обратно в [`packages/mcp/src/lib/collaboration.ts`](packages/mcp/src/lib/collaboration.ts) сделана как **полная замена документа** — весь фрагмент удаляется и пересобирается заново: ```js const tempDoc = buildYDoc(newDoc); const fragment = ydoc.getXmlFragment("default"); ydoc.transact(() => { if (fragment.length > 0) { fragment.delete(0, fragment.length); // удаляются ВСЕ Yjs-элементы } Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(tempDoc)); // вставляются совершенно новые элементы }); ``` Yjs — это CRDT: у каждого куска текста свой идентификатор, и курсор в редакторе «приклеен» к этим идентификаторам через y-prosemirror. При полном удалении + пересборке **все старые идентификаторы исчезают**. Удалённое обновление прилетает в открытый редактор, y-prosemirror пытается восстановить позицию курсора по старому id — а его больше нет, позиция не резолвится, выделение схлопывается в дефолт (конец документа). Отсюда прыжок на последний символ. Этот разрушительный write-back — общий путь записи для **всех** инструментов агента (правка текста, вставка/удаление узлов и т.д.), но именно на комментариях это особенно заметно: комментарий ничего не меняет в тексте, а курсор всё равно улетает. ## Почему это регрессия До коммита `4201f0a3` («feat(comments): make AI comments inline-only with robust anchoring») шаг привязки комментария **молча проваливался** — mark по факту не накладывался, реальной мутации живого документа не происходило, курсор не трогался. После него привязка стала реально срабатывать, и теперь каждый комментарий проходит через ту самую полную замену, которая дёргает курсор. Сам по себе фикс привязки корректен — проблема в разрушительном способе записи в документ. ## Предлагаемое решение Заменить полную замену в `mutatePageContent` на `updateYFragment` из `y-prosemirror` — это ровно тот механизм, которым редактор синхронит свои правки в Yjs. Он делает структурный диф нового документа против текущего фрагмента и меняет только реально изменившиеся узлы, сохраняя Yjs-идентификаторы неизменных узлов. Курсор в открытом редакторе остаётся на месте. Эскиз (`packages/mcp/src/lib/collaboration.ts`): ```js import { updateYFragment } from "y-prosemirror"; import { getSchema } from "@tiptap/core"; import { Node as PMNode } from "@tiptap/pm/model"; // внутри onSynced, вместо delete(0,len) + applyUpdate: const safe = sanitizeForYjs(newDoc); // тот же guard, что в buildYDoc const pmNode = PMNode.fromJSON(schema, safe); // schema = getSchema(docmostExtensions) const fragment = ydoc.getXmlFragment("default"); ydoc.transact(() => { updateYFragment(ydoc, fragment, pmNode, { mapping: new Map(), isOMark: new Map() }); }); ``` Поведение `updateYFragment` проверено по исходнику (`y-prosemirror@1.3.7`): при пустом `mapping` он всё равно дифит против заполненного фрагмента — ищет совпадающий префикс/суффикс детей, у текстовых узлов применяет дельта-формат (`updateYText`), у элементов рекурсивно сравнивает и трогает только изменённое. Для комментария это сводится к наложению mark `comment` на нужный диапазон через `format()`, без удаления остального. ## Затронутые файлы - `packages/mcp/src/lib/collaboration.ts` — заменить write-back в `mutatePageContent`; добавить импорты и memoized-schema (по образцу `packages/mcp/src/lib/diff.ts`); сохранить диагностику `findUnstorableAttr` на ошибке кодирования. - `packages/mcp/package.json` — добавить `y-prosemirror` в прямые зависимости (сейчас транзитивная). - После правки — пересобрать пакет: `pnpm --filter @docmost/mcp build` (сервер грузит `@docmost/mcp` из `build/`). ## Зона влияния / риски - Меняется путь записи для **всех** write-инструментов агента, проходящих через `mutatePageContent` (правка текста, узлы, привязка комментариев, `replacePageContent`/`update_page*`). Это строгое улучшение: минимальный диф вместо полной замены — меньше шума в коллаборации, сохраняются block-id, не сбрасываются чужие курсоры. - `updateYFragment` — это штатная функция привязки самого редактора (`prosemirrorToYXmlFragment` использует её так же), так что использование поддерживаемое. - Бонус: при последующих правках агента инлайн-комментарии лучше «переживают» изменения, т.к. узлы с marks не пересоздаются целиком. ## Тестирование - Прогнать `packages/mcp/test/mock/create-comment.test.mjs` и юнит-тесты пакета. - Добавить проверку, что после привязки комментария идентичность неизменных Yjs-узлов сохраняется (нет полного пересоздания фрагмента) и что mark `comment` реально наложен на диапазон. - Ручная проверка сценария из «Воспроизведение»: курсор остаётся на месте после комментария агента.
vvzvlad added the bug label 2026-06-24 05:26:05 +03:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#152