UNPKG

@atlaskit/editor-plugin-collab-edit

Version:

Collab Edit plugin for @atlaskit/editor-core

314 lines (302 loc) 15.5 kB
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; } };