Skip to content

fix(cli): headless bridge syncs browser-authored comment metadata from Y.Array (SD-3214)#3402

Draft
tupizz wants to merge 3 commits into
mainfrom
tadeu/sd-3214-fix-headless-comment-sync
Draft

fix(cli): headless bridge syncs browser-authored comment metadata from Y.Array (SD-3214)#3402
tupizz wants to merge 3 commits into
mainfrom
tadeu/sd-3214-fix-headless-comment-sync

Conversation

@tupizz
Copy link
Copy Markdown
Contributor

@tupizz tupizz commented May 19, 2026

Summary

Fixes SD-3214. Two commits in this PR:

  1. Read-side: headless SDK's editor.doc.comments.list() now surfaces full metadata (text, creatorName, creatorEmail, createdTime) for browser-authored comments. Pre-fix it only had anchor-derived fields.
  2. Write-side: CLI-initiated comments.delete / comments.patch({status:'resolved'}) / comments.patch({status:'active'}) now propagate to other collaborators via Y.Array. Without this, the customer's "agent resolves/responds to comments" workflow would have read correctly but written into a black hole.

Customer use case (Viven Puthenpurayil, via Lighthouse): an agent reads comments from the document, addresses them, then resolves/responds. Both halves of the workflow work end-to-end after this PR.

Root causes

Read-side gap (commit 1)

Browser and CLI share most of the core (Editor, comment mark, CommentEntityStore, Document API adapters). The per-environment orchestration was asymmetric:

        BROWSER                              CLI
   ┌──────────────────────┐          ┌──────────────────────┐
   │ initCollaboration-   │          │ headless-comment-    │
   │ Comments + loadCom-  │          │ bridge.ts            │
   │ mentsFromYdoc        │          │                      │
   │   read  ✓ / write ✓  │          │   read  ✗  ← bug     │
   │                      │          │   write ✓            │
   └──────────────────────┘          └──────────────────────┘
                            ↓ shared ↓
                CommentEntityStore + Document API
                Yjs + y-prosemirror substrate

The browser had both halves of the Y.Array dance. The CLI bridge had only the write half — so nothing pushed Y.Array entries into the editor's CommentEntityStore, which is what comments.list() reads from.

Write-side gap (commit 2)

The engine commands resolveComment, reopenComment, and removeComment set tr.setMeta(CommentsPluginKey, { event: ... }) but never call editor.emit('commentsUpdate', ...). So:

  • The bridge's existing case 'deleted' | 'update' | 'resolved' handlers (which call deleteYComment / updateYComment) were unreachable from CLI-initiated mutations.
  • The browser's ui.comments.* surface (which routes through editor.doc.comments.*) was silently broken for collab — fine for local Vue list, but Y.Array never updated.
  • The browser-internal commentsStore.deleteComment path stays unaffected because it calls editor.commands.removeComment directly and manually handles Y.Array sync via syncCommentsToClients.

Fix

Commit 1: read-side

  • packages/super-editor: new shared helper syncCommentEntitiesFromCollaboration(editor, entries, { previouslySynced? }) in comment-entity-store.ts. Maps Y.Array entry shape to CommentEntityRecord, upserts via upsertCommentEntity, filters out trackedChange: true entries (different domain), and prunes remote deletions when given the prior sync set (cascades thread replies via removeCommentEntityTree).
  • apps/cli: bridge gains attachEditor(editor) called after Editor.open(). Seeds the store from the current Y.Array, then installs an observer that re-syncs on remote events while skipping own-origin echoes using the transaction.origin.user shape browser writers use. Tracks previousSyncedIds across observer fires for prune correctness.

Commit 2: write-side

  • packages/super-editor: resolveCommentHandler, reopenCommentHandler, and removeCommentHandler in comments-wrappers.ts now emit commentsUpdate after a successful mutation. Symmetric with how addCommentHandler and editCommentHandler already rely on engine-level emits.

CLI engine-agnostic boundary preserved: the bridge forwards an opaque editor handle to the helper but never touches editor.state / editor.storage directly. The helper lives in super-editor so the next consumer (mobile SDK, edge worker) doesn't re-derive the mapping.

Audit notes (why the write-side fix went in the wrapper, not the engine command)

Initial scoping put the write-side gap as a follow-up because changing engine commands risks double-fire in the browser. The audit found:

  • Browser path A: ui.comments.deleteeditor.doc.comments.delete → wrapper handler → engine command. No emit anywhere. Vue list silently goes stale; Y.Array unchanged.
  • Browser path B: commentsStore.deleteComment (sidebar trash click, etc.) → engine command directly. Manual superdoc.emit + syncCommentsToClients. Bypasses the wrapper entirely.

Emitting from the wrapper (this PR) fixes path A for both browser and CLI without affecting path B. Engine commands stay untouched — no double-fire risk.

