fix(cli): headless bridge syncs browser-authored comment metadata from Y.Array (SD-3214)#3402
Conversation
…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.
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.
In-process two-Editor harness on a shared Y.Doc. Exercises the three flows of the customer use case: read browser-authored metadata, resolve a comment from the agent, delete a comment from the agent. Each scenario prints PASS/FAIL so reviewers can eyeball the fix end-to-end without standing up Liveblocks/Hocuspocus infra. NODE_ENV=test bun apps/cli/scripts/repro-sd-3214.ts README documents how to toggle each half of the fix off (sed-revert / git-checkout the relevant files) to compare buggy vs fixed output.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a1210c6633
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
…SD-3214) Regenerates the SD-3176 legacy-subpath snapshot to accept the new `syncCommentEntitiesFromCollaboration` helper that SD-3214 adds to `superdoc/super-editor`. The growth is intentional and reviewed under the SD-3175 path-as-contract umbrella. Regenerated with: node tests/consumer-typecheck/snapshot-superdoc-legacy-exports.mjs --write
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
luccas-harbour
left a comment
There was a problem hiding this comment.
hey @tupizz!
I had a look at this one and codex found these issues below. Can you have a look? Other than that, everything looks good.
-
[P2] Track own Y.Array writes before filtering — apps/cli/src/lib/headless-comment-bridge.ts:337-338
When this client creates a comment afterattachEditor, the Y.Array observer returns for the matchinguserOrigin, sopreviousSyncedIdsnever learns about that comment even though it was written to collaboration. If another client later deletes that headless-authored comment,syncCommentEntitiesFromCollaboration(..., { previouslySynced: previousSyncedIds })has no prior id to prune, anddoc.comments.list()can keep returning the stale local store entry. -
[P2] Keep deleted thread descendants out of the sync set — packages/super-editor/src/editors/v1/document-api-adapters/helpers/comment-entity-store.ts:262-262
When a browser deletes a parent thread, its existing collaboration helper removes only the parent Y.Array entry while the browser UI also drops replies locally. This sync pass prunes the reply viaremoveCommentEntityTree, but then returnsseen, which still contains the reply because it remained in the upstream array; on the next Y.Array event that reply is considered valid again and is upserted as an orphan indoc.comments.list().
…ored comments (SD-3214) Bridge previously early-returned the Y.Array observer on own-origin events, which meant `previousSyncedIds` never recorded ids the agent itself wrote. A subsequent remote delete then had no prior id to detect, so the entity-store metadata (text / creatorName / createdTime / …) stayed in doc.comments.list() even after the canonical Y.Array entry was gone. Re-syncs on every observer fire are idempotent for already-present entries; the only effective change is that the synced-id bookkeeping now includes agent-authored comments and the prune step can act on them. Surfaced by Codex review (P2). Regression test exercises agent.comments.create followed by a remote-origin yArray.delete and asserts the stale metadata is pruned.
…SD-3214) Browser-side deleteYComment only removes the parent's Y.Array index — reply entries linger upstream until the browser flushes them. The headless sync would upsert those replies in the same pass that cascade-removed the parent via removeCommentEntityTree, and `seen` would still contain the reply id. On the next observer fire the reply was re-upserted as an orphan with no parent, surfacing in doc.comments.list(). Pre-pass collects every upstream id (commentId AND importedId), and the upsert loop skips entries whose parentCommentId is not in that set. parentCommentId may reference parent.importedId for DOCX-imported threads, so both are tracked. Surfaced by Codex review (P2). Unit tests cover the orphan-reply scenarios plus sanity coverage that valid threads + legacy DOCX importedId references still pass through.
|
Both Codex P2s addressed — verified each against the codebase + browser-side prior art before touching code, then TDD per fix. P2 #1 — Track own Y.Array writes ( P2 #2 — Keep deleted thread descendants out of the sync set ( Suites (re-run after each fix):
Note on the Codex P1 (inline) on |
|
hey @tupizz! thanks for addressing the issues that codex had found it looks like there's still one remaining, related to the second P2 that codex had flagged:
can you check this one? |
…b comment sync (SD-3214) Previous single-pass filter handled A→B (B skipped when A is gone) but broke on A→B→C: B was skipped, yet its id stayed in the upstream set, so C survived the upsert and dangled as an orphan whose chain led nowhere. On the next observer fire C resurfaced in doc.comments.list(). Pre-pass now iteratively drops orphan ids from the upstream set until stable. Each iteration removes entries whose declared parent is no longer in the set; the next iteration re-evaluates entries transitively orphaned by the previous removal. Worst-case O(depth × entries), bounded by Y.Array size (small in practice). Surfaced by Codex follow-up review. Unit tests cover A→B→C delete-A and the multi-sync resurrection variant.
|
Addressed in The hole in the previous fix Fix Worst-case O(depth × validEntries), but Y.Array of comments is small in practice — the depth term is the thread nesting depth, typically 1–3. Coverage
The existing parent-with-direct-reply case (A→B) and the legacy- Suites (re-run after fix + post-rebase against the latest tip of the branch):
PTAL. |
|
🎉 This PR is included in superdoc-cli v0.12.0 The release is available on GitHub release |
|
🎉 This PR is included in superdoc-sdk v1.11.0 |
|
🎉 This PR is included in @superdoc-dev/mcp v0.7.0 The release is available on GitHub release |
|
🎉 This PR is included in superdoc v1.35.0 The release is available on GitHub release |
|
🎉 This PR is included in @superdoc-dev/react v1.6.0 The release is available on GitHub release |
|
🎉 This PR is included in vscode-ext v2.7.0 |
Summary
Fixes SD-3214. Two commits in this PR:
editor.doc.comments.list()now surfaces full metadata (text,creatorName,creatorEmail,createdTime) for browser-authored comments. Pre-fix it only had anchor-derived fields.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: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 whatcomments.list()reads from.Write-side gap (commit 2)
The engine commands
resolveComment,reopenComment, andremoveCommentsettr.setMeta(CommentsPluginKey, { event: ... })but never calleditor.emit('commentsUpdate', ...). So:case 'deleted' | 'update' | 'resolved'handlers (which calldeleteYComment/updateYComment) were unreachable from CLI-initiated mutations.ui.comments.*surface (which routes througheditor.doc.comments.*) was silently broken for collab — fine for local Vue list, but Y.Array never updated.commentsStore.deleteCommentpath stays unaffected because it callseditor.commands.removeCommentdirectly and manually handles Y.Array sync viasyncCommentsToClients.Fix
Commit 1: read-side
packages/super-editor: new shared helpersyncCommentEntitiesFromCollaboration(editor, entries, { previouslySynced? })incomment-entity-store.ts. Maps Y.Array entry shape toCommentEntityRecord, upserts viaupsertCommentEntity, filters outtrackedChange: trueentries (different domain), and prunes remote deletions when given the prior sync set (cascades thread replies viaremoveCommentEntityTree).apps/cli: bridge gainsattachEditor(editor)called afterEditor.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 thetransaction.origin.usershape browser writers use. TrackspreviousSyncedIdsacross observer fires for prune correctness.Commit 2: write-side
packages/super-editor:resolveCommentHandler,reopenCommentHandler, andremoveCommentHandlerincomments-wrappers.tsnow emitcommentsUpdateafter a successful mutation. Symmetric with howaddCommentHandlerandeditCommentHandleralready 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.storagedirectly. The helper lives insuper-editorso 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:
ui.comments.delete→editor.doc.comments.delete→ wrapper handler → engine command. No emit anywhere. Vue list silently goes stale; Y.Array unchanged.commentsStore.deleteComment(sidebar trash click, etc.) → engine command directly. Manualsuperdoc.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)
Write-side (pre-fix, fails; post-fix, passes)
Test plan
Unit (13 new cases in
comment-entity-store.test.ts)textalias forcommentText, importedId fallback, missing id skip.Integration (10 cases in
sd-3214-browser-comment-metadata.regression.test.ts)comments.delete→ Y.Array reflects removal.comments.patch({status:'resolved'})→ Y.Array getsisDone: true+resolvedTime.Suites
pnpm --filter @superdoc-dev/cli test— 1248 pass, 0 fail, 8269 expect(), 52 files.pnpm --filter super-editor test— 1035 files / 13117 pass / 13 skip.headless-comment-bridge.test.ts(27 cases) untouched and green.comments-wrappers.test.tsgreen — no double-emit regressions.Acceptance criteria (from ticket)
doc.comments.list()returns full metadata for browser-authored comments.Files changed
packages/super-editor/.../helpers/comment-entity-store.tssyncCommentEntitiesFromCollaborationhelper.packages/super-editor/.../plan-engine/comments-wrappers.tscommentsUpdateafter successful mutation.packages/super-editor/src/editors/v1/index.jsapps/cli/src/lib/headless-comment-bridge.tsattachEditor, Y.Array observer, prune-aware re-sync.apps/cli/src/lib/document.tscommentBridge.attachEditor(editor)afterEditor.open.apps/cli/src/__tests__/lib/_collab-password-worker.tsapps/cli/src/lib/__tests__/sd-3214-browser-comment-metadata.regression.test.tspackages/super-editor/.../helpers/comment-entity-store.test.tsRelated