@atlaskit/editor-plugin-collab-edit 15.0.7 → 16.0.0

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,33 @@
1
1
  # @atlaskit/editor-plugin-collab-edit
2
2
 
3
+ ## 16.0.0
4
+
5
+ ### Patch Changes
6
+
7
+ - [`ddb1f3da1e991`](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/commits/ddb1f3da1e991) -
8
+ Track acceptance rate for backend-persisted agent edits. These arrive as remote collab steps
9
+ rather than frontend-streamed transformations, so they previously started no analytics session and
10
+ were absent from the metric. They now reuse the existing survival definition, and are recorded
11
+ only on the requesting user's client.
12
+
13
+ Gated by `platform_editor_agent_be_streaming`, which controls whether remote agent steps are
14
+ annotated at all, and by the agent type allowlist in
15
+ `platform_editor_backend_review_moment_agent_types`, which defaults to empty. Recording cannot
16
+ occur unless both permit it.
17
+
18
+ - Updated dependencies
19
+
20
+ ## 15.0.8
21
+
22
+ ### Patch Changes
23
+
24
+ - [`1d1cb3e25a787`](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/commits/1d1cb3e25a787) -
25
+ Fix BE-streaming Review moment data loss and chimera diffs: derive coarse review segments as
26
+ closed, whole top-level node slices (mapped consistently between the pre-edit and post-edit doc),
27
+ filter out position-neutral phantom collab touches, and fall back to a safe add-only segment when
28
+ a change relocates content. Behind the platform_editor_backend_review_moment_agent_types gate.
29
+ - Updated dependencies
30
+
3
31
  ## 15.0.7
4
32
 
5
33
  ### Patch Changes
@@ -7,27 +7,54 @@ exports.getAgentEditSegments = exports.REVIEW_MOMENT_AGENT_TYPES_CONFIG = void 0
7
7
  var _expVal = require("@atlaskit/platform-feature-experiments/exp-val");
8
8
  var _prosemirrorCollab = require("@atlaskit/prosemirror-collab");
9
9
  var _agentShimmerRanges = require("./agent-shimmer-ranges");
10
+ var _pluginKey = require("./plugin-key");
10
11
  // Statsig dynamic config: which BE-streaming `agentType` values may open Review Moment.
11
12
  // Code default is `[]` (fail closed) until the list is set in Statsig.
12
13
  var REVIEW_MOMENT_AGENT_TYPES_CONFIG = exports.REVIEW_MOMENT_AGENT_TYPES_CONFIG = 'platform_editor_backend_review_moment_agent_types';
13
14
 
14
15
  // [CCI-17994] Post Stream Review ("Review moment") recording for BE streaming.
