@atlaskit/editor-plugin-collab-edit 13.1.2 → 13.2.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.
@@ -9,7 +9,8 @@ import { Step } from '@atlaskit/editor-prosemirror/transform';
9
9
  import { receiveTransaction } from '@atlaskit/prosemirror-collab';
10
10
  import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals';
11
11
  import { expVal } from '@atlaskit/tmp-editor-statsig/expVal';
12
- import { ADD_AGENT_SHIMMER_META, AGENT_SHIMMER_DEFAULT_DURATION_MS, REMOVE_AGENT_SHIMMER_META } from './main/agent-shimmer-decorations';
12
+ import { getAgentEditShimmerNotShownPayload } from './analytics';
13
+ import { ADD_AGENT_SHIMMER_META, AGENT_EDIT_HIGHLIGHT_DEFAULT_DURATION_MS, AGENT_SHIMMER_DEFAULT_DURATION_MS, HIGHLIGHT_AGENT_SHIMMER_META, REMOVE_AGENT_SHIMMER_META } from './main/agent-shimmer-decorations';
13
14
  import { getAgentShimmerRanges } from './main/agent-shimmer-ranges';
14
15
  import { replaceDocument } from './utils';
15
16
 
@@ -35,7 +36,7 @@ export const handleInit = (initData, view, options, editorAnalyticsApi) => {
35
36
  tr.setMeta('isRemote', true);
36
37
  view.dispatch(tr);
37
38
  } else if (json) {
38
- applyRemoteSteps(json, view);
39
+ applyRemoteSteps(json, view, undefined, options, editorAnalyticsApi);
39
40
  }
40
41
  };
41
42
  export const handleConnection = (connectionData, view) => {
@@ -54,16 +55,16 @@ export const handlePresence = (presenceData, view) => {
54
55
  } = view;
55
56
  view.dispatch(tr.setMeta('presence', presenceData));
56
57
  };
57
- export const applyRemoteData = (remoteData, view, options) => {
58
+ export const applyRemoteData = (remoteData, view, options, editorAnalyticsApi) => {
58
59
  const {
59
60
  json,
60
61
  userIds = []
61
62
  } = remoteData;
62
63
  if (json) {
63
- applyRemoteSteps(json, view, userIds, options);
64
+ applyRemoteSteps(json, view, userIds, options, editorAnalyticsApi);
64
65
  }
65
66
  };
66
- export const applyRemoteSteps = (json, view, userIds, options) => {
67
+ export const applyRemoteSteps = (json, view, userIds, options, editorAnalyticsApi) => {
67
68
  if (!json || !json.length) {
68
69
  return;
69
70
  }
@@ -88,16 +89,26 @@ export const applyRemoteSteps = (json, view, userIds, options) => {
88
89
  tr.setMeta('isRemote', true);
89
90
 
90
91
  // Agent edit shimmer: mark the ranges agent steps just wrote so the plugin reveals them with the
91
- // gloss-sweep purple-highlight sequence. Gated as a whole so no experiment reads run
92
- // off-experiment; off-path leaves `agentShimmers` empty and everything below is a no-op.
93
- let durationMs = 0;
92
+ // skeleton loader, then (optionally) a purple "just edited" highlight. Gated as a whole so no
93
+ // experiment reads run off-experiment; off-path leaves `agentShimmers` empty and everything below
94
+ // is a no-op.
95
+ let shimmerDurationMs = 0;
96
+ let highlightDurationMs = 0;
94
97
  let agentShimmers = [];
95
98
  if (expValEquals('platform_editor_agent_be_streaming', 'isEnabled', true)) {
96
- durationMs = expVal('platform_editor_agent_be_streaming', 'durationMs', AGENT_SHIMMER_DEFAULT_DURATION_MS);
99
+ // The skeleton and purple-highlight phases toggle independently. `shimmerDurationMs` is the
100
+ // skeleton lifetime; `0` skips the skeleton (an edit can still get the purple highlight).
101
+ shimmerDurationMs = expVal('platform_editor_agent_be_streaming', 'shimmerDurationMs', AGENT_SHIMMER_DEFAULT_DURATION_MS);
102
+ // `highlightDurationMs` is the purple "just edited" highlight lifetime; `0` skips the highlight
103
+ // (the skeleton still shows). `0` on both shows nothing.
104
+ highlightDurationMs = expVal('platform_editor_agent_be_streaming', 'highlightDurationMs', AGENT_EDIT_HIGHLIGHT_DEFAULT_DURATION_MS);
97
105
  // Telepointer shown by default; `telepointerDisabled` hides it (inverted because `expVal`
98
106
  // only permits `false` as a boolean default).
99
107
  const telepointerEnabled = !expVal('platform_editor_agent_be_streaming', 'telepointerDisabled', false);
100
- agentShimmers = getAgentShimmerRanges(json, steps, tr, view, durationMs, telepointerEnabled);
108
+ agentShimmers = getAgentShimmerRanges(json, steps, tr, view, shimmerDurationMs, highlightDurationMs, telepointerEnabled,
109
+ // An agent edit that applied without the shimmer fires the operational event on the same
110
+ // transaction, so success stays event-free and only degrades are reported.
111
+ (reason, agentType, error) => editorAnalyticsApi === null || editorAnalyticsApi === void 0 ? void 0 : editorAnalyticsApi.attachAnalyticsEvent(getAgentEditShimmerNotShownPayload(reason, agentType, error))(tr));
101
112
  if (agentShimmers.length) {
102
113
  tr.setMeta(ADD_AGENT_SHIMMER_META, agentShimmers);
103
114
  }
@@ -113,23 +124,46 @@ export const applyRemoteSteps = (json, view, userIds, options) => {
113
124
  }
114
125
  view.dispatch(tr);
115
126
 
116
- // Remove each shimmer after `durationMs`. `dispatchMeta` is guarded so a timer firing after
127
+ // Schedule each shimmer's phase changes. `dispatchMeta` is guarded so a timer firing after
117
128
  // teardown is a harmless no-op. Only reached when the gated block above produced ranges, so this
118
129
  // whole path is off-experiment-safe.
119
130
  if (agentShimmers.length) {
131
+ let tornDownReported = false;
120
132
  const dispatchMeta = (metaKey, value) => {
121
133
  try {
122
134
  view.dispatch(view.state.tr.setMeta(metaKey, value));
123
135
  } catch {
124
- // View torn down before the timer fired — nothing to clean up.
136
+ // View torn down before the timer fired — nothing to clean up. Report once (not once
137
+ // per shimmer) so we can see how often shimmers are interrupted by teardown.
138
+ if (!tornDownReported) {
139
+ tornDownReported = true;
140
+ editorAnalyticsApi === null || editorAnalyticsApi === void 0 ? void 0 : editorAnalyticsApi.fireAnalyticsEvent(getAgentEditShimmerNotShownPayload('tornDownMidAnimation'));
141
+ }
125
142
  }
126
143
  };
127
144
  agentShimmers.forEach(({
128
145
  shimmerId
129
146
  }) => {
130
- setTimeout(() => {
131
- dispatchMeta(REMOVE_AGENT_SHIMMER_META, shimmerId);
132
- }, durationMs);
147
+ if (shimmerDurationMs > 0) {
148
+ // Skeleton first. When it clears, show the purple highlight (if enabled) then remove;
149
+ // otherwise remove straight away.
150
+ setTimeout(() => {
151
+ if (highlightDurationMs > 0) {
152
+ dispatchMeta(HIGHLIGHT_AGENT_SHIMMER_META, shimmerId);
153
+ setTimeout(() => {
154
+ dispatchMeta(REMOVE_AGENT_SHIMMER_META, shimmerId);
155
+ }, highlightDurationMs);
156
+ } else {
157
+ dispatchMeta(REMOVE_AGENT_SHIMMER_META, shimmerId);
158
+ }
159
+ }, shimmerDurationMs);
160
+ } else {
161
+ // Skeleton disabled: the range already starts in the highlight phase, so just time its
162
+ // removal. (Reached only when `highlightDurationMs > 0`, else no ranges were produced.)
163
+ setTimeout(() => {
164
+ dispatchMeta(REMOVE_AGENT_SHIMMER_META, shimmerId);
165
+ }, highlightDurationMs);
166
+ }
133
167
  });
134
168
  }
135
169
  }
@@ -26,6 +26,29 @@ export const addSynchronyErrorAnalytics = (state, tr, featureFlags, editorAnalyt
26
26
  return tr;
27
27
  };
28
28
  };
29
+
30
+ /**
31
+ * Builds the `agentEditShimmerNotShown` operational event fired when an agent-authored edit applied
32
+ * instantly without the skeleton shimmer/telepointer. Success is the ABSENCE of this event; a
33
+ * neutral action + `reason` keeps expected degrades out of error dashboards. Attributes are non-PII:
34
+ * only the agent kind and a sanitised error name/message (never the raw Error or any doc content).
35
+ */
36
+ export const getAgentEditShimmerNotShownPayload = (reason, agentType, error) => ({
37
+ action: ACTION.AGENT_EDIT_SHIMMER_NOT_SHOWN,
38
+ actionSubject: ACTION_SUBJECT.COLLAB,
39
+ eventType: EVENT_TYPE.OPERATIONAL,
40
+ attributes: {
41
+ reason,
42
+ // Spread conditionally so absent context isn't emitted as `undefined` (the teardown path has no
43
+ // single agent, and non-throw reasons have no error).
44
+ ...(agentType ? {
45
+ agentType
46
+ } : {}),
47
+ ...(error ? {
48
+ error: `${error.name}: ${error.message}`
49
+ } : {})
50
+ }
51
+ });
29
52
  export const addSynchronyEntityAnalytics = (state, tr) => {
30
53
  return (type, editorAnalyticsApi) => {
31
54
  editorAnalyticsApi === null || editorAnalyticsApi === void 0 ? void 0 : editorAnalyticsApi.attachAnalyticsEvent({
@@ -44,7 +44,7 @@ export const subscribe = effect((view, provider, options, featureFlags, _provide
44
44
  handleInit(data, view, options, editorAnalyticsApi);
45
45
  },
46
46
  connectedHandler: data => handleConnection(data, view),
47
- dataHandler: data => applyRemoteData(data, view, options),
47
+ dataHandler: data => applyRemoteData(data, view, options, editorAnalyticsApi),
48
48
  presenceHandler: data => handlePresence(data, view),
49
49
  telepointerHandler: data => handleTelePointer(data, view),
50
50
  localStepsHandler: data => {
@@ -6,17 +6,31 @@ import { Decoration } from '@atlaskit/editor-prosemirror/view';
6
6
 
7
7
  /** Default time the skeleton shimmer stays on the agent-authored content (ms). */
8
8
  export const AGENT_SHIMMER_DEFAULT_DURATION_MS = 3000;
9
+ /**
10
+ * Default time the purple "just edited" highlight stays after the skeleton clears (ms); `0` disables
11
+ * the highlight phase so the shimmer just reveals the content.
12
+ */
13
+ export const AGENT_EDIT_HIGHLIGHT_DEFAULT_DURATION_MS = 2000;
9
14
 
10
15
  // Skeleton-loader bar over the agent-authored range, plus a Rovo AI telepointer at the end.
11
16
  export const AGENT_SHIMMER_CLASS = 'collab-agent-shimmer';
17
+ // Purple "just edited" highlight shown over the range after the skeleton clears — same style as the
18
+ // editor AI "improve writing" in-editor highlight (subtle purple background + dashed purple underline).
19
+ export const AGENT_EDIT_HIGHLIGHT_CLASS = 'collab-agent-edit-highlight';
12
20
  export const ROVO_AGENT_TELEPOINTER_CLASS = 'ai-in-editor-telepointer';
13
21
  export const ROVO_AGENT_TELEPOINTER_LABEL_CLASS = 'ai-in-editor-telepointer-label';
14
22
  export const ADD_AGENT_SHIMMER_META = 'addAgentShimmer'; // register the shimmer decorations
23
+ export const HIGHLIGHT_AGENT_SHIMMER_META = 'highlightAgentShimmer'; // skeleton → purple highlight phase
15
24
  export const REMOVE_AGENT_SHIMMER_META = 'removeAgentShimmer'; // remove them once the shimmer ends
16
25
 
17
- // A range an agent step wrote; the skeleton + telepointer decorations are drawn over `from`..`to`
18
- // and kept until removal (so positions can be re-mapped). Pure data only. `telepointerLabel` is the
19
- // label for the trailing agent telepointer (the agent's type); when absent, no telepointer is shown.
26
+ // A shimmer runs in two phases: the `skeleton` loader, then (optionally) the purple `highlight` over
27
+ // the revealed content, before removal.
28
+
29
+ // A range an agent step wrote; the shimmer decorations are drawn over `from`..`to` and kept until
30
+ // removal (so positions can be re-mapped). Pure data only. `phase` selects skeleton vs purple
31
+ // highlight. `telepointerLabel` is the label for the trailing agent telepointer (shown through both
32
+ // phases); when absent, no telepointer is shown. `highlightDurationMs` is the purple-highlight
33
+ // lifetime, used to size its ease in/out animation so it matches the removal timer.
20
34
 
21
35
  // Rovo AI in-editor telepointer/cursor shown at the end of an agent-authored range (same DOM/style
22
36
  // pattern as editor-plugin-ai's in-editor direct-streaming telepointer).
@@ -38,7 +52,7 @@ const createRovoAgentTelepointer = label => {
38
52
  * supersedes any still-in-flight shimmer), and drops a range when its removal timer fires. Returns a
39
53
  * fresh array (never mutates in place) plus whether anything changed.
40
54
  */
41
- export const reduceAgentShimmers = (current, tr, added, removedShimmerId) => {
55
+ export const reduceAgentShimmers = (current, tr, added, removedShimmerId, highlightShimmerId) => {
42
56
  let next = current;
43
57
  let changed = false;
44
58
 
@@ -58,6 +72,16 @@ export const reduceAgentShimmers = (current, tr, added, removedShimmerId) => {
58
72
  }));
59
73
  changed = true;
60
74
  }
75
+ // Transition a shimmer from the skeleton phase to the purple highlight phase (skeleton timer fired).
76
+ // Guard on an actual match so a timer firing after the shimmer was already removed (superseded by a
77
+ // new batch, or cleared) doesn't rebuild the array and trigger a pointless decoration rebuild.
78
+ if (highlightShimmerId && next.some(shimmer => shimmer.shimmerId === highlightShimmerId)) {
79
+ next = next.map(shimmer => shimmer.shimmerId === highlightShimmerId ? {
80
+ ...shimmer,
81
+ phase: 'highlight'
82
+ } : shimmer);
83
+ changed = true;
84
+ }
61
85
  if (removedShimmerId) {
62
86
  next = next.filter(shimmer => shimmer.shimmerId !== removedShimmerId);
63
87
  changed = true;
@@ -69,34 +93,46 @@ export const reduceAgentShimmers = (current, tr, added, removedShimmerId) => {
69
93
  };
70
94
 
71
95
  /**
72
- * Builds the inline skeleton-bar + trailing telepointer decorations for the active shimmer ranges.
73
- * `getValidPos` clamps a raw position to a valid decoration position (owned by `plugin-state`).
74
- * One bad range is isolated via `onError` so it can't kill the others.
96
+ * Builds the inline decorations (skeleton bar or purple highlight, per phase) plus the trailing
97
+ * telepointer for the active shimmer ranges. `getValidPos` clamps a raw position to a valid
98
+ * decoration position (owned by `plugin-state`). One bad range is isolated via `onError` so it can't
99
+ * kill the others.
75
100
  */
76
101
  export const buildAgentShimmerDecorations = (tr, shimmers, getValidPos, onError) => {
77
102
  const decorations = [];
78
- const docEnd = tr.doc.nodeSize - 2;
79
103
  shimmers.forEach(({
80
104
  shimmerId,
81
105
  from,
82
106
  to,
83
- telepointerLabel
107
+ telepointerLabel,
108
+ phase,
109
+ highlightDurationMs
84
110
  }) => {
85
111
  try {
112
+ // `getValidPos` already clamps to the last valid position, so only the lower bound needs
113
+ // guarding here (a raw `from < 1` would throw in `doc.resolve`).
86
114
  const validFrom = getValidPos(tr, Math.max(from, 1));
87
- const validTo = getValidPos(tr, Math.min(to, docEnd));
115
+ const validTo = getValidPos(tr, to);
88
116
  if (validTo <= validFrom) {
89
117
  return;
90
118
  }
91
- // Skeleton-loader bar over the whole agent-authored range...
92
- decorations.push(Decoration.inline(validFrom, validTo, {
93
- class: AGENT_SHIMMER_CLASS
94
- }, {
119
+ // Inline decoration over the whole range: the grey skeleton loader, or (once revealed) the
120
+ // purple "just edited" highlight. The highlight eases in and out over its lifetime via a CSS
121
+ // animation whose duration is set inline so it matches the removal timer.
122
+ const isHighlight = phase === 'highlight';
123
+ const inlineAttrs = {
124
+ class: isHighlight ? AGENT_EDIT_HIGHLIGHT_CLASS : AGENT_SHIMMER_CLASS
125
+ };
126
+ if (isHighlight && highlightDurationMs > 0) {
127
+ inlineAttrs.style = `animation-duration: ${highlightDurationMs}ms`;
128
+ }
129
+ decorations.push(Decoration.inline(validFrom, validTo, inlineAttrs, {
95
130
  isAgentShimmer: true,
96
131
  shimmerId
97
132
  }));
98
- // ...and, when enabled, a Rovo AI telepointer/cursor (labelled with the agent's type) at the
99
- // end of the range.
133
+ // Rovo AI telepointer/cursor (labelled with the agent's type) at the end of the range, shown
134
+ // through BOTH the skeleton and purple-highlight phases so the agent's cursor stays put until
135
+ // the edit is fully revealed.
100
136
  if (telepointerLabel) {
101
137
  decorations.push(Decoration.widget(validTo, createRovoAgentTelepointer(telepointerLabel), {
102
138
  isAgentShimmer: true,
@@ -2,8 +2,9 @@ import { getCollabState } from '@atlaskit/prosemirror-collab';
2
2
  import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals';
3
3
  // When an agent step lands we cover the top-level block(s) it wrote with a skeleton-loader shimmer
4
4
  // (plus a Rovo agent telepointer at the end of the range), then remove it on a timer to reveal the
5
- // content. Gated behind the `platform_editor_agent_be_streaming` experiment; `durationMs` sets how
6
- // long the shimmer stays and a 0 duration disables it.
5
+ // content. Gated behind the `platform_editor_agent_be_streaming` experiment. `shimmerDurationMs` and
6
+ // `highlightDurationMs` size the skeleton and purple-highlight phases and toggle independently: `0` on
7
+ // either skips that phase, `0` on both shows nothing.
7
8
  let agentShimmerIdCounter = 0;
8
9
 
9
10
  // A step is position-neutral when its StepMap changes no range's length — i.e. it shifts no
@@ -20,6 +21,22 @@ export const isPositionNeutralStep = step => {
20
21
  return neutral;
21
22
  };
22
23
 
24
+ // Top-level block/position helpers over a doc. Kept at module scope (rather than re-created as
25
+ // closures on every call) so they are defined once, reusable, and unit-testable. Each clamps into
26
+ // valid document coordinates before resolving.
27
+ const clampToDoc = (doc, pos) => Math.min(Math.max(pos, 1), doc.content.size);
28
+ const topLevelBlockIndexAt = (doc, pos) => doc.resolve(clampToDoc(doc, pos)).index(0);
29
+ // Start of the content of the top-level block containing `pos`, so the whole block is covered.
30
+ const topLevelBlockContentStart = (doc, pos) => {
31
+ const $pos = doc.resolve(clampToDoc(doc, pos));
32
+ return $pos.depth >= 1 ? $pos.start(1) : pos;
33
+ };
34
+ // End of the content of the top-level block containing `pos`, so the whole block is covered.
35
+ const topLevelBlockContentEnd = (doc, pos) => {
36
+ const $pos = doc.resolve(clampToDoc(doc, pos));
37
+ return $pos.depth >= 1 ? $pos.end(1) : pos;
38
+ };
39
+
23
40
  /**
24
41
  * Derive the shimmer ranges for the agent-authored steps in a received batch, in final-doc
25
42
  * coordinates. `agentType` present ⇒ agent-authored (per the NCS↔Editor steps contract). Each
@@ -36,21 +53,30 @@ export const isPositionNeutralStep = step => {
36
53
  * rebased local step changed sizes (a rare, safe degrade). Any unexpected error also degrades to no
37
54
  * shimmer, so this never throws into the shared remote-step handler.
38
55
  */
39
- export const getAgentShimmerRanges = (json, steps, tr, view, durationMs, telepointerEnabled) => {
56
+ export const getAgentShimmerRanges = (json, steps, tr, view, shimmerDurationMs, highlightDurationMs, telepointerEnabled, onNotShown) => {
40
57
  var _json$find;
41
58
  if (!expValEquals('platform_editor_agent_be_streaming', 'isEnabled', true)) {
42
59
  return [];
43
60
  }
44
- // Nothing to reveal if the shimmer is disabled.
45
- if (durationMs <= 0) {
61
+ // The two phases toggle independently: `shimmerDurationMs` sizes the skeleton, `highlightDurationMs`
62
+ // sizes the purple highlight, and `0` on either skips just that phase. Nothing to reveal only when
63
+ // both are off.
64
+ if (shimmerDurationMs <= 0 && highlightDurationMs <= 0) {
46
65
  return [];
47
66
  }
67
+ // Start in the skeleton phase when it's enabled, otherwise straight into the purple highlight phase
68
+ // (skeleton toggled off, highlight on).
69
+ const initialPhase = shimmerDurationMs > 0 ? 'skeleton' : 'highlight';
48
70
  // Telepointer label = the agent's type upper-cased (e.g. `mcp` → "MCP"), falling back to a generic
49
71
  // "Agent"; `undefined` when the telepointer is disabled, so the plugin skips it. `agentType` is the
50
72
  // same on every step of an agent batch, so read it from the first agent-authored step.
51
73
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
52
74
  const agentType = (_json$find = json.find(step => typeof (step === null || step === void 0 ? void 0 : step.agentType) === 'string')) === null || _json$find === void 0 ? void 0 : _json$find.agentType;
53
- const telepointerLabel = telepointerEnabled ? (agentType === null || agentType === void 0 ? void 0 : agentType.toUpperCase()) || 'Agent' : undefined;
75
+ // No agent-authored steps in this batch nothing to shimmer, and not a "not shown" case.
76
+ if (agentType === undefined) {
77
+ return [];
78
+ }
79
+ const telepointerLabel = telepointerEnabled ? agentType.toUpperCase() || 'Agent' : undefined;
54
80
  // When the batch was rebased over local unconfirmed steps, our index-based range math is only
55
81
  // valid if those local steps shifted no positions. Attribute-only steps (e.g. `localId`) and
56
82
  // same-size replacements are position-neutral, so the shimmer stays correct. Skip only when a
@@ -59,6 +85,7 @@ export const getAgentShimmerRanges = (json, steps, tr, view, durationMs, telepoi
59
85
  var _getCollabState$uncon, _getCollabState;
60
86
  const unconfirmed = (_getCollabState$uncon = (_getCollabState = getCollabState(view.state)) === null || _getCollabState === void 0 ? void 0 : _getCollabState.unconfirmed) !== null && _getCollabState$uncon !== void 0 ? _getCollabState$uncon : [];
61
87
  if (unconfirmed.some(entry => !isPositionNeutralStep(entry.step))) {
88
+ onNotShown === null || onNotShown === void 0 ? void 0 : onNotShown('rebasedConcurrentEdit', agentType);
62
89
  return [];
63
90
  }
64
91
  }
@@ -98,6 +125,7 @@ export const getAgentShimmerRanges = (json, steps, tr, view, durationMs, telepoi
98
125
  });
99
126
  });
100
127
  if (!infos.length) {
128
+ onNotShown === null || onNotShown === void 0 ? void 0 : onNotShown('nothingToShow', agentType);
101
129
  return [];
102
130
  }
103
131
 
@@ -109,22 +137,10 @@ export const getAgentShimmerRanges = (json, steps, tr, view, durationMs, telepoi
109
137
  // touched blocks (index n and n+1) merge into one group so a multi-block rewrite reveals together;
110
138
  // a genuinely untouched block in between (index gap > 1) splits the run, so far-apart edits stay
111
139
  // independent.
112
- const docSize = tr.doc.content.size;
113
- const clampPos = pos => Math.min(Math.max(pos, 1), docSize);
114
- const blockIndexAt = pos => tr.doc.resolve(clampPos(pos)).index(0);
115
- // Start/end of the content of the top-level block containing `pos`, so the whole block is covered.
116
- const blockContentStart = pos => {
117
- const $pos = tr.doc.resolve(clampPos(pos));
118
- return $pos.depth >= 1 ? $pos.start(1) : pos;
119
- };
120
- const blockContentEnd = pos => {
121
- const $pos = tr.doc.resolve(clampPos(pos));
122
- return $pos.depth >= 1 ? $pos.end(1) : pos;
123
- };
124
140
  const sorted = [...infos].sort((a, b) => a.from - b.from || a.to - b.to);
125
141
  const groups = [];
126
142
  sorted.forEach(info => {
127
- const block = blockIndexAt(info.from);
143
+ const block = topLevelBlockIndexAt(tr.doc, info.from);
128
144
  const current = groups[groups.length - 1];
129
145
  if (current && block <= current.maxBlock + 1) {
130
146
  current.to = Math.max(current.to, info.to);
@@ -139,13 +155,16 @@ export const getAgentShimmerRanges = (json, steps, tr, view, durationMs, telepoi
139
155
  });
140
156
  return groups.map(group => ({
141
157
  shimmerId: `agent-shimmer-${agentShimmerIdCounter++}`,
142
- from: blockContentStart(group.from),
143
- to: blockContentEnd(group.to),
144
- telepointerLabel
158
+ from: topLevelBlockContentStart(tr.doc, group.from),
159
+ to: topLevelBlockContentEnd(tr.doc, group.to),
160
+ telepointerLabel,
161
+ phase: initialPhase,
162
+ highlightDurationMs
145
163
  }));
146
- } catch {
164
+ } catch (err) {
147
165
  // Never let shimmer range derivation throw into the shared remote-step handler; degrade to no
148
166
  // shimmer.
167
+ onNotShown === null || onNotShown === void 0 ? void 0 : onNotShown('captureThrew', agentType, err);
149
168
  return [];
150
169
  }
151
170
  };
@@ -5,7 +5,7 @@ import { Selection } from '@atlaskit/editor-prosemirror/state';
5
5
  import { DecorationSet } from '@atlaskit/editor-prosemirror/view';
6
6
  import { Participants } from '../participants';
7
7
  import { createTelepointers, findPointers, getPositionOfTelepointer, isReplaceStep, hasExistingNudge } from '../utils';
8
- import { ADD_AGENT_SHIMMER_META, buildAgentShimmerDecorations, reduceAgentShimmers, REMOVE_AGENT_SHIMMER_META } from './agent-shimmer-decorations';
8
+ import { ADD_AGENT_SHIMMER_META, buildAgentShimmerDecorations, HIGHLIGHT_AGENT_SHIMMER_META, reduceAgentShimmers, REMOVE_AGENT_SHIMMER_META } from './agent-shimmer-decorations';
9
9
 
10
10
  /**
11
11
  * Returns position where it's possible to place a decoration.
@@ -69,6 +69,7 @@ export class PluginState {
69
69
  const nudgeTelepointerData = tr.getMeta('nudgeTelepointer');
70
70
  const agentShimmerData = tr.getMeta(ADD_AGENT_SHIMMER_META);
71
71
  const removeAgentShimmerId = tr.getMeta(REMOVE_AGENT_SHIMMER_META);
72
+ const highlightAgentShimmerId = tr.getMeta(HIGHLIGHT_AGENT_SHIMMER_META);
72
73
  const sessionIdData = tr.getMeta('sessionId');
73
74
  let collabInitialised = tr.getMeta('collabInitialised');
74
75
  if (typeof collabInitialised !== 'boolean') {
@@ -224,7 +225,7 @@ export class PluginState {
224
225
  const {
225
226
  changed,
226
227
  next
227
- } = reduceAgentShimmers(this.agentShimmers, tr, agentShimmerData, removeAgentShimmerId);
228
+ } = reduceAgentShimmers(this.agentShimmers, tr, agentShimmerData, removeAgentShimmerId, highlightAgentShimmerId);
228
229
  if (changed) {
229
230
  // Build into local arrays; only merge into the shared add/remove once the block succeeds.
230
231
  const agentRemove = this.decorationSet.find(undefined, undefined, spec => spec.isAgentShimmer);
@@ -9,7 +9,8 @@ import { Step } from '@atlaskit/editor-prosemirror/transform';
9
9
  import { receiveTransaction } from '@atlaskit/prosemirror-collab';
10
10
  import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals';
11
11
  import { expVal } from '@atlaskit/tmp-editor-statsig/expVal';
12
- import { ADD_AGENT_SHIMMER_META, AGENT_SHIMMER_DEFAULT_DURATION_MS, REMOVE_AGENT_SHIMMER_META } from './main/agent-shimmer-decorations';
12
+ import { getAgentEditShimmerNotShownPayload } from './analytics';
13
+ import { ADD_AGENT_SHIMMER_META, AGENT_EDIT_HIGHLIGHT_DEFAULT_DURATION_MS, AGENT_SHIMMER_DEFAULT_DURATION_MS, HIGHLIGHT_AGENT_SHIMMER_META, REMOVE_AGENT_SHIMMER_META } from './main/agent-shimmer-decorations';
13
14
  import { getAgentShimmerRanges } from './main/agent-shimmer-ranges';
14
15
  import { replaceDocument } from './utils';
15
16
 
@@ -31,7 +32,7 @@ export var handleInit = function handleInit(initData, view, options, editorAnaly
31
32
  tr.setMeta('isRemote', true);
32
33
  view.dispatch(tr);
33
34
  } else if (json) {
34
- applyRemoteSteps(json, view);
35
+ applyRemoteSteps(json, view, undefined, options, editorAnalyticsApi);
35
36
  }
36
37
  };
37
38
  export var handleConnection = function handleConnection(connectionData, view) {
@@ -42,15 +43,15 @@ export var handlePresence = function handlePresence(presenceData, view) {
42
43
  var tr = view.state.tr;
43
44
  view.dispatch(tr.setMeta('presence', presenceData));
44
45
  };
45
- export var applyRemoteData = function applyRemoteData(remoteData, view, options) {
46
+ export var applyRemoteData = function applyRemoteData(remoteData, view, options, editorAnalyticsApi) {
46
47
  var json = remoteData.json,
47
48
  _remoteData$userIds = remoteData.userIds,
48
49
  userIds = _remoteData$userIds === void 0 ? [] : _remoteData$userIds;
49
50
  if (json) {
50
- applyRemoteSteps(json, view, userIds, options);
51
+ applyRemoteSteps(json, view, userIds, options, editorAnalyticsApi);
51
52
  }
52
53
  };
53
- export var applyRemoteSteps = function applyRemoteSteps(json, view, userIds, options) {
54
+ export var applyRemoteSteps = function applyRemoteSteps(json, view, userIds, options, editorAnalyticsApi) {
54
55
  if (!json || !json.length) {
55
56
  return;
56
57
  }
@@ -75,16 +76,28 @@ export var applyRemoteSteps = function applyRemoteSteps(json, view, userIds, opt
75
76
  tr.setMeta('isRemote', true);
76
77
 
77
78
  // Agent edit shimmer: mark the ranges agent steps just wrote so the plugin reveals them with the
78
- // gloss-sweep purple-highlight sequence. Gated as a whole so no experiment reads run
79
- // off-experiment; off-path leaves `agentShimmers` empty and everything below is a no-op.
80
- var durationMs = 0;
79
+ // skeleton loader, then (optionally) a purple "just edited" highlight. Gated as a whole so no
80
+ // experiment reads run off-experiment; off-path leaves `agentShimmers` empty and everything below
81
+ // is a no-op.
82
+ var shimmerDurationMs = 0;
83
+ var highlightDurationMs = 0;
81
84
  var agentShimmers = [];
82
85
  if (expValEquals('platform_editor_agent_be_streaming', 'isEnabled', true)) {
83
- durationMs = expVal('platform_editor_agent_be_streaming', 'durationMs', AGENT_SHIMMER_DEFAULT_DURATION_MS);
86
+ // The skeleton and purple-highlight phases toggle independently. `shimmerDurationMs` is the
87
+ // skeleton lifetime; `0` skips the skeleton (an edit can still get the purple highlight).
88
+ shimmerDurationMs = expVal('platform_editor_agent_be_streaming', 'shimmerDurationMs', AGENT_SHIMMER_DEFAULT_DURATION_MS);
89
+ // `highlightDurationMs` is the purple "just edited" highlight lifetime; `0` skips the highlight
90
+ // (the skeleton still shows). `0` on both shows nothing.
91
+ highlightDurationMs = expVal('platform_editor_agent_be_streaming', 'highlightDurationMs', AGENT_EDIT_HIGHLIGHT_DEFAULT_DURATION_MS);
84
92
  // Telepointer shown by default; `telepointerDisabled` hides it (inverted because `expVal`
85
93
  // only permits `false` as a boolean default).
86
94
  var telepointerEnabled = !expVal('platform_editor_agent_be_streaming', 'telepointerDisabled', false);
87
- agentShimmers = getAgentShimmerRanges(json, steps, tr, view, durationMs, telepointerEnabled);
95
+ agentShimmers = getAgentShimmerRanges(json, steps, tr, view, shimmerDurationMs, highlightDurationMs, telepointerEnabled,
96
+ // An agent edit that applied without the shimmer fires the operational event on the same
97
+ // transaction, so success stays event-free and only degrades are reported.
98
+ function (reason, agentType, error) {
99
+ return editorAnalyticsApi === null || editorAnalyticsApi === void 0 ? void 0 : editorAnalyticsApi.attachAnalyticsEvent(getAgentEditShimmerNotShownPayload(reason, agentType, error))(tr);
100
+ });
88
101
  if (agentShimmers.length) {
89
102
  tr.setMeta(ADD_AGENT_SHIMMER_META, agentShimmers);
90
103
  }
@@ -100,22 +113,45 @@ export var applyRemoteSteps = function applyRemoteSteps(json, view, userIds, opt
100
113
  }
101
114
  view.dispatch(tr);
102
115
 
103
- // Remove each shimmer after `durationMs`. `dispatchMeta` is guarded so a timer firing after
116
+ // Schedule each shimmer's phase changes. `dispatchMeta` is guarded so a timer firing after
104
117
  // teardown is a harmless no-op. Only reached when the gated block above produced ranges, so this
105
118
  // whole path is off-experiment-safe.
106
119
  if (agentShimmers.length) {
120
+ var tornDownReported = false;
107
121
  var dispatchMeta = function dispatchMeta(metaKey, value) {
108
122
  try {
109
123
  view.dispatch(view.state.tr.setMeta(metaKey, value));
110
124
  } catch (_unused) {
111
- // View torn down before the timer fired — nothing to clean up.
125
+ // View torn down before the timer fired — nothing to clean up. Report once (not once
126
+ // per shimmer) so we can see how often shimmers are interrupted by teardown.
127
+ if (!tornDownReported) {
128
+ tornDownReported = true;
129
+ editorAnalyticsApi === null || editorAnalyticsApi === void 0 || editorAnalyticsApi.fireAnalyticsEvent(getAgentEditShimmerNotShownPayload('tornDownMidAnimation'));
130
+ }
112
131
  }
113
132
  };
114
133
  agentShimmers.forEach(function (_ref) {
115
134
  var shimmerId = _ref.shimmerId;
116
- setTimeout(function () {
117
- dispatchMeta(REMOVE_AGENT_SHIMMER_META, shimmerId);
118
- }, durationMs);
135
+ if (shimmerDurationMs > 0) {
136
+ // Skeleton first. When it clears, show the purple highlight (if enabled) then remove;
137
+ // otherwise remove straight away.
138
+ setTimeout(function () {
139
+ if (highlightDurationMs > 0) {
140
+ dispatchMeta(HIGHLIGHT_AGENT_SHIMMER_META, shimmerId);
141
+ setTimeout(function () {
142
+ dispatchMeta(REMOVE_AGENT_SHIMMER_META, shimmerId);
143
+ }, highlightDurationMs);
144
+ } else {
145
+ dispatchMeta(REMOVE_AGENT_SHIMMER_META, shimmerId);
146
+ }
147
+ }, shimmerDurationMs);
148
+ } else {
149
+ // Skeleton disabled: the range already starts in the highlight phase, so just time its
150
+ // removal. (Reached only when `highlightDurationMs > 0`, else no ranges were produced.)
151
+ setTimeout(function () {
152
+ dispatchMeta(REMOVE_AGENT_SHIMMER_META, shimmerId);
153
+ }, highlightDurationMs);
154
+ }
119
155
  });
120
156
  }
121
157
  }
@@ -1,3 +1,6 @@
1
+ import _defineProperty from "@babel/runtime/helpers/defineProperty";
2
+ function ownKeys(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbols) { var o = Object.getOwnPropertySymbols(e); r && (o = o.filter(function (r) { return Object.getOwnPropertyDescriptor(e, r).enumerable; })), t.push.apply(t, o); } return t; }
3
+ function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? ownKeys(Object(t), !0).forEach(function (r) { _defineProperty(e, r, t[r]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); }); } return e; }
1
4
  import { ACTION, ACTION_SUBJECT, EVENT_TYPE } from '@atlaskit/editor-common/analytics';
2
5
  import { getDocStructure } from '@atlaskit/editor-common/core-utils';
3
6
  import { sniffUserBrowserExtensions } from '@atlaskit/editor-common/utils';
@@ -26,6 +29,27 @@ export var addSynchronyErrorAnalytics = function addSynchronyErrorAnalytics(stat
26
29
  return tr;
27
30
  };
28
31
  };
32
+
33
+ /**
34
+ * Builds the `agentEditShimmerNotShown` operational event fired when an agent-authored edit applied
35
+ * instantly without the skeleton shimmer/telepointer. Success is the ABSENCE of this event; a
36
+ * neutral action + `reason` keeps expected degrades out of error dashboards. Attributes are non-PII:
37
+ * only the agent kind and a sanitised error name/message (never the raw Error or any doc content).
38
+ */
39
+ export var getAgentEditShimmerNotShownPayload = function getAgentEditShimmerNotShownPayload(reason, agentType, error) {
40
+ return {
41
+ action: ACTION.AGENT_EDIT_SHIMMER_NOT_SHOWN,
42
+ actionSubject: ACTION_SUBJECT.COLLAB,
43
+ eventType: EVENT_TYPE.OPERATIONAL,
44
+ attributes: _objectSpread(_objectSpread({
45
+ reason: reason
46
+ }, agentType ? {
47
+ agentType: agentType
48
+ } : {}), error ? {
49
+ error: "".concat(error.name, ": ").concat(error.message)
50
+ } : {})
51
+ };
52
+ };
29
53
  export var addSynchronyEntityAnalytics = function addSynchronyEntityAnalytics(state, tr) {
30
54
  return function (type, editorAnalyticsApi) {
31
55
  editorAnalyticsApi === null || editorAnalyticsApi === void 0 || editorAnalyticsApi.attachAnalyticsEvent({
@@ -50,7 +50,7 @@ export var subscribe = effect(function (view, provider, options, featureFlags, _
50
50
  return handleConnection(data, view);
51
51
  },
52
52
  dataHandler: function dataHandler(data) {
53
- return applyRemoteData(data, view, options);
53
+ return applyRemoteData(data, view, options, editorAnalyticsApi);
54
54
  },
55
55
  presenceHandler: function presenceHandler(data) {
56
56
  return handlePresence(data, view);