@atlaskit/editor-plugin-autocomplete 4.0.2 → 4.0.4

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.
@@ -560,7 +560,7 @@ export const predict = textBefore => {
560
560
  * array and a sparse numeric-keyed object are emitted identically as named
561
561
  * exports. Kept in lock-step with the matching helper in local-slow-lane-client.ts.
562
562
  */
563
- const unwrapJsonModule = (mod, shape) => {
563
+ function unwrapJsonModule(mod, shape) {
564
564
  if (mod == null || typeof mod !== 'object') {
565
565
  return null;
566
566
  }
@@ -585,7 +585,7 @@ const unwrapJsonModule = (mod, shape) => {
585
585
  return namespace.default;
586
586
  }
587
587
  return null;
588
- };
588
+ }
589
589
  export const loadVectorsAsync = async options => {
590
590
  var _options$isLocalLLM;
591
591
  if (vectorStore || vectorsLoadStarted) {
@@ -1,4 +1,6 @@
1
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; }
2
4
  /**
3
5
  * UFO experience tracking helpers for `@atlaskit/editor-plugin-autocomplete`.
4
6
  *
@@ -89,15 +91,21 @@ export var failExp = function failExp(name, id, metadata) {
89
91
  // UFO errors must never break the plugin
90
92
  }
91
93
  };
92
- export var abortExp = function abortExp(name, id, reason) {
94
+ export var abortExp = function abortExp(name, id, reason, metadata) {
93
95
  if (!isUfoEnabled()) {
94
96
  return;
95
97
  }
96
98
  try {
97
- experiences[name].getInstance(id).abort(reason ? {
98
- metadata: {
99
- reason: reason
100
- }
99
+ // `reason` is the dedicated, canonical channel for the abort reason and
100
+ // intentionally takes precedence over a `reason` key in `metadata`. Callers
101
+ // must pass the reason via the param, not inside `metadata` (the `reason`
102
+ // key there is reserved and would be overwritten).
103
+ var merged = _objectSpread(_objectSpread({}, metadata), reason ? {
104
+ reason: reason
105
+ } : {});
106
+ var hasMetadata = Object.keys(merged).length > 0;
107
+ experiences[name].getInstance(id).abort(hasMetadata ? {
108
+ metadata: merged
101
109
  } : undefined);
102
110
  } catch (_unused4) {
103
111
  // UFO errors must never break the plugin
@@ -242,6 +242,7 @@ export var createAutocompletePlugin = function createAutocompletePlugin(options,
242
242
  * user has already typed several words before the promise resolved.
243
243
  */
244
244
  var currentView = null;
245
+ var unsubscribeFromContextUpdates;
245
246
  /**
246
247
  * Set after accepting a suggestion so the next doc-change update
247
248
  * skips scheduling a new prediction for the just-inserted text.
@@ -663,7 +664,21 @@ export var createAutocompletePlugin = function createAutocompletePlugin(options,
663
664
  }
664
665
  }
665
666
  },
666
- view: function view() {
667
+ view: function view(editorView) {
668
+ // Capture up front so a subscription notification before the first PM
669
+ // transaction can still drive slowLaneClient.updateContext (gated on currentView).
670
+ currentView = editorView;
671
+
672
+ // Push channel for hosts that keep producing context after mount (e.g. Rovo chat).
673
+ if (isAutocompleteEnabled && options !== null && options !== void 0 && options.subscribeToContextUpdates) {
674
+ unsubscribeFromContextUpdates = options.subscribeToContextUpdates(function () {
675
+ // Bypass throttle for freshness; the in-flight guard prevents overlap.
676
+ refreshContext({
677
+ source: 'subscription',
678
+ allowThrottle: false
679
+ });
680
+ });
681
+ }
667
682
  return {
668
683
  update: function update(view, prevState) {
669
684
  if (!isAutocompleteEnabled) {
@@ -692,7 +707,9 @@ export var createAutocompletePlugin = function createAutocompletePlugin(options,
692
707
  // thread still loading). Retry on word boundaries until we have
693
708
  // the parent comment, throttled so we don't refetch constantly
694
709
  // and capped so non-comment editors stop retrying entirely.
695
- if (!((_resolvedContext = resolvedContext) !== null && _resolvedContext !== void 0 && _resolvedContext.parentCommentContent) && wordBoundaryRefreshAttempts < MAX_CONTEXT_REFRESH_ATTEMPTS) {
710
+ // Skipped for push-channel hosts (subscribeToContextUpdates): they
711
+ // never grow parentCommentContent, so polling only burns the budget.
712
+ if (!(options !== null && options !== void 0 && options.subscribeToContextUpdates) && !((_resolvedContext = resolvedContext) !== null && _resolvedContext !== void 0 && _resolvedContext.parentCommentContent) && wordBoundaryRefreshAttempts < MAX_CONTEXT_REFRESH_ATTEMPTS) {
696
713
  // Only count the attempt when a fetch actually started, so an
697
714
  // in-flight or throttled no-op doesn't burn the retry budget.
698
715
  if (refreshContext({
@@ -706,8 +723,11 @@ export var createAutocompletePlugin = function createAutocompletePlugin(options,
706
723
  }
707
724
  },
708
725
  destroy: function destroy() {
726
+ var _unsubscribeFromConte;
709
727
  destroyed = true;
710
728
  currentView = null;
729
+ (_unsubscribeFromConte = unsubscribeFromContextUpdates) === null || _unsubscribeFromConte === void 0 || _unsubscribeFromConte();
730
+ unsubscribeFromContextUpdates = undefined;
711
731
  if (debounceTimer) {
712
732
  clearTimeout(debounceTimer);
713
733
  }
@@ -48,6 +48,7 @@ function _arrayLikeToArray(r, a) { (null == a || a > r.length) && (a = r.length)
48
48
  * asynchronously after each updateContext() call.
49
49
  */
50
50
 
51
+ import { abortExp, EXPERIENCE_NAME, failExp, startExp, succeedExp } from '../analytics/ufo';
51
52
  import { isAutocompleteDebugEnabled } from './debug-mode';
52
53
  import { isWordBoundary } from './slow-lane-client';
53
54
 
@@ -257,7 +258,7 @@ var bePayloadDataPromise;
257
258
  * :param shape: `'object'` if the source JSON is `{...}`, `'array'` if `[...]`.
258
259
  * :returns: The parsed JSON value, or `null` if neither interop mode applies.
259
260
  */
260
- var unwrapJsonModule = function unwrapJsonModule(mod, shape) {
261
+ function unwrapJsonModule(mod, shape) {
261
262
  if (mod == null || _typeof(mod) !== 'object') {
262
263
  return null;
263
264
  }
@@ -309,7 +310,7 @@ var unwrapJsonModule = function unwrapJsonModule(mod, shape) {
309
310
  return namespace.default;
310
311
  }
311
312
  return null;
312
- };
313
+ }
313
314
 
314
315
  /**
315
316
  * Lazily load and build the BE-parity lookup tables from their JSON payloads.
@@ -947,17 +948,23 @@ export var createLocalSlowLaneClient = function createLocalSlowLaneClient() {
947
948
  */
948
949
  var runInference = /*#__PURE__*/function () {
949
950
  var _ref0 = _asyncToGenerator( /*#__PURE__*/_regeneratorRuntime.mark(function _callee4(text, requestId) {
950
- var lmText, semanticText, arcticInput, captureCompletionTime, _data, tStart, tLmDone, tEmbDone, _yield$Promise$all5, _yield$Promise$all6, embeddingResponse, rawLogits, payload, embedding, sumSq, i, topTokens, errorMsg, _t5;
951
+ var experienceId, lmText, semanticText, arcticInput, captureCompletionTime, _data, tStart, tLmDone, tEmbDone, _yield$Promise$all5, _yield$Promise$all6, embeddingResponse, rawLogits, payload, embedding, sumSq, i, topTokens, errorMsg, _t5;
951
952
  return _regeneratorRuntime.wrap(function (_context4) {
952
953
  while (1) switch (_context4.prev = _context4.next) {
953
954
  case 0:
955
+ captureCompletionTime = function _captureCompletionTim(promise, onResolved) {
956
+ return promise.then(function (value) {
957
+ onResolved(performance.now());
958
+ return value;
959
+ });
960
+ };
954
961
  if (!(!engine || destroyed)) {
955
962
  _context4.next = 1;
956
963
  break;
957
964
  }
958
965
  return _context4.abrupt("return");
959
966
  case 1:
960
- // Clear the capture buffer so we read only this pass's logits. The engine
967
+ experienceId = String(requestId); // Clear the capture buffer so we read only this pass's logits. The engine
961
968
  // serialises per-model requests and updateContext is debounced, so the
962
969
  // latest request's decode step is the last to populate `captured` before
963
970
  // we read it below; stale requests bail on the latestRequestId guard.
@@ -970,12 +977,6 @@ export var createLocalSlowLaneClient = function createLocalSlowLaneClient() {
970
977
  lmText = truncateToLastNWords(text, BE_PARITY.MAX_CONTEXT_TOKENS);
971
978
  semanticText = truncateToLastNWords(text, BE_PARITY.MAX_CONTEXT_WORDS);
972
979
  arcticInput = wrapForArctic(semanticText);
973
- captureCompletionTime = function captureCompletionTime(promise, onResolved) {
974
- return promise.then(function (value) {
975
- onResolved(performance.now());
976
- return value;
977
- });
978
- };
979
980
  if (isAutocompleteDebugEnabled()) {
980
981
  // eslint-disable-next-line no-console
981
982
  console.log("%c[LocalSlowLane] %c\uD83D\uDD22 Arctic input (".concat(arcticInput.length, " chars, ").concat(splitOnWhitespace(semanticText).length, " words): \"").concat(arcticInput.length > 100 ? "".concat(arcticInput.slice(0, 100), "\u2026") : arcticInput, "\""), 'color: #9c27b0; font-weight: bold;', 'color: #009688;');
@@ -983,6 +984,16 @@ export var createLocalSlowLaneClient = function createLocalSlowLaneClient() {
983
984
  console.log("%c[LocalSlowLane] %c\uD83E\uDDE0 LM input (".concat(lmText.length, " chars, ").concat(splitOnWhitespace(lmText).length, " words): \"").concat(lmText.length > 100 ? "".concat(lmText.slice(0, 100), "\u2026") : lmText, "\""), 'color: #9c27b0; font-weight: bold;', 'color: #2196f3;');
984
985
  }
985
986
  _context4.prev = 2;
987
+ // Reuse the network slow-lane-fetch UFO experience (tagged isLocalLLM:true,
988
+ // matching LOAD_VECTORS/LOAD_VOCABULARY) so on-device inference
989
+ // latency/success-rate feeds the same FE Reliability SLO. Started inside
990
+ // the try — post engine-init (parity with the network fetch, which
991
+ // excludes the one-time model load) and so the catch below always
992
+ // terminates the experience, even on a synchronous throw.
993
+ startExp(EXPERIENCE_NAME.SLOW_LANE_FETCH, experienceId, {
994
+ textLength: text.length,
995
+ isLocalLLM: true
996
+ });
986
997
  tStart = performance.now();
987
998
  tLmDone = 0;
988
999
  tEmbDone = 0;
@@ -1015,6 +1026,9 @@ export var createLocalSlowLaneClient = function createLocalSlowLaneClient() {
1015
1026
  _context4.next = 4;
1016
1027
  break;
1017
1028
  }
1029
+ abortExp(EXPERIENCE_NAME.SLOW_LANE_FETCH, experienceId, destroyed ? 'destroyed' : 'superseded', {
1030
+ isLocalLLM: true
1031
+ });
1018
1032
  return _context4.abrupt("return");
1019
1033
  case 4:
1020
1034
  // ── LM logits: whole-word BE-parity payload ──────────────────
@@ -1066,6 +1080,12 @@ export var createLocalSlowLaneClient = function createLocalSlowLaneClient() {
1066
1080
  // eslint-disable-next-line no-console
1067
1081
  console.groupEnd();
1068
1082
  }
1083
+ succeedExp(EXPERIENCE_NAME.SLOW_LANE_FETCH, experienceId, {
1084
+ textLength: text.length,
1085
+ hasVector: storedContextVector !== null,
1086
+ hasLmLogits: storedLmLogits !== null,
1087
+ isLocalLLM: true
1088
+ });
1069
1089
  onUpdate === null || onUpdate === void 0 || onUpdate({
1070
1090
  textLength: text.length,
1071
1091
  hasVector: storedContextVector !== null,
@@ -1080,10 +1100,17 @@ export var createLocalSlowLaneClient = function createLocalSlowLaneClient() {
1080
1100
  _context4.next = 6;
1081
1101
  break;
1082
1102
  }
1103
+ abortExp(EXPERIENCE_NAME.SLOW_LANE_FETCH, experienceId, destroyed ? 'destroyed' : 'superseded', {
1104
+ isLocalLLM: true
1105
+ });
1083
1106
  return _context4.abrupt("return");
1084
1107
  case 6:
1085
1108
  storedContextVector = null;
1086
1109
  storedLmLogits = null;
1110
+ failExp(EXPERIENCE_NAME.SLOW_LANE_FETCH, experienceId, {
1111
+ errorType: 'inference',
1112
+ isLocalLLM: true
1113
+ });
1087
1114
  onUpdate === null || onUpdate === void 0 || onUpdate({
1088
1115
  textLength: text.length,
1089
1116
  hasVector: false,
@@ -81,7 +81,8 @@ export var createSlowLaneClient = function createSlowLaneClient(config) {
81
81
  session_id: sessionId
82
82
  };
83
83
  startExp(EXPERIENCE_NAME.SLOW_LANE_FETCH, requestId, {
84
- textLength: text.length
84
+ textLength: text.length,
85
+ isLocalLLM: false
85
86
  });
86
87
  if (isAutocompleteDebugEnabled()) {
87
88
  // eslint-disable-next-line no-console
@@ -110,7 +111,8 @@ export var createSlowLaneClient = function createSlowLaneClient(config) {
110
111
  storedLmLogits = null;
111
112
  failExp(EXPERIENCE_NAME.SLOW_LANE_FETCH, requestId, {
112
113
  status: res.status,
113
- errorType: 'http_error'
114
+ errorType: 'http_error',
115
+ isLocalLLM: false
114
116
  });
115
117
  if (isAutocompleteDebugEnabled()) {
116
118
  // eslint-disable-next-line no-console
@@ -145,7 +147,8 @@ export var createSlowLaneClient = function createSlowLaneClient(config) {
145
147
  succeedExp(EXPERIENCE_NAME.SLOW_LANE_FETCH, requestId, {
146
148
  textLength: text.length,
147
149
  hasVector: storedContextVector !== null,
148
- hasLmLogits: storedLmLogits !== null
150
+ hasLmLogits: storedLmLogits !== null,
151
+ isLocalLLM: false
149
152
  });
150
153
  onUpdate === null || onUpdate === void 0 || onUpdate({
151
154
  textLength: text.length,
@@ -161,7 +164,8 @@ export var createSlowLaneClient = function createSlowLaneClient(config) {
161
164
  storedContextVector = null;
162
165
  storedLmLogits = null;
163
166
  failExp(EXPERIENCE_NAME.SLOW_LANE_FETCH, requestId, {
164
- errorType: 'network'
167
+ errorType: 'network',
168
+ isLocalLLM: false
165
169
  });
166
170
  if (isAutocompleteDebugEnabled()) {
167
171
  // eslint-disable-next-line no-console
@@ -191,7 +195,9 @@ export var createSlowLaneClient = function createSlowLaneClient(config) {
191
195
  debounceTimer = setTimeout(function () {
192
196
  debounceTimer = null;
193
197
  if (inflightRequestId !== null) {
194
- abortExp(EXPERIENCE_NAME.SLOW_LANE_FETCH, inflightRequestId, 'superseded');
198
+ abortExp(EXPERIENCE_NAME.SLOW_LANE_FETCH, inflightRequestId, 'superseded', {
199
+ isLocalLLM: false
200
+ });
195
201
  }
196
202
  var requestId = String(++requestSeq);
197
203
  inflightRequestId = requestId;
@@ -690,7 +690,7 @@ export var predict = function predict(textBefore) {
690
690
  * array and a sparse numeric-keyed object are emitted identically as named
691
691
  * exports. Kept in lock-step with the matching helper in local-slow-lane-client.ts.
692
692
  */
693
- var unwrapJsonModule = function unwrapJsonModule(mod, shape) {
693
+ function unwrapJsonModule(mod, shape) {
694
694
  if (mod == null || _typeof(mod) !== 'object') {
695
695
  return null;
696
696
  }
@@ -726,7 +726,7 @@ var unwrapJsonModule = function unwrapJsonModule(mod, shape) {
726
726
  return namespace.default;
727
727
  }
728
728
  return null;
729
- };
729
+ }
730
730
  export var loadVectorsAsync = /*#__PURE__*/function () {
731
731
  var _ref6 = _asyncToGenerator( /*#__PURE__*/_regeneratorRuntime.mark(function _callee(options) {
732
732
  var _options$isLocalLLM;
@@ -35,4 +35,4 @@ export type AutocompleteExperienceName = (typeof EXPERIENCE_NAME)[keyof typeof E
35
35
  export declare const startExp: (name: AutocompleteExperienceName, id: string, metadata?: CustomData) => void;
36
36
  export declare const succeedExp: (name: AutocompleteExperienceName, id: string, metadata?: CustomData) => void;
37
37
  export declare const failExp: (name: AutocompleteExperienceName, id: string, metadata?: CustomData) => void;
38
- export declare const abortExp: (name: AutocompleteExperienceName, id: string, reason?: string) => void;
38
+ export declare const abortExp: (name: AutocompleteExperienceName, id: string, reason?: string, metadata?: CustomData) => void;
@@ -1,2 +1,3 @@
1
1
  export { autocompletePlugin } from './autocompletePlugin';
2
2
  export type { AutocompletePlugin } from './autocompletePluginType';
3
+ export type { AutocompleteContext, AutocompletePluginOptions, } from './pm-plugins/autocomplete-plugin';
@@ -40,6 +40,12 @@ export interface AutocompletePluginOptions {
40
40
  * Use this to serve vectors from a CDN or media service in production.
41
41
  */
42
42
  getVectorsBinaryUrl?: () => Promise<string>;
43
+ /**
44
+ * Subscription for hosts with live external context (e.g. Rovo chat). The plugin
45
+ * re-fetches via getContext() on each notification and unsubscribes on destroy.
46
+ * Only meaningful alongside `getContext`; without it each notification is a no-op.
47
+ */
48
+ subscribeToContextUpdates?: (onContextUpdated: () => void) => () => void;
43
49
  /**
44
50
  * User locale used to determine whether autocomplete should run.
45
51
  * Defaults to browser locale when omitted.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atlaskit/editor-plugin-autocomplete",
3
- "version": "4.0.2",
3
+ "version": "4.0.4",
4
4
  "description": "Client-side text autocomplete plugin for @atlaskit/editor-core",
5
5
  "author": "Atlassian Pty Ltd",
6
6
  "license": "Apache-2.0",
@@ -27,7 +27,7 @@
27
27
  "wink-nlp": "^2.4.0"
28
28
  },
29
29
  "peerDependencies": {
30
- "@atlaskit/editor-common": "^116.4.0",
30
+ "@atlaskit/editor-common": "^116.15.0",
31
31
  "@atlaskit/editor-plugin-analytics": "^12.0.0",
32
32
  "react": "^18.2.0"
33
33
  },
@@ -120,12 +120,23 @@ export const failExp = (
120
120
  }
121
121
  };
122
122
 
123
- export const abortExp = (name: AutocompleteExperienceName, id: string, reason?: string): void => {
123
+ export const abortExp = (
124
+ name: AutocompleteExperienceName,
125
+ id: string,
126
+ reason?: string,
127
+ metadata?: CustomData,
128
+ ): void => {
124
129
  if (!isUfoEnabled()) {
125
130
  return;
126
131
  }
127
132
  try {
128
- experiences[name].getInstance(id).abort(reason ? { metadata: { reason } } : undefined);
133
+ // `reason` is the dedicated, canonical channel for the abort reason and
134
+ // intentionally takes precedence over a `reason` key in `metadata`. Callers
135
+ // must pass the reason via the param, not inside `metadata` (the `reason`
136
+ // key there is reserved and would be overwritten).
137
+ const merged: CustomData = { ...metadata, ...(reason ? { reason } : {}) };
138
+ const hasMetadata = Object.keys(merged).length > 0;
139
+ experiences[name].getInstance(id).abort(hasMetadata ? { metadata: merged } : undefined);
129
140
  } catch {
130
141
  // UFO errors must never break the plugin
131
142
  }
package/src/index.ts CHANGED
@@ -3,3 +3,7 @@
3
3
 
4
4
  export { autocompletePlugin } from './autocompletePlugin';
5
5
  export type { AutocompletePlugin } from './autocompletePluginType';
6
+ export type {
7
+ AutocompleteContext,
8
+ AutocompletePluginOptions,
9
+ } from './pm-plugins/autocomplete-plugin';
@@ -214,6 +214,12 @@ export interface AutocompletePluginOptions {
214
214
  * Use this to serve vectors from a CDN or media service in production.
215
215
  */
216
216
  getVectorsBinaryUrl?: () => Promise<string>;
217
+ /**
218
+ * Subscription for hosts with live external context (e.g. Rovo chat). The plugin
219
+ * re-fetches via getContext() on each notification and unsubscribes on destroy.
220
+ * Only meaningful alongside `getContext`; without it each notification is a no-op.
221
+ */
222
+ subscribeToContextUpdates?: (onContextUpdated: () => void) => () => void;
217
223
  /**
218
224
  * User locale used to determine whether autocomplete should run.
219
225
  * Defaults to browser locale when omitted.
@@ -329,6 +335,7 @@ export const createAutocompletePlugin = (
329
335
  * user has already typed several words before the promise resolved.
330
336
  */
331
337
  let currentView: EditorView | null = null;
338
+ let unsubscribeFromContextUpdates: (() => void) | undefined;
332
339
  /**
333
340
  * Set after accepting a suggestion so the next doc-change update
334
341
  * skips scheduling a new prediction for the just-inserted text.
@@ -582,7 +589,9 @@ export const createAutocompletePlugin = (
582
589
  // nodeAfter correctly handles inline atoms (mentions, emojis) where
583
590
  // parentOffset and textContent indices diverge.
584
591
  const nodeAfter = selection.$from.nodeAfter;
585
- const charAfterCursor = nodeAfter?.isText ? getLeadingTextCharacter(nodeAfter.text) : undefined;
592
+ const charAfterCursor = nodeAfter?.isText
593
+ ? getLeadingTextCharacter(nodeAfter.text)
594
+ : undefined;
586
595
  // Only suppress for Unicode letters, digits, and underscore — hyphens,
587
596
  // apostrophes, and similar punctuation are valid left-edge boundaries
588
597
  // and should not block suggestions (e.g. cursor before '-' in compound-word).
@@ -790,66 +799,85 @@ export const createAutocompletePlugin = (
790
799
  },
791
800
  },
792
801
 
793
- view: () => ({
794
- update: (view: EditorView, prevState: EditorState) => {
795
- if (!isAutocompleteEnabled) {
796
- return;
797
- }
802
+ view: (editorView: EditorView) => {
803
+ // Capture up front so a subscription notification before the first PM
804
+ // transaction can still drive slowLaneClient.updateContext (gated on currentView).
805
+ currentView = editorView;
798
806
 
799
- currentView = view;
800
- if (!prevState.doc.eq(view.state.doc)) {
801
- if (justAccepted) {
802
- justAccepted = false;
803
-
804
- // Snapshot the post-acceptance text so follow-up transactions hit
805
- // the dismissedContext guard and abort until the user types again.
806
- dismissedContext = getTextBeforeCursor(view.state);
807
+ // Push channel for hosts that keep producing context after mount (e.g. Rovo chat).
808
+ if (isAutocompleteEnabled && options?.subscribeToContextUpdates) {
809
+ unsubscribeFromContextUpdates = options.subscribeToContextUpdates(() => {
810
+ // Bypass throttle for freshness; the in-flight guard prevents overlap.
811
+ refreshContext({ source: 'subscription', allowThrottle: false });
812
+ });
813
+ }
807
814
 
808
- if (debounceTimer) {
809
- clearTimeout(debounceTimer);
810
- }
815
+ return {
816
+ update: (view: EditorView, prevState: EditorState) => {
817
+ if (!isAutocompleteEnabled) {
811
818
  return;
812
819
  }
813
820
 
814
- maybeUpdateSessionFrequency(view, prevState);
815
-
816
- const textBefore = getTextBeforeCursor(view.state);
817
- if (isWordBoundary(textBefore)) {
818
- slowLaneClient.updateContext(
819
- buildSlowLaneText(view.state.doc.textContent, resolvedContext),
820
- );
821
-
822
- // Context may not have resolved on first focus (e.g. comment
823
- // thread still loading). Retry on word boundaries until we have
824
- // the parent comment, throttled so we don't refetch constantly
825
- // and capped so non-comment editors stop retrying entirely.
826
- if (
827
- !resolvedContext?.parentCommentContent &&
828
- wordBoundaryRefreshAttempts < MAX_CONTEXT_REFRESH_ATTEMPTS
829
- ) {
830
- // Only count the attempt when a fetch actually started, so an
831
- // in-flight or throttled no-op doesn't burn the retry budget.
832
- if (refreshContext({ source: 'word-boundary' })) {
833
- wordBoundaryRefreshAttempts++;
821
+ currentView = view;
822
+ if (!prevState.doc.eq(view.state.doc)) {
823
+ if (justAccepted) {
824
+ justAccepted = false;
825
+
826
+ // Snapshot the post-acceptance text so follow-up transactions hit
827
+ // the dismissedContext guard and abort until the user types again.
828
+ dismissedContext = getTextBeforeCursor(view.state);
829
+
830
+ if (debounceTimer) {
831
+ clearTimeout(debounceTimer);
834
832
  }
833
+ return;
835
834
  }
836
- }
837
835
 
838
- schedulePrediction(view);
839
- }
840
- },
841
- destroy: () => {
842
- destroyed = true;
843
- currentView = null;
844
- if (debounceTimer) {
845
- clearTimeout(debounceTimer);
846
- }
847
- if (hasDestroy(slowLaneClient)) {
848
- slowLaneClient.destroy();
849
- }
850
- ingestedContextTexts.clear();
851
- setDefaultSlowLaneClient(null);
852
- },
853
- }),
836
+ maybeUpdateSessionFrequency(view, prevState);
837
+
838
+ const textBefore = getTextBeforeCursor(view.state);
839
+ if (isWordBoundary(textBefore)) {
840
+ slowLaneClient.updateContext(
841
+ buildSlowLaneText(view.state.doc.textContent, resolvedContext),
842
+ );
843
+
844
+ // Context may not have resolved on first focus (e.g. comment
845
+ // thread still loading). Retry on word boundaries until we have
846
+ // the parent comment, throttled so we don't refetch constantly
847
+ // and capped so non-comment editors stop retrying entirely.
848
+ // Skipped for push-channel hosts (subscribeToContextUpdates): they
849
+ // never grow parentCommentContent, so polling only burns the budget.
850
+ if (
851
+ !options?.subscribeToContextUpdates &&
852
+ !resolvedContext?.parentCommentContent &&
853
+ wordBoundaryRefreshAttempts < MAX_CONTEXT_REFRESH_ATTEMPTS
854
+ ) {
855
+ // Only count the attempt when a fetch actually started, so an
856
+ // in-flight or throttled no-op doesn't burn the retry budget.
857
+ if (refreshContext({ source: 'word-boundary' })) {
858
+ wordBoundaryRefreshAttempts++;
859
+ }
860
+ }
861
+ }
862
+
863
+ schedulePrediction(view);
864
+ }
865
+ },
866
+ destroy: () => {
867
+ destroyed = true;
868
+ currentView = null;
869
+ unsubscribeFromContextUpdates?.();
870
+ unsubscribeFromContextUpdates = undefined;
871
+ if (debounceTimer) {
872
+ clearTimeout(debounceTimer);
873
+ }
874
+ if (hasDestroy(slowLaneClient)) {
875
+ slowLaneClient.destroy();
876
+ }
877
+ ingestedContextTexts.clear();
878
+ setDefaultSlowLaneClient(null);
879
+ },
880
+ };
881
+ },
854
882
  });
855
883
  };