How to reproduce the original bugs

Read-side (pre-fix, fails; post-fix, passes)

const ydoc = new YDoc();
const yComment = new YMap(Object.entries({
  commentId: 'c1',
  commentText: 'Please review.',
  creatorName: 'Browser User',
  creatorEmail: 'browser@example.com',
  createdTime: 1700000000000,
}));
ydoc.transact(() => { ydoc.getArray('comments').push([yComment]); }, { user: { name: 'Browser User', email: 'browser@example.com' } });

const opened = await openDocument(undefined, io, { ydoc, collaborationProvider, user, isNewFile: true });
const list = opened.editor.doc.comments.list();
// PRE-FIX:  list.items.find(c => c.id === 'c1') === undefined
// POST-FIX: { text: 'Please review.', creatorName: 'Browser User', ... }

Write-side (pre-fix, fails; post-fix, passes)

const session = await openDocument(undefined, io, { ydoc, collaborationProvider, user: { name: 'Agent', email: 'agent@x' }, isNewFile: true });
session.editor.doc.create.paragraph({ at: { kind: 'documentEnd' }, text: 'Clause.' });
const block = session.editor.doc.query.match({ select: { type: 'text', pattern: 'Clause' }, require: 'first' }).items[0].blocks[0];
session.editor.doc.comments.create({ target: { kind: 'text', blockId: block.blockId, range: block.range }, text: 'pending' });

const id = (ydoc.getArray('comments').toJSON())[0].commentId;
session.editor.doc.comments.patch({ commentId: id, status: 'resolved' });

const after = ydoc.getArray('comments').toJSON()[0];
// PRE-FIX:  after.isDone === undefined, after.resolvedTime === undefined
// POST-FIX: after.isDone === true,      after.resolvedTime === <number>

Test plan

Unit (13 new cases in comment-entity-store.test.ts)

  • Upsert paths: new entry, text alias for commentText, importedId fallback, missing id skip.
  • Merge-without-clobber.
  • Tracked-change filter.
  • Resolution metadata propagation.
  • Deletion path: prune previously-synced, leave locally-authored alone, cascade thread replies, no-op on no removals.

Integration (10 cases in sd-3214-browser-comment-metadata.regression.test.ts)

  • Pre-open Y.Array entry; post-open observe; remote update.
  • Option A (shared Y.Doc): two-Editor scenario; full metadata propagation; origin filter (no self-echo); remote delete prunes.
  • Option B (two Y.Docs + Yjs update relay): closer to wire-protocol reality; late-join author.
  • CLI write-side delete: agent comments.delete → Y.Array reflects removal.
  • CLI write-side resolve: agent comments.patch({status:'resolved'}) → Y.Array gets isDone: true + resolvedTime.

Suites

  • pnpm --filter @superdoc-dev/cli test1248 pass, 0 fail, 8269 expect(), 52 files.
  • pnpm --filter super-editor test1035 files / 13117 pass / 13 skip.
  • Existing headless-comment-bridge.test.ts (27 cases) untouched and green.
  • Existing comments-wrappers.test.ts green — no double-emit regressions.

Acceptance criteria (from ticket)

  • doc.comments.list() returns full metadata for browser-authored comments.
  • Initial sync covers pre-existing Y.Array entries.
  • Observer picks up entries authored after connect.
  • Remote deletions prune the headless store.
  • Agent resolve/delete/reopen propagate to other collaborators via Y.Array.
  • Existing browser comment flow untouched (super-editor + bridge suites green).
  • Regression tests prevent re-introduction.

Files changed

File Role
packages/super-editor/.../helpers/comment-entity-store.ts New syncCommentEntitiesFromCollaboration helper.
packages/super-editor/.../plan-engine/comments-wrappers.ts Wrapper handlers emit commentsUpdate after successful mutation.
packages/super-editor/src/editors/v1/index.js Re-export the helper.
apps/cli/src/lib/headless-comment-bridge.ts New attachEditor, Y.Array observer, prune-aware re-sync.
apps/cli/src/lib/document.ts Wire commentBridge.attachEditor(editor) after Editor.open.
apps/cli/src/__tests__/lib/_collab-password-worker.ts Add the new export to the subprocess mock.
apps/cli/src/lib/__tests__/sd-3214-browser-comment-metadata.regression.test.ts New 10-case integration suite.
packages/super-editor/.../helpers/comment-entity-store.test.ts 13 new unit cases.

Related

  • Customer ask: Viven Puthenpurayil (Lighthouse) — agent workflow over user comments.
  • Blocks: IT-1066 (Investigate reading browser comments via headless SDK client).

…m Y.Array (SD-3214)

The headless SDK's `editor.doc.comments.list()` was returning partial data
for browser-authored comments: `id`, `target`, `anchoredText`, `status`
populated correctly from the PM anchor mark (synced via y-prosemirror), but
`text`, `creatorName`, `creatorEmail`, `createdTime` came back empty.

