@atlaskit/editor-plugin-collab-edit 15.0.6 → 15.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # @atlaskit/editor-plugin-collab-edit
2
2
 
3
+ ## 15.0.8
4
+
5
+ ### Patch Changes
6
+
7
+ - [`1d1cb3e25a787`](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/commits/1d1cb3e25a787) -
8
+ Fix BE-streaming Review moment data loss and chimera diffs: derive coarse review segments as
9
+ closed, whole top-level node slices (mapped consistently between the pre-edit and post-edit doc),
10
+ filter out position-neutral phantom collab touches, and fall back to a safe add-only segment when
11
+ a change relocates content. Behind the platform_editor_backend_review_moment_agent_types gate.
12
+ - Updated dependencies
13
+
14
+ ## 15.0.7
15
+
16
+ ### Patch Changes
17
+
18
+ - Updated dependencies
19
+
3
20
  ## 15.0.6
4
21
 
5
22
  ### Patch Changes
@@ -13,21 +13,47 @@ var REVIEW_MOMENT_AGENT_TYPES_CONFIG = exports.REVIEW_MOMENT_AGENT_TYPES_CONFIG
13
13
 
14
14
  // [CCI-17994] Post Stream Review ("Review moment") recording for BE streaming.
15
15
  //
16
- // Sibling to `getAgentShimmerRanges`: same idea of deriving what the agent wrote
17
- // from a received remote-step batch, but for a DIFFERENT consumer. The shimmer
18
- // only needs the NEW extent of added content (to draw a skeleton), so it drops
19
- // pure deletions and expands to whole blocks. The Review moment instead needs, per
20
- // contiguous change, BOTH the original slice (pre-edit, for undo / "compare with
21
- // original") and the new slice (for redo), AND it must keep deletions (a removed
22
- // paragraph is a reviewable change). So this derives the actual changed extents
23
- // and reconstructs the pre-edit slice from `tr.before`.
16
+ // Sibling to `getAgentShimmerRanges`, but for a different consumer: the shimmer
17
+ // only needs the NEW extent of added content, whereas the Review moment needs, per
18
+ // contiguous change, BOTH the pre-edit slice (for undo) and the new slice (for
19
+ // redo), and must keep deletions. The output is a neutral
20
+ // `AgentRemoteEditReviewData`; `editor-plugin-ai` turns each segment into a coarse
21
+ // `aiContentPositions` entry and reuses the entire FE Review moment pipeline.
24
22
  //
25
- // The output is a neutral `AgentRemoteEditReviewData` (contract in editor-common);
26
- // `editor-plugin-ai`'s session-context plugin turns each segment into a coarse
27
- // `aiContentPositions` entry, reusing the entire FE Review moment pipeline.
23
+ // That pipeline requires every coarse entry to be a CLOSED, whole-node slice
24
+ // occupying exactly `[startPos, endPos]`. If a slice is left OPEN (a partial node,
25
+ // e.g. "…\nD") it drops the node wrapper on reconstruction — losing content on undo
26
+ // (data-loss bug) and bleeding highlights into neighbouring nodes. So we expand
27
+ // every change to whole-node outer boundaries and only emit self-consistent
28
+ // entries (see the group loop below).
28
29
 
29
30
  var clampToDoc = function clampToDoc(doc, pos) {
30
- return Math.min(Math.max(pos, 0), doc.content.size);
31
+ return Math.min(Math.max(pos, 1), doc.content.size);
32
+ };
33
+ var topLevelBlockIndexAt = function topLevelBlockIndexAt(doc, pos) {
34
+ return doc.resolve(clampToDoc(doc, pos)).index(0);
35
+ };
36
+
37
+ // Outer boundaries (before/after tokens) of the top-level node containing `pos`.
38
+ // Slicing between these includes each node's own wrapper tokens, so the slice is
39
+ // CLOSED (`openStart === openEnd === 0`) and preserves node type on reconstruction
40
+ // (a `heading` stays a heading). `depth === 0` means `pos` sits between top-level
41
+ // nodes, so it is already a node boundary.
42
+ // Whether `pos` sits INSIDE a top-level node (depth > 0) vs exactly on a node
43
+ // boundary (depth 0). Unlike `clampToDoc`, this clamps to `[0, size]` (allowing 0)
44
+ // so the doc-start boundary is correctly reported as a boundary, not forced into
45
+ // the first node. Used to tell a structural add/remove (zero-width span at a node
46
+ // boundary) from a text-only insert/delete inside a surviving node.
47
+ var isInsideNode = function isInsideNode(doc, pos) {
48
+ return doc.resolve(Math.min(Math.max(pos, 0), doc.content.size)).depth > 0;
49
+ };
50
+ var topLevelNodeStart = function topLevelNodeStart(doc, pos) {
51
+ var $pos = doc.resolve(clampToDoc(doc, pos));
52
+ return $pos.depth === 0 ? $pos.pos : $pos.before(1);
53
+ };
54
+ var topLevelNodeEnd = function topLevelNodeEnd(doc, pos) {
55
+ var $pos = doc.resolve(clampToDoc(doc, pos));
56
+ return $pos.depth === 0 ? $pos.pos : $pos.after(1);
31
57
  };
32
58
 
33
59
  // Map a position in doc_{i+1} forward through the remaining steps to final-doc
@@ -41,11 +67,31 @@ var mapToFinalDoc = function mapToFinalDoc(steps, pos, stepIndex, bias) {
41
67
  return p;
42
68
  };
43
69
 
