From f6e333cf60f2ed94d05753bb93fd06b32d9321cb Mon Sep 17 00:00:00 2001 From: abhinav7x94 Date: Sun, 16 Aug 2026 08:31:49 +0530 Subject: [PATCH] fix(extension): discard stale recall responses --- .../entrypoints/content/chatgpt.ts | 154 ++++++++--- .../entrypoints/content/claude.ts | 223 ++++++++++------ .../entrypoints/content/gemini.ts | 153 ++++++++--- .../entrypoints/content/memory-suggestion.ts | 2 +- .../recall-freshness.integration.test.ts | 245 ++++++++++++++++++ .../content/recall-request-freshness.test.ts | 107 ++++++++ .../content/recall-request-freshness.ts | 38 +++ .../entrypoints/content/t3.ts | 206 +++++++++------ 8 files changed, 890 insertions(+), 238 deletions(-) create mode 100644 apps/browser-extension/entrypoints/content/recall-freshness.integration.test.ts create mode 100644 apps/browser-extension/entrypoints/content/recall-request-freshness.test.ts create mode 100644 apps/browser-extension/entrypoints/content/recall-request-freshness.ts diff --git a/apps/browser-extension/entrypoints/content/chatgpt.ts b/apps/browser-extension/entrypoints/content/chatgpt.ts index cf7a3002..70b90f34 100644 --- a/apps/browser-extension/entrypoints/content/chatgpt.ts +++ b/apps/browser-extension/entrypoints/content/chatgpt.ts @@ -24,13 +24,16 @@ import { showMemorySuggestion, syncAcceptedSupermemoryState, } from "./memory-suggestion" +import { createRecallRequestFreshnessGuard } from "./recall-request-freshness" let chatGPTDebounceTimeout: NodeJS.Timeout | null = null let chatGPTRouteObserver: MutationObserver | null = null let chatGPTUrlCheckInterval: NodeJS.Timeout | null = null let chatGPTObserverThrottle: NodeJS.Timeout | null = null +let chatGPTRecallInput: HTMLElement | null = null const CHATGPT_DEBUG = false const CHATGPT_LOG_PREFIX = "[supermemory:chatgpt]" +const chatGPTRecallRequests = createRecallRequestFreshnessGuard() export function initializeChatGPT() { debugChatGPT("initializeChatGPT called", { @@ -89,6 +92,9 @@ function setupChatGPTRouteChangeDetection() { const checkForRouteChange = () => { if (window.location.href !== currentUrl) { + invalidateChatGPTRecallRequests() + const input = getChatGPTRecallInput() + if (input) clearPendingChatGPTRecall(input, false) currentUrl = window.location.href debugChatGPT("route changed, re-adding supermemory elements", currentUrl) setTimeout(() => { @@ -102,6 +108,7 @@ function setupChatGPTRouteChangeDetection() { chatGPTUrlCheckInterval = setInterval(checkForRouteChange, 2000) chatGPTRouteObserver = new MutationObserver((mutations) => { + trackChatGPTRecallInput() if (chatGPTObserverThrottle) { return } @@ -158,11 +165,12 @@ function setupChatGPTRouteChangeDetection() { } async function getRelatedMemoriesForChatGPT(actionSource: string) { + let request: ReturnType | null = null try { const isAutoSearch = actionSource === POSTHOG_EVENT_KEY.CHATGPT_CHAT_MEMORIES_AUTO_SEARCHED - const userQuery = - document.getElementById("prompt-textarea")?.textContent || "" + const state = getChatGPTRecallState() + const userQuery = state.query const icon = document.querySelectorAll( '[id*="sm-chatgpt-input-bar-element-before-composer"]', @@ -175,6 +183,8 @@ async function getRelatedMemoriesForChatGPT(actionSource: string) { return } + request = chatGPTRecallRequests.begin(state) + if (isAutoSearch) { const promptElement = document.getElementById("prompt-textarea") if (promptElement) { @@ -201,6 +211,10 @@ async function getRelatedMemoriesForChatGPT(actionSource: string) { timeoutPromise, ]) + if (!chatGPTRecallRequests.isCurrent(request, getChatGPTRecallState())) { + return + } + if (response?.success && response?.data) { const promptElement = document.getElementById("prompt-textarea") if (promptElement) { @@ -241,6 +255,12 @@ async function getRelatedMemoriesForChatGPT(actionSource: string) { } } } catch (error) { + if ( + request && + !chatGPTRecallRequests.isCurrent(request, getChatGPTRecallState()) + ) { + return + } console.error("Error getting related memories:", error) try { const icon = document.querySelectorAll( @@ -487,6 +507,79 @@ function getChatGPTPromptInput(): HTMLElement | null { ) as HTMLElement | null } +function getChatGPTRecallInput(): HTMLElement | null { + return document.getElementById("prompt-textarea") || getChatGPTPromptInput() +} + +function invalidateChatGPTRecallRequests() { + chatGPTRecallRequests.invalidate() + if (chatGPTDebounceTimeout) { + clearTimeout(chatGPTDebounceTimeout) + chatGPTDebounceTimeout = null + } +} + +function trackChatGPTRecallInput() { + setChatGPTRecallInput(getChatGPTRecallInput()) +} + +function setChatGPTRecallInput(input: HTMLElement | null) { + if (input === chatGPTRecallInput) return + const previousInput = chatGPTRecallInput + chatGPTRecallInput = input + invalidateChatGPTRecallRequests() + if (previousInput) clearPendingChatGPTRecall(previousInput, false) +} + +function clearPendingChatGPTRecall( + input: HTMLElement, + preserveAcceptedIcon = true, +) { + const hadAcceptedMarker = input.dataset.supermemoriesInjected === "true" + syncAcceptedSupermemoryState(input) + const hasAcceptedContext = hasAcceptedSupermemoryContext(input) + + clearMemorySuggestion("chatgpt", input) + if (hasAcceptedContext) input.dataset.supermemoriesInjected = "true" + document + .querySelectorAll('[id*="sm-chatgpt-input-bar-element-before-composer"]') + .forEach((icon) => { + const iconElement = icon as HTMLElement + iconElement.querySelector("[data-supermemory-marker-popover]")?.remove() + if ( + preserveAcceptedIcon && + hadAcceptedMarker && + hasAcceptedContext && + iconElement.dataset.memoriesData + ) { + setMemoryMarkerStatus(iconElement, "found") + return + } + delete iconElement.dataset.memoriesData + setMemoryMarkerStatus(iconElement, "neutral") + }) +} + +function attachChatGPTRecallFreshness(input: HTMLElement) { + if (input.hasAttribute("data-supermemory-recall-freshness")) return + input.setAttribute("data-supermemory-recall-freshness", "true") + setChatGPTRecallInput(input) + + input.addEventListener("input", () => { + invalidateChatGPTRecallRequests() + clearPendingChatGPTRecall(input) + }) +} + +function getChatGPTRecallState() { + const input = getChatGPTRecallInput() + return { + input, + query: input?.textContent || "", + url: window.location.href, + } +} + function findChatGPTComposerRoot(input: HTMLElement): HTMLElement { const form = input.closest("form") as HTMLElement | null if (form) return form @@ -611,16 +704,14 @@ function getChatGPTDomSnapshot() { } } -async function setupChatGPTAutoFetch() { +export async function setupChatGPTAutoFetch() { + const promptTextarea = getChatGPTRecallInput() + if (!promptTextarea) return + attachChatGPTRecallFreshness(promptTextarea) + const autoSearch = (await autoSearchEnabled.getValue()) ?? false - - if (!autoSearch) { - return - } - - const promptTextarea = document.getElementById("prompt-textarea") if ( - !promptTextarea || + !autoSearch || promptTextarea.hasAttribute("data-supermemory-auto-fetch") ) { return @@ -630,26 +721,25 @@ async function setupChatGPTAutoFetch() { const handleInput = () => { const content = promptTextarea.textContent?.trim() || "" - syncAcceptedSupermemoryState(promptTextarea) - - if (content.length === 0) { - clearMemorySuggestion("chatgpt", promptTextarea) - document - .querySelectorAll( - '[id*="sm-chatgpt-input-bar-element-before-composer"]', - ) - .forEach((icon) => { - setMemoryMarkerStatus(icon as HTMLElement, "neutral") - }) - } if (chatGPTDebounceTimeout) { clearTimeout(chatGPTDebounceTimeout) } + const scheduledRequest = chatGPTRecallRequests.begin( + getChatGPTRecallState(), + ) chatGPTDebounceTimeout = setTimeout(async () => { + chatGPTDebounceTimeout = null + if ( + !chatGPTRecallRequests.isCurrent( + scheduledRequest, + getChatGPTRecallState(), + ) + ) { + return + } if (hasAcceptedSupermemoryContext(promptTextarea)) { - clearMemorySuggestion("chatgpt", promptTextarea) return } @@ -657,24 +747,6 @@ async function setupChatGPTAutoFetch() { await getRelatedMemoriesForChatGPT( POSTHOG_EVENT_KEY.CHATGPT_CHAT_MEMORIES_AUTO_SEARCHED, ) - } else if (content.length === 0) { - const icons = document.querySelectorAll( - '[id*="sm-chatgpt-input-bar-element-before-composer"]', - ) - - icons.forEach((icon) => { - const iconElement = icon as HTMLElement - setMemoryMarkerStatus(iconElement, "neutral") - if (iconElement.dataset.originalHtml) { - iconElement.innerHTML = iconElement.dataset.originalHtml - delete iconElement.dataset.originalHtml - delete iconElement.dataset.memoriesData - } - }) - - if (promptTextarea.dataset.supermemories) { - clearMemorySuggestion("chatgpt", promptTextarea) - } } }, UI_CONFIG.AUTO_SEARCH_DEBOUNCE_DELAY) } diff --git a/apps/browser-extension/entrypoints/content/claude.ts b/apps/browser-extension/entrypoints/content/claude.ts index f31c2bb6..61ff5402 100644 --- a/apps/browser-extension/entrypoints/content/claude.ts +++ b/apps/browser-extension/entrypoints/content/claude.ts @@ -24,13 +24,16 @@ import { showMemorySuggestion, syncAcceptedSupermemoryState, } from "./memory-suggestion" +import { createRecallRequestFreshnessGuard } from "./recall-request-freshness" let claudeDebounceTimeout: NodeJS.Timeout | null = null let claudeRouteObserver: MutationObserver | null = null let claudeUrlCheckInterval: NodeJS.Timeout | null = null let claudeObserverThrottle: NodeJS.Timeout | null = null +let claudeRecallInput: HTMLElement | null = null const CLAUDE_DEBUG = false const CLAUDE_LOG_PREFIX = "[supermemory:claude]" +const claudeRecallRequests = createRecallRequestFreshnessGuard() export function initializeClaude() { debugClaude("initializeClaude called", { @@ -89,6 +92,9 @@ function setupClaudeRouteChangeDetection() { const checkForRouteChange = () => { if (window.location.href !== currentUrl) { + invalidateClaudeRecallRequests() + const input = getClaudePromptInput() + if (input) clearPendingClaudeRecall(input, false) currentUrl = window.location.href debugClaude("route changed, re-adding supermemory icon", currentUrl) setTimeout(() => { @@ -102,6 +108,7 @@ function setupClaudeRouteChangeDetection() { claudeUrlCheckInterval = setInterval(checkForRouteChange, 2000) claudeRouteObserver = new MutationObserver((mutations) => { + trackClaudeRecallInput() if (claudeObserverThrottle) { return } @@ -243,6 +250,113 @@ function getClaudePromptInput(): HTMLElement | null { ) as HTMLElement | null } +function invalidateClaudeRecallRequests() { + claudeRecallRequests.invalidate() + if (claudeDebounceTimeout) { + clearTimeout(claudeDebounceTimeout) + claudeDebounceTimeout = null + } +} + +function trackClaudeRecallInput() { + setClaudeRecallInput(getClaudePromptInput()) +} + +function setClaudeRecallInput(input: HTMLElement | null) { + if (input === claudeRecallInput) return + const previousInput = claudeRecallInput + claudeRecallInput = input + invalidateClaudeRecallRequests() + if (previousInput) clearPendingClaudeRecall(previousInput, false) +} + +function getClaudeInputText(input: HTMLElement | null): string { + if (!input) return "" + if ( + input instanceof HTMLTextAreaElement || + input instanceof HTMLInputElement + ) { + return input.value || "" + } + return input.innerText || input.textContent || "" +} + +function clearPendingClaudeRecall( + input: HTMLElement, + preserveAcceptedIcon = true, +) { + const hadAcceptedMarker = input.dataset.supermemoriesInjected === "true" + syncAcceptedSupermemoryState(input) + const hasAcceptedContext = hasAcceptedSupermemoryContext(input) + + clearMemorySuggestion("claude", input) + if (hasAcceptedContext) input.dataset.supermemoriesInjected = "true" + document + .querySelectorAll('[id*="sm-claude-input-bar-element"]') + .forEach((icon) => { + const iconElement = icon as HTMLElement + iconElement.querySelector("[data-supermemory-marker-popover]")?.remove() + if ( + preserveAcceptedIcon && + hadAcceptedMarker && + hasAcceptedContext && + iconElement.dataset.memoriesData + ) { + setMemoryMarkerStatus(iconElement, "found") + return + } + delete iconElement.dataset.memoriesData + setMemoryMarkerStatus(iconElement, "neutral") + }) +} + +function attachClaudeRecallFreshness(input: HTMLElement) { + if (input.hasAttribute("data-supermemory-recall-freshness")) return + input.setAttribute("data-supermemory-recall-freshness", "true") + setClaudeRecallInput(input) + + input.addEventListener("input", () => { + invalidateClaudeRecallRequests() + clearPendingClaudeRecall(input) + }) +} + +function getClaudeRecallState() { + let input = getClaudePromptInput() + let query = "" + const supermemoryContainer = document.querySelector( + '[data-supermemory-icon-added="true"]', + ) + + if (supermemoryContainer?.parentElement?.previousElementSibling) { + const paragraph = + supermemoryContainer.parentElement.previousElementSibling.querySelector( + "p", + ) + query = paragraph?.innerText || paragraph?.textContent || "" + } + + if (!query.trim()) { + query = getClaudeInputText(input) + } + + if (!query.trim()) { + const inputElements = document.querySelectorAll( + 'div[contenteditable="true"], textarea, input[type="text"]', + ) + for (const candidate of inputElements) { + const text = getClaudeInputText(candidate) + if (text.trim()) { + input = candidate + query = text.trim() + break + } + } + } + + return { input, query, url: window.location.href } +} + function findComposerRoot(input: HTMLElement): HTMLElement { return ( (input.closest("form") as HTMLElement | null) || @@ -358,44 +472,12 @@ function getClaudeDomSnapshot() { } async function getRelatedMemoriesForClaude(actionSource: string) { + let request: ReturnType | null = null try { const isAutoSearch = actionSource === POSTHOG_EVENT_KEY.CLAUDE_CHAT_MEMORIES_AUTO_SEARCHED - let userQuery = "" - - const supermemoryContainer = document.querySelector( - '[data-supermemory-icon-added="true"]', - ) - if (supermemoryContainer?.parentElement?.previousElementSibling) { - const pTag = - supermemoryContainer.parentElement.previousElementSibling.querySelector( - "p", - ) - userQuery = pTag?.innerText || pTag?.textContent || "" - } - - if (!userQuery.trim()) { - const textareaElement = document.querySelector( - 'div[contenteditable="true"]', - ) as HTMLElement - userQuery = - textareaElement?.innerText || textareaElement?.textContent || "" - } - - if (!userQuery.trim()) { - const inputElements = document.querySelectorAll( - 'div[contenteditable="true"], textarea, input[type="text"]', - ) - for (const element of inputElements) { - const text = - (element as HTMLElement).innerText || - (element as HTMLInputElement).value - if (text?.trim()) { - userQuery = text.trim() - break - } - } - } + const state = getClaudeRecallState() + const userQuery = state.query debugClaude("query extracted", { queryLength: userQuery.length, @@ -415,6 +497,8 @@ async function getRelatedMemoriesForClaude(actionSource: string) { return } + request = claudeRecallRequests.begin(state) + if (isAutoSearch) { const input = getClaudePromptInput() if (input) { @@ -441,14 +525,16 @@ async function getRelatedMemoriesForClaude(actionSource: string) { timeoutPromise, ]) + if (!claudeRecallRequests.isCurrent(request, getClaudeRecallState())) { + return + } + debugClaude("memory search response", { success: response?.success, }) if (response?.success && response?.data) { - const textareaElement = document.querySelector( - 'div[contenteditable="true"]', - ) as HTMLElement + const textareaElement = state.input if (textareaElement) { const memoryText = showMemorySuggestion( @@ -488,6 +574,12 @@ async function getRelatedMemoriesForClaude(actionSource: string) { } } } catch (error) { + if ( + request && + !claudeRecallRequests.isCurrent(request, getClaudeRecallState()) + ) { + return + } console.error("Error getting related memories for Claude:", error) try { const icon = document.querySelector( @@ -838,17 +930,13 @@ function setupClaudePromptCapture() { } async function setupClaudeAutoFetch() { + const textareaElement = getClaudePromptInput() + if (!textareaElement) return + attachClaudeRecallFreshness(textareaElement) + const autoSearch = (await autoSearchEnabled.getValue()) ?? false - if (!autoSearch) { - return - } - - const textareaElement = document.querySelector( - 'div[contenteditable="true"]', - ) as HTMLElement - if ( - !textareaElement || + !autoSearch || textareaElement.hasAttribute("data-supermemory-auto-fetch") ) { return @@ -857,25 +945,24 @@ async function setupClaudeAutoFetch() { textareaElement.setAttribute("data-supermemory-auto-fetch", "true") const handleInput = () => { - const content = textareaElement.textContent?.trim() || "" - syncAcceptedSupermemoryState(textareaElement) - - if (content.length === 0) { - clearMemorySuggestion("claude", textareaElement) - document - .querySelectorAll('[id*="sm-claude-input-bar-element"]') - .forEach((icon) => { - setMemoryMarkerStatus(icon as HTMLElement, "neutral") - }) - } + const content = getClaudeInputText(textareaElement).trim() if (claudeDebounceTimeout) { clearTimeout(claudeDebounceTimeout) } + const scheduledRequest = claudeRecallRequests.begin(getClaudeRecallState()) claudeDebounceTimeout = setTimeout(async () => { + claudeDebounceTimeout = null + if ( + !claudeRecallRequests.isCurrent( + scheduledRequest, + getClaudeRecallState(), + ) + ) { + return + } if (hasAcceptedSupermemoryContext(textareaElement)) { - clearMemorySuggestion("claude", textareaElement) return } @@ -883,24 +970,6 @@ async function setupClaudeAutoFetch() { await getRelatedMemoriesForClaude( POSTHOG_EVENT_KEY.CLAUDE_CHAT_MEMORIES_AUTO_SEARCHED, ) - } else if (content.length === 0) { - const icons = document.querySelectorAll( - '[id*="sm-claude-input-bar-element"]', - ) - - icons.forEach((icon) => { - const iconElement = icon as HTMLElement - setMemoryMarkerStatus(iconElement, "neutral") - if (iconElement.dataset.originalHtml) { - iconElement.innerHTML = iconElement.dataset.originalHtml - delete iconElement.dataset.originalHtml - delete iconElement.dataset.memoriesData - } - }) - - if (textareaElement.dataset.supermemories) { - clearMemorySuggestion("claude", textareaElement) - } } }, UI_CONFIG.AUTO_SEARCH_DEBOUNCE_DELAY) } diff --git a/apps/browser-extension/entrypoints/content/gemini.ts b/apps/browser-extension/entrypoints/content/gemini.ts index f819d3d6..76238395 100644 --- a/apps/browser-extension/entrypoints/content/gemini.ts +++ b/apps/browser-extension/entrypoints/content/gemini.ts @@ -24,13 +24,16 @@ import { showMemorySuggestion, syncAcceptedSupermemoryState, } from "./memory-suggestion" +import { createRecallRequestFreshnessGuard } from "./recall-request-freshness" let geminiDebounceTimeout: NodeJS.Timeout | null = null let geminiRouteObserver: MutationObserver | null = null let geminiUrlCheckInterval: NodeJS.Timeout | null = null let geminiObserverThrottle: NodeJS.Timeout | null = null +let geminiRecallInput: GeminiInput | null = null const GEMINI_DEBUG = false const GEMINI_LOG_PREFIX = "[supermemory:gemini]" +const geminiRecallRequests = createRecallRequestFreshnessGuard() type GeminiInput = HTMLElement | HTMLTextAreaElement @@ -94,6 +97,9 @@ function setupGeminiRouteChangeDetection() { const checkForRouteChange = () => { if (window.location.href !== currentUrl) { + invalidateGeminiRecallRequests() + const input = getGeminiPromptInput() + if (input) clearPendingGeminiRecall(input, false) currentUrl = window.location.href debugGemini("route changed, rechecking UI", currentUrl) setTimeout(recheckGeminiUI, 1000) @@ -103,6 +109,7 @@ function setupGeminiRouteChangeDetection() { geminiUrlCheckInterval = setInterval(checkForRouteChange, 2000) geminiRouteObserver = new MutationObserver((mutations) => { + trackGeminiRecallInput() if (geminiObserverThrottle) { return } @@ -123,7 +130,6 @@ function setupGeminiRouteChangeDetection() { ) }), ) - if (shouldRecheck) { geminiObserverThrottle = setTimeout(() => { geminiObserverThrottle = null @@ -364,12 +370,83 @@ function getInputText(input: GeminiInput | null): string { return input.innerText || input.textContent || "" } +function invalidateGeminiRecallRequests() { + geminiRecallRequests.invalidate() + if (geminiDebounceTimeout) { + clearTimeout(geminiDebounceTimeout) + geminiDebounceTimeout = null + } +} + +function trackGeminiRecallInput() { + setGeminiRecallInput(getGeminiPromptInput()) +} + +function setGeminiRecallInput(input: GeminiInput | null) { + if (input === geminiRecallInput) return + const previousInput = geminiRecallInput + geminiRecallInput = input + invalidateGeminiRecallRequests() + if (previousInput) clearPendingGeminiRecall(previousInput, false) +} + +function clearPendingGeminiRecall( + input: GeminiInput, + preserveAcceptedIcon = true, +) { + const hadAcceptedMarker = input.dataset.supermemoriesInjected === "true" + syncAcceptedSupermemoryState(input) + const hasAcceptedContext = hasAcceptedSupermemoryContext(input) + + clearMemorySuggestion("gemini", input) + if (hasAcceptedContext) input.dataset.supermemoriesInjected = "true" + document + .querySelectorAll(`[id*="${ELEMENT_IDS.GEMINI_INPUT_BAR_ELEMENT}"]`) + .forEach((icon) => { + const iconElement = icon as HTMLElement + iconElement.querySelector("[data-supermemory-marker-popover]")?.remove() + if ( + preserveAcceptedIcon && + hadAcceptedMarker && + hasAcceptedContext && + iconElement.dataset.memoriesData + ) { + setMemoryMarkerStatus(iconElement, "found") + return + } + delete iconElement.dataset.memoriesData + delete iconElement.dataset.supermemories + setMemoryMarkerStatus(iconElement, "neutral") + }) +} + +function attachGeminiRecallFreshness(input: GeminiInput) { + if (input.hasAttribute("data-supermemory-recall-freshness")) return + input.setAttribute("data-supermemory-recall-freshness", "true") + setGeminiRecallInput(input) + + input.addEventListener("input", () => { + invalidateGeminiRecallRequests() + clearPendingGeminiRecall(input) + }) +} + +function getGeminiRecallState() { + const input = getGeminiPromptInput() + return { + input, + query: getInputText(input).trim(), + url: window.location.href, + } +} + async function getRelatedMemoriesForGemini(actionSource: string) { + let request: ReturnType | null = null try { const isAutoSearch = actionSource === POSTHOG_EVENT_KEY.GEMINI_CHAT_MEMORIES_AUTO_SEARCHED - const input = getGeminiPromptInput() - const userQuery = getInputText(input).trim() + const state = getGeminiRecallState() + const { input, query: userQuery } = state debugGemini("manual/auto memory search requested", { actionSource, hasInput: !!input, @@ -390,6 +467,8 @@ async function getRelatedMemoriesForGemini(actionSource: string) { return } + request = geminiRecallRequests.begin(state) + if (input && isAutoSearch) { showLoadingSuggestion("gemini", input) } @@ -414,6 +493,10 @@ async function getRelatedMemoriesForGemini(actionSource: string) { timeoutPromise, ])) as { success?: boolean; data?: string } + if (!geminiRecallRequests.isCurrent(request, getGeminiRecallState())) { + return + } + debugGemini("memory search response", response) if (response?.success && response?.data && input) { @@ -436,6 +519,12 @@ async function getRelatedMemoriesForGemini(actionSource: string) { updateGeminiIconFeedback("No memories found", iconElement, 1800) } } catch (error) { + if ( + request && + !geminiRecallRequests.isCurrent(request, getGeminiRecallState()) + ) { + return + } console.error("Error getting related memories for Gemini:", error) const iconElement = document.querySelector( `[id*="${ELEMENT_IDS.GEMINI_INPUT_BAR_ELEMENT}"]`, @@ -592,18 +681,18 @@ function setupGeminiPromptCapture() { } async function setupGeminiAutoFetch() { - const autoSearch = (await autoSearchEnabled.getValue()) ?? false - debugGemini("setup auto fetch", { autoSearch }) - if (!autoSearch) { + const input = getGeminiPromptInput() + if (!input) { + debugGemini("auto fetch skipped", { + hasInput: false, + }) return } + attachGeminiRecallFreshness(input) - const input = getGeminiPromptInput() - if (!input || input.hasAttribute("data-supermemory-auto-fetch")) { - debugGemini("auto fetch skipped", { - hasInput: !!input, - alreadyAttached: input?.hasAttribute("data-supermemory-auto-fetch"), - }) + const autoSearch = (await autoSearchEnabled.getValue()) ?? false + debugGemini("setup auto fetch", { autoSearch }) + if (!autoSearch || input.hasAttribute("data-supermemory-auto-fetch")) { return } @@ -612,24 +701,23 @@ async function setupGeminiAutoFetch() { const handleInput = () => { const content = getInputText(input).trim() - syncAcceptedSupermemoryState(input) - - if (content.length === 0) { - clearMemorySuggestion("gemini", input) - document - .querySelectorAll(`[id*="${ELEMENT_IDS.GEMINI_INPUT_BAR_ELEMENT}"]`) - .forEach((icon) => { - setMemoryMarkerStatus(icon as HTMLElement, "neutral") - }) - } if (geminiDebounceTimeout) { clearTimeout(geminiDebounceTimeout) } + const scheduledRequest = geminiRecallRequests.begin(getGeminiRecallState()) geminiDebounceTimeout = setTimeout(async () => { + geminiDebounceTimeout = null + if ( + !geminiRecallRequests.isCurrent( + scheduledRequest, + getGeminiRecallState(), + ) + ) { + return + } if (hasAcceptedSupermemoryContext(input)) { - clearMemorySuggestion("gemini", input) return } @@ -637,25 +725,6 @@ async function setupGeminiAutoFetch() { await getRelatedMemoriesForGemini( POSTHOG_EVENT_KEY.GEMINI_CHAT_MEMORIES_AUTO_SEARCHED, ) - } else if (content.length === 0) { - const icons = document.querySelectorAll( - `[id*="${ELEMENT_IDS.GEMINI_INPUT_BAR_ELEMENT}"]`, - ) - - icons.forEach((icon) => { - const iconElement = icon as HTMLElement - iconElement.querySelector("[data-supermemory-status-badge]")?.remove() - delete iconElement.dataset.supermemoryStatus - delete iconElement.dataset.memoriesData - if (iconElement.dataset.originalHtml) { - iconElement.innerHTML = iconElement.dataset.originalHtml - delete iconElement.dataset.originalHtml - } - }) - - if (input.dataset.supermemories) { - clearMemorySuggestion("gemini", input) - } } }, UI_CONFIG.AUTO_SEARCH_DEBOUNCE_DELAY) } diff --git a/apps/browser-extension/entrypoints/content/memory-suggestion.ts b/apps/browser-extension/entrypoints/content/memory-suggestion.ts index 27b65b08..83a0d214 100644 --- a/apps/browser-extension/entrypoints/content/memory-suggestion.ts +++ b/apps/browser-extension/entrypoints/content/memory-suggestion.ts @@ -198,9 +198,9 @@ export function acceptMemorySuggestion( event.stopPropagation() const text = input.dataset.supermemories - appendTextToInput(input, text) delete input.dataset.supermemories input.dataset.supermemoriesInjected = "true" + appendTextToInput(input, text) removeMemorySuggestion(platform) return true diff --git a/apps/browser-extension/entrypoints/content/recall-freshness.integration.test.ts b/apps/browser-extension/entrypoints/content/recall-freshness.integration.test.ts new file mode 100644 index 00000000..4f79ba70 --- /dev/null +++ b/apps/browser-extension/entrypoints/content/recall-freshness.integration.test.ts @@ -0,0 +1,245 @@ +import { afterEach, describe, expect, it, mock } from "bun:test" +import { GlobalWindow } from "happy-dom" + +mock.module("#imports", () => ({ + storage: { + defineItem: () => ({ + getValue: async () => false, + setValue: async () => {}, + }), + }, +})) + +const { setupChatGPTAutoFetch } = await import("./chatgpt") +const { acceptMemorySuggestion } = await import("./memory-suggestion") +const { getRelatedMemoriesForT3, setupT3AutoFetch } = await import("./t3") + +const installedGlobals = [ + "browser", + "document", + "Event", + "HTMLElement", + "HTMLInputElement", + "HTMLTextAreaElement", + "KeyboardEvent", + "Node", + "window", +] as const +const originalGlobals = new Map( + installedGlobals.map((name) => [ + name, + Object.getOwnPropertyDescriptor(globalThis, name), + ]), +) + +let page: GlobalWindow | null = null + +function installDom() { + page = new GlobalWindow({ url: "https://t3.chat/chat/test" }) + const values = { + browser: undefined, + document: page.document, + Event: page.Event, + HTMLElement: page.HTMLElement, + HTMLInputElement: page.HTMLInputElement, + HTMLTextAreaElement: page.HTMLTextAreaElement, + KeyboardEvent: page.KeyboardEvent, + Node: page.Node, + window: page, + } + + for (const name of installedGlobals) { + if (name === "browser") continue + Object.defineProperty(globalThis, name, { + configurable: true, + value: values[name], + writable: true, + }) + } +} + +afterEach(() => { + page?.happyDOM.close() + page = null + for (const name of installedGlobals) { + const descriptor = originalGlobals.get(name) + if (descriptor) { + Object.defineProperty(globalThis, name, descriptor) + } else { + Reflect.deleteProperty(globalThis, name) + } + } +}) + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise + }) + return { promise, resolve } +} + +describe("T3 recall freshness handler", () => { + it("clears stale auto-send data on edit even when auto-search is off", async () => { + installDom() + const input = document.createElement("textarea") + input.dataset.supermemories = "stale memory A" + const icon = document.createElement("div") + icon.id = "sm-t3-input-bar-element-test" + icon.innerHTML = "Included Memories" + icon.dataset.originalHtml = "" + icon.dataset.memoriesData = '["stale memory A"]' + document.body.append(input, icon) + const responseA = deferred() + Object.defineProperty(globalThis, "browser", { + configurable: true, + value: { + runtime: { + sendMessage: () => responseA.promise, + }, + }, + writable: true, + }) + + await setupT3AutoFetch() + input.value = "prompt A" + const inFlightA = getRelatedMemoriesForT3("test") + input.value = "prompt B" + input.dispatchEvent(new Event("input", { bubbles: true })) + + expect(input.dataset.supermemories).toBeUndefined() + expect(icon.dataset.memoriesData).toBeUndefined() + expect(icon.dataset.originalHtml).toBeUndefined() + expect(icon.textContent).toBe("Search memories") + expect(input.dataset.supermemoryRecallFreshness).toBe("true") + expect(input.dataset.supermemoryAutoFetch).toBeUndefined() + + responseA.resolve({ success: true, data: ["memory A"] }) + await inFlightA + expect(input.dataset.supermemories).toBeUndefined() + expect(icon.dataset.memoriesData).toBeUndefined() + }) + + it("keeps fast B when slow A resolves last", async () => { + installDom() + const prompt = document.createElement("div") + const input = document.createElement("textarea") + input.value = "prompt A" + prompt.appendChild(input) + const controls = document.createElement("div") + const iconContainer = document.createElement("div") + iconContainer.dataset.supermemoryIconAdded = "true" + const icon = document.createElement("div") + icon.id = "sm-t3-input-bar-element-test" + icon.innerHTML = "" + iconContainer.appendChild(icon) + controls.appendChild(iconContainer) + document.body.append(prompt, controls) + + const pending = new Map>>() + Object.defineProperty(globalThis, "browser", { + configurable: true, + value: { + runtime: { + sendMessage: ({ data }: { data: string }) => { + const request = deferred() + pending.set(data, request) + return request.promise + }, + }, + }, + writable: true, + }) + + const slowA = getRelatedMemoriesForT3("test") + input.value = "prompt B" + const fastB = getRelatedMemoriesForT3("test") + pending.get("prompt B")?.resolve({ success: true, data: ["memory B"] }) + await fastB + pending.get("prompt A")?.resolve({ success: true, data: ["memory A"] }) + await slowA + + expect(input.dataset.supermemories).toContain("memory B") + expect(input.dataset.supermemories).not.toContain("memory A") + expect(icon.dataset.memoriesData).toBe('["memory B"]') + }) + + it("clears replacement-composer loading state", async () => { + installDom() + const firstInput = document.createElement("textarea") + const icon = document.createElement("div") + icon.id = "sm-t3-input-bar-element-test" + icon.innerHTML = "Searching memories" + icon.dataset.originalHtml = "" + icon.dataset.memoriesData = '["memory A"]' + document.body.append(firstInput, icon) + + await setupT3AutoFetch() + const replacementInput = document.createElement("textarea") + firstInput.replaceWith(replacementInput) + await setupT3AutoFetch() + + expect(icon.textContent).toBe("Search memories") + expect(icon.dataset.originalHtml).toBeUndefined() + expect(icon.dataset.memoriesData).toBeUndefined() + expect(replacementInput.dataset.supermemoryRecallFreshness).toBe("true") + }) +}) + +describe("accepted recall context", () => { + it("preserves included memories through the synchronous acceptance input", async () => { + installDom() + const form = document.createElement("form") + const input = document.createElement("textarea") + input.dataset.testid = "prompt-textarea" + input.value = "prompt" + input.dataset.supermemories = + "\n\nSupermemories of user (only for the reference): 1. memory M1" + form.appendChild(input) + const icon = document.createElement("div") + icon.id = "sm-chatgpt-input-bar-element-before-composer-test" + icon.dataset.memoriesData = '["memory M1"]' + icon.dataset.supermemoryStatus = "searching" + document.body.append(form, icon) + + await setupChatGPTAutoFetch() + const accepted = acceptMemorySuggestion( + new KeyboardEvent("keydown", { + cancelable: true, + key: "Tab", + }), + "chatgpt", + input, + ) + + expect(accepted).toBe(true) + expect(input.value).toContain("memory M1") + expect(input.dataset.supermemories).toBeUndefined() + expect(input.dataset.supermemoriesInjected).toBe("true") + expect(icon.dataset.memoriesData).toBe('["memory M1"]') + expect(icon.dataset.supermemoryStatus).toBe("found") + expect(icon.querySelector("[data-supermemory-status-badge]")).not.toBeNull() + + icon.dataset.supermemoryStatus = "searching" + input.value = input.value.replace("prompt", "edited prompt") + input.dispatchEvent(new Event("input", { bubbles: true })) + expect(icon.dataset.memoriesData).toBe('["memory M1"]') + expect(icon.dataset.supermemoryStatus).toBe("found") + + input.dataset.supermemories = + "\n\nSupermemories of user (only for the reference): 1. memory M2" + delete input.dataset.supermemoriesInjected + icon.dataset.memoriesData = '["memory M2"]' + input.value = input.value.replace("edited", "edited again") + input.dispatchEvent(new Event("input", { bubbles: true })) + expect(input.value).toContain("memory M1") + expect(input.dataset.supermemories).toBeUndefined() + expect(input.dataset.supermemoriesInjected).toBe("true") + expect(icon.dataset.memoriesData).toBeUndefined() + expect(icon.dataset.supermemoryStatus).toBeUndefined() + + input.dispatchEvent(new Event("input", { bubbles: true })) + expect(icon.dataset.supermemoryStatus).toBeUndefined() + expect(icon.querySelector("[data-supermemory-status-badge]")).toBeNull() + }) +}) diff --git a/apps/browser-extension/entrypoints/content/recall-request-freshness.test.ts b/apps/browser-extension/entrypoints/content/recall-request-freshness.test.ts new file mode 100644 index 00000000..12841eea --- /dev/null +++ b/apps/browser-extension/entrypoints/content/recall-request-freshness.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "bun:test" +import { createRecallRequestFreshnessGuard } from "./recall-request-freshness" + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, reject, resolve } +} + +describe("recall request freshness", () => { + it("lets fast B commit and discards slow A", async () => { + const guard = createRecallRequestFreshnessGuard<{ id: string }>() + const input = { id: "composer" } + let state = { input, query: "prompt A", url: "/chat" } + const commits: string[] = [] + const slowA = deferred() + const fastB = deferred() + + const requestA = guard.begin(state) + const settleA = slowA.promise.then((value) => { + if (guard.isCurrent(requestA, state)) commits.push(value) + }) + + state = { ...state, query: "prompt B" } + guard.invalidate() + const requestB = guard.begin(state) + const settleB = fastB.promise.then((value) => { + if (guard.isCurrent(requestB, state)) commits.push(value) + }) + + fastB.resolve("memory B") + await settleB + slowA.resolve("memory A") + await settleA + + expect(commits).toEqual(["memory B"]) + }) + + it("keeps a T3 edit-gap from auto-sending stale memory", async () => { + const guard = createRecallRequestFreshnessGuard<{ + dataset: { supermemories?: string } + }>() + const input: { dataset: { supermemories?: string } } = { dataset: {} } + let state = { input, query: "prompt A", url: "/chat" } + const result = deferred() + const request = guard.begin(state) + const settle = result.promise.then((value) => { + if (guard.isCurrent(request, state)) { + input.dataset.supermemories = value + } + }) + + state = { ...state, query: "prompt B" } + guard.invalidate() + result.resolve("stale memory A") + await settle + const promptSent = `${state.query}${input.dataset.supermemories || ""}` + + expect(promptSent).toBe("prompt B") + }) + + it("keeps an edit invalidated even when the user restores the same text", async () => { + const guard = createRecallRequestFreshnessGuard<{ dataset: string }>() + const input = { dataset: "" } + let state = { input, query: "prompt A", url: "/chat" } + const result = deferred() + const request = guard.begin(state) + const settle = result.promise.then((value) => { + if (guard.isCurrent(request, state)) input.dataset = value + }) + + state = { ...state, query: "temporary edit" } + guard.invalidate() + state = { ...state, query: "prompt A" } + result.resolve("stale memory A") + await settle + + expect(input.dataset).toBe("") + }) + + it("rejects stale errors, route changes, and replaced composers", async () => { + const guard = createRecallRequestFreshnessGuard<{ id: string }>() + const oldInput = { id: "old" } + let state = { input: oldInput, query: "prompt", url: "/chat/one" } + const failure = deferred() + const errors: unknown[] = [] + const request = guard.begin(state) + const settle = failure.promise.catch((error) => { + if (guard.isCurrent(request, state)) errors.push(error) + }) + + state = { + input: { id: "new" }, + query: "prompt", + url: "/chat/two", + } + guard.invalidate() + failure.reject(new Error("stale failure")) + await settle + + expect(errors).toEqual([]) + }) +}) diff --git a/apps/browser-extension/entrypoints/content/recall-request-freshness.ts b/apps/browser-extension/entrypoints/content/recall-request-freshness.ts new file mode 100644 index 00000000..fc2596bc --- /dev/null +++ b/apps/browser-extension/entrypoints/content/recall-request-freshness.ts @@ -0,0 +1,38 @@ +export interface RecallRequestState { + input: TInput | null + query: string + url: string +} + +export interface RecallRequestToken + extends RecallRequestState { + generation: number +} + +export function createRecallRequestFreshnessGuard() { + let generation = 0 + + return { + invalidate() { + generation += 1 + }, + + begin(state: RecallRequestState): RecallRequestToken { + generation += 1 + return { ...state, generation } + }, + + isCurrent( + request: RecallRequestToken, + state: RecallRequestState, + ) { + return ( + request.generation === generation && + request.input !== null && + request.input === state.input && + request.query === state.query && + request.url === state.url + ) + }, + } +} diff --git a/apps/browser-extension/entrypoints/content/t3.ts b/apps/browser-extension/entrypoints/content/t3.ts index bddd83ed..3c9ec338 100644 --- a/apps/browser-extension/entrypoints/content/t3.ts +++ b/apps/browser-extension/entrypoints/content/t3.ts @@ -16,11 +16,14 @@ import { renumberIncludedMemories, serializeMemoriesForDataset, } from "./memory-suggestion" +import { createRecallRequestFreshnessGuard } from "./recall-request-freshness" let t3DebounceTimeout: NodeJS.Timeout | null = null let t3RouteObserver: MutationObserver | null = null let t3UrlCheckInterval: NodeJS.Timeout | null = null let t3ObserverThrottle: NodeJS.Timeout | null = null +let t3RecallInput: HTMLElement | null = null +const t3RecallRequests = createRecallRequestFreshnessGuard() let t3IncludedPopup: { el: HTMLElement onClick: (event: MouseEvent) => void @@ -72,7 +75,9 @@ function setupT3RouteChangeDetection() { const checkForRouteChange = () => { if (window.location.href !== currentUrl) { - disposeT3IncludedPopup() + invalidateT3RecallRequests() + const input = getT3PromptInput() + if (input) clearT3Recall(input) currentUrl = window.location.href setTimeout(() => { addSupermemoryIconToT3Input() @@ -84,6 +89,7 @@ function setupT3RouteChangeDetection() { t3UrlCheckInterval = setInterval(checkForRouteChange, 2000) t3RouteObserver = new MutationObserver((mutations) => { + trackT3RecallInput() if (t3ObserverThrottle) { return } @@ -167,39 +173,112 @@ function addSupermemoryIconToT3Input() { container.insertBefore(supermemoryIcon, container.firstChild) } -async function getRelatedMemoriesForT3(actionSource: string) { - try { - let userQuery = "" +function getT3InputText(input: HTMLElement | null): string { + if (!input) return "" + if (input instanceof HTMLTextAreaElement) return input.value || "" + return input.innerText || input.textContent || "" +} - const supermemoryContainer = document.querySelector( - '[data-supermemory-icon-added="true"]', - ) - if (supermemoryContainer?.parentElement?.previousElementSibling) { - const textareaElement = - supermemoryContainer.parentElement.previousElementSibling.querySelector( - "textarea", - ) - userQuery = textareaElement?.value || "" +function getT3PromptInput(): HTMLElement | null { + return ( + (document.querySelector("textarea") as HTMLTextAreaElement | null) || + (document.querySelector( + 'div[contenteditable="true"]', + ) as HTMLElement | null) + ) +} + +function invalidateT3RecallRequests() { + t3RecallRequests.invalidate() + if (t3DebounceTimeout) { + clearTimeout(t3DebounceTimeout) + t3DebounceTimeout = null + } +} + +function trackT3RecallInput() { + setT3RecallInput(getT3PromptInput()) +} + +function setT3RecallInput(input: HTMLElement | null) { + if (input === t3RecallInput) return + const previousInput = t3RecallInput + t3RecallInput = input + invalidateT3RecallRequests() + if (previousInput) clearT3Recall(previousInput) +} + +function clearT3Recall(input: HTMLElement) { + delete input.dataset.supermemories + disposeT3IncludedPopup() + document + .querySelectorAll('[id*="sm-t3-input-bar-element"]') + .forEach((icon) => { + const iconElement = icon as HTMLElement + if (iconElement.dataset.originalHtml) { + iconElement.innerHTML = iconElement.dataset.originalHtml + delete iconElement.dataset.originalHtml + } + delete iconElement.dataset.memoriesData + }) +} + +export function attachT3RecallFreshnessHandler(input: HTMLElement) { + if (input.hasAttribute("data-supermemory-recall-freshness")) return + input.setAttribute("data-supermemory-recall-freshness", "true") + setT3RecallInput(input) + + input.addEventListener("input", () => { + invalidateT3RecallRequests() + clearT3Recall(input) + }) +} + +function getT3RecallState() { + let input: HTMLElement | null = null + let query = "" + const supermemoryContainer = document.querySelector( + '[data-supermemory-icon-added="true"]', + ) + + if (supermemoryContainer?.parentElement?.previousElementSibling) { + input = + supermemoryContainer.parentElement.previousElementSibling.querySelector( + "textarea", + ) + query = getT3InputText(input) + } + + if (!query.trim()) { + const contentEditable = document.querySelector( + 'div[contenteditable="true"]', + ) as HTMLElement | null + const contentEditableText = getT3InputText(contentEditable) + if (contentEditableText.trim()) { + input = contentEditable + query = contentEditableText } + } - if (!userQuery.trim()) { - const textareaElement = document.querySelector( - 'div[contenteditable="true"]', - ) as HTMLElement - userQuery = - textareaElement?.innerText || textareaElement?.textContent || "" - } - - if (!userQuery.trim()) { - const textareas = document.querySelectorAll("textarea") - for (const textarea of textareas) { - const text = (textarea as HTMLTextAreaElement).value - if (text?.trim()) { - userQuery = text.trim() - break - } + if (!query.trim()) { + for (const textarea of document.querySelectorAll("textarea")) { + const text = textarea.value || "" + if (text.trim()) { + input = textarea + query = text.trim() + break } } + } + + return { input, query, url: window.location.href } +} + +export async function getRelatedMemoriesForT3(actionSource: string) { + let request: ReturnType | null = null + try { + const state = getT3RecallState() + const userQuery = state.query if (!userQuery.trim()) { return @@ -214,6 +293,8 @@ async function getRelatedMemoriesForT3(actionSource: string) { return } + request = t3RecallRequests.begin(state) + updateT3IconFeedback("Searching memories...", iconElement) const timeoutPromise = new Promise((_, reject) => @@ -232,23 +313,12 @@ async function getRelatedMemoriesForT3(actionSource: string) { timeoutPromise, ]) - if (response?.success && response?.data) { - let textareaElement = null - const supermemoryContainer = document.querySelector( - '[data-supermemory-icon-added="true"]', - ) - if (supermemoryContainer?.parentElement?.previousElementSibling) { - textareaElement = - supermemoryContainer.parentElement.previousElementSibling.querySelector( - "textarea", - ) - } + if (!t3RecallRequests.isCurrent(request, getT3RecallState())) { + return + } - if (!textareaElement) { - textareaElement = document.querySelector( - 'div[contenteditable="true"]', - ) as HTMLElement - } + if (response?.success && response?.data) { + const textareaElement = state.input if (textareaElement) { textareaElement.dataset.supermemories = buildSupermemoryText( @@ -269,6 +339,9 @@ async function getRelatedMemoriesForT3(actionSource: string) { updateT3IconFeedback("No memories found", iconElement) } } catch (error) { + if (request && !t3RecallRequests.isCurrent(request, getT3RecallState())) { + return + } console.error("Error getting related memories for T3:", error) try { const icon = document.querySelector( @@ -681,19 +754,16 @@ function setupT3PromptCapture() { document.addEventListener("keydown", handleT3EnterKey, true) } -async function setupT3AutoFetch() { - const autoSearch = (await autoSearchEnabled.getValue()) ?? false - - if (!autoSearch) { +export async function setupT3AutoFetch() { + const textareaElement = getT3PromptInput() + if (!textareaElement) { return } + attachT3RecallFreshnessHandler(textareaElement) - const textareaElement = - (document.querySelector("textarea") as HTMLTextAreaElement) || - (document.querySelector('div[contenteditable="true"]') as HTMLElement) - + const autoSearch = (await autoSearchEnabled.getValue()) ?? false if ( - !textareaElement || + !autoSearch || textareaElement.hasAttribute("data-supermemory-auto-fetch") ) { return @@ -705,37 +775,19 @@ async function setupT3AutoFetch() { if (t3DebounceTimeout) { clearTimeout(t3DebounceTimeout) } + const content = getT3InputText(textareaElement).trim() + const scheduledRequest = t3RecallRequests.begin(getT3RecallState()) t3DebounceTimeout = setTimeout(async () => { - let content = "" - if (textareaElement.tagName === "TEXTAREA") { - content = (textareaElement as HTMLTextAreaElement).value?.trim() || "" - } else { - content = textareaElement.textContent?.trim() || "" + t3DebounceTimeout = null + if (!t3RecallRequests.isCurrent(scheduledRequest, getT3RecallState())) { + return } if (content.length > 2) { await getRelatedMemoriesForT3( POSTHOG_EVENT_KEY.T3_CHAT_MEMORIES_AUTO_SEARCHED, ) - } else if (content.length === 0) { - const icons = document.querySelectorAll( - '[id*="sm-t3-input-bar-element"]', - ) - - icons.forEach((icon) => { - const iconElement = icon as HTMLElement - if (iconElement.dataset.originalHtml) { - iconElement.innerHTML = iconElement.dataset.originalHtml - delete iconElement.dataset.originalHtml - delete iconElement.dataset.memoriesData - } - }) - - if (textareaElement.dataset.supermemories) { - delete textareaElement.dataset.supermemories - } - disposeT3IncludedPopup() } }, UI_CONFIG.AUTO_SEARCH_DEBOUNCE_DELAY) }