@atlaskit/editor-plugin-collab-edit
Version:
Collab Edit plugin for @atlaskit/editor-core
314 lines (302 loc) • 15.5 kB
JavaScript
import { slicesEqualIgnoringLocalId } from '@atlaskit/editor-common/collab-agent-review-slice-compare';
import { logException } from '@atlaskit/editor-common/monitoring';
import { expVal } from '@atlaskit/platform-feature-experiments/exp-val';
import { getCollabState } from '@atlaskit/prosemirror-collab';
import { getAgentEditRequester } from './agent-edit-requester';
import { getMarkStepRange, isPositionNeutralStep } from './agent-shimmer-ranges';
// Statsig dynamic config: which BE-streaming `agentType` values may open Review Moment.
// Code default is `[]` (fail closed) until the list is set in Statsig.
export var REVIEW_MOMENT_AGENT_TYPES_CONFIG = 'platform_editor_backend_review_moment_agent_types';
// [CCI-17994] Post Stream Review ("Review moment") recording for BE streaming.
//
// Sibling to `getAgentShimmerRanges`, but for a different consumer: the shimmer
// only needs the NEW extent of added content, whereas the Review moment needs, per
// contiguous change, BOTH the pre-edit slice (for undo) and the new slice (for
// redo), and must keep deletions. The output is a neutral
// `AgentRemoteEditReviewData`; `editor-plugin-ai` turns each segment into a coarse
// `aiContentPositions` entry and reuses the entire FE Review moment pipeline.
//
// That pipeline requires every coarse entry to be a CLOSED, whole-node slice
// occupying exactly `[startPos, endPos]`. If a slice is left OPEN (a partial node,
// e.g. "…\nD") it drops the node wrapper on reconstruction — losing content on undo
// (data-loss bug) and bleeding highlights into neighbouring nodes. So we expand
// every change to whole-node outer boundaries and only emit self-consistent
// entries (see the group loop below).
var clampToDoc = function clampToDoc(doc, pos) {
return Math.min(Math.max(pos, 1), doc.content.size);
};
var topLevelBlockIndexAt = function topLevelBlockIndexAt(doc, pos) {
return doc.resolve(clampToDoc(doc, pos)).index(0);
};
// Outer boundaries (before/after tokens) of the top-level node containing `pos`.
// Slicing between these includes each node's own wrapper tokens, so the slice is
// CLOSED (`openStart === openEnd === 0`) and preserves node type on reconstruction
// (a `heading` stays a heading). `depth === 0` means `pos` sits between top-level
// nodes, so it is already a node boundary.
// Whether `pos` sits INSIDE a top-level node (depth > 0) vs exactly on a node
// boundary (depth 0). Unlike `clampToDoc`, this clamps to `[0, size]` (allowing 0)
// so the doc-start boundary is correctly reported as a boundary, not forced into
// the first node. Used to tell a structural add/remove (zero-width span at a node
// boundary) from a text-only insert/delete inside a surviving node.
var isInsideNode = function isInsideNode(doc, pos) {
return doc.resolve(Math.min(Math.max(pos, 0), doc.content.size)).depth > 0;
};
var topLevelNodeStart = function topLevelNodeStart(doc, pos) {
var $pos = doc.resolve(clampToDoc(doc, pos));
return $pos.depth === 0 ? $pos.pos : $pos.before(1);
};
var topLevelNodeEnd = function topLevelNodeEnd(doc, pos) {
var $pos = doc.resolve(clampToDoc(doc, pos));
return $pos.depth === 0 ? $pos.pos : $pos.after(1);
};
// Map a position in doc_{i+1} forward through the remaining steps to final-doc
// coords. Mirrors the shimmer's `mapToFinalDoc` — a later step's own inserted
// content starts at its `from`; only subsequent steps shift it.
var mapToFinalDoc = function mapToFinalDoc(steps, pos, stepIndex, bias) {
var p = pos;
for (var j = stepIndex + 1; j < steps.length; j++) {
p = steps[j].getMap().map(p, bias);
}
return p;
};
// Map a step's `old` coord (valid in doc_i, this step's input) back to pre-batch
// (`tr.before`) coords by inverting the PRECEDING steps' maps in reverse. Needed to
// recover a deletion's original span: its new extent is zero-width, so it cannot be
// found by inverse-mapping the collapsed new point.
var mapToBeforeDoc = function mapToBeforeDoc(steps, pos, stepIndex, bias) {
var p = pos;
for (var j = stepIndex - 1; j >= 0; j--) {
p = steps[j].getMap().invert().map(p, bias);
}
return p;
};
/**
* A change region tracked in BOTH coordinate spaces: `[from, to]` in the final doc
* (`tr.doc`) and `[origFrom, origTo]` in the pre-batch doc (`tr.before`). Carrying
* the original span explicitly lets a deletion (zero-width final extent) still
* recover its removed content.
*/
/**
* Derive per-change Review moment segments from an agent-authored remote-step
* batch. Returns `null` when there is nothing to record (no agent steps, agentType
* not allowlisted, a rebase invalidated our index math, or derivation threw) so the
* caller can no-op safely — this must never throw into the shared remote-step
* handler.
*
* @param json the raw received step JSON (carries `agentType` / `agentId` / `userId`)
* @param steps the parsed PM steps (index-aligned with `json`)
* @param tr the transaction that applied `steps` (so `tr.before`/`tr.doc`/`tr.mapping` are available)
* @param view the editor view (for the collab rebase guard)
*/
export var getAgentEditSegments = function getAgentEditSegments(json, steps, tr, view) {
var agentEditRequester = getAgentEditRequester(json, view);
if (!agentEditRequester) {
return null;
}
var actorUserId = agentEditRequester.actorUserId,
agentId = agentEditRequester.agentId,
agentType = agentEditRequester.agentType,
isLocalUserRequester = agentEditRequester.isLocalUserRequester;
// Only record Review Moment for agentTypes allowlisted in Statsig. Default `[]` fails
// closed until the config is populated (CCI-18607).
var allowedAgentTypes = expVal(REVIEW_MOMENT_AGENT_TYPES_CONFIG, 'value', []);
if (!allowedAgentTypes.includes(agentType)) {
return null;
}
// Hybrid end-of-edit seam: if the BE/NCS flags a batch as the terminal one, carry
// it so the AI plugin can open review immediately instead of waiting for the
// debounce. Additive marker; absent for streaming (non-final) batches.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
var complete = json.some(function (step) {
return (step === null || step === void 0 ? void 0 : step.agentEditComplete) === true;
});
// Same rebase guard as the shimmer: index-based range math is only valid if any
// rebased-over local steps shifted no positions. Degrade to no recording otherwise.
if (Number(tr.getMeta('rebased')) > 0) {
var _getCollabState$uncon, _getCollabState;
var unconfirmed = (_getCollabState$uncon = (_getCollabState = getCollabState(view.state)) === null || _getCollabState === void 0 ? void 0 : _getCollabState.unconfirmed) !== null && _getCollabState$uncon !== void 0 ? _getCollabState$uncon : [];
if (unconfirmed.some(function (entry) {
return !isPositionNeutralStep(entry.step);
})) {
return null;
}
}
try {
// Each agent step's changed extent in FINAL-doc coords, taken from its StepMap
// (the canonical, step-type-agnostic source). Unlike the shimmer we KEEP
// zero-width new extents — a pure deletion has `newEnd === newStart` but is a
// reviewable `remove`.
var ranges = [];
json.forEach(function (rawStep, index) {
if (typeof (rawStep === null || rawStep === void 0 ? void 0 : rawStep.agentType) !== 'string') {
return;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
var pmStep = steps[index];
if (typeof (pmStep === null || pmStep === void 0 ? void 0 : pmStep.getMap) !== 'function') {
return;
}
// Mark-only steps have an empty StepMap, so the getMap().forEach below never fires for them.
// Derive their range from step.from/.to so a formatting-only agent edit still becomes a segment.
var markRange = getMarkStepRange(steps[index]);
if (markRange) {
var mappedFrom = mapToFinalDoc(steps, markRange.from, index, -1);
var mappedTo = mapToFinalDoc(steps, markRange.to, index, 1);
var mappedOrigFrom = mapToBeforeDoc(steps, markRange.from, index, -1);
var mappedOrigTo = mapToBeforeDoc(steps, markRange.to, index, 1);
if (mappedTo > mappedFrom) {
try {
var before = tr.before.slice(mappedOrigFrom, mappedOrigTo);
var after = tr.doc.slice(mappedFrom, mappedTo);
if (!before.content.eq(after.content)) {
ranges.push({
from: mappedFrom,
to: mappedTo,
origFrom: mappedOrigFrom,
origTo: mappedOrigTo
});
}
} catch (error) {
// Degrade gracefully (skip this step's range) but track the error so a silent
// fall-through does not hide a systemic slicing problem.
logException(error, {
location: 'editor-plugin-collab-edit/agent-review-segments/markStepSlice'
});
}
}
return;
}
pmStep.getMap().forEach(function (oldStart, oldEnd, newStart, newEnd) {
var mappedFrom = mapToFinalDoc(steps, newStart, index, -1);
var mappedTo = mapToFinalDoc(steps, newEnd, index, 1);
// The same change in pre-batch coords, so a deletion (zero-width new
// extent) still carries its original span.
var mappedOrigFrom = mapToBeforeDoc(steps, oldStart, index, -1);
var mappedOrigTo = mapToBeforeDoc(steps, oldEnd, index, 1);
// Skip position-neutral phantom touches: the collab apply stamps same-size
// re-writes on unrelated nodes (e.g. `localId` on panels). Drop them only
// when byte-identical, so a real same-size replacement is preserved.
if (oldEnd - oldStart === newEnd - newStart) {
try {
var _before = tr.before.slice(oldStart, oldEnd);
var _after = tr.doc.slice(mappedFrom, mappedTo);
if (_before.content.eq(_after.content)) {
return;
}
} catch (_unused) {
// Comparison unsafe: keep the range; whole-block expansion + the
// later identical-content skip still guard against phantoms.
}
}
ranges.push({
from: mappedFrom,
to: mappedTo,
origFrom: mappedOrigFrom,
origTo: mappedOrigTo
});
});
});
if (!ranges.length) {
return null;
}
// Coalesce fragments in the same/adjacent top-level block into one region (a
// single agent edit arrives as many small replace fragments); an untouched block
// between them splits the run so far-apart edits stay separate. Each region
// becomes one coarse segment; the AI plugin refines it further.
var sorted = [].concat(ranges).sort(function (a, b) {
return a.from - b.from || a.to - b.to;
});
var groups = [];
sorted.forEach(function (range) {
var block = topLevelBlockIndexAt(tr.doc, range.from);
var current = groups[groups.length - 1];
// Coalesce changes in the same or directly-adjacent top-level block (index n /
// n+1) into one region, exactly like the shimmer — a single agent edit arrives
// as many small fragments. A genuinely untouched block in between (index gap
// > 1) splits the run, keeping far-apart edits separate. The original span is
// unioned in parallel so a deletion's removed content is preserved.
if (current && block <= current.maxBlock + 1) {
current.to = Math.max(current.to, range.to);
current.maxBlock = Math.max(current.maxBlock, block);
current.origFrom = Math.min(current.origFrom, range.origFrom);
current.origTo = Math.max(current.origTo, range.origTo);
} else {
groups.push({
from: range.from,
to: range.to,
maxBlock: block,
origFrom: range.origFrom,
origTo: range.origTo
});
}
});
var segments = [];
groups.forEach(function (group) {
try {
// Expand the NEW extent to whole-node outer boundaries → a CLOSED slice.
// A zero-width new extent at a top-level node boundary is a structural
// deletion: leave it empty (do NOT whole-node expand, or it would grab the
// unchanged neighbour node that now sits at that point and be discarded as an
// identical phantom). A zero-width new extent INSIDE a node is a text-only
// deletion from a surviving node, so expand to the whole node (an `update`).
var newIsStructuralRemove = group.from === group.to && !isInsideNode(tr.doc, group.from);
var newFrom = topLevelNodeStart(tr.doc, Math.min(group.from, group.to));
var newTo = newIsStructuralRemove ? newFrom : topLevelNodeEnd(tr.doc, Math.max(group.from, group.to));
var newSlice = tr.doc.slice(newFrom, newTo);
// Build the ORIGINAL slice from the carried pre-batch span, expanded to whole
// nodes → a CLOSED slice. Using the carried `origFrom/origTo` (from the
// StepMap `old` coords) rather than inverse-mapping the new bounds is what
// lets a deletion recover its removed content.
//
// A zero-width original span is only a true structural ADD when it sits at a
// top-level node boundary (inserting a whole new node). A zero-width span
// INSIDE a node is a text insertion into that node, so expand to the whole
// node (an `update`) — matching how an in-place edit reviews.
var origIsStructuralAdd = group.origFrom === group.origTo && !isInsideNode(tr.before, group.origFrom);
var origFrom = topLevelNodeStart(tr.before, Math.min(group.origFrom, group.origTo));
var origTo = origIsStructuralAdd ? origFrom : topLevelNodeEnd(tr.before, Math.max(group.origFrom, group.origTo));
var originalSlice = tr.before.slice(origFrom, origTo);
var originalEmpty = originalSlice.content.size === 0;
var newEmpty = newSlice.content.size === 0;
// A truly empty-to-empty region is not a change — skip it.
if (originalEmpty && newEmpty) {
return;
}
// Skip phantom artifacts: a same-size StepMap "touch" at an unrelated node
// (e.g. a `localId` stamp on a panel during collab apply) expands to a
// segment whose original and new content are identical — not a real change.
// Layout-only marks (`breakout`) and `localId`s are ignored so a
// breakout-width-only change is not recorded as a coarse segment: the FE
// Review filter drops it anyway, so recording it here would anchor the modal
// to a node with no visible diff.
if (slicesEqualIgnoringLocalId(originalSlice, newSlice)) {
return;
}
var kind = originalEmpty ? 'add' : newEmpty ? 'remove' : 'update';
segments.push({
startPos: newFrom,
endPos: newTo,
originalSlice: originalSlice,
newSlice: newSlice,
kind: kind
});
} catch (_unused2) {
// One bad group must not drop the others.
}
});
if (!segments.length) {
return null;
}
return {
actorUserId: actorUserId,
agentId: agentId,
agentType: agentType,
complete: complete,
isLocalUserRequester: isLocalUserRequester,
segments: segments
};
} catch (_unused3) {
// Never throw into the shared remote-step handler; degrade to no recording.
return null;
}
};