15
16
  //
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`.
17
+ // Sibling to `getAgentShimmerRanges`, but for a different consumer: the shimmer
18
+ // only needs the NEW extent of added content, whereas the Review moment needs, per
19
+ // contiguous change, BOTH the pre-edit slice (for undo) and the new slice (for
20
+ // redo), and must keep deletions. The output is a neutral
21
+ // `AgentRemoteEditReviewData`; `editor-plugin-ai` turns each segment into a coarse
22
+ // `aiContentPositions` entry and reuses the entire FE Review moment pipeline.
24
23
  //
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.
24
+ // That pipeline requires every coarse entry to be a CLOSED, whole-node slice
25
+ // occupying exactly `[startPos, endPos]`. If a slice is left OPEN (a partial node,
26
+ // e.g. "…\nD") it drops the node wrapper on reconstruction — losing content on undo
27
+ // (data-loss bug) and bleeding highlights into neighbouring nodes. So we expand
28
+ // every change to whole-node outer boundaries and only emit self-consistent
29
+ // entries (see the group loop below).
28
30
 
29
31
  var clampToDoc = function clampToDoc(doc, pos) {
30
- return Math.min(Math.max(pos, 0), doc.content.size);
32
+ return Math.min(Math.max(pos, 1), doc.content.size);
33
+ };
34
+ var topLevelBlockIndexAt = function topLevelBlockIndexAt(doc, pos) {
35
+ return doc.resolve(clampToDoc(doc, pos)).index(0);
36
+ };
37
+
38
+ // Outer boundaries (before/after tokens) of the top-level node containing `pos`.
39
+ // Slicing between these includes each node's own wrapper tokens, so the slice is
40
+ // CLOSED (`openStart === openEnd === 0`) and preserves node type on reconstruction
41
+ // (a `heading` stays a heading). `depth === 0` means `pos` sits between top-level
42
+ // nodes, so it is already a node boundary.
43
+ // Whether `pos` sits INSIDE a top-level node (depth > 0) vs exactly on a node
44
+ // boundary (depth 0). Unlike `clampToDoc`, this clamps to `[0, size]` (allowing 0)
45
+ // so the doc-start boundary is correctly reported as a boundary, not forced into
46
+ // the first node. Used to tell a structural add/remove (zero-width span at a node
47
+ // boundary) from a text-only insert/delete inside a surviving node.
48
+ var isInsideNode = function isInsideNode(doc, pos) {
49
+ return doc.resolve(Math.min(Math.max(pos, 0), doc.content.size)).depth > 0;
50
+ };
51
+ var topLevelNodeStart = function topLevelNodeStart(doc, pos) {
52
+ var $pos = doc.resolve(clampToDoc(doc, pos));
53
+ return $pos.depth === 0 ? $pos.pos : $pos.before(1);
54
+ };
55
+ var topLevelNodeEnd = function topLevelNodeEnd(doc, pos) {
56
+ var $pos = doc.resolve(clampToDoc(doc, pos));
57
+ return $pos.depth === 0 ? $pos.pos : $pos.after(1);
31
58
  };
32
59
 
33
60
  // Map a position in doc_{i+1} forward through the remaining steps to final-doc
@@ -41,11 +68,31 @@ var mapToFinalDoc = function mapToFinalDoc(steps, pos, stepIndex, bias) {
41
68
  return p;
42
69
  };
43
70
 
71
+ // Map a step's `old` coord (valid in doc_i, this step's input) back to pre-batch
72
+ // (`tr.before`) coords by inverting the PRECEDING steps' maps in reverse. Needed to
73
+ // recover a deletion's original span: its new extent is zero-width, so it cannot be
74
+ // found by inverse-mapping the collapsed new point.
75
+ var mapToBeforeDoc = function mapToBeforeDoc(steps, pos, stepIndex, bias) {
76
+ var p = pos;
77
+ for (var j = stepIndex - 1; j >= 0; j--) {
78
+ p = steps[j].getMap().invert().map(p, bias);
79
+ }
80
+ return p;
81
+ };
82
+
83
+ /**
84
+ * A change region tracked in BOTH coordinate spaces: `[from, to]` in the final doc
85
+ * (`tr.doc`) and `[origFrom, origTo]` in the pre-batch doc (`tr.before`). Carrying
86
+ * the original span explicitly lets a deletion (zero-width final extent) still
87
+ * recover its removed content.
88
+ */
89
+
44
90
  /**
45
91
  * 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.
92
+ * batch. Returns `null` when there is nothing to record (no agent steps, agentType
93
+ * not allowlisted, a rebase invalidated our index math, or derivation threw) so the
94
+ * caller can no-op safely — this must never throw into the shared remote-step
95
+ * handler.
49
96
  *
50
97
  * @param json the raw received step JSON (carries `agentType` / `agentId` / `userId`)
51
98
  * @param steps the parsed PM steps (index-aligned with `json`)
@@ -53,6 +100,7 @@ var mapToFinalDoc = function mapToFinalDoc(steps, pos, stepIndex, bias) {
53
100
  * @param view the editor view (for the collab rebase guard)
54
101
  */
55
102
  var getAgentEditSegments = exports.getAgentEditSegments = function getAgentEditSegments(json, steps, tr, view) {
103
+ var _collabPluginState$ac;
56
104
  // `agentType` present ⇒ agent-authored (per the NCS↔Editor steps contract). Read
57
105
  // from the first agent step; it is identical across a batch.
58
106
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -72,6 +120,13 @@ var getAgentEditSegments = exports.getAgentEditSegments = function getAgentEditS
72
120
  // The user the agent acted on behalf of (the requester). Optional/additive on the
73
121
  // step contract, so it may be absent.
74
122
  var actorUserId = typeof (agentStep === null || agentStep === void 0 ? void 0 : agentStep.userId) === 'string' ? agentStep.userId : undefined;
123
+ // Resolve the local user's AAID via the collab session: the plugin state knows this
124
+ // client's session id, and the participant list maps that to a `userId`. Both are
125
+ // optional, so an unresolvable identity degrades to `false` (see the contract).
126
+ var collabPluginState = _pluginKey.pluginKey.getState(view.state);
127
+ var localSessionId = collabPluginState === null || collabPluginState === void 0 ? void 0 : collabPluginState.sessionId;
128
+ var localUserId = localSessionId ? collabPluginState === null || collabPluginState === void 0 || (_collabPluginState$ac = collabPluginState.activeParticipants) === null || _collabPluginState$ac === void 0 || (_collabPluginState$ac = _collabPluginState$ac.get(localSessionId)) === null || _collabPluginState$ac === void 0 ? void 0 : _collabPluginState$ac.userId : undefined;
129
+ var isLocalUserRequester = actorUserId !== undefined && localUserId !== undefined && actorUserId === localUserId;
75
130
  // Which agent instance wrote the batch. Additive on the step contract like
76
131
  // `userId`, so treat it as optional. Carried for contract clarity / future
77
132
  // per-agent attribution; nothing consumes it yet.
@@ -96,23 +151,11 @@ var getAgentEditSegments = exports.getAgentEditSegments = function getAgentEditS
96
151
  }
97
152
  }
98
153
  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 = [];
154
+ // Each agent step's changed extent in FINAL-doc coords, taken from its StepMap
155
+ // (the canonical, step-type-agnostic source). Unlike the shimmer we KEEP
156
+ // zero-width new extents — a pure deletion has `newEnd === newStart` but is a
157
+ // reviewable `remove`.
158
+ var ranges = [];
116
159
  json.forEach(function (rawStep, index) {
117
160
  if (typeof (rawStep === null || rawStep === void 0 ? void 0 : rawStep.agentType) !== 'string') {
118
161
  return;
@@ -123,55 +166,121 @@ var getAgentEditSegments = exports.getAgentEditSegments = function getAgentEditS
123
166
  return;
124
167
  }
125
168
  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;
169
+ var mappedFrom = mapToFinalDoc(steps, newStart, index, -1);
170
+ var mappedTo = mapToFinalDoc(steps, newEnd, index, 1);
171
+ // The same change in pre-batch coords, so a deletion (zero-width new
172
+ // extent) still carries its original span.
173
+ var mappedOrigFrom = mapToBeforeDoc(steps, oldStart, index, -1);
174
+ var mappedOrigTo = mapToBeforeDoc(steps, oldEnd, index, 1);
175
+ // Skip position-neutral phantom touches: the collab apply stamps same-size
176
+ // re-writes on unrelated nodes (e.g. `localId` on panels). Drop them only
177
+ // when byte-identical, so a real same-size replacement is preserved.
178
+ if (oldEnd - oldStart === newEnd - newStart) {
179
+ try {
180
+ var before = tr.before.slice(oldStart, oldEnd);
181
+ var after = tr.doc.slice(mappedFrom, mappedTo);
182
+ if (before.content.eq(after.content)) {
183
+ return;
184
+ }
185
+ } catch (_unused) {
186
+ // Comparison unsafe: keep the range; whole-block expansion + the
187
+ // later identical-content skip still guard against phantoms.
161
188
  }
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
189
  }
190
+ ranges.push({
191
+ from: mappedFrom,
192
+ to: mappedTo,
193
+ origFrom: mappedOrigFrom,
194
+ origTo: mappedOrigTo
195
+ });
173
196
  });
174
197
  });
198
+ if (!ranges.length) {
199
+ return null;
200
+ }
201
+
202
+ // Coalesce fragments in the same/adjacent top-level block into one region (a
203
+ // single agent edit arrives as many small replace fragments); an untouched block
204
+ // between them splits the run so far-apart edits stay separate. Each region
205
+ // becomes one coarse segment; the AI plugin refines it further.
206
+ var sorted = [].concat(ranges).sort(function (a, b) {
207
+ return a.from - b.from || a.to - b.to;
208
+ });
209
+ var groups = [];
210
+ sorted.forEach(function (range) {
211
+ var block = topLevelBlockIndexAt(tr.doc, range.from);
212
+ var current = groups[groups.length - 1];
213
+ // Coalesce changes in the same or directly-adjacent top-level block (index n /
214
+ // n+1) into one region, exactly like the shimmer — a single agent edit arrives
215
+ // as many small fragments. A genuinely untouched block in between (index gap
216
+ // > 1) splits the run, keeping far-apart edits separate. The original span is
217
+ // unioned in parallel so a deletion's removed content is preserved.
218
+ if (current && block <= current.maxBlock + 1) {
219
+ current.to = Math.max(current.to, range.to);
220
+ current.maxBlock = Math.max(current.maxBlock, block);
221
+ current.origFrom = Math.min(current.origFrom, range.origFrom);
222
+ current.origTo = Math.max(current.origTo, range.origTo);
223
+ } else {
224
+ groups.push({
225
+ from: range.from,
226
+ to: range.to,
227
+ maxBlock: block,
228
+ origFrom: range.origFrom,
229
+ origTo: range.origTo
230
+ });
231
+ }
232
+ });
233
+ var segments = [];
234
+ groups.forEach(function (group) {
235
+ try {
236
+ // Expand the NEW extent to whole-node outer boundaries → a CLOSED slice.
237
+ // A zero-width new extent at a top-level node boundary is a structural
238
+ // deletion: leave it empty (do NOT whole-node expand, or it would grab the
239
+ // unchanged neighbour node that now sits at that point and be discarded as an
240
+ // identical phantom). A zero-width new extent INSIDE a node is a text-only
241
+ // deletion from a surviving node, so expand to the whole node (an `update`).
242
+ var newIsStructuralRemove = group.from === group.to && !isInsideNode(tr.doc, group.from);
243
+ var newFrom = topLevelNodeStart(tr.doc, Math.min(group.from, group.to));
244
+ var newTo = newIsStructuralRemove ? newFrom : topLevelNodeEnd(tr.doc, Math.max(group.from, group.to));
245
+ var newSlice = tr.doc.slice(newFrom, newTo);
246
+
247
+ // Build the ORIGINAL slice from the carried pre-batch span, expanded to whole
248
+ // nodes → a CLOSED slice. Using the carried `origFrom/origTo` (from the
249
+ // StepMap `old` coords) rather than inverse-mapping the new bounds is what
250
+ // lets a deletion recover its removed content.
251
+ //
252
+ // A zero-width original span is only a true structural ADD when it sits at a
253
+ // top-level node boundary (inserting a whole new node). A zero-width span
254
+ // INSIDE a node is a text insertion into that node, so expand to the whole
255
+ // node (an `update`) — matching how an in-place edit reviews.
256
+ var origIsStructuralAdd = group.origFrom === group.origTo && !isInsideNode(tr.before, group.origFrom);
257
+ var origFrom = topLevelNodeStart(tr.before, Math.min(group.origFrom, group.origTo));
258
+ var origTo = origIsStructuralAdd ? origFrom : topLevelNodeEnd(tr.before, Math.max(group.origFrom, group.origTo));
259
+ var originalSlice = tr.before.slice(origFrom, origTo);
260
+ var originalEmpty = originalSlice.content.size === 0;
261
+ var newEmpty = newSlice.content.size === 0;
262
+ // A truly empty-to-empty region is not a change — skip it.
263
+ if (originalEmpty && newEmpty) {
264
+ return;
265
+ }
266
+ // Skip phantom artifacts: a same-size StepMap "touch" at an unrelated node
267
+ // (e.g. a `localId` stamp on a panel during collab apply) expands to a
268
+ // segment whose original and new content are identical — not a real change.
269
+ if (originalSlice.content.eq(newSlice.content)) {
270
+ return;
271
+ }
272
+ var kind = originalEmpty ? 'add' : newEmpty ? 'remove' : 'update';
273
+ segments.push({
274
+ startPos: newFrom,
275
+ endPos: newTo,
276
+ originalSlice: originalSlice,
277
+ newSlice: newSlice,
278
+ kind: kind
279
+ });
280
+ } catch (_unused2) {
281
+ // One bad group must not drop the others.
282
+ }
283
+ });
175
284
  if (!segments.length) {
176
285
  return null;
177
286
  }
@@ -180,9 +289,10 @@ var getAgentEditSegments = exports.getAgentEditSegments = function getAgentEditS
180
289
  agentId: agentId,
181
290
  agentType: agentType,
182
291
  complete: complete,
292
+ isLocalUserRequester: isLocalUserRequester,
183
293
  segments: segments
184
294
  };
185
- } catch (_unused2) {
295
+ } catch (_unused3) {
186
296
  // Never throw into the shared remote-step handler; degrade to no recording.
187
297
  return null;
188
298
  }
@@ -1,6 +1,7 @@
1
1
  import { expVal } from '@atlaskit/platform-feature-experiments/exp-val';
2
2
  import { getCollabState } from '@atlaskit/prosemirror-collab';
3
3
  import { isPositionNeutralStep } from './agent-shimmer-ranges';
4
+ import { pluginKey } from './plugin-key';
4
5
 
5
6
  // Statsig dynamic config: which BE-streaming `agentType` values may open Review Moment.
6
7
  // Code default is `[]` (fail closed) until the list is set in Statsig.
@@ -8,20 +9,42 @@ export const REVIEW_MOMENT_AGENT_TYPES_CONFIG = 'platform_editor_backend_review_
8
9
 
9
10
  // [CCI-17994] Post Stream Review ("Review moment") recording for BE streaming.
10
11
  //
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`.
12
+ // Sibling to `getAgentShimmerRanges`, but for a different consumer: the shimmer
13
+ // only needs the NEW extent of added content, whereas the Review moment needs, per
14
+ // contiguous change, BOTH the pre-edit slice (for undo) and the new slice (for
15
+ // redo), and must keep deletions. The output is a neutral
16
+ // `AgentRemoteEditReviewData`; `editor-plugin-ai` turns each segment into a coarse
17
+ // `aiContentPositions` entry and reuses the entire FE Review moment pipeline.
19
18
  //
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.
19
+ // That pipeline requires every coarse entry to be a CLOSED, whole-node slice
20
+ // occupying exactly `[startPos, endPos]`. If a slice is left OPEN (a partial node,
21
+ // e.g. "…\nD") it drops the node wrapper on reconstruction — losing content on undo
22
+ // (data-loss bug) and bleeding highlights into neighbouring nodes. So we expand
23
+ // every change to whole-node outer boundaries and only emit self-consistent
24
+ // entries (see the group loop below).
23
25
 
