From 048e6d6e5d6467cb4002706d54c2344074a25eee Mon Sep 17 00:00:00 2001 From: Gabriel Chittolina Date: Tue, 2 Jun 2026 16:54:28 -0300 Subject: [PATCH 1/3] fix(custom-ui): dedupe tracked changes from merged activity feed (SD-3357) SuperDoc surfaces tracked changes in both ui.comments.items (as trackedChange:true comments) and ui.trackChanges.items with the same id. The ActivitySidebar merged both slices without filtering, so each suggestion rendered twice (one comment card + one change card). Skip comment.trackedChange items when building the feed, and drop a leftover debug log. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../custom-ui/src/components/ActivitySidebar.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/demos/editor/custom-ui/src/components/ActivitySidebar.tsx b/demos/editor/custom-ui/src/components/ActivitySidebar.tsx index 2c2ba5f069..cf38ff26b9 100644 --- a/demos/editor/custom-ui/src/components/ActivitySidebar.tsx +++ b/demos/editor/custom-ui/src/components/ActivitySidebar.tsx @@ -70,9 +70,19 @@ export function ActivitySidebar({ composeOpen, onCloseComposer, decided }: Props // (separate ticket), we'll be able to interleave by document // position; until then this stable two-bucket ordering matches what // the controller used to do internally. + // + // SuperDoc models tracked changes as comment-linked entities, so + // `ui.comments.items` already includes one `trackedChange: true` + // comment per tracked change — with the same id the change carries + // in `ui.trackChanges.items`. Skip those here; we render the change + // half from `trackChanges.items` below. Without this filter every + // suggestion shows twice (one comment card + one change card). const feed = useMemo(() => { const items: ActivityItem[] = []; - for (const c of comments.items) items.push({ kind: 'comment', id: c.id, comment: c }); + for (const c of comments.items) { + if (c.trackedChange) continue; + items.push({ kind: 'comment', id: c.id, comment: c }); + } for (const tc of trackChanges.items) items.push({ kind: 'change', id: tc.id, change: tc.change }); return items; }, [comments.items, trackChanges.items]); From 341510d9a9e5e0e6b14690ad24b229d8c5a54c6f Mon Sep 17 00:00:00 2001 From: Gabriel Chittolina Date: Tue, 2 Jun 2026 17:12:41 -0300 Subject: [PATCH 2/3] fix(custom-ui): dedupe activity feed by id, not trackedChange flag (SD-3357) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A real comment thread anchored over text that overlaps a suggestion also gets trackedChange:true (comments.list links it via assignTrackedChangeLink), so filtering on the flag wrongly hid those discussions. Dedupe by id instead — synthetic tracked-change comment rows reuse the tracked-change id, while real comments keep their own. Also exclude pairedWithChangeId so replacement pairs collapsed by trackChanges.list (replacements: paired) don't leak one half back as a comment. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/ActivitySidebar.tsx | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/demos/editor/custom-ui/src/components/ActivitySidebar.tsx b/demos/editor/custom-ui/src/components/ActivitySidebar.tsx index cf38ff26b9..7ec2eb713e 100644 --- a/demos/editor/custom-ui/src/components/ActivitySidebar.tsx +++ b/demos/editor/custom-ui/src/components/ActivitySidebar.tsx @@ -72,15 +72,27 @@ export function ActivitySidebar({ composeOpen, onCloseComposer, decided }: Props // the controller used to do internally. // // SuperDoc models tracked changes as comment-linked entities, so - // `ui.comments.items` already includes one `trackedChange: true` - // comment per tracked change — with the same id the change carries - // in `ui.trackChanges.items`. Skip those here; we render the change - // half from `trackChanges.items` below. Without this filter every + // `ui.comments.items` mirrors each tracked change as a synthetic + // comment whose id is the tracked-change id. Without de-duping, every // suggestion shows twice (one comment card + one change card). + // + // We dedupe by id, NOT by the `trackedChange` flag: a real comment + // thread also gets `trackedChange: true` when its anchor overlaps a + // suggestion (`comments.list()` links it via `assignTrackedChangeLink`), + // so a flag filter would wrongly hide those discussions. Only the + // synthetic rows reuse a tracked-change id; real comments have their + // own. `pairedWithChangeId` covers replacement pairs that + // `trackChanges.list()` collapses into one row (this demo runs + // `replacements: 'paired'`) but which still surface as two comments. const feed = useMemo(() => { + const changeIds = new Set(); + for (const tc of trackChanges.items) { + changeIds.add(tc.id); + if (tc.change.pairedWithChangeId) changeIds.add(tc.change.pairedWithChangeId); + } const items: ActivityItem[] = []; for (const c of comments.items) { - if (c.trackedChange) continue; + if (changeIds.has(c.id)) continue; items.push({ kind: 'comment', id: c.id, comment: c }); } for (const tc of trackChanges.items) items.push({ kind: 'change', id: tc.id, change: tc.change }); From 6257031ac36e5b13899351547ae266e59e6bfb6c Mon Sep 17 00:00:00 2001 From: Gabriel Chittolina Date: Wed, 3 Jun 2026 11:49:45 -0300 Subject: [PATCH 3/3] fix: clarify deduplication logic in activity feed comments --- .../custom-ui/src/components/ActivitySidebar.tsx | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/demos/editor/custom-ui/src/components/ActivitySidebar.tsx b/demos/editor/custom-ui/src/components/ActivitySidebar.tsx index 7ec2eb713e..0f19301031 100644 --- a/demos/editor/custom-ui/src/components/ActivitySidebar.tsx +++ b/demos/editor/custom-ui/src/components/ActivitySidebar.tsx @@ -81,15 +81,16 @@ export function ActivitySidebar({ composeOpen, onCloseComposer, decided }: Props // suggestion (`comments.list()` links it via `assignTrackedChangeLink`), // so a flag filter would wrongly hide those discussions. Only the // synthetic rows reuse a tracked-change id; real comments have their - // own. `pairedWithChangeId` covers replacement pairs that - // `trackChanges.list()` collapses into one row (this demo runs - // `replacements: 'paired'`) but which still surface as two comments. + // own. Note: this demo runs `replacements: 'independent'`, so both + // sides of a replacement surface as separate change rows and both + // synthetic comment ids land in the dedupe set. Under the default + // `'paired'` mode the collapsed row drops the delete-side id, which + // `trackChanges.list()` does not currently expose — the delete-side + // synthetic comment would leak as a duplicate. Engine follow-up needed + // before this pattern is safe in paired mode. const feed = useMemo(() => { const changeIds = new Set(); - for (const tc of trackChanges.items) { - changeIds.add(tc.id); - if (tc.change.pairedWithChangeId) changeIds.add(tc.change.pairedWithChangeId); - } + for (const tc of trackChanges.items) changeIds.add(tc.id); const items: ActivityItem[] = []; for (const c of comments.items) { if (changeIds.has(c.id)) continue;