@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 +28 -0
- package/dist/cjs/pm-plugins/main/agent-review-segments.js +188 -78
- package/dist/es2019/pm-plugins/main/agent-review-segments.js +181 -77
- package/dist/esm/pm-plugins/main/agent-review-segments.js +188 -78
- package/dist/types/pm-plugins/main/agent-review-segments.d.ts +4 -3
- package/package.json +7 -7
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
|
|
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`.
|
|
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
|
-
//
|
|
26
|
-
// `
|
|
27
|
-
//
|
|
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,
|
|
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,
|
|
47
|
-
* rebase invalidated our index math, or derivation threw) so the
|
|
48
|
-
* safely — this must never throw into the shared remote-step
|
|
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
|
-
//
|
|
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 = [];
|
|
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
|
-
|
|
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;
|
|
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 (
|
|
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
|
|
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`.
|
|
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
|
-
//
|
|
21
|
-
// `
|
|
22
|
-
//
|
|
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,
|
|
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,
|
|
40
|
-
* rebase invalidated our index math, or derivation threw) so the
|
|
41
|
-
* safely — this must never throw into the shared remote-step
|
|
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
|
-
//
|
|
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 = [];
|
|
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
|
-
|
|
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;
|
|
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
|
|
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`.
|
|
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
|
-
//
|
|
21
|
-
// `
|
|
22
|
-
//
|
|
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,
|
|
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,
|
|
42
|
-
* rebase invalidated our index math, or derivation threw) so the
|
|
43
|
-
* safely — this must never throw into the shared remote-step
|
|
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
|
-
//
|
|
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 = [];
|
|
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
|
-
|
|
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;
|
|
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 (
|
|
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,
|
|
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`)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@atlaskit/editor-plugin-collab-edit",
|
|
3
|
-
"version": "
|
|
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": "^
|
|
26
|
-
"@atlaskit/editor-plugin-connectivity": "^
|
|
27
|
-
"@atlaskit/editor-plugin-editor-viewmode": "^
|
|
28
|
-
"@atlaskit/editor-plugin-feature-flags": "^
|
|
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.
|
|
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": "^
|
|
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
|
},
|