24
- const clampToDoc = (doc, pos) => Math.min(Math.max(pos, 0), doc.content.size);
26
+ const clampToDoc = (doc, pos) => Math.min(Math.max(pos, 1), doc.content.size);
27
+ const topLevelBlockIndexAt = (doc, pos) => doc.resolve(clampToDoc(doc, pos)).index(0);
28
+
29
+ // Outer boundaries (before/after tokens) of the top-level node containing `pos`.
30
+ // Slicing between these includes each node's own wrapper tokens, so the slice is
31
+ // CLOSED (`openStart === openEnd === 0`) and preserves node type on reconstruction
32
+ // (a `heading` stays a heading). `depth === 0` means `pos` sits between top-level
33
+ // nodes, so it is already a node boundary.
34
+ // Whether `pos` sits INSIDE a top-level node (depth > 0) vs exactly on a node
35
+ // boundary (depth 0). Unlike `clampToDoc`, this clamps to `[0, size]` (allowing 0)
36
+ // so the doc-start boundary is correctly reported as a boundary, not forced into
37
+ // the first node. Used to tell a structural add/remove (zero-width span at a node
38
+ // boundary) from a text-only insert/delete inside a surviving node.
39
+ const isInsideNode = (doc, pos) => doc.resolve(Math.min(Math.max(pos, 0), doc.content.size)).depth > 0;
40
+ const topLevelNodeStart = (doc, pos) => {
41
+ const $pos = doc.resolve(clampToDoc(doc, pos));
42
+ return $pos.depth === 0 ? $pos.pos : $pos.before(1);
43
+ };
44
+ const topLevelNodeEnd = (doc, pos) => {
45
+ const $pos = doc.resolve(clampToDoc(doc, pos));
46
+ return $pos.depth === 0 ? $pos.pos : $pos.after(1);
47
+ };
25
48
 