70
+ // Map a step's `old` coord (valid in doc_i, this step's input) back to pre-batch
71
+ // (`tr.before`) coords by inverting the PRECEDING steps' maps in reverse. Needed to
72
+ // recover a deletion's original span: its new extent is zero-width, so it cannot be
73
+ // found by inverse-mapping the collapsed new point.
74
+ var mapToBeforeDoc = function mapToBeforeDoc(steps, pos, stepIndex, bias) {
75
+ var p = pos;
76
+ for (var j = stepIndex - 1; j >= 0; j--) {
77
+ p = steps[j].getMap().invert().map(p, bias);
78
+ }
79
+ return p;
80
+ };
81
+
82
+ /**
83
+ * A change region tracked in BOTH coordinate spaces: `[from, to]` in the final doc
84
+ * (`tr.doc`) and `[origFrom, origTo]` in the pre-batch doc (`tr.before`). Carrying
85
+ * the original span explicitly lets a deletion (zero-width final extent) still
86
+ * recover its removed content.
87
+ */
88
+
44
89
  /**
45
90
  * Derive per-change Review moment segments from an agent-authored remote-step
46
- * batch. Returns `null` when there is nothing to record (no agent steps, or a
47
- * rebase invalidated our index math, or derivation threw) so the caller can no-op
48
- * safely — this must never throw into the shared remote-step handler.
91
+ * batch. Returns `null` when there is nothing to record (no agent steps, agentType
92
+ * not allowlisted, a rebase invalidated our index math, or derivation threw) so the
93
+ * caller can no-op safely — this must never throw into the shared remote-step
94
+ * handler.
49
95
  *
50
96
  * @param json the raw received step JSON (carries `agentType` / `agentId` / `userId`)
51
97
  * @param steps the parsed PM steps (index-aligned with `json`)
@@ -96,23 +142,11 @@ var getAgentEditSegments = exports.getAgentEditSegments = function getAgentEditS
96
142
  }
97
143
  }
98
144
  try {
99
- // Compute one segment per step-level StepMap range. Each agent step's changed
100
- // extent is taken from its StepMap (the canonical, step-type-agnostic source).
101
- // Unlike the shimmer we KEEP zero-width new extents — a pure deletion has
102
- // `newEnd === newStart` but is a reviewable `remove`.
103
- //
104
- // We derive the originalSlice/newSlice PER RANGE (per step) rather than
105
- // grouping ranges first and then inverting over a merged range. Grouping
106
- // before slicing corrupts the invert-mapping: when multiple discrete
107
- // operations (e.g. two deleteNode + one insertNodeAfter) are merged into one
108
- // range, mapping that merged range back through the inverted mapping produces
109
- // a chimera — a blend of old and new text that never existed in the document.
110
- //
111
- // The downstream PSR pipeline (mergeOverlappingSegments +
112
- // calculateTopLevelNodeSegments) already handles overlapping/adjacent coarse
113
- // entries correctly, so grouping here is unnecessary.
114
-
115
- var segments = [];
145
+ // Each agent step's changed extent in FINAL-doc coords, taken from its StepMap
146
+ // (the canonical, step-type-agnostic source). Unlike the shimmer we KEEP
147
+ // zero-width new extents — a pure deletion has `newEnd === newStart` but is a
148
+ // reviewable `remove`.
149
+ var ranges = [];
116
150
  json.forEach(function (rawStep, index) {
117
151
  if (typeof (rawStep === null || rawStep === void 0 ? void 0 : rawStep.agentType) !== 'string') {
118
152
  return;
@@ -123,55 +157,121 @@ var getAgentEditSegments = exports.getAgentEditSegments = function getAgentEditS
123
157
  return;
124
158
  }
125
159
  pmStep.getMap().forEach(function (oldStart, oldEnd, newStart, newEnd) {
126
- try {
127
- // NEW-doc coordinates: map this step's newStart/newEnd forward
128
- // through all subsequent steps to get final-doc positions.
129
- var finalFrom = mapToFinalDoc(steps, newStart, index, -1);
130
- var finalTo = mapToFinalDoc(steps, newEnd, index, 1);
131
- var newFrom = clampToDoc(tr.doc, Math.min(finalFrom, finalTo));
132
- var newTo = clampToDoc(tr.doc, Math.max(finalFrom, finalTo));
133
-
134
- // ORIGINAL-doc coordinates: map this step's oldStart/oldEnd
135
- // BACKWARD through all preceding steps to get pre-batch
136
- // (`tr.before`) positions. Each preceding step's StepMap tells
137
- // us how positions shifted; we invert that shift by mapping
138
- // through the inverted StepMap.
139
- //
140
- // This is the key difference from using `tr.mapping.invert()`
141
- // which inverts ALL steps at once — that produces chimera text
142
- // when multiple discrete operations (delete + delete + insert)
143
- // interact in the same region. Per-step backward mapping keeps
144
- // each operation's original range isolated.
145
- var origStart = oldStart;
146
- var origEnd = oldEnd;
147
- for (var j = index - 1; j >= 0; j--) {
148
- var prevMap = steps[j].getMap().invert();
149
- origStart = prevMap.map(origStart, -1);
150
- origEnd = prevMap.map(origEnd, 1);
151
- }
152
- var origFrom = clampToDoc(tr.before, Math.min(origStart, origEnd));
153
- var origTo = clampToDoc(tr.before, Math.max(origStart, origEnd));
154
- var originalSlice = tr.before.slice(origFrom, origTo);
155
- var newSlice = tr.doc.slice(newFrom, newTo);
156
- var originalEmpty = originalSlice.content.size === 0;
157
- var newEmpty = newSlice.content.size === 0;
158
- // A truly empty-to-empty region is not a change — skip it.
159
- if (originalEmpty && newEmpty) {
160
- return;
160
+ var mappedFrom = mapToFinalDoc(steps, newStart, index, -1);
161
+ var mappedTo = mapToFinalDoc(steps, newEnd, index, 1);
162
+ // The same change in pre-batch coords, so a deletion (zero-width new
163
+ // extent) still carries its original span.
164
+ var mappedOrigFrom = mapToBeforeDoc(steps, oldStart, index, -1);
165
+ var mappedOrigTo = mapToBeforeDoc(steps, oldEnd, index, 1);
166
+ // Skip position-neutral phantom touches: the collab apply stamps same-size
167
+ // re-writes on unrelated nodes (e.g. `localId` on panels). Drop them only
168
+ // when byte-identical, so a real same-size replacement is preserved.
169
+ if (oldEnd - oldStart === newEnd - newStart) {
170
+ try {
171
+ var before = tr.before.slice(oldStart, oldEnd);
172
+ var after = tr.doc.slice(mappedFrom, mappedTo);
173
+ if (before.content.eq(after.content)) {
174
+ return;
175
+ }
176
+ } catch (_unused) {
177
+ // Comparison unsafe: keep the range; whole-block expansion + the
178
+ // later identical-content skip still guard against phantoms.
161
179
  }
162
- var kind = originalEmpty ? 'add' : newEmpty ? 'remove' : 'update';
163
- segments.push({
164
- startPos: newFrom,
165
- endPos: newTo,
166
- originalSlice: originalSlice,
167
- newSlice: newSlice,
168
- kind: kind
169
- });
170
- } catch (_unused) {
171
- // One bad range must not drop the others.
172
180
  }
181
+ ranges.push({
182
+ from: mappedFrom,
183
+ to: mappedTo,
184
+ origFrom: mappedOrigFrom,
185
+ origTo: mappedOrigTo
186
+ });
173
187
  });
174
188
  });
189
+ if (!ranges.length) {
190
+ return null;
191
+ }
192
+
193
+ // Coalesce fragments in the same/adjacent top-level block into one region (a
194
+ // single agent edit arrives as many small replace fragments); an untouched block
195
+ // between them splits the run so far-apart edits stay separate. Each region
196
+ // becomes one coarse segment; the AI plugin refines it further.
197
+ var sorted = [].concat(ranges).sort(function (a, b) {
198
+ return a.from - b.from || a.to - b.to;
199
+ });
200
+ var groups = [];
201
+ sorted.forEach(function (range) {
202
+ var block = topLevelBlockIndexAt(tr.doc, range.from);
203
+ var current = groups[groups.length - 1];
204
+ // Coalesce changes in the same or directly-adjacent top-level block (index n /
205
+ // n+1) into one region, exactly like the shimmer — a single agent edit arrives
206
+ // as many small fragments. A genuinely untouched block in between (index gap
207
+ // > 1) splits the run, keeping far-apart edits separate. The original span is
208
+ // unioned in parallel so a deletion's removed content is preserved.
209
+ if (current && block <= current.maxBlock + 1) {
210
+ current.to = Math.max(current.to, range.to);
211
+ current.maxBlock = Math.max(current.maxBlock, block);
212
+ current.origFrom = Math.min(current.origFrom, range.origFrom);
213
+ current.origTo = Math.max(current.origTo, range.origTo);
214
+ } else {
215
+ groups.push({
216
+ from: range.from,
217
+ to: range.to,
218
+ maxBlock: block,
219
+ origFrom: range.origFrom,
220
+ origTo: range.origTo
221
+ });
222
+ }
223
+ });
224
+ var segments = [];
225
+ groups.forEach(function (group) {
226
+ try {
227
+ // Expand the NEW extent to whole-node outer boundaries → a CLOSED slice.
228
+ // A zero-width new extent at a top-level node boundary is a structural
229
+ // deletion: leave it empty (do NOT whole-node expand, or it would grab the
230
+ // unchanged neighbour node that now sits at that point and be discarded as an
231
+ // identical phantom). A zero-width new extent INSIDE a node is a text-only
232
+ // deletion from a surviving node, so expand to the whole node (an `update`).
233
+ var newIsStructuralRemove = group.from === group.to && !isInsideNode(tr.doc, group.from);
234
+ var newFrom = topLevelNodeStart(tr.doc, Math.min(group.from, group.to));
235
+ var newTo = newIsStructuralRemove ? newFrom : topLevelNodeEnd(tr.doc, Math.max(group.from, group.to));
236
+ var newSlice = tr.doc.slice(newFrom, newTo);
237
+
238
+ // Build the ORIGINAL slice from the carried pre-batch span, expanded to whole
239
+ // nodes → a CLOSED slice. Using the carried `origFrom/origTo` (from the
240
+ // StepMap `old` coords) rather than inverse-mapping the new bounds is what
241
+ // lets a deletion recover its removed content.
242
+ //
243
+ // A zero-width original span is only a true structural ADD when it sits at a
244
+ // top-level node boundary (inserting a whole new node). A zero-width span
245
+ // INSIDE a node is a text insertion into that node, so expand to the whole
246
+ // node (an `update`) — matching how an in-place edit reviews.
247
+ var origIsStructuralAdd = group.origFrom === group.origTo && !isInsideNode(tr.before, group.origFrom);
248
+ var origFrom = topLevelNodeStart(tr.before, Math.min(group.origFrom, group.origTo));
249
+ var origTo = origIsStructuralAdd ? origFrom : topLevelNodeEnd(tr.before, Math.max(group.origFrom, group.origTo));
250
+ var originalSlice = tr.before.slice(origFrom, origTo);
251
+ var originalEmpty = originalSlice.content.size === 0;
252
+ var newEmpty = newSlice.content.size === 0;
253
+ // A truly empty-to-empty region is not a change — skip it.
254
+ if (originalEmpty && newEmpty) {
255
+ return;
256
+ }
257
+ // Skip phantom artifacts: a same-size StepMap "touch" at an unrelated node
258
+ // (e.g. a `localId` stamp on a panel during collab apply) expands to a
259
+ // segment whose original and new content are identical — not a real change.
260
+ if (originalSlice.content.eq(newSlice.content)) {
261
+ return;
262
+ }
263
+ var kind = originalEmpty ? 'add' : newEmpty ? 'remove' : 'update';
264
+ segments.push({
265
+ startPos: newFrom,
266
+ endPos: newTo,
267
+ originalSlice: originalSlice,
268
+ newSlice: newSlice,
269
+ kind: kind
270
+ });
271
+ } catch (_unused2) {
272
+ // One bad group must not drop the others.
273
+ }
274
+ });
175
275
  if (!segments.length) {
176
276
  return null;
177
277
  }
@@ -182,7 +282,7 @@ var getAgentEditSegments = exports.getAgentEditSegments = function getAgentEditS
182
282
  complete: complete,
183
283
  segments: segments
184
284
  };
185
- } catch (_unused2) {
285
+ } catch (_unused3) {
186
286
  // Never throw into the shared remote-step handler; degrade to no recording.
187
287
  return null;
188
288
  }
@@ -8,20 +8,42 @@ export const REVIEW_MOMENT_AGENT_TYPES_CONFIG = 'platform_editor_backend_review_
8
8
 
9
9
  // [CCI-17994] Post Stream Review ("Review moment") recording for BE streaming.
10
10
  //
11
- // Sibling to `getAgentShimmerRanges`: same idea of deriving what the agent wrote
12
- // from a received remote-step batch, but for a DIFFERENT consumer. The shimmer
13
- // only needs the NEW extent of added content (to draw a skeleton), so it drops
14
- // pure deletions and expands to whole blocks. The Review moment instead needs, per
15
- // contiguous change, BOTH the original slice (pre-edit, for undo / "compare with
16
- // original") and the new slice (for redo), AND it must keep deletions (a removed
17
- // paragraph is a reviewable change). So this derives the actual changed extents
18
- // and reconstructs the pre-edit slice from `tr.before`.
11
+ // Sibling to `getAgentShimmerRanges`, but for a different consumer: the shimmer
12
+ // only needs the NEW extent of added content, whereas the Review moment needs, per
13
+ // contiguous change, BOTH the pre-edit slice (for undo) and the new slice (for
14
+ // redo), and must keep deletions. The output is a neutral
15
+ // `AgentRemoteEditReviewData`; `editor-plugin-ai` turns each segment into a coarse
16
+ // `aiContentPositions` entry and reuses the entire FE Review moment pipeline.
19
17
  //
20
- // The output is a neutral `AgentRemoteEditReviewData` (contract in editor-common);
21
- // `editor-plugin-ai`'s session-context plugin turns each segment into a coarse
22
- // `aiContentPositions` entry, reusing the entire FE Review moment pipeline.
18
+ // That pipeline requires every coarse entry to be a CLOSED, whole-node slice
19
+ // occupying exactly `[startPos, endPos]`. If a slice is left OPEN (a partial node,
20
+ // e.g. "…\nD") it drops the node wrapper on reconstruction — losing content on undo
21
+ // (data-loss bug) and bleeding highlights into neighbouring nodes. So we expand
22
+ // every change to whole-node outer boundaries and only emit self-consistent
23
+ // entries (see the group loop below).
23
24
 
24
- const clampToDoc = (doc, pos) => Math.min(Math.max(pos, 0), doc.content.size);
25
+ const clampToDoc = (doc, pos) => Math.min(Math.max(pos, 1), doc.content.size);
26
+ const topLevelBlockIndexAt = (doc, pos) => doc.resolve(clampToDoc(doc, pos)).index(0);
27
+
28
+ // Outer boundaries (before/after tokens) of the top-level node containing `pos`.
29
+ // Slicing between these includes each node's own wrapper tokens, so the slice is
30
+ // CLOSED (`openStart === openEnd === 0`) and preserves node type on reconstruction
31
+ // (a `heading` stays a heading). `depth === 0` means `pos` sits between top-level
32
+ // nodes, so it is already a node boundary.
33
+ // Whether `pos` sits INSIDE a top-level node (depth > 0) vs exactly on a node
34
+ // boundary (depth 0). Unlike `clampToDoc`, this clamps to `[0, size]` (allowing 0)
35
+ // so the doc-start boundary is correctly reported as a boundary, not forced into
36
+ // the first node. Used to tell a structural add/remove (zero-width span at a node
37
+ // boundary) from a text-only insert/delete inside a surviving node.
38
+ const isInsideNode = (doc, pos) => doc.resolve(Math.min(Math.max(pos, 0), doc.content.size)).depth > 0;
39
+ const topLevelNodeStart = (doc, pos) => {
40
+ const $pos = doc.resolve(clampToDoc(doc, pos));
41
+ return $pos.depth === 0 ? $pos.pos : $pos.before(1);
42
+ };
43
+ const topLevelNodeEnd = (doc, pos) => {
44
+ const $pos = doc.resolve(clampToDoc(doc, pos));
45
+ return $pos.depth === 0 ? $pos.pos : $pos.after(1);
46
+ };
25
47
 
26
48
  // Map a position in doc_{i+1} forward through the remaining steps to final-doc
27
49
  // coords. Mirrors the shimmer's `mapToFinalDoc` — a later step's own inserted
@@ -34,11 +56,31 @@ const mapToFinalDoc = (steps, pos, stepIndex, bias) => {
34
56
  return p;
35
57
  };
36
58
 
59
+ // Map a step's `old` coord (valid in doc_i, this step's input) back to pre-batch
60
+ // (`tr.before`) coords by inverting the PRECEDING steps' maps in reverse. Needed to
61
+ // recover a deletion's original span: its new extent is zero-width, so it cannot be
62
+ // found by inverse-mapping the collapsed new point.
63
+ const mapToBeforeDoc = (steps, pos, stepIndex, bias) => {
64
+ let p = pos;
65
+ for (let j = stepIndex - 1; j >= 0; j--) {
66
+ p = steps[j].getMap().invert().map(p, bias);
67
+ }
68
+ return p;
69
+ };
70
+
71
+ /**
72
+ * A change region tracked in BOTH coordinate spaces: `[from, to]` in the final doc
73
+ * (`tr.doc`) and `[origFrom, origTo]` in the pre-batch doc (`tr.before`). Carrying
74
+ * the original span explicitly lets a deletion (zero-width final extent) still
75
+ * recover its removed content.
76
+ */
77
+
37
78
  /**
38
79
  * Derive per-change Review moment segments from an agent-authored remote-step
39
- * batch. Returns `null` when there is nothing to record (no agent steps, or a
40
- * rebase invalidated our index math, or derivation threw) so the caller can no-op
41
- * safely — this must never throw into the shared remote-step handler.
80
+ * batch. Returns `null` when there is nothing to record (no agent steps, agentType
81
+ * not allowlisted, a rebase invalidated our index math, or derivation threw) so the
82
+ * caller can no-op safely — this must never throw into the shared remote-step
83
+ * handler.
42
84
  *
43
85
  * @param json the raw received step JSON (carries `agentType` / `agentId` / `userId`)
44
86
  * @param steps the parsed PM steps (index-aligned with `json`)
@@ -83,23 +125,11 @@ export const getAgentEditSegments = (json, steps, tr, view) => {
83
125
  }
84
126
  }
85
127
  try {
86
- // Compute one segment per step-level StepMap range. Each agent step's changed
87
- // extent is taken from its StepMap (the canonical, step-type-agnostic source).
88
- // Unlike the shimmer we KEEP zero-width new extents — a pure deletion has
89
- // `newEnd === newStart` but is a reviewable `remove`.
90
- //
91
- // We derive the originalSlice/newSlice PER RANGE (per step) rather than
92
- // grouping ranges first and then inverting over a merged range. Grouping
93
- // before slicing corrupts the invert-mapping: when multiple discrete
94
- // operations (e.g. two deleteNode + one insertNodeAfter) are merged into one
95
- // range, mapping that merged range back through the inverted mapping produces
96
- // a chimera — a blend of old and new text that never existed in the document.
97
- //
98
- // The downstream PSR pipeline (mergeOverlappingSegments +
99
- // calculateTopLevelNodeSegments) already handles overlapping/adjacent coarse
100
- // entries correctly, so grouping here is unnecessary.
101
-
102
- const segments = [];
128
+ // Each agent step's changed extent in FINAL-doc coords, taken from its StepMap
129
+ // (the canonical, step-type-agnostic source). Unlike the shimmer we KEEP
130
+ // zero-width new extents — a pure deletion has `newEnd === newStart` but is a
131
+ // reviewable `remove`.
132
+ const ranges = [];
103
133
  json.forEach((rawStep, index) => {
104
134
  if (typeof (rawStep === null || rawStep === void 0 ? void 0 : rawStep.agentType) !== 'string') {
105
135
  return;
@@ -110,55 +140,119 @@ export const getAgentEditSegments = (json, steps, tr, view) => {
110
140
  return;
111
141
  }
112
142
  pmStep.getMap().forEach((oldStart, oldEnd, newStart, newEnd) => {
113
- try {
114
- // NEW-doc coordinates: map this step's newStart/newEnd forward
115
- // through all subsequent steps to get final-doc positions.
116
- const finalFrom = mapToFinalDoc(steps, newStart, index, -1);
117
- const finalTo = mapToFinalDoc(steps, newEnd, index, 1);
118
- const newFrom = clampToDoc(tr.doc, Math.min(finalFrom, finalTo));
119
- const newTo = clampToDoc(tr.doc, Math.max(finalFrom, finalTo));
120
-
121
- // ORIGINAL-doc coordinates: map this step's oldStart/oldEnd
122
- // BACKWARD through all preceding steps to get pre-batch
123
- // (`tr.before`) positions. Each preceding step's StepMap tells
124
- // us how positions shifted; we invert that shift by mapping
125
- // through the inverted StepMap.
126
- //
127
- // This is the key difference from using `tr.mapping.invert()`
128
- // which inverts ALL steps at once — that produces chimera text
129
- // when multiple discrete operations (delete + delete + insert)
130
- // interact in the same region. Per-step backward mapping keeps
131
- // each operation's original range isolated.
132
- let origStart = oldStart;
133
- let origEnd = oldEnd;
134
- for (let j = index - 1; j >= 0; j--) {
135
- const prevMap = steps[j].getMap().invert();
136
- origStart = prevMap.map(origStart, -1);
137
- origEnd = prevMap.map(origEnd, 1);
138
- }
139
- const origFrom = clampToDoc(tr.before, Math.min(origStart, origEnd));
140
- const origTo = clampToDoc(tr.before, Math.max(origStart, origEnd));
141
- const originalSlice = tr.before.slice(origFrom, origTo);
142
- const newSlice = tr.doc.slice(newFrom, newTo);
143
- const originalEmpty = originalSlice.content.size === 0;
144
- const newEmpty = newSlice.content.size === 0;
145
- // A truly empty-to-empty region is not a change — skip it.
146
- if (originalEmpty && newEmpty) {
147
- return;
143
+ const mappedFrom = mapToFinalDoc(steps, newStart, index, -1);
144
+ const mappedTo = mapToFinalDoc(steps, newEnd, index, 1);
145
+ // The same change in pre-batch coords, so a deletion (zero-width new
146
+ // extent) still carries its original span.
147
+ const mappedOrigFrom = mapToBeforeDoc(steps, oldStart, index, -1);
148
+ const mappedOrigTo = mapToBeforeDoc(steps, oldEnd, index, 1);
149
+ // Skip position-neutral phantom touches: the collab apply stamps same-size
150
+ // re-writes on unrelated nodes (e.g. `localId` on panels). Drop them only
151
+ // when byte-identical, so a real same-size replacement is preserved.
152
+ if (oldEnd - oldStart === newEnd - newStart) {
153
+ try {
154
+ const before = tr.before.slice(oldStart, oldEnd);
155
+ const after = tr.doc.slice(mappedFrom, mappedTo);
156
+ if (before.content.eq(after.content)) {
157
+ return;
158
+ }
159
+ } catch {
160
+ // Comparison unsafe: keep the range; whole-block expansion + the
161
+ // later identical-content skip still guard against phantoms.
148
162
  }
149
- const kind = originalEmpty ? 'add' : newEmpty ? 'remove' : 'update';
150
- segments.push({
151
- startPos: newFrom,
152
- endPos: newTo,
153
- originalSlice,
154
- newSlice,
155
- kind
156
- });
157
- } catch {
158
- // One bad range must not drop the others.
159
163
  }
164
+ ranges.push({
165
+ from: mappedFrom,
166
+ to: mappedTo,
167
+ origFrom: mappedOrigFrom,
168
+ origTo: mappedOrigTo
169
+ });
160
170
  });
161
171
  });
172
+ if (!ranges.length) {
173
+ return null;
174
+ }
175
+
176
+ // Coalesce fragments in the same/adjacent top-level block into one region (a
177
+ // single agent edit arrives as many small replace fragments); an untouched block
178
+ // between them splits the run so far-apart edits stay separate. Each region
179
+ // becomes one coarse segment; the AI plugin refines it further.
180
+ const sorted = [...ranges].sort((a, b) => a.from - b.from || a.to - b.to);
181
+ const groups = [];
182
+ sorted.forEach(range => {
183
+ const block = topLevelBlockIndexAt(tr.doc, range.from);
184
+ const current = groups[groups.length - 1];
185
+ // Coalesce changes in the same or directly-adjacent top-level block (index n /
186
+ // n+1) into one region, exactly like the shimmer — a single agent edit arrives
187
+ // as many small fragments. A genuinely untouched block in between (index gap
188
+ // > 1) splits the run, keeping far-apart edits separate. The original span is
189
+ // unioned in parallel so a deletion's removed content is preserved.
190
+ if (current && block <= current.maxBlock + 1) {
191
+ current.to = Math.max(current.to, range.to);
192
+ current.maxBlock = Math.max(current.maxBlock, block);
193
+ current.origFrom = Math.min(current.origFrom, range.origFrom);
194
+ current.origTo = Math.max(current.origTo, range.origTo);
195
+ } else {
196
+ groups.push({
197
+ from: range.from,
198
+ to: range.to,
199
+ maxBlock: block,
200
+ origFrom: range.origFrom,
201
+ origTo: range.origTo
202
+ });
203
+ }
204
+ });
205
+ const segments = [];
206
+ groups.forEach(group => {
207
+ try {
208
+ // Expand the NEW extent to whole-node outer boundaries → a CLOSED slice.
209
+ // A zero-width new extent at a top-level node boundary is a structural
210
+ // deletion: leave it empty (do NOT whole-node expand, or it would grab the
211
+ // unchanged neighbour node that now sits at that point and be discarded as an
212
+ // identical phantom). A zero-width new extent INSIDE a node is a text-only
213
+ // deletion from a surviving node, so expand to the whole node (an `update`).
214
+ const newIsStructuralRemove = group.from === group.to && !isInsideNode(tr.doc, group.from);
215
+ const newFrom = topLevelNodeStart(tr.doc, Math.min(group.from, group.to));
216
+ const newTo = newIsStructuralRemove ? newFrom : topLevelNodeEnd(tr.doc, Math.max(group.from, group.to));
217
+ const newSlice = tr.doc.slice(newFrom, newTo);
218
+
219
+ // Build the ORIGINAL slice from the carried pre-batch span, expanded to whole
220
+ // nodes → a CLOSED slice. Using the carried `origFrom/origTo` (from the
221
+ // StepMap `old` coords) rather than inverse-mapping the new bounds is what
222
+ // lets a deletion recover its removed content.
223
+ //
224
+ // A zero-width original span is only a true structural ADD when it sits at a
225
+ // top-level node boundary (inserting a whole new node). A zero-width span
226
+ // INSIDE a node is a text insertion into that node, so expand to the whole
227
+ // node (an `update`) — matching how an in-place edit reviews.
228
+ const origIsStructuralAdd = group.origFrom === group.origTo && !isInsideNode(tr.before, group.origFrom);
229
+ const origFrom = topLevelNodeStart(tr.before, Math.min(group.origFrom, group.origTo));
230
+ const origTo = origIsStructuralAdd ? origFrom : topLevelNodeEnd(tr.before, Math.max(group.origFrom, group.origTo));
231
+ const originalSlice = tr.before.slice(origFrom, origTo);
232
+ const originalEmpty = originalSlice.content.size === 0;
233
+ const newEmpty = newSlice.content.size === 0;
234
+ // A truly empty-to-empty region is not a change — skip it.
235
+ if (originalEmpty && newEmpty) {
236
+ return;
237
+ }
238
+ // Skip phantom artifacts: a same-size StepMap "touch" at an unrelated node
239
+ // (e.g. a `localId` stamp on a panel during collab apply) expands to a
240
+ // segment whose original and new content are identical — not a real change.
241
+ if (originalSlice.content.eq(newSlice.content)) {
242
+ return;
243
+ }
244
+ const kind = originalEmpty ? 'add' : newEmpty ? 'remove' : 'update';
245
+ segments.push({
246
+ startPos: newFrom,
247
+ endPos: newTo,
248
+ originalSlice,
249
+ newSlice,
250
+ kind
251
+ });
252
+ } catch {
253
+ // One bad group must not drop the others.
254
+ }
255
+ });
162
256
  if (!segments.length) {
163
257
  return null;
164
258
  }
@@ -8,21 +8,47 @@ export var REVIEW_MOMENT_AGENT_TYPES_CONFIG = 'platform_editor_backend_review_mo
8
8
 
9
9
  // [CCI-17994] Post Stream Review ("Review moment") recording for BE streaming.
10
10
  //
11
- // Sibling to `getAgentShimmerRanges`: same idea of deriving what the agent wrote
12
- // from a received remote-step batch, but for a DIFFERENT consumer. The shimmer
13
- // only needs the NEW extent of added content (to draw a skeleton), so it drops
14
- // pure deletions and expands to whole blocks. The Review moment instead needs, per
15
- // contiguous change, BOTH the original slice (pre-edit, for undo / "compare with
16
- // original") and the new slice (for redo), AND it must keep deletions (a removed
17
- // paragraph is a reviewable change). So this derives the actual changed extents
18
- // and reconstructs the pre-edit slice from `tr.before`.
11
+ // Sibling to `getAgentShimmerRanges`, but for a different consumer: the shimmer
12
+ // only needs the NEW extent of added content, whereas the Review moment needs, per
13
+ // contiguous change, BOTH the pre-edit slice (for undo) and the new slice (for
14
+ // redo), and must keep deletions. The output is a neutral
15
+ // `AgentRemoteEditReviewData`; `editor-plugin-ai` turns each segment into a coarse
16
+ // `aiContentPositions` entry and reuses the entire FE Review moment pipeline.
19
17
  //
20
- // The output is a neutral `AgentRemoteEditReviewData` (contract in editor-common);
21
- // `editor-plugin-ai`'s session-context plugin turns each segment into a coarse
22
- // `aiContentPositions` entry, reusing the entire FE Review moment pipeline.
18
+ // That pipeline requires every coarse entry to be a CLOSED, whole-node slice
19
+ // occupying exactly `[startPos, endPos]`. If a slice is left OPEN (a partial node,
20
+ // e.g. "…\nD") it drops the node wrapper on reconstruction — losing content on undo
21
+ // (data-loss bug) and bleeding highlights into neighbouring nodes. So we expand
22
+ // every change to whole-node outer boundaries and only emit self-consistent
23
+ // entries (see the group loop below).
23
24
 
24
25
  var clampToDoc = function clampToDoc(doc, pos) {
25
- return Math.min(Math.max(pos, 0), doc.content.size);
26
+ return Math.min(Math.max(pos, 1), doc.content.size);
27
+ };
28
+ var topLevelBlockIndexAt = function topLevelBlockIndexAt(doc, pos) {
29
+ return doc.resolve(clampToDoc(doc, pos)).index(0);
30
+ };
31
+
32
+ // Outer boundaries (before/after tokens) of the top-level node containing `pos`.
33
+ // Slicing between these includes each node's own wrapper tokens, so the slice is
34
+ // CLOSED (`openStart === openEnd === 0`) and preserves node type on reconstruction
35
+ // (a `heading` stays a heading). `depth === 0` means `pos` sits between top-level
36
+ // nodes, so it is already a node boundary.
37
+ // Whether `pos` sits INSIDE a top-level node (depth > 0) vs exactly on a node
38
+ // boundary (depth 0). Unlike `clampToDoc`, this clamps to `[0, size]` (allowing 0)
39
+ // so the doc-start boundary is correctly reported as a boundary, not forced into
40
+ // the first node. Used to tell a structural add/remove (zero-width span at a node
41
+ // boundary) from a text-only insert/delete inside a surviving node.
42
+ var isInsideNode = function isInsideNode(doc, pos) {
43
+ return doc.resolve(Math.min(Math.max(pos, 0), doc.content.size)).depth > 0;
44
+ };
45
+ var topLevelNodeStart = function topLevelNodeStart(doc, pos) {
46
+ var $pos = doc.resolve(clampToDoc(doc, pos));
47
+ return $pos.depth === 0 ? $pos.pos : $pos.before(1);
48
+ };
49
+ var topLevelNodeEnd = function topLevelNodeEnd(doc, pos) {
50
+ var $pos = doc.resolve(clampToDoc(doc, pos));
51
+ return $pos.depth === 0 ? $pos.pos : $pos.after(1);
26
52
  };
27
53
 
28
54
  // Map a position in doc_{i+1} forward through the remaining steps to final-doc
@@ -36,11 +62,31 @@ var mapToFinalDoc = function mapToFinalDoc(steps, pos, stepIndex, bias) {
36
62
  return p;
37
63
  };
38
64
 
65
+ // Map a step's `old` coord (valid in doc_i, this step's input) back to pre-batch
66
+ // (`tr.before`) coords by inverting the PRECEDING steps' maps in reverse. Needed to
67
+ // recover a deletion's original span: its new extent is zero-width, so it cannot be
68
+ // found by inverse-mapping the collapsed new point.
69
+ var mapToBeforeDoc = function mapToBeforeDoc(steps, pos, stepIndex, bias) {
70
+ var p = pos;
71
+ for (var j = stepIndex - 1; j >= 0; j--) {
72
+ p = steps[j].getMap().invert().map(p, bias);
73
+ }
74
+ return p;
75
+ };
76
+
77
+ /**
78
+ * A change region tracked in BOTH coordinate spaces: `[from, to]` in the final doc
79
+ * (`tr.doc`) and `[origFrom, origTo]` in the pre-batch doc (`tr.before`). Carrying
80
+ * the original span explicitly lets a deletion (zero-width final extent) still
81
+ * recover its removed content.
82
+ */
83
+
39
84
  /**
40
85
  * Derive per-change Review moment segments from an agent-authored remote-step
41
- * batch. Returns `null` when there is nothing to record (no agent steps, or a
42
- * rebase invalidated our index math, or derivation threw) so the caller can no-op
43
- * safely — this must never throw into the shared remote-step handler.
86
+ * batch. Returns `null` when there is nothing to record (no agent steps, agentType
87
+ * not allowlisted, a rebase invalidated our index math, or derivation threw) so the
88
+ * caller can no-op safely — this must never throw into the shared remote-step
89
+ * handler.
44
90
  *
45
91
  * @param json the raw received step JSON (carries `agentType` / `agentId` / `userId`)
46
92
  * @param steps the parsed PM steps (index-aligned with `json`)
@@ -91,23 +137,11 @@ export var getAgentEditSegments = function getAgentEditSegments(json, steps, tr,
91
137
  }
92
138
  }
93
139
  try {
94
- // Compute one segment per step-level StepMap range. Each agent step's changed
95
- // extent is taken from its StepMap (the canonical, step-type-agnostic source).
96
- // Unlike the shimmer we KEEP zero-width new extents — a pure deletion has
97
- // `newEnd === newStart` but is a reviewable `remove`.
98
- //
99
- // We derive the originalSlice/newSlice PER RANGE (per step) rather than
100
- // grouping ranges first and then inverting over a merged range. Grouping
101
- // before slicing corrupts the invert-mapping: when multiple discrete
102
- // operations (e.g. two deleteNode + one insertNodeAfter) are merged into one
103
- // range, mapping that merged range back through the inverted mapping produces
104
- // a chimera — a blend of old and new text that never existed in the document.
105
- //
106
- // The downstream PSR pipeline (mergeOverlappingSegments +
107
- // calculateTopLevelNodeSegments) already handles overlapping/adjacent coarse
108
- // entries correctly, so grouping here is unnecessary.
109
-
110
- var segments = [];
140
+ // Each agent step's changed extent in FINAL-doc coords, taken from its StepMap
141
+ // (the canonical, step-type-agnostic source). Unlike the shimmer we KEEP
142
+ // zero-width new extents — a pure deletion has `newEnd === newStart` but is a
143
+ // reviewable `remove`.
144
+ var ranges = [];
111
145
  json.forEach(function (rawStep, index) {
112
146
  if (typeof (rawStep === null || rawStep === void 0 ? void 0 : rawStep.agentType) !== 'string') {
113
147
  return;
@@ -118,55 +152,121 @@ export var getAgentEditSegments = function getAgentEditSegments(json, steps, tr,
118
152
  return;
119
153
  }
120
154
  pmStep.getMap().forEach(function (oldStart, oldEnd, newStart, newEnd) {
121
- try {
122
- // NEW-doc coordinates: map this step's newStart/newEnd forward
123
- // through all subsequent steps to get final-doc positions.
124
- var finalFrom = mapToFinalDoc(steps, newStart, index, -1);
125
- var finalTo = mapToFinalDoc(steps, newEnd, index, 1);
126
- var newFrom = clampToDoc(tr.doc, Math.min(finalFrom, finalTo));
127
- var newTo = clampToDoc(tr.doc, Math.max(finalFrom, finalTo));
128
-
129
- // ORIGINAL-doc coordinates: map this step's oldStart/oldEnd
130
- // BACKWARD through all preceding steps to get pre-batch
131
- // (`tr.before`) positions. Each preceding step's StepMap tells
132
- // us how positions shifted; we invert that shift by mapping
133
- // through the inverted StepMap.
134
- //
135
- // This is the key difference from using `tr.mapping.invert()`
136
- // which inverts ALL steps at once — that produces chimera text
137
- // when multiple discrete operations (delete + delete + insert)
138
- // interact in the same region. Per-step backward mapping keeps
139
- // each operation's original range isolated.
140
- var origStart = oldStart;
141
- var origEnd = oldEnd;
142
- for (var j = index - 1; j >= 0; j--) {
143
- var prevMap = steps[j].getMap().invert();
144
- origStart = prevMap.map(origStart, -1);
145
- origEnd = prevMap.map(origEnd, 1);
146
- }
147
- var origFrom = clampToDoc(tr.before, Math.min(origStart, origEnd));
148
- var origTo = clampToDoc(tr.before, Math.max(origStart, origEnd));
149
- var originalSlice = tr.before.slice(origFrom, origTo);
150
- var newSlice = tr.doc.slice(newFrom, newTo);
151
- var originalEmpty = originalSlice.content.size === 0;
152
- var newEmpty = newSlice.content.size === 0;
153
- // A truly empty-to-empty region is not a change — skip it.
154
- if (originalEmpty && newEmpty) {
155
- return;
155
+ var mappedFrom = mapToFinalDoc(steps, newStart, index, -1);
156
+ var mappedTo = mapToFinalDoc(steps, newEnd, index, 1);
157
+ // The same change in pre-batch coords, so a deletion (zero-width new
158
+ // extent) still carries its original span.
159
+ var mappedOrigFrom = mapToBeforeDoc(steps, oldStart, index, -1);
160
+ var mappedOrigTo = mapToBeforeDoc(steps, oldEnd, index, 1);
161
+ // Skip position-neutral phantom touches: the collab apply stamps same-size
162
+ // re-writes on unrelated nodes (e.g. `localId` on panels). Drop them only
163
+ // when byte-identical, so a real same-size replacement is preserved.
164
+ if (oldEnd - oldStart === newEnd - newStart) {
165
+ try {
166
+ var before = tr.before.slice(oldStart, oldEnd);
167
+ var after = tr.doc.slice(mappedFrom, mappedTo);
168
+ if (before.content.eq(after.content)) {
169
+ return;
170
+ }
171
+ } catch (_unused) {
172
+ // Comparison unsafe: keep the range; whole-block expansion + the
173
+ // later identical-content skip still guard against phantoms.
156
174
  }
157
- var kind = originalEmpty ? 'add' : newEmpty ? 'remove' : 'update';
158
- segments.push({
159
- startPos: newFrom,
160
- endPos: newTo,
161
- originalSlice: originalSlice,
162
- newSlice: newSlice,
163
- kind: kind
164
- });
165
- } catch (_unused) {
166
- // One bad range must not drop the others.
167
175
  }
176
+ ranges.push({
177
+ from: mappedFrom,
178
+ to: mappedTo,
179
+ origFrom: mappedOrigFrom,
180
+ origTo: mappedOrigTo
181
+ });
168
182
  });
169
183
  });
184
+ if (!ranges.length) {
185
+ return null;
186
+ }
187
+
188
+ // Coalesce fragments in the same/adjacent top-level block into one region (a
189
+ // single agent edit arrives as many small replace fragments); an untouched block
190
+ // between them splits the run so far-apart edits stay separate. Each region
191
+ // becomes one coarse segment; the AI plugin refines it further.
192
+ var sorted = [].concat(ranges).sort(function (a, b) {
193
+ return a.from - b.from || a.to - b.to;
194
+ });
195
+ var groups = [];
196
+ sorted.forEach(function (range) {
197
+ var block = topLevelBlockIndexAt(tr.doc, range.from);
198
+ var current = groups[groups.length - 1];
199
+ // Coalesce changes in the same or directly-adjacent top-level block (index n /
200
+ // n+1) into one region, exactly like the shimmer — a single agent edit arrives
201
+ // as many small fragments. A genuinely untouched block in between (index gap
202
+ // > 1) splits the run, keeping far-apart edits separate. The original span is
203
+ // unioned in parallel so a deletion's removed content is preserved.
204
+ if (current && block <= current.maxBlock + 1) {
205
+ current.to = Math.max(current.to, range.to);
206
+ current.maxBlock = Math.max(current.maxBlock, block);
207
+ current.origFrom = Math.min(current.origFrom, range.origFrom);
208
+ current.origTo = Math.max(current.origTo, range.origTo);
209
+ } else {
210
+ groups.push({
211
+ from: range.from,
212
+ to: range.to,
213
+ maxBlock: block,
214
+ origFrom: range.origFrom,
215
+ origTo: range.origTo
216
+ });
217
+ }
218
+ });
219
+ var segments = [];
220
+ groups.forEach(function (group) {
221
+ try {
222
+ // Expand the NEW extent to whole-node outer boundaries → a CLOSED slice.
223
+ // A zero-width new extent at a top-level node boundary is a structural
224
+ // deletion: leave it empty (do NOT whole-node expand, or it would grab the
225
+ // unchanged neighbour node that now sits at that point and be discarded as an
226
+ // identical phantom). A zero-width new extent INSIDE a node is a text-only
227
+ // deletion from a surviving node, so expand to the whole node (an `update`).
228
+ var newIsStructuralRemove = group.from === group.to && !isInsideNode(tr.doc, group.from);
229
+ var newFrom = topLevelNodeStart(tr.doc, Math.min(group.from, group.to));
230
+ var newTo = newIsStructuralRemove ? newFrom : topLevelNodeEnd(tr.doc, Math.max(group.from, group.to));
231
+ var newSlice = tr.doc.slice(newFrom, newTo);
232
+
233
+ // Build the ORIGINAL slice from the carried pre-batch span, expanded to whole
234
+ // nodes → a CLOSED slice. Using the carried `origFrom/origTo` (from the
235
+ // StepMap `old` coords) rather than inverse-mapping the new bounds is what
236
+ // lets a deletion recover its removed content.
237
+ //
238
+ // A zero-width original span is only a true structural ADD when it sits at a
239
+ // top-level node boundary (inserting a whole new node). A zero-width span
240
+ // INSIDE a node is a text insertion into that node, so expand to the whole
241
+ // node (an `update`) — matching how an in-place edit reviews.
242
+ var origIsStructuralAdd = group.origFrom === group.origTo && !isInsideNode(tr.before, group.origFrom);
243
+ var origFrom = topLevelNodeStart(tr.before, Math.min(group.origFrom, group.origTo));
244
+ var origTo = origIsStructuralAdd ? origFrom : topLevelNodeEnd(tr.before, Math.max(group.origFrom, group.origTo));
245
+ var originalSlice = tr.before.slice(origFrom, origTo);
246
+ var originalEmpty = originalSlice.content.size === 0;
247
+ var newEmpty = newSlice.content.size === 0;
248
+ // A truly empty-to-empty region is not a change — skip it.
249
+ if (originalEmpty && newEmpty) {
250
+ return;
251
+ }
252
+ // Skip phantom artifacts: a same-size StepMap "touch" at an unrelated node
253
+ // (e.g. a `localId` stamp on a panel during collab apply) expands to a
254
+ // segment whose original and new content are identical — not a real change.
255
+ if (originalSlice.content.eq(newSlice.content)) {
256
+ return;
257
+ }
258
+ var kind = originalEmpty ? 'add' : newEmpty ? 'remove' : 'update';
259
+ segments.push({
260
+ startPos: newFrom,
261
+ endPos: newTo,
262
+ originalSlice: originalSlice,
263
+ newSlice: newSlice,
264
+ kind: kind
265
+ });
266
+ } catch (_unused2) {
267
+ // One bad group must not drop the others.
268
+ }
269
+ });
170
270
  if (!segments.length) {
171
271
  return null;
172
272
  }
@@ -177,7 +277,7 @@ export var getAgentEditSegments = function getAgentEditSegments(json, steps, tr,
177
277
  complete: complete,
178
278
  segments: segments
179
279
  };
180
- } catch (_unused2) {
280
+ } catch (_unused3) {
181
281
  // Never throw into the shared remote-step handler; degrade to no recording.
182
282
  return null;
183
283
  }
@@ -5,9 +5,10 @@ import type { EditorView } from '@atlaskit/editor-prosemirror/view';
5
5
  export declare const REVIEW_MOMENT_AGENT_TYPES_CONFIG = "platform_editor_backend_review_moment_agent_types";
6
6
  /**
7
7
  * Derive per-change Review moment segments from an agent-authored remote-step
8
- * batch. Returns `null` when there is nothing to record (no agent steps, or a
9
- * rebase invalidated our index math, or derivation threw) so the caller can no-op
10
- * safely — this must never throw into the shared remote-step handler.
8
+ * batch. Returns `null` when there is nothing to record (no agent steps, agentType
9
+ * not allowlisted, a rebase invalidated our index math, or derivation threw) so the
10
+ * caller can no-op safely — this must never throw into the shared remote-step
11
+ * handler.
11
12
  *
12
13
  * @param json the raw received step JSON (carries `agentType` / `agentId` / `userId`)
13
14
  * @param steps the parsed PM steps (index-aligned with `json`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atlaskit/editor-plugin-collab-edit",
3
- "version": "15.0.6",
3
+ "version": "15.0.8",
4
4
  "description": "Collab Edit plugin for @atlaskit/editor-core",
5
5
  "author": "Atlassian Pty Ltd",
6
6
  "license": "Apache-2.0",
@@ -32,13 +32,13 @@
32
32
  "@atlaskit/platform-feature-experiments": "^0.3.0",
33
33
  "@atlaskit/platform-feature-flags": "^2.1.0",
34
34
  "@atlaskit/prosemirror-collab": "^1.0.0",
35
- "@atlaskit/tmp-editor-statsig": "^146.0.0",
36
- "@atlaskit/tokens": "^16.6.0",
35
+ "@atlaskit/tmp-editor-statsig": "^147.0.0",
36
+ "@atlaskit/tokens": "^16.7.0",
37
37
  "@babel/runtime": "^7.0.0",
38
38
  "memoize-one": "^6.0.0"
39
39
  },
40
40
  "peerDependencies": {
41
- "@atlaskit/editor-common": "^118.4.0",
41
+ "@atlaskit/editor-common": "^118.10.0",
42
42
  "react": "^18.2.0 || ^19.2.0",
43
43
  "react-dom": "^18.2.0 || ^19.2.0"
44
44
  },