@atlaskit/editor-plugin-autocomplete 3.3.0 → 3.4.1

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.
@@ -14,6 +14,7 @@ import type { EditorView } from '@atlaskit/editor-prosemirror/view';
14
14
 
15
15
  import type { AutocompletePlugin } from '../autocompletePluginType';
16
16
 
17
+ import { isAutocompleteDebugEnabled } from './debug-mode';
17
18
  import { createGhostTextDecorationSet } from './ghost-text-decoration';
18
19
  import { createLocalSlowLaneClient, type LocalSlowLaneClient } from './local-slow-lane-client';
19
20
  import { createSlowLaneClient, setDefaultSlowLaneClient, isWordBoundary } from './slow-lane-client';
@@ -28,6 +29,19 @@ import {
28
29
  export const autocompletePluginKey: PluginKey = new PluginKey('autocomplete');
29
30
 
30
31
  const DEBOUNCE_MS = 150;
32
+ const NETWORK_SLOW_LANE_DEBOUNCE_MS = 300;
33
+ const LOCAL_SLOW_LANE_DEBOUNCE_MS = 100;
34
+ const CONTEXT_REFRESH_THROTTLE_MS = 1000;
35
+ // Caps the dedup Set so long editing sessions don't retain every distinct
36
+ // version of the (potentially hundreds-of-KB) page content for the plugin's
37
+ // lifetime. Eviction is FIFO; a re-ingest of an evicted text is harmless.
38
+ const MAX_INGESTED_CONTEXT_TEXTS = 50;
39
+ // Caps how many times the word-boundary path will retry getContext() while the
40
+ // parent comment is still missing. Combined with the 1s throttle this gives a
41
+ // ~5s window to cover a still-loading comment thread, then stops permanently so
42
+ // non-comment editors (where parentCommentContent never arrives) don't refetch
43
+ // on every word boundary for the plugin's lifetime.
44
+ const MAX_CONTEXT_REFRESH_ATTEMPTS = 5;
31
45
 
32
46
  const hasDestroy = (
33
47
  client: ReturnType<typeof createSlowLaneClient> | LocalSlowLaneClient,
@@ -241,6 +255,12 @@ export const createAutocompletePlugin = (
241
255
  let debounceTimer: ReturnType<typeof setTimeout> | null = null;
242
256
  let hasIngestedPage = false;
243
257
  let resolvedContext: AutocompleteContext | undefined;
258
+ /**
259
+ * Kept in sync with the live EditorView so the async getContext() promise
260
+ * can re-trigger a slow-lane update the moment context arrives, even if the
261
+ * user has already typed several words before the promise resolved.
262
+ */
263
+ let currentView: EditorView | null = null;
244
264
  /**
245
265
  * Set after accepting a suggestion so the next doc-change update
246
266
  * skips scheduling a new prediction for the just-inserted text.
@@ -257,12 +277,24 @@ export const createAutocompletePlugin = (
257
277
  let lastSuggestionTypedLength = 0;
258
278
  let lastSuggestionLength = 0;
259
279
 
280
+ /**
281
+ * cold → no slow-lane vector received yet; frequency-only trie scoring
282
+ * server → server slow-lane API returned the context vector
283
+ * localLlm → on-device WebGPU/MLC model returned the context vector
284
+ */
285
+ const getCompletionSource = (): 'cold' | 'server' | 'localLlm' => {
286
+ if (!slowLaneClient.getContextVector()) {
287
+ return 'cold';
288
+ }
289
+ return options?.useLocalModel ? 'localLlm' : 'server';
290
+ };
291
+
260
292
  const fireSuggestionDismissedAnalytics = (reason: 'escape' | 'blur'): void => {
261
293
  api?.analytics?.actions.fireAnalyticsEvent({
262
294
  action: ACTION.SUGGESTION_DISMISSED,
263
295
  actionSubject: ACTION_SUBJECT.CONTEXTUAL_TYPEAHEAD,
264
296
  eventType: EVENT_TYPE.TRACK,
265
- attributes: { reason },
297
+ attributes: { completionSource: getCompletionSource(), reason },
266
298
  });
267
299
  };
268
300
 
@@ -276,6 +308,7 @@ export const createAutocompletePlugin = (
276
308
  actionSubject: ACTION_SUBJECT.CONTEXTUAL_TYPEAHEAD,
277
309
  eventType: EVENT_TYPE.TRACK,
278
310
  attributes: {
311
+ completionSource: getCompletionSource(),
279
312
  suggestionLength,
280
313
  typedLength,
281
314
  kssDelta,
@@ -285,14 +318,139 @@ export const createAutocompletePlugin = (
285
318
 
286
319
  const slowLaneClient = options?.useLocalModel
287
320
  ? createLocalSlowLaneClient({
288
- debounceMs: 300,
321
+ debounceMs: LOCAL_SLOW_LANE_DEBOUNCE_MS,
289
322
  })
290
323
  : createSlowLaneClient({
291
324
  baseUrl: '',
292
- debounceMs: 300,
325
+ debounceMs: NETWORK_SLOW_LANE_DEBOUNCE_MS,
293
326
  });
294
327
  setDefaultSlowLaneClient(slowLaneClient);
295
328
 
329
+ let contextRequestInFlight = false;
330
+ let lastContextRefreshAt = 0;
331
+ // Bounds the word-boundary retry loop so it terminates even when the editor is
332
+ // not in a comment thread (parentCommentContent never resolves).
333
+ let wordBoundaryRefreshAttempts = 0;
334
+ // Set when the plugin is torn down so in-flight getContext() resolutions don't
335
+ // mutate the global text-predictor state after destruction.
336
+ let destroyed = false;
337
+ const ingestedContextTexts = new Set<string>();
338
+
339
+ const logContextResolved = (source: string, context?: AutocompleteContext): void => {
340
+ if (!isAutocompleteDebugEnabled()) {
341
+ return;
342
+ }
343
+
344
+ // eslint-disable-next-line no-console
345
+ console.log(
346
+ '%c[Autocomplete] %cgetContext resolved',
347
+ 'color: #00b8d9; font-weight: bold;',
348
+ 'color: inherit;',
349
+ {
350
+ source,
351
+ hasParentComment: !!context?.parentCommentContent,
352
+ parentCommentPreview: context?.parentCommentContent?.slice(0, 80),
353
+ siblingCount: context?.siblingCommentsContents?.length ?? 0,
354
+ hasFullPage: !!context?.fullPageContent,
355
+ },
356
+ );
357
+ };
358
+
359
+ const applyContext = (context: AutocompleteContext): void => {
360
+ // Merge rather than replace: the word-boundary retry may resolve only a
361
+ // late-arriving field (e.g. parentCommentContent) without re-sending
362
+ // fullPageContent, so replacing would drop previously resolved context.
363
+ // Strip undefined values first so a field explicitly set to undefined by
364
+ // getContext doesn't overwrite a previously resolved value.
365
+ const definedContext = Object.fromEntries(
366
+ Object.entries(context).filter(([, value]) => value !== undefined),
367
+ );
368
+ resolvedContext = { ...resolvedContext, ...definedContext };
369
+
370
+ const ingestContextText = (text?: string): void => {
371
+ if (!text || ingestedContextTexts.has(text)) {
372
+ return;
373
+ }
374
+
375
+ ingestedContextTexts.add(text);
376
+ // Evict oldest entries (Set preserves insertion order) to bound memory.
377
+ while (ingestedContextTexts.size > MAX_INGESTED_CONTEXT_TEXTS) {
378
+ const oldest = ingestedContextTexts.values().next().value;
379
+ if (oldest === undefined) {
380
+ break;
381
+ }
382
+ ingestedContextTexts.delete(oldest);
383
+ }
384
+ ingestDocumentPage(text);
385
+ };
386
+
387
+ ingestContextText(context.fullPageContent);
388
+ ingestContextText(context.parentCommentContent);
389
+ for (const siblingCommentContent of context.siblingCommentsContents ?? []) {
390
+ ingestContextText(siblingCommentContent);
391
+ }
392
+
393
+ // Context arrived after word boundaries may already have fired. Re-send
394
+ // slow-lane context immediately so the next inference includes the thread.
395
+ if (currentView) {
396
+ slowLaneClient.updateContext(
397
+ buildSlowLaneText(currentView.state.doc.textContent, resolvedContext),
398
+ );
399
+ }
400
+ };
401
+
402
+ /**
403
+ * Returns true when a fetch was actually started, false when it was skipped
404
+ * (no getContext, a request already in flight, or throttled). Callers that
405
+ * track a retry budget should only count attempts where this returns true.
406
+ */
407
+ const refreshContext = ({
408
+ source,
409
+ allowThrottle = true,
410
+ }: {
411
+ allowThrottle?: boolean;
412
+ source: string;
413
+ }): boolean => {
414
+ if (!options?.getContext || contextRequestInFlight) {
415
+ return false;
416
+ }
417
+
418
+ const now = Date.now();
419
+ if (allowThrottle && now - lastContextRefreshAt < CONTEXT_REFRESH_THROTTLE_MS) {
420
+ return false;
421
+ }
422
+
423
+ contextRequestInFlight = true;
424
+ lastContextRefreshAt = now;
425
+
426
+ options
427
+ .getContext()
428
+ .then((context) => {
429
+ // Bail if the plugin was destroyed while the fetch was in flight —
430
+ // applyContext mutates global text-predictor state we must not touch
431
+ // after teardown.
432
+ if (destroyed) {
433
+ return;
434
+ }
435
+ logContextResolved(source, context);
436
+ if (!context) {
437
+ return;
438
+ }
439
+
440
+ applyContext(context);
441
+ })
442
+ .catch((error) => {
443
+ logException(error as Error, {
444
+ location: 'editor-plugin-autocomplete/getContext',
445
+ });
446
+ })
447
+ .finally(() => {
448
+ contextRequestInFlight = false;
449
+ });
450
+
451
+ return true;
452
+ };
453
+
296
454
  /**
297
455
  * Schedule a prediction after a short debounce.
298
456
  * Tier 1 predictions are synchronous (<0.1ms) but we still debounce
@@ -339,6 +497,7 @@ export const createAutocompletePlugin = (
339
497
  action: ACTION.SUGGESTION_VIEWED,
340
498
  actionSubject: ACTION_SUBJECT.CONTEXTUAL_TYPEAHEAD,
341
499
  eventType: EVENT_TYPE.TRACK,
500
+ attributes: { completionSource: getCompletionSource() },
342
501
  });
343
502
  }
344
503
  }
@@ -490,29 +649,10 @@ export const createAutocompletePlugin = (
490
649
  });
491
650
  if (!hasIngestedPage) {
492
651
  hasIngestedPage = true;
493
- if (options?.getContext) {
494
- options
495
- .getContext()
496
- .then((context) => {
497
- if (!context) {
498
- return;
499
- }
500
- resolvedContext = context;
501
-
502
- if (context.fullPageContent) {
503
- ingestDocumentPage(context.fullPageContent);
504
- }
505
- if (context.parentCommentContent) {
506
- ingestDocumentPage(context.parentCommentContent);
507
- }
508
- context.siblingCommentsContents?.forEach(ingestDocumentPage);
509
- })
510
- .catch((error) => {
511
- logException(error as Error, {
512
- location: 'editor-plugin-autocomplete/getContext',
513
- });
514
- });
515
- }
652
+ refreshContext({
653
+ source: 'focus',
654
+ allowThrottle: false,
655
+ });
516
656
  }
517
657
  return false;
518
658
  },
@@ -521,6 +661,7 @@ export const createAutocompletePlugin = (
521
661
 
522
662
  view: () => ({
523
663
  update: (view: EditorView, prevState: EditorState) => {
664
+ currentView = view;
524
665
  if (!prevState.doc.eq(view.state.doc)) {
525
666
  if (justAccepted) {
526
667
  justAccepted = false;
@@ -542,18 +683,36 @@ export const createAutocompletePlugin = (
542
683
  slowLaneClient.updateContext(
543
684
  buildSlowLaneText(view.state.doc.textContent, resolvedContext),
544
685
  );
686
+
687
+ // Context may not have resolved on first focus (e.g. comment
688
+ // thread still loading). Retry on word boundaries until we have
689
+ // the parent comment, throttled so we don't refetch constantly
690
+ // and capped so non-comment editors stop retrying entirely.
691
+ if (
692
+ !resolvedContext?.parentCommentContent &&
693
+ wordBoundaryRefreshAttempts < MAX_CONTEXT_REFRESH_ATTEMPTS
694
+ ) {
695
+ // Only count the attempt when a fetch actually started, so an
696
+ // in-flight or throttled no-op doesn't burn the retry budget.
697
+ if (refreshContext({ source: 'word-boundary' })) {
698
+ wordBoundaryRefreshAttempts++;
699
+ }
700
+ }
545
701
  }
546
702
 
547
703
  schedulePrediction(view);
548
704
  }
549
705
  },
550
706
  destroy: () => {
707
+ destroyed = true;
708
+ currentView = null;
551
709
  if (debounceTimer) {
552
710
  clearTimeout(debounceTimer);
553
711
  }
554
712
  if (hasDestroy(slowLaneClient)) {
555
713
  slowLaneClient.destroy();
556
714
  }
715
+ ingestedContextTexts.clear();
557
716
  setDefaultSlowLaneClient(null);
558
717
  },
559
718
  }),
@@ -595,6 +595,14 @@ export const createLocalSlowLaneClient = (
595
595
  let lastRequestedText = '';
596
596
  let requestCounter = 0;
597
597
  let latestRequestId = -1;
598
+ let inferenceInFlight = false;
599
+ let activeInferenceText: string | null = null;
600
+ // The requestId of the inference currently in flight. Tracked so the
601
+ // in-flight dedup path can restore `latestRequestId` to it — otherwise an
602
+ // intermediate keystroke that bumped `latestRequestId` would cause the
603
+ // in-flight (still-current) result to be discarded as stale.
604
+ let activeInferenceRequestId = -1;
605
+ let pendingInference: { requestId: number; text: string } | null = null;
598
606
  let ready = false;
599
607
  let destroyed = false;
600
608
  let initFailed = false;
@@ -920,8 +928,8 @@ export const createLocalSlowLaneClient = (
920
928
  hasLmLogits: storedLmLogits !== null,
921
929
  });
922
930
  } catch (err) {
923
- // Discard errors for stale requests
924
- if (requestId < latestRequestId) {
931
+ // Discard errors for stale requests or after teardown
932
+ if (requestId < latestRequestId || destroyed) {
925
933
  return;
926
934
  }
927
935
 
@@ -943,11 +951,55 @@ export const createLocalSlowLaneClient = (
943
951
 
944
952
  // ── Context update (debounced) ─────────────────────────────────────────
945
953
 
954
+ const startInference = (text: string, requestId: number): void => {
955
+ // Self-contained guard: never start a new inference cycle after teardown,
956
+ // regardless of caller discipline.
957
+ if (destroyed) {
958
+ return;
959
+ }
960
+
961
+ inferenceInFlight = true;
962
+ activeInferenceText = text;
963
+ activeInferenceRequestId = requestId;
964
+
965
+ void ensureEngineInitialized()
966
+ .then(() => runInference(text, requestId))
967
+ .catch(() => {})
968
+ .finally(() => {
969
+ inferenceInFlight = false;
970
+ activeInferenceText = null;
971
+ activeInferenceRequestId = -1;
972
+
973
+ const next = pendingInference;
974
+ pendingInference = null;
975
+ if (next && !destroyed) {
976
+ startInference(next.text, next.requestId);
977
+ }
978
+ });
979
+ };
980
+
946
981
  const doUpdateContext = (text: string): void => {
947
982
  if (destroyed || !text || text.trim().length === 0) {
948
983
  return;
949
984
  }
950
985
 
986
+ if (inferenceInFlight && text === activeInferenceText) {
987
+ // The latest desired text already matches the in-flight inference, so
988
+ // re-running it would be wasted work. But an intermediate keystroke may
989
+ // have bumped `latestRequestId` past the in-flight request (and then been
990
+ // coalesced away), which would cause runInference to discard the
991
+ // still-current result as stale. Pin `latestRequestId` back to the active
992
+ // request so its result is accepted, and drop any now-superseded pending
993
+ // request.
994
+ latestRequestId = activeInferenceRequestId;
995
+ pendingInference = null;
996
+ return;
997
+ }
998
+
999
+ if (inferenceInFlight && pendingInference?.text === text) {
1000
+ return;
1001
+ }
1002
+
951
1003
  const requestId = ++requestCounter;
952
1004
  latestRequestId = requestId;
953
1005
 
@@ -967,15 +1019,26 @@ export const createLocalSlowLaneClient = (
967
1019
  console.groupEnd();
968
1020
  }
969
1021
 
970
- void ensureEngineInitialized()
971
- .then(() => runInference(text, requestId))
972
- .catch(() => {});
1022
+ if (inferenceInFlight) {
1023
+ pendingInference = { text, requestId };
1024
+ return;
1025
+ }
1026
+
1027
+ startInference(text, requestId);
973
1028
  };
974
1029
 
975
1030
  const updateContextDebounced = (text: string): void => {
976
1031
  if (debounceTimer) {
977
1032
  clearTimeout(debounceTimer);
978
1033
  }
1034
+ if (inferenceInFlight) {
1035
+ pendingInference = null;
1036
+ if (text === activeInferenceText) {
1037
+ latestRequestId = activeInferenceRequestId;
1038
+ lastRequestedText = text;
1039
+ return;
1040
+ }
1041
+ }
979
1042
  lastRequestedText = text;
980
1043
  debounceTimer = setTimeout(() => {
981
1044
  debounceTimer = null;
@@ -1008,6 +1071,10 @@ export const createLocalSlowLaneClient = (
1008
1071
  unloadEngine(engineToUnload);
1009
1072
  }
1010
1073
  engineInitPromise = null;
1074
+ inferenceInFlight = false;
1075
+ activeInferenceText = null;
1076
+ activeInferenceRequestId = -1;
1077
+ pendingInference = null;
1011
1078
  storedContextVector = null;
1012
1079
  storedLmLogits = null;
1013
1080
  },