UNPKG

@atlaskit/editor-plugin-collab-edit

Version:

Collab Edit plugin for @atlaskit/editor-core

269 lines (261 loc) 13.9 kB
import { slicesEqualIgnoringLocalId } from '@atlaskit/editor-common/collab-agent-review-slice-compare'; import { logException } from '@atlaskit/editor-common/monitoring'; import { AddMarkStep, RemoveMarkStep } from '@atlaskit/editor-prosemirror/transform'; import { getCollabState } from '@atlaskit/prosemirror-collab'; import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals'; // When an agent step lands we cover the top-level block(s) it wrote with a skeleton-loader shimmer // (plus a Rovo agent telepointer at the end of the range), then remove it on a timer to reveal the // content. Gated behind the `platform_editor_agent_be_streaming` experiment. `shimmerDurationMs` and // `highlightDurationMs` size the skeleton and purple-highlight phases and toggle independently: `0` on // either skips that phase, `0` on both shows nothing. var agentShimmerIdCounter = 0; // A step is position-neutral when its StepMap changes no range's length — i.e. it shifts no // positions. Attribute-only steps (e.g. `localId` assignment) produce an empty StepMap, and // same-size replacements preserve lengths, so both are position-neutral. Used to decide whether a // rebase over local unconfirmed steps could have invalidated our index-based range math. export var isPositionNeutralStep = function isPositionNeutralStep(step) { var neutral = true; step.getMap().forEach(function (oldStart, oldEnd, newStart, newEnd) { if (oldEnd - oldStart !== newEnd - newStart) { neutral = false; } }); return neutral; }; // Mark-only steps (AddMarkStep / RemoveMarkStep) change no positions, so their StepMap is empty and // `getMap().forEach` never yields a range. Their affected span is carried directly on `step.from` / // `step.to` (valid in the step's input-doc coordinates). Returning it lets the range builders below // treat a formatting-only agent edit (e.g. bold) as a real change rather than silently dropping it. export var getMarkStepRange = function getMarkStepRange(step) { if (step instanceof AddMarkStep || step instanceof RemoveMarkStep) { return { from: step.from, to: step.to }; } return null; }; // Top-level block/position helpers over a doc. Kept at module scope (rather than re-created as // closures on every call) so they are defined once, reusable, and unit-testable. Each clamps into // valid document coordinates before resolving. 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); }; // Start of the content of the top-level block containing `pos`, so the whole block is covered. var topLevelBlockContentStart = function topLevelBlockContentStart(doc, pos) { var $pos = doc.resolve(clampToDoc(doc, pos)); return $pos.depth >= 1 ? $pos.start(1) : pos; }; // End of the content of the top-level block containing `pos`, so the whole block is covered. var topLevelBlockContentEnd = function topLevelBlockContentEnd(doc, pos) { var $pos = doc.resolve(clampToDoc(doc, pos)); return $pos.depth >= 1 ? $pos.end(1) : pos; }; /** * Derive the shimmer ranges for the agent-authored steps in a received batch, in final-doc * coordinates. `agentType` present ⇒ agent-authored (per the NCS↔Editor steps contract). Each * emitted range is expanded to the whole top-level block(s) the agent touched, which the plugin * covers with the skeleton shimmer. Ranges with no new content (pure deletions) are dropped. * * Steps whose new content is in the same or directly-adjacent top-level block are coalesced into one * range, so an edit that arrives as several steps in a region shimmers as a single unit. Edits * separated by an untouched block stay independent. * * Correctness: the range math assumes `tr` is a linear 1:1 apply of `steps`. Under the native collab * plugin, `receiveTransaction` rebases incoming steps over unconfirmed local steps when they exist; * that only invalidates our positions if a local step shifted positions, so we skip solely when a * rebased local step changed sizes (a rare, safe degrade). Any unexpected error also degrades to no * shimmer, so this never throws into the shared remote-step handler. */ var deriveAgentShimmerRanges = function deriveAgentShimmerRanges(json, steps, tr, view, shimmerDurationMs, highlightDurationMs, telepointerEnabled, onNotShown) { var _json$find; // The two phases toggle independently: `shimmerDurationMs` sizes the skeleton, `highlightDurationMs` // sizes the purple highlight, and `0` on either skips just that phase. Nothing to reveal only when // both are off. if (shimmerDurationMs <= 0 && highlightDurationMs <= 0) { return []; } // Start in the skeleton phase when it's enabled, otherwise straight into the purple highlight phase // (skeleton toggled off, highlight on). var initialPhase = shimmerDurationMs > 0 ? 'skeleton' : 'highlight'; // Telepointer label = the agent's type upper-cased (e.g. `mcp` → "MCP"), falling back to a generic // "Agent"; `undefined` when the telepointer is disabled, so the plugin skips it. `agentType` is the // same on every step of an agent batch, so read it from the first agent-authored step. // eslint-disable-next-line @typescript-eslint/no-explicit-any var agentType = (_json$find = json.find(function (step) { return typeof (step === null || step === void 0 ? void 0 : step.agentType) === 'string'; })) === null || _json$find === void 0 ? void 0 : _json$find.agentType; // No agent-authored steps in this batch — nothing to shimmer, and not a "not shown" case. if (agentType === undefined) { return []; } var telepointerLabel = telepointerEnabled ? agentType.toUpperCase() || 'Agent' : undefined; // When the batch was rebased over local unconfirmed steps, our index-based range math is only // valid if those local steps shifted no positions. Attribute-only steps (e.g. `localId`) and // same-size replacements are position-neutral, so the shimmer stays correct. Skip only when a // local step actually changed sizes (a genuine concurrent content edit). 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); })) { onNotShown === null || onNotShown === void 0 || onNotShown('rebasedConcurrentEdit', agentType); return []; } } try { // Map an inserted range in doc_{i+1} forward through the remaining steps to final-doc coords. // (A later step's own inserted content starts at its `from`; only subsequent steps shift it.) var mapToFinalDoc = function mapToFinalDoc(pos, stepIndex, bias) { var p = pos; for (var j = stepIndex + 1; j < steps.length; j++) { p = steps[j].getMap().map(p, bias); } return p; }; // Derive each agent step's changed ranges from its StepMap — the canonical, step-type-agnostic // source of what a step wrote. We only need the NEW extent (the content now in the document), in // final-doc coords, since the highlight decorates content that is already present. var infos = []; json.forEach(function (rawStep, index) { var _tr$docs$index2; 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 instead so formatting-only agent edits still shimmer. var markRange = getMarkStepRange(steps[index]); if (markRange) { var _tr$docs$index; var beforeDocForMark = (_tr$docs$index = tr.docs[index]) !== null && _tr$docs$index !== void 0 ? _tr$docs$index : tr.before; var from = mapToFinalDoc(markRange.from, index, -1); var to = mapToFinalDoc(markRange.to, index, 1); if (to > from) { try { var before = beforeDocForMark.slice(markRange.from, markRange.to); var after = tr.doc.slice(from, to); if (!slicesEqualIgnoringLocalId(before, after)) { infos.push({ from: from, to: to }); } } catch (error) { // Degrade gracefully (no shimmer for this step) but track the error so a silent // fall-through does not hide a systemic slicing problem. logException(error, { location: 'editor-plugin-collab-edit/agent-shimmer-ranges/markStepSlice' }); } } return; } // The doc this step was applied to (its `old` coords resolve here). `tr.docs[index]` is the // document state before step `index`; fall back to the batch's before-doc for the first step. var beforeDoc = (_tr$docs$index2 = tr.docs[index]) !== null && _tr$docs$index2 !== void 0 ? _tr$docs$index2 : tr.before; pmStep.getMap().forEach(function (oldStart, oldEnd, newStart, newEnd) { if (newEnd <= newStart) { return; // pure deletion / attribute-only — no new content to highlight } var from = mapToFinalDoc(newStart, index, -1); var to = mapToFinalDoc(newEnd, index, 1); // Skip phantom same-size re-writes (e.g. a `localId`/breakout re-stamp on an untouched // panel): their StepMap reports new content but nothing visibly changed. Drop only when // before/after are equal ignoring `localId` and layout marks, so real same-size edits // still shimmer. Mirrors the Review-moment segment producer's guard. if (oldEnd - oldStart === newEnd - newStart) { try { var _before = beforeDoc.slice(oldStart, oldEnd); var _after = tr.doc.slice(from, to); if (slicesEqualIgnoringLocalId(_before, _after)) { return; } } catch (_unused) { // Comparison unsafe: keep the range; whole-block expansion still renders it. } } infos.push({ from: from, to: to }); }); }); if (!infos.length) { onNotShown === null || onNotShown === void 0 || onNotShown('nothingToShow', agentType); return []; } // Group the changed ranges by the TOP-LEVEL block they landed in, then highlight each touched // block IN FULL. NCS emits a single agent edit as many small replace fragments and often keeps a // common prefix/suffix untouched, so the changed sub-ranges cover only part of a block (e.g. half // a rewritten heading). Expanding to the whole block's content makes the entire heading/paragraph // shimmer as one unit rather than leaving the unchanged half undecorated. Directly-adjacent // touched blocks (index n and n+1) merge into one group so a multi-block rewrite reveals together; // a genuinely untouched block in between (index gap > 1) splits the run, so far-apart edits stay // independent. var sorted = [].concat(infos).sort(function (a, b) { return a.from - b.from || a.to - b.to; }); var groups = []; sorted.forEach(function (info) { var block = topLevelBlockIndexAt(tr.doc, info.from); var current = groups[groups.length - 1]; if (current && block <= current.maxBlock + 1) { current.to = Math.max(current.to, info.to); current.maxBlock = Math.max(current.maxBlock, block); } else { groups.push({ from: info.from, to: info.to, maxBlock: block }); } }); return groups.map(function (group) { return { shimmerId: "agent-shimmer-".concat(agentShimmerIdCounter++), from: topLevelBlockContentStart(tr.doc, group.from), to: topLevelBlockContentEnd(tr.doc, group.to), telepointerLabel: telepointerLabel, phase: initialPhase, highlightDurationMs: highlightDurationMs }; }); } catch (err) { // Never let shimmer range derivation throw into the shared remote-step handler; degrade to no // shimmer. onNotShown === null || onNotShown === void 0 || onNotShown('captureThrew', agentType, err); return []; } }; export var getAgentShimmerRanges = function getAgentShimmerRanges(json, steps, tr, view, shimmerDurationMs, highlightDurationMs, telepointerEnabled, onNotShown) { if (!expValEquals('platform_editor_agent_be_streaming', 'isEnabled', true)) { return []; } return deriveAgentShimmerRanges(json, steps, tr, view, shimmerDurationMs, highlightDurationMs, telepointerEnabled, onNotShown); }; /** * Produces only the neutral changed ranges needed by the shared AI chrome. * The caller supplies lifetime configuration and any presentation override separately, * together with these ranges, in `AgentEditChromeData`. */ export var getAgentEditChromeRanges = function getAgentEditChromeRanges(json, steps, tr, view, onNotShown) { // Reuse legacy range derivation with skeleton and telepointer output disabled. A non-zero // highlight duration keeps capture enabled; the returned legacy phase and duration are discarded. return deriveAgentShimmerRanges(json, steps, tr, view, 0, 1, false, onNotShown).map(function (_ref) { var from = _ref.from, to = _ref.to; return { from: from, to: to }; }); };