@atlaskit/editor-plugin-collab-edit 15.0.7 → 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 +11 -0
- package/dist/cjs/pm-plugins/main/agent-review-segments.js +178 -78
- package/dist/es2019/pm-plugins/main/agent-review-segments.js +171 -77
- package/dist/esm/pm-plugins/main/agent-review-segments.js +178 -78
- package/dist/types/pm-plugins/main/agent-review-segments.d.ts +4 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
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
|
+
|
|
3
14
|
## 15.0.7
|
|
4
15
|
|
|
5
16
|
### 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
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
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
|
-
//
|
|
26
|
-
// `
|
|
27
|
-
//
|
|
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,
|
|
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,
|
|
47
|
-
* rebase invalidated our index math, or derivation threw) so the
|
|
48
|
-
* safely — this must never throw into the shared remote-step
|
|
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
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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 (
|
|
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
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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
|
-
//
|
|
21
|
-
// `
|
|
22
|
-
//
|
|
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,
|
|
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,
|
|
40
|
-
* rebase invalidated our index math, or derivation threw) so the
|
|
41
|
-
* safely — this must never throw into the shared remote-step
|
|
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
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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
|
-
//
|
|
21
|
-
// `
|
|
22
|
-
//
|
|
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,
|
|
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,
|
|
42
|
-
* rebase invalidated our index math, or derivation threw) so the
|
|
43
|
-
* safely — this must never throw into the shared remote-step
|
|
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
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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 (
|
|
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,
|
|
9
|
-
* rebase invalidated our index math, or derivation threw) so the
|
|
10
|
-
* safely — this must never throw into the shared remote-step
|
|
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`)
|