26
49
  // Map a position in doc_{i+1} forward through the remaining steps to final-doc
27
50
  // coords. Mirrors the shimmer's `mapToFinalDoc` — a later step's own inserted
@@ -34,11 +57,31 @@ const mapToFinalDoc = (steps, pos, stepIndex, bias) => {
34
57
  return p;
35
58
  };
36
59
 
60
+ // Map a step's `old` coord (valid in doc_i, this step's input) back to pre-batch
61
+ // (`tr.before`) coords by inverting the PRECEDING steps' maps in reverse. Needed to
62
+ // recover a deletion's original span: its new extent is zero-width, so it cannot be
63
+ // found by inverse-mapping the collapsed new point.
64
+ const mapToBeforeDoc = (steps, pos, stepIndex, bias) => {
65
+ let p = pos;
66
+ for (let j = stepIndex - 1; j >= 0; j--) {
67
+ p = steps[j].getMap().invert().map(p, bias);
68
+ }
69
+ return p;
70
+ };
71
+
72
+ /**
73
+ * A change region tracked in BOTH coordinate spaces: `[from, to]` in the final doc
74
+ * (`tr.doc`) and `[origFrom, origTo]` in the pre-batch doc (`tr.before`). Carrying
75
+ * the original span explicitly lets a deletion (zero-width final extent) still
76
+ * recover its removed content.
77
+ */
78
+
37
79
  /**
38
80
  * 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.
81
+ * batch. Returns `null` when there is nothing to record (no agent steps, agentType
82
+ * not allowlisted, a rebase invalidated our index math, or derivation threw) so the
83
+ * caller can no-op safely — this must never throw into the shared remote-step
84
+ * handler.
42
85
  *
43
86
  * @param json the raw received step JSON (carries `agentType` / `agentId` / `userId`)
44
87
  * @param steps the parsed PM steps (index-aligned with `json`)
@@ -46,6 +89,7 @@ const mapToFinalDoc = (steps, pos, stepIndex, bias) => {
46
89
  * @param view the editor view (for the collab rebase guard)
47
90
  */
48
91
  export const getAgentEditSegments = (json, steps, tr, view) => {
92
+ var _collabPluginState$ac, _collabPluginState$ac2;
49
93
  // `agentType` present ⇒ agent-authored (per the NCS↔Editor steps contract). Read
50
94
  // from the first agent step; it is identical across a batch.
51
95
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -63,6 +107,13 @@ export const getAgentEditSegments = (json, steps, tr, view) => {
63
107
  // The user the agent acted on behalf of (the requester). Optional/additive on the
64
108
  // step contract, so it may be absent.
65
109
  const actorUserId = typeof (agentStep === null || agentStep === void 0 ? void 0 : agentStep.userId) === 'string' ? agentStep.userId : undefined;
110
+ // Resolve the local user's AAID via the collab session: the plugin state knows this
111
+ // client's session id, and the participant list maps that to a `userId`. Both are
112
+ // optional, so an unresolvable identity degrades to `false` (see the contract).
113
+ const collabPluginState = pluginKey.getState(view.state);
114
+ const localSessionId = collabPluginState === null || collabPluginState === void 0 ? void 0 : collabPluginState.sessionId;
115
+ const localUserId = localSessionId ? collabPluginState === null || collabPluginState === void 0 ? void 0 : (_collabPluginState$ac = collabPluginState.activeParticipants) === null || _collabPluginState$ac === void 0 ? void 0 : (_collabPluginState$ac2 = _collabPluginState$ac.get(localSessionId)) === null || _collabPluginState$ac2 === void 0 ? void 0 : _collabPluginState$ac2.userId : undefined;
116
+ const isLocalUserRequester = actorUserId !== undefined && localUserId !== undefined && actorUserId === localUserId;
66
117
  // Which agent instance wrote the batch. Additive on the step contract like
67
118
  // `userId`, so treat it as optional. Carried for contract clarity / future
68
119
  // per-agent attribution; nothing consumes it yet.
@@ -83,23 +134,11 @@ export const getAgentEditSegments = (json, steps, tr, view) => {
83
134
  }
84
135
  }
85
136
  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 = [];
137
+ // Each agent step's changed extent in FINAL-doc coords, taken from its StepMap
138
+ // (the canonical, step-type-agnostic source). Unlike the shimmer we KEEP
139
+ // zero-width new extents — a pure deletion has `newEnd === newStart` but is a
140
+ // reviewable `remove`.
141
+ const ranges = [];
103
142
  json.forEach((rawStep, index) => {
104
143
  if (typeof (rawStep === null || rawStep === void 0 ? void 0 : rawStep.agentType) !== 'string') {
105
144
  return;
@@ -110,55 +149,119 @@ export const getAgentEditSegments = (json, steps, tr, view) => {
110
149
  return;
111
150
  }
112
151
  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;
152
+ const mappedFrom = mapToFinalDoc(steps, newStart, index, -1);
153
+ const mappedTo = mapToFinalDoc(steps, newEnd, index, 1);
154
+ // The same change in pre-batch coords, so a deletion (zero-width new
155
+ // extent) still carries its original span.
156
+ const mappedOrigFrom = mapToBeforeDoc(steps, oldStart, index, -1);
157
+ const mappedOrigTo = mapToBeforeDoc(steps, oldEnd, index, 1);
158
+ // Skip position-neutral phantom touches: the collab apply stamps same-size
159
+ // re-writes on unrelated nodes (e.g. `localId` on panels). Drop them only
160
+ // when byte-identical, so a real same-size replacement is preserved.
161
+ if (oldEnd - oldStart === newEnd - newStart) {
162
+ try {
163
+ const before = tr.before.slice(oldStart, oldEnd);
164
+ const after = tr.doc.slice(mappedFrom, mappedTo);
165
+ if (before.content.eq(after.content)) {
166
+ return;
167
+ }
168
+ } catch {
169
+ // Comparison unsafe: keep the range; whole-block expansion + the
170
+ // later identical-content skip still guard against phantoms.
148
171
  }
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
172
  }
173
+ ranges.push({
174
+ from: mappedFrom,
175
+ to: mappedTo,
176
+ origFrom: mappedOrigFrom,
177
+ origTo: mappedOrigTo
178
+ });
160
179
  });
161
180
  });
181
+ if (!ranges.length) {
182
+ return null;
183
+ }
184
+
185
+ // Coalesce fragments in the same/adjacent top-level block into one region (a
186
+ // single agent edit arrives as many small replace fragments); an untouched block
187
+ // between them splits the run so far-apart edits stay separate. Each region
188
+ // becomes one coarse segment; the AI plugin refines it further.
189
+ const sorted = [...ranges].sort((a, b) => a.from - b.from || a.to - b.to);
190
+ const groups = [];
191
+ sorted.forEach(range => {
192
+ const block = topLevelBlockIndexAt(tr.doc, range.from);
193
+ const current = groups[groups.length - 1];
194
+ // Coalesce changes in the same or directly-adjacent top-level block (index n /
195
+ // n+1) into one region, exactly like the shimmer — a single agent edit arrives
196
+ // as many small fragments. A genuinely untouched block in between (index gap
197
+ // > 1) splits the run, keeping far-apart edits separate. The original span is
198
+ // unioned in parallel so a deletion's removed content is preserved.
199
+ if (current && block <= current.maxBlock + 1) {
200
+ current.to = Math.max(current.to, range.to);
201
+ current.maxBlock = Math.max(current.maxBlock, block);
202
+ current.origFrom = Math.min(current.origFrom, range.origFrom);
203
+ current.origTo = Math.max(current.origTo, range.origTo);
204
+ } else {
205
+ groups.push({
206
+ from: range.from,
207
+ to: range.to,
208
+ maxBlock: block,
209
+ origFrom: range.origFrom,
210
+ origTo: range.origTo
211
+ });
212
+ }
213
+ });
214
+ const segments = [];
215
+ groups.forEach(group => {
216
+ try {
217
+ // Expand the NEW extent to whole-node outer boundaries → a CLOSED slice.
218
+ // A zero-width new extent at a top-level node boundary is a structural
219
+ // deletion: leave it empty (do NOT whole-node expand, or it would grab the
220
+ // unchanged neighbour node that now sits at that point and be discarded as an
221
+ // identical phantom). A zero-width new extent INSIDE a node is a text-only
222
+ // deletion from a surviving node, so expand to the whole node (an `update`).
223
+ const newIsStructuralRemove = group.from === group.to && !isInsideNode(tr.doc, group.from);
224
+ const newFrom = topLevelNodeStart(tr.doc, Math.min(group.from, group.to));
225
+ const newTo = newIsStructuralRemove ? newFrom : topLevelNodeEnd(tr.doc, Math.max(group.from, group.to));
226
+ const newSlice = tr.doc.slice(newFrom, newTo);
227
+
228
+ // Build the ORIGINAL slice from the carried pre-batch span, expanded to whole
229
+ // nodes → a CLOSED slice. Using the carried `origFrom/origTo` (from the
230
+ // StepMap `old` coords) rather than inverse-mapping the new bounds is what
231
+ // lets a deletion recover its removed content.
232
+ //
233
+ // A zero-width original span is only a true structural ADD when it sits at a
234
+ // top-level node boundary (inserting a whole new node). A zero-width span
235
+ // INSIDE a node is a text insertion into that node, so expand to the whole
236
+ // node (an `update`) — matching how an in-place edit reviews.
237
+ const origIsStructuralAdd = group.origFrom === group.origTo && !isInsideNode(tr.before, group.origFrom);
238
+ const origFrom = topLevelNodeStart(tr.before, Math.min(group.origFrom, group.origTo));
239
+ const origTo = origIsStructuralAdd ? origFrom : topLevelNodeEnd(tr.before, Math.max(group.origFrom, group.origTo));
240
+ const originalSlice = tr.before.slice(origFrom, origTo);
241
+ const originalEmpty = originalSlice.content.size === 0;
242
+ const newEmpty = newSlice.content.size === 0;
243
+ // A truly empty-to-empty region is not a change — skip it.
244
+ if (originalEmpty && newEmpty) {
245
+ return;
246
+ }
247
+ // Skip phantom artifacts: a same-size StepMap "touch" at an unrelated node
248
+ // (e.g. a `localId` stamp on a panel during collab apply) expands to a
249
+ // segment whose original and new content are identical — not a real change.
250
+ if (originalSlice.content.eq(newSlice.content)) {
251
+ return;
252
+ }
253
+ const kind = originalEmpty ? 'add' : newEmpty ? 'remove' : 'update';
254
+ segments.push({
255
+ startPos: newFrom,
256
+ endPos: newTo,
257
+ originalSlice,
258
+ newSlice,
259
+ kind
260
+ });
261
+ } catch {
262
+ // One bad group must not drop the others.
263
+ }
264
+ });
162
265
  if (!segments.length) {
163
266
  return null;
164
267
  }
@@ -167,6 +270,7 @@ export const getAgentEditSegments = (json, steps, tr, view) => {
167
270
  agentId,
168
271
  agentType,
169
272
  complete,
273
+ isLocalUserRequester,
170
274
  segments
171
275
  };
172
276
  } catch {
@@ -1,6 +1,7 @@
1
1
  import { expVal } from '@atlaskit/platform-feature-experiments/exp-val';
2
2
  import { getCollabState } from '@atlaskit/prosemirror-collab';
3
3
  import { isPositionNeutralStep } from './agent-shimmer-ranges';
4
+ import { pluginKey } from './plugin-key';
4
5
 
5
6
  // Statsig dynamic config: which BE-streaming `agentType` values may open Review Moment.
6
7
  // Code default is `[]` (fail closed) until the list is set in Statsig.
@@ -8,21 +9,47 @@ export var REVIEW_MOMENT_AGENT_TYPES_CONFIG = 'platform_editor_backend_review_mo
8
9
 
9
10
  // [CCI-17994] Post Stream Review ("Review moment") recording for BE streaming.
10
11
  //
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`.
12
+ // Sibling to `getAgentShimmerRanges`, but for a different consumer: the shimmer
13
+ // only needs the NEW extent of added content, whereas the Review moment needs, per
14
+ // contiguous change, BOTH the pre-edit slice (for undo) and the new slice (for
15
+ // redo), and must keep deletions. The output is a neutral
16
+ // `AgentRemoteEditReviewData`; `editor-plugin-ai` turns each segment into a coarse
17
+ // `aiContentPositions` entry and reuses the entire FE Review moment pipeline.
19
18
  //
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.
19
+ // That pipeline requires every coarse entry to be a CLOSED, whole-node slice
20
+ // occupying exactly `[startPos, endPos]`. If a slice is left OPEN (a partial node,
21
+ // e.g. "…\nD") it drops the node wrapper on reconstruction — losing content on undo
22
+ // (data-loss bug) and bleeding highlights into neighbouring nodes. So we expand
23
+ // every change to whole-node outer boundaries and only emit self-consistent
24
+ // entries (see the group loop below).
23
25
 
24
26
  var clampToDoc = function clampToDoc(doc, pos) {
25
- return Math.min(Math.max(pos, 0), doc.content.size);
27
+ return Math.min(Math.max(pos, 1), doc.content.size);
28
+ };
29
+ var topLevelBlockIndexAt = function topLevelBlockIndexAt(doc, pos) {
30
+ return doc.resolve(clampToDoc(doc, pos)).index(0);
31
+ };
32
+
33
+ // Outer boundaries (before/after tokens) of the top-level node containing `pos`.
34
+ // Slicing between these includes each node's own wrapper tokens, so the slice is
35
+ // CLOSED (`openStart === openEnd === 0`) and preserves node type on reconstruction
36
+ // (a `heading` stays a heading). `depth === 0` means `pos` sits between top-level
37
+ // nodes, so it is already a node boundary.
38
+ // Whether `pos` sits INSIDE a top-level node (depth > 0) vs exactly on a node
39
+ // boundary (depth 0). Unlike `clampToDoc`, this clamps to `[0, size]` (allowing 0)
40
+ // so the doc-start boundary is correctly reported as a boundary, not forced into
41
+ // the first node. Used to tell a structural add/remove (zero-width span at a node
42
+ // boundary) from a text-only insert/delete inside a surviving node.
43
+ var isInsideNode = function isInsideNode(doc, pos) {
44
+ return doc.resolve(Math.min(Math.max(pos, 0), doc.content.size)).depth > 0;
45
+ };
46
+ var topLevelNodeStart = function topLevelNodeStart(doc, pos) {
47
+ var $pos = doc.resolve(clampToDoc(doc, pos));
48
+ return $pos.depth === 0 ? $pos.pos : $pos.before(1);
49
+ };
50
+ var topLevelNodeEnd = function topLevelNodeEnd(doc, pos) {
51
+ var $pos = doc.resolve(clampToDoc(doc, pos));
52
+ return $pos.depth === 0 ? $pos.pos : $pos.after(1);
26
53
  };
27
54
 
28
55
  // Map a position in doc_{i+1} forward through the remaining steps to final-doc
@@ -36,11 +63,31 @@ var mapToFinalDoc = function mapToFinalDoc(steps, pos, stepIndex, bias) {
36
63
  return p;
37
64
  };
38
65
 
66
+ // Map a step's `old` coord (valid in doc_i, this step's input) back to pre-batch
67
+ // (`tr.before`) coords by inverting the PRECEDING steps' maps in reverse. Needed to
68
+ // recover a deletion's original span: its new extent is zero-width, so it cannot be
69
+ // found by inverse-mapping the collapsed new point.
70
+ var mapToBeforeDoc = function mapToBeforeDoc(steps, pos, stepIndex, bias) {
71
+ var p = pos;
72
+ for (var j = stepIndex - 1; j >= 0; j--) {
73
+ p = steps[j].getMap().invert().map(p, bias);
74
+ }
75
+ return p;
76
+ };
77
+
78
+ /**
79
+ * A change region tracked in BOTH coordinate spaces: `[from, to]` in the final doc
80
+ * (`tr.doc`) and `[origFrom, origTo]` in the pre-batch doc (`tr.before`). Carrying
81
+ * the original span explicitly lets a deletion (zero-width final extent) still
82
+ * recover its removed content.
83
+ */
84
+
39
85
  /**
40
86
  * 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.
87
+ * batch. Returns `null` when there is nothing to record (no agent steps, agentType
88
+ * not allowlisted, a rebase invalidated our index math, or derivation threw) so the
89
+ * caller can no-op safely — this must never throw into the shared remote-step
90
+ * handler.
44
91
  *
45
92
  * @param json the raw received step JSON (carries `agentType` / `agentId` / `userId`)
46
93
  * @param steps the parsed PM steps (index-aligned with `json`)
@@ -48,6 +95,7 @@ var mapToFinalDoc = function mapToFinalDoc(steps, pos, stepIndex, bias) {
48
95
  * @param view the editor view (for the collab rebase guard)
49
96
  */
50
97
  export var getAgentEditSegments = function getAgentEditSegments(json, steps, tr, view) {
98
+ var _collabPluginState$ac;
51
99
  // `agentType` present ⇒ agent-authored (per the NCS↔Editor steps contract). Read
52
100
  // from the first agent step; it is identical across a batch.
53
101
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -67,6 +115,13 @@ export var getAgentEditSegments = function getAgentEditSegments(json, steps, tr,
67
115
  // The user the agent acted on behalf of (the requester). Optional/additive on the
68
116
  // step contract, so it may be absent.
69
117
  var actorUserId = typeof (agentStep === null || agentStep === void 0 ? void 0 : agentStep.userId) === 'string' ? agentStep.userId : undefined;
118
+ // Resolve the local user's AAID via the collab session: the plugin state knows this
119
+ // client's session id, and the participant list maps that to a `userId`. Both are
120
+ // optional, so an unresolvable identity degrades to `false` (see the contract).
121
+ var collabPluginState = pluginKey.getState(view.state);
122
+ var localSessionId = collabPluginState === null || collabPluginState === void 0 ? void 0 : collabPluginState.sessionId;
123
+ var localUserId = localSessionId ? collabPluginState === null || collabPluginState === void 0 || (_collabPluginState$ac = collabPluginState.activeParticipants) === null || _collabPluginState$ac === void 0 || (_collabPluginState$ac = _collabPluginState$ac.get(localSessionId)) === null || _collabPluginState$ac === void 0 ? void 0 : _collabPluginState$ac.userId : undefined;
124
+ var isLocalUserRequester = actorUserId !== undefined && localUserId !== undefined && actorUserId === localUserId;
70
125
  // Which agent instance wrote the batch. Additive on the step contract like
71
126
  // `userId`, so treat it as optional. Carried for contract clarity / future
72
127
  // per-agent attribution; nothing consumes it yet.
@@ -91,23 +146,11 @@ export var getAgentEditSegments = function getAgentEditSegments(json, steps, tr,
91
146
  }
92
147
  }
93
148
  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 = [];
149
+ // Each agent step's changed extent in FINAL-doc coords, taken from its StepMap
150
+ // (the canonical, step-type-agnostic source). Unlike the shimmer we KEEP
151
+ // zero-width new extents — a pure deletion has `newEnd === newStart` but is a
152
+ // reviewable `remove`.
153
+ var ranges = [];
111
154
  json.forEach(function (rawStep, index) {
112
155
  if (typeof (rawStep === null || rawStep === void 0 ? void 0 : rawStep.agentType) !== 'string') {
113
156
  return;
@@ -118,55 +161,121 @@ export var getAgentEditSegments = function getAgentEditSegments(json, steps, tr,
118
161
  return;
119
162
  }
120
163
  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;
164
+ var mappedFrom = mapToFinalDoc(steps, newStart, index, -1);
165
+ var mappedTo = mapToFinalDoc(steps, newEnd, index, 1);
166
+ // The same change in pre-batch coords, so a deletion (zero-width new
167
+ // extent) still carries its original span.
168
+ var mappedOrigFrom = mapToBeforeDoc(steps, oldStart, index, -1);
169
+ var mappedOrigTo = mapToBeforeDoc(steps, oldEnd, index, 1);
170
+ // Skip position-neutral phantom touches: the collab apply stamps same-size
171
+ // re-writes on unrelated nodes (e.g. `localId` on panels). Drop them only
172
+ // when byte-identical, so a real same-size replacement is preserved.
173
+ if (oldEnd - oldStart === newEnd - newStart) {
174
+ try {
175
+ var before = tr.before.slice(oldStart, oldEnd);
176
+ var after = tr.doc.slice(mappedFrom, mappedTo);
177
+ if (before.content.eq(after.content)) {
178
+ return;
179
+ }
180
+ } catch (_unused) {
181
+ // Comparison unsafe: keep the range; whole-block expansion + the
182
+ // later identical-content skip still guard against phantoms.
156
183
  }
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
184
  }
185
+ ranges.push({
186
+ from: mappedFrom,
187
+ to: mappedTo,
188
+ origFrom: mappedOrigFrom,
189
+ origTo: mappedOrigTo
190
+ });
168
191
  });
169
192
  });
193
+ if (!ranges.length) {
194
+ return null;
195
+ }
196
+
197
+ // Coalesce fragments in the same/adjacent top-level block into one region (a
198
+ // single agent edit arrives as many small replace fragments); an untouched block
199
+ // between them splits the run so far-apart edits stay separate. Each region
200
+ // becomes one coarse segment; the AI plugin refines it further.
201
+ var sorted = [].concat(ranges).sort(function (a, b) {
202
+ return a.from - b.from || a.to - b.to;
203
+ });
204
+ var groups = [];
205
+ sorted.forEach(function (range) {
206
+ var block = topLevelBlockIndexAt(tr.doc, range.from);
207
+ var current = groups[groups.length - 1];
208
+ // Coalesce changes in the same or directly-adjacent top-level block (index n /
209
+ // n+1) into one region, exactly like the shimmer — a single agent edit arrives
210
+ // as many small fragments. A genuinely untouched block in between (index gap
211
+ // > 1) splits the run, keeping far-apart edits separate. The original span is
212
+ // unioned in parallel so a deletion's removed content is preserved.
213
+ if (current && block <= current.maxBlock + 1) {
214
+ current.to = Math.max(current.to, range.to);
215
+ current.maxBlock = Math.max(current.maxBlock, block);
216
+ current.origFrom = Math.min(current.origFrom, range.origFrom);
217
+ current.origTo = Math.max(current.origTo, range.origTo);
218
+ } else {
219
+ groups.push({
220
+ from: range.from,
221
+ to: range.to,
222
+ maxBlock: block,
223
+ origFrom: range.origFrom,
224
+ origTo: range.origTo
225
+ });
226
+ }
227
+ });
228
+ var segments = [];
229
+ groups.forEach(function (group) {
230
+ try {
231
+ // Expand the NEW extent to whole-node outer boundaries → a CLOSED slice.
232
+ // A zero-width new extent at a top-level node boundary is a structural
233
+ // deletion: leave it empty (do NOT whole-node expand, or it would grab the
234
+ // unchanged neighbour node that now sits at that point and be discarded as an
235
+ // identical phantom). A zero-width new extent INSIDE a node is a text-only
236
+ // deletion from a surviving node, so expand to the whole node (an `update`).
237
+ var newIsStructuralRemove = group.from === group.to && !isInsideNode(tr.doc, group.from);
238
+ var newFrom = topLevelNodeStart(tr.doc, Math.min(group.from, group.to));
239
+ var newTo = newIsStructuralRemove ? newFrom : topLevelNodeEnd(tr.doc, Math.max(group.from, group.to));
240
+ var newSlice = tr.doc.slice(newFrom, newTo);
241
+
242
+ // Build the ORIGINAL slice from the carried pre-batch span, expanded to whole
243
+ // nodes → a CLOSED slice. Using the carried `origFrom/origTo` (from the
244
+ // StepMap `old` coords) rather than inverse-mapping the new bounds is what
245
+ // lets a deletion recover its removed content.
246
+ //
247
+ // A zero-width original span is only a true structural ADD when it sits at a
248
+ // top-level node boundary (inserting a whole new node). A zero-width span
249
+ // INSIDE a node is a text insertion into that node, so expand to the whole
250
+ // node (an `update`) — matching how an in-place edit reviews.
251
+ var origIsStructuralAdd = group.origFrom === group.origTo && !isInsideNode(tr.before, group.origFrom);
252
+ var origFrom = topLevelNodeStart(tr.before, Math.min(group.origFrom, group.origTo));
253
+ var origTo = origIsStructuralAdd ? origFrom : topLevelNodeEnd(tr.before, Math.max(group.origFrom, group.origTo));
254
+ var originalSlice = tr.before.slice(origFrom, origTo);
255
+ var originalEmpty = originalSlice.content.size === 0;
256
+ var newEmpty = newSlice.content.size === 0;
257
+ // A truly empty-to-empty region is not a change — skip it.
258
+ if (originalEmpty && newEmpty) {
259
+ return;
260
+ }
261
+ // Skip phantom artifacts: a same-size StepMap "touch" at an unrelated node
262
+ // (e.g. a `localId` stamp on a panel during collab apply) expands to a
263
+ // segment whose original and new content are identical — not a real change.
264
+ if (originalSlice.content.eq(newSlice.content)) {
265
+ return;
266
+ }
267
+ var kind = originalEmpty ? 'add' : newEmpty ? 'remove' : 'update';
268
+ segments.push({
269
+ startPos: newFrom,
270
+ endPos: newTo,
271
+ originalSlice: originalSlice,
272
+ newSlice: newSlice,
273
+ kind: kind
274
+ });
275
+ } catch (_unused2) {
276
+ // One bad group must not drop the others.
277
+ }
278
+ });
170
279
  if (!segments.length) {
171
280
  return null;
172
281
  }
@@ -175,9 +284,10 @@ export var getAgentEditSegments = function getAgentEditSegments(json, steps, tr,
175
284
  agentId: agentId,
176
285
  agentType: agentType,
177
286
  complete: complete,
287
+ isLocalUserRequester: isLocalUserRequester,
178
288
  segments: segments
179
289
  };
180
- } catch (_unused2) {
290
+ } catch (_unused3) {
181
291
  // Never throw into the shared remote-step handler; degrade to no recording.
182
292
  return null;
183
293
  }
@@ -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.7",
3
+ "version": "16.0.0",
4
4
  "description": "Collab Edit plugin for @atlaskit/editor-core",
5
5
  "author": "Atlassian Pty Ltd",
6
6
  "license": "Apache-2.0",
@@ -22,23 +22,23 @@
22
22
  "@atlaskit/adf-schema": "^56.7.0",
23
23
  "@atlaskit/custom-steps": "^1.0.0",
24
24
  "@atlaskit/editor-json-transformer": "^9.2.0",
25
- "@atlaskit/editor-plugin-analytics": "^14.0.0",
26
- "@atlaskit/editor-plugin-connectivity": "^14.0.0",
27
- "@atlaskit/editor-plugin-editor-viewmode": "^16.0.0",
28
- "@atlaskit/editor-plugin-feature-flags": "^13.0.0",
25
+ "@atlaskit/editor-plugin-analytics": "^15.0.0",
26
+ "@atlaskit/editor-plugin-connectivity": "^15.0.0",
27
+ "@atlaskit/editor-plugin-editor-viewmode": "^17.0.0",
28
+ "@atlaskit/editor-plugin-feature-flags": "^14.0.0",
29
29
  "@atlaskit/editor-prosemirror": "^8.0.0",
30
30
  "@atlaskit/editor-shared-styles": "^4.0.0",
31
31
  "@atlaskit/frontend-utilities": "^4.1.0",
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": "^147.0.0",
35
+ "@atlaskit/tmp-editor-statsig": "^147.1.0",
36
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.10.0",
41
+ "@atlaskit/editor-common": "^119.0.0",
42
42
  "react": "^18.2.0 || ^19.2.0",
43
43
  "react-dom": "^18.2.0 || ^19.2.0"
44
44
  },