Cause: browser SuperDoc clients write comment data over two channels —
the PM XmlFragment carries anchor marks, and `ydoc.getArray('comments')`
carries metadata. The CLI's `headless-comment-bridge.ts` only had the
write half of this dance. It never fed Y.Array entries into the editor's
`CommentEntityStore`, which is what `comments.list()` reads from.

Fix:

- New shared helper in super-editor: `syncCommentEntitiesFromCollaboration`
  maps Y.Array entry shape to `CommentEntityRecord`, upserts via the existing
  `upsertCommentEntity`, filters out tracked-change entries (separate domain),
  and supports an optional `previouslySynced` set to prune remote deletions
  via `removeCommentEntityTree`.
- Bridge gains `attachEditor(editor)` called after `Editor.open()` resolves.
  Seeds the store from the current Y.Array snapshot, then installs an
  observer that re-syncs on remote events while skipping own-origin echoes
  using the same `transaction.origin.user` shape the browser writers use.
- `document.ts` wires `attachEditor` once.

CLI engine-agnostic boundary preserved: the bridge forwards the editor
handle to the shared helper but never touches `editor.state` / `editor.storage`
directly. The helper lives in super-editor so the next consumer (mobile
SDK, edge worker) doesn't re-derive the mapping.

Coverage:

- 13 new unit tests in `comment-entity-store.test.ts` cover upsert paths,
  alias handling (`text` vs `commentText`), tracked-change filtering,
  importedId fallback, merge-without-clobber, resolution metadata, and
  the four deletion scenarios (prune previously-synced, leave locally-
  authored alone, cascade thread replies, no-op when no removals).
- 8 integration tests in `sd-3214-browser-comment-metadata.regression.test.ts`
  cover: pre-open Y.Array entry, post-open observe, remote update,
  Option A (two Editors on shared Y.Doc), Option A origin-filter
  (no self-echo), Option A remote delete, Option B (two Y.Docs with
  Yjs update relay), Option B late-join.

Out of scope (separate gap, documented in the test file): the CLI's
write-side delete path doesn't currently propagate to Y.Array because
the `removeComment` editor command sets `tr.setMeta` but never emits
`commentsUpdate({ type: 'deleted' })`. The bridge's existing 'deleted'
handler is therefore unused for CLI-initiated deletes. This affects
the customer's "agent resolves comments" flow and is worth its own
ticket. The bridge's READ-side prune (this PR) is correct: it would
fire if any client — including a future CLI write path or a real
browser — removes the Y.Array entry.

Full super-editor suite (13115) and CLI suite (1246) green.
@linear-code
Copy link
Copy Markdown

linear-code Bot commented May 19, 2026

SD-3214

@tupizz tupizz self-assigned this May 19, 2026
The customer's "agent resolves comments" flow needs CLI-initiated
`comments.delete` and `comments.patch({ status: 'resolved' })` calls
to reach other browser collaborators via Y.Array. Pre-fix, none of
those write paths propagated:

- The engine commands (`resolveComment`, `reopenComment`, `removeComment`)
  set `tr.setMeta(CommentsPluginKey, { event: ... })` but never call
  `editor.emit('commentsUpdate', ...)`.
- The headless bridge's existing `case 'deleted'`/`case 'update'`
  handlers therefore never fire for CLI-initiated mutations.
- The browser's own delete path (`commentsStore.deleteComment`) sidesteps
  this by manually calling `superdoc.emit('comments-update', ...)` +
  `syncCommentsToClients(...)`, but `ui.comments.delete` (which routes
  through `editor.doc.comments.delete`) hits the same gap — so the
  browser's Document-API surface was also silently broken for collab.

Fix: emit `commentsUpdate` from `resolveCommentHandler`,
`reopenCommentHandler`, and `removeCommentHandler` in `comments-wrappers.ts`
after a successful mutation. Symmetric with how `addCommentHandler` and
`editCommentHandler` already rely on engine-level emits.

Effects:

- CLI bridge's `onCommentsUpdate('deleted' | 'update' | 'resolved')`
  handlers fire and call `deleteYComment` / `updateYComment` correctly.
- Browser's `onEditorCommentsUpdate` DELETED/UPDATE branches in
  `SuperDoc.vue` start receiving events for the `ui.comments.*` surface,
  refreshing the Vue list to match the entity store.
- Browser's `commentsStore.deleteComment` path (calls `editor.commands.removeComment`
  directly, bypassing the wrapper) is unaffected — no double-fire.

Adds 2 integration tests covering the customer's flow: agent deletes a
comment and Y.Array reflects it; agent resolves a comment and `isDone`
+ `resolvedTime` reach Y.Array.

Full super-editor suite (13117) and CLI suite (1248) green.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant