diff --git a/src/adapters/chrome/content-script/suggestions/SuggestionEntrySession.ts b/src/adapters/chrome/content-script/suggestions/SuggestionEntrySession.ts index a86bf8ab..1f67896e 100644 --- a/src/adapters/chrome/content-script/suggestions/SuggestionEntrySession.ts +++ b/src/adapters/chrome/content-script/suggestions/SuggestionEntrySession.ts @@ -53,6 +53,7 @@ export class SuggestionEntrySession { private readonly insertSpaceAfterAutocomplete: boolean; private readonly logRenderedSuggestionPopup: SuggestionEntrySessionOptions["logRenderedSuggestionPopup"]; private readonly logNoVisibleSuggestions: (context: PredictionResponse) => void; + private lastAcceptedSuggestion: string | null = null; constructor(options: SuggestionEntrySessionOptions) { this.entry = options.entry; @@ -1188,12 +1189,21 @@ export class SuggestionEntrySession { } private acceptSuggestionInternal(suggestion: string): boolean { + if ( + this.lastAcceptedSuggestion === suggestion && + this.entry.suppressNextSuggestionInputPrediction && + this.entry.pendingExtensionEdit?.source === "suggestion" + ) { + return false; + } + this.entry.suppressNextSuggestionInputPrediction = true; const accepted = this.textEditService.acceptSuggestion(this.entry, suggestion); if (!accepted) { this.entry.suppressNextSuggestionInputPrediction = false; return false; } + this.lastAcceptedSuggestion = suggestion; this.finishAcceptedSuggestion( accepted.triggerText, accepted.insertedText, @@ -1292,6 +1302,7 @@ export class SuggestionEntrySession { } private clearAcceptedSuggestionTransientState(): void { + this.lastAcceptedSuggestion = null; this.clearPendingExtensionEdit(); this.entry.missingTrailingSpace = false; this.entry.expectedCursorPos = 0; diff --git a/src/adapters/chrome/content-script/suggestions/SuggestionTextEditService.ts b/src/adapters/chrome/content-script/suggestions/SuggestionTextEditService.ts index f3a78c47..5cb954c2 100644 --- a/src/adapters/chrome/content-script/suggestions/SuggestionTextEditService.ts +++ b/src/adapters/chrome/content-script/suggestions/SuggestionTextEditService.ts @@ -1071,29 +1071,7 @@ export class SuggestionTextEditService { cursorAfter, { scopeRoot: activeBlock }, ); - const deferredHostNoMutation = - "appliedBy" in initialApplyResult && - initialApplyResult.appliedBy === "host-beforeinput" && - !initialApplyResult.didMutateDom; - - if (!deferredHostNoMutation) { - return initialApplyResult; - } - - const domFallbackResult = this.replaceTextByOffsets( - elem, - blockSourceText, - replaceStart, - replaceEnd, - replacementText, - cursorAfter, - { - preferDomMutation: true, - scopeRoot: activeBlock, - }, - ); - - return domFallbackResult.didMutateDom ? domFallbackResult : initialApplyResult; + return initialApplyResult; } private acceptContentEditableSuggestion( diff --git a/tests/SuggestionEntrySession.test.ts b/tests/SuggestionEntrySession.test.ts index e921d1f2..dc900bc8 100644 --- a/tests/SuggestionEntrySession.test.ts +++ b/tests/SuggestionEntrySession.test.ts @@ -533,6 +533,49 @@ test("session ignores stale prediction responses after suggestion acceptance", ( expect(renderMenu).not.toHaveBeenCalled(); }); +test("session ignores an immediate duplicate suggestion accept while the first accepted edit is still pending", () => { + const editable = document.createElement("div"); + editable.setAttribute("contenteditable", "true"); + Object.defineProperty(editable, "isContentEditable", { value: true, configurable: true }); + + const entry = createSuggestionEntry({ + elem: editable as SuggestionEntry["elem"], + requestId: 2, + suggestions: ["beta"], + latestMentionText: "bet", + }); + const textEditService = { + acceptSuggestion: jest.fn(() => { + entry.pendingExtensionEdit = { + replaceStart: 0, + originalText: "bet", + replacementText: "beta", + cursorBefore: 3, + cursorAfter: 4, + postEditFingerprint: { + fullText: "beta", + cursorOffset: 4, + selectionCollapsed: true, + }, + source: "suggestion", + }; + return { + triggerText: "bet", + insertedText: "beta", + cursorAfter: 4, + cursorAfterIsBlockLocal: false, + }; + }), + applyGrammarEdit: jest.fn(() => ({ applied: false, didDispatchInput: false })), + syncManualAutoFixSuppression: jest.fn(), + }; + const session = makeSession({ entry, textEditService }); + + expect(session.acceptSuggestionAtIndex(0)).toBe(true); + expect(session.acceptSuggestionAtIndex(0)).toBe(false); + expect(textEditService.acceptSuggestion).toHaveBeenCalledTimes(1); +}); + test("session suppresses the synthetic input emitted by accepted suggestions", () => { const clearPendingFallback = jest.fn(); const predictionCoordinator = { diff --git a/tests/SuggestionTextEditService.test.ts b/tests/SuggestionTextEditService.test.ts index 35ca3393..788bfe7c 100644 --- a/tests/SuggestionTextEditService.test.ts +++ b/tests/SuggestionTextEditService.test.ts @@ -1264,7 +1264,7 @@ describe("SuggestionTextEditService", () => { expect(editable.textContent).toBe("Wh"); }); - test("retries generic contenteditable acceptance with direct DOM mutation after a canceled no-op beforeinput", () => { + test("keeps generic contenteditable acceptance deferred when beforeinput is canceled without immediate DOM mutation", () => { const service = new SuggestionTextEditService({ findMentionToken, isSeparator: (value) => /\s/.test(value), @@ -1297,7 +1297,7 @@ describe("SuggestionTextEditService", () => { cursorAfter: 5, cursorAfterIsBlockLocal: true, }); - expect(editable.textContent).toBe("What\u00A0"); + expect(editable.textContent).toBe("Wh"); expect(entry.pendingExtensionEdit?.awaitingHostInputEcho).toBe(true); });