diff --git a/apps/browser-extension/entrypoints/content/chatgpt.ts b/apps/browser-extension/entrypoints/content/chatgpt.ts index cf7a3002..ef1a37c7 100644 --- a/apps/browser-extension/entrypoints/content/chatgpt.ts +++ b/apps/browser-extension/entrypoints/content/chatgpt.ts @@ -16,8 +16,9 @@ import { import { acceptMemorySuggestion, clearMemorySuggestion, + clearPendingMemorySuggestion, + createMemorySuggestionPayload, hasAcceptedSupermemoryContext, - serializeMemoriesForDataset, setMemoryMarkerStatus, showLoadingSuggestion, showMarkerPopover, @@ -164,21 +165,19 @@ async function getRelatedMemoriesForChatGPT(actionSource: string) { const userQuery = document.getElementById("prompt-textarea")?.textContent || "" - const icon = document.querySelectorAll( + const iconElement = document.querySelectorAll( '[id*="sm-chatgpt-input-bar-element-before-composer"]', - )[0] - - const iconElement = icon as HTMLElement + )[0] as HTMLElement | undefined + const currentPromptElement = document.getElementById("prompt-textarea") + clearPendingMemorySuggestion("chatgpt", currentPromptElement, iconElement) if (!iconElement) { console.warn("ChatGPT icon element not found, cannot update feedback") return } - if (isAutoSearch) { - const promptElement = document.getElementById("prompt-textarea") - if (promptElement) { - showLoadingSuggestion("chatgpt", promptElement) + if (currentPromptElement) { + showLoadingSuggestion("chatgpt", currentPromptElement) } setMemoryMarkerStatus(iconElement, "searching") } else { @@ -201,21 +200,23 @@ async function getRelatedMemoriesForChatGPT(actionSource: string) { timeoutPromise, ]) - if (response?.success && response?.data) { + const memorySuggestion = response?.success + ? createMemorySuggestionPayload(response.data) + : null + + if (memorySuggestion) { const promptElement = document.getElementById("prompt-textarea") if (promptElement) { const memoryText = showMemorySuggestion( "chatgpt", promptElement, - response.data, + memorySuggestion, ) debugChatGPT("memory suggestion rendered", { memoryLength: memoryText.length, }) - iconElement.dataset.memoriesData = serializeMemoriesForDataset( - response.data, - ) + iconElement.dataset.memoriesData = memorySuggestion.memoriesData if (isAutoSearch) { setMemoryMarkerStatus(iconElement, "found") @@ -234,6 +235,11 @@ async function getRelatedMemoriesForChatGPT(actionSource: string) { } } else { console.warn("No memories found or API response invalid") + clearPendingMemorySuggestion( + "chatgpt", + document.getElementById("prompt-textarea"), + iconElement, + ) if (isAutoSearch) { setMemoryMarkerStatus(iconElement, "none") } else { @@ -245,7 +251,12 @@ async function getRelatedMemoriesForChatGPT(actionSource: string) { try { const icon = document.querySelectorAll( '[id*="sm-chatgpt-input-bar-element-before-composer"]', - )[0] as HTMLElement + )[0] as HTMLElement | undefined + clearPendingMemorySuggestion( + "chatgpt", + document.getElementById("prompt-textarea"), + icon, + ) if (icon) { if ( actionSource === POSTHOG_EVENT_KEY.CHATGPT_CHAT_MEMORIES_AUTO_SEARCHED diff --git a/apps/browser-extension/entrypoints/content/claude.ts b/apps/browser-extension/entrypoints/content/claude.ts index f31c2bb6..08d4b0de 100644 --- a/apps/browser-extension/entrypoints/content/claude.ts +++ b/apps/browser-extension/entrypoints/content/claude.ts @@ -16,8 +16,9 @@ import { import { acceptMemorySuggestion, clearMemorySuggestion, + clearPendingMemorySuggestion, + createMemorySuggestionPayload, hasAcceptedSupermemoryContext, - serializeMemoriesForDataset, setMemoryMarkerStatus, showLoadingSuggestion, showMarkerPopover, @@ -401,24 +402,25 @@ async function getRelatedMemoriesForClaude(actionSource: string) { queryLength: userQuery.length, }) + const iconElement = document.querySelector( + '[id*="sm-claude-input-bar-element"]', + ) as HTMLElement | null + const currentPromptInput = getClaudePromptInput() + clearPendingMemorySuggestion("claude", currentPromptInput, iconElement) + if (!userQuery.trim()) { debugClaude("memory search skipped because query is empty") return } - const icon = document.querySelector('[id*="sm-claude-input-bar-element"]') - - const iconElement = icon as HTMLElement - if (!iconElement) { console.warn("Claude icon element not found, cannot update feedback") return } if (isAutoSearch) { - const input = getClaudePromptInput() - if (input) { - showLoadingSuggestion("claude", input) + if (currentPromptInput) { + showLoadingSuggestion("claude", currentPromptInput) } setMemoryMarkerStatus(iconElement, "searching") } else { @@ -445,24 +447,24 @@ async function getRelatedMemoriesForClaude(actionSource: string) { success: response?.success, }) - if (response?.success && response?.data) { - const textareaElement = document.querySelector( - 'div[contenteditable="true"]', - ) as HTMLElement + const memorySuggestion = response?.success + ? createMemorySuggestionPayload(response.data) + : null + + if (memorySuggestion) { + const textareaElement = getClaudePromptInput() if (textareaElement) { const memoryText = showMemorySuggestion( "claude", textareaElement, - response.data, + memorySuggestion, ) debugClaude("memory suggestion rendered", { memoryLength: memoryText.length, }) - iconElement.dataset.memoriesData = serializeMemoriesForDataset( - response.data, - ) + iconElement.dataset.memoriesData = memorySuggestion.memoriesData if (isAutoSearch) { setMemoryMarkerStatus(iconElement, "found") @@ -481,6 +483,11 @@ async function getRelatedMemoriesForClaude(actionSource: string) { } } else { console.warn("No memories found or API response invalid for Claude") + clearPendingMemorySuggestion( + "claude", + getClaudePromptInput(), + iconElement, + ) if (isAutoSearch) { setMemoryMarkerStatus(iconElement, "none") } else { @@ -492,7 +499,8 @@ async function getRelatedMemoriesForClaude(actionSource: string) { try { const icon = document.querySelector( '[id*="sm-claude-input-bar-element"]', - ) as HTMLElement + ) as HTMLElement | null + clearPendingMemorySuggestion("claude", getClaudePromptInput(), icon) if (icon) { if ( actionSource === POSTHOG_EVENT_KEY.CLAUDE_CHAT_MEMORIES_AUTO_SEARCHED diff --git a/apps/browser-extension/entrypoints/content/gemini.ts b/apps/browser-extension/entrypoints/content/gemini.ts index f819d3d6..e7d02f65 100644 --- a/apps/browser-extension/entrypoints/content/gemini.ts +++ b/apps/browser-extension/entrypoints/content/gemini.ts @@ -16,8 +16,9 @@ import { import { acceptMemorySuggestion, clearMemorySuggestion, + clearPendingMemorySuggestion, + createMemorySuggestionPayload, hasAcceptedSupermemoryContext, - serializeMemoriesForDataset, setMemoryMarkerStatus, showLoadingSuggestion, showMarkerPopover, @@ -376,15 +377,16 @@ async function getRelatedMemoriesForGemini(actionSource: string) { queryLength: userQuery.length, }) + const iconElement = document.querySelector( + `[id*="${ELEMENT_IDS.GEMINI_INPUT_BAR_ELEMENT}"]`, + ) as HTMLElement | null + clearPendingMemorySuggestion("gemini", input, iconElement) + if (!userQuery) { debugGemini("memory search skipped because query is empty") return } - const iconElement = document.querySelector( - `[id*="${ELEMENT_IDS.GEMINI_INPUT_BAR_ELEMENT}"]`, - ) as HTMLElement | null - if (!iconElement) { console.warn("Gemini icon element not found, cannot update feedback") return @@ -412,15 +414,17 @@ async function getRelatedMemoriesForGemini(actionSource: string) { actionSource, }), timeoutPromise, - ])) as { success?: boolean; data?: string } + ])) as { success?: boolean; data?: unknown } debugGemini("memory search response", response) - if (response?.success && response?.data && input) { - const memoryText = showMemorySuggestion("gemini", input, response.data) - iconElement.dataset.memoriesData = serializeMemoriesForDataset( - response.data, - ) + const memorySuggestion = response?.success + ? createMemorySuggestionPayload(response.data) + : null + + if (memorySuggestion && input) { + const memoryText = showMemorySuggestion("gemini", input, memorySuggestion) + iconElement.dataset.memoriesData = memorySuggestion.memoriesData iconElement.dataset.supermemories = memoryText if (isAutoSearch) { setMemoryMarkerStatus(iconElement, "found") @@ -430,6 +434,8 @@ async function getRelatedMemoriesForGemini(actionSource: string) { return } + clearPendingMemorySuggestion("gemini", input, iconElement) + if (isAutoSearch) { setMemoryMarkerStatus(iconElement, "none") } else { @@ -440,6 +446,7 @@ async function getRelatedMemoriesForGemini(actionSource: string) { const iconElement = document.querySelector( `[id*="${ELEMENT_IDS.GEMINI_INPUT_BAR_ELEMENT}"]`, ) as HTMLElement | null + clearPendingMemorySuggestion("gemini", getGeminiPromptInput(), iconElement) if (iconElement) { if ( actionSource === POSTHOG_EVENT_KEY.GEMINI_CHAT_MEMORIES_AUTO_SEARCHED diff --git a/apps/browser-extension/entrypoints/content/memory-suggestion.test.ts b/apps/browser-extension/entrypoints/content/memory-suggestion.test.ts new file mode 100644 index 00000000..7907f805 --- /dev/null +++ b/apps/browser-extension/entrypoints/content/memory-suggestion.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "bun:test" +import { + acceptMemorySuggestion, + buildSupermemoryText, + clearPendingMemoryState, + createMemorySuggestionPayload, + serializeMemoriesForDataset, +} from "./memory-suggestion" + +describe("memory suggestion formatting", () => { + it("does not build an injectable prompt for empty memories", () => { + for (const memories of [[], [" ", "\n"], " ", null, undefined]) { + expect(serializeMemoriesForDataset(memories)).toBe("") + expect(buildSupermemoryText(memories)).toBe("") + expect(createMemorySuggestionPayload(memories)).toBeNull() + } + }) + + it("keeps the existing prompt format for non-empty memories", () => { + const memories = ["1. First memory \n", "2. Second memory \n"] + const suggestionText = + "\n\nSupermemories of user (only for the reference): 1. First memory \n2. Second memory" + + expect(buildSupermemoryText(memories)).toBe(suggestionText) + expect(createMemorySuggestionPayload(memories)).toEqual({ + suggestionText, + memoriesData: JSON.stringify(["1. First memory", "2. Second memory"]), + }) + }) + + it("clears only pending memory state", () => { + let popoverRemoved = false + const input = { + dataset: { + supermemories: "old pending memories", + supermemoriesInjected: "true", + }, + } + const icon = { + dataset: { + memoriesData: '["old pending memories"]', + supermemories: "old suggestion", + }, + querySelector(selector: string) { + expect(selector).toBe("[data-supermemory-marker-popover]") + return { + remove() { + popoverRemoved = true + }, + } + }, + } + + clearPendingMemoryState(input, icon) + + expect(input.dataset.supermemories).toBeUndefined() + expect(input.dataset.supermemoriesInjected).toBe("true") + expect(icon.dataset.memoriesData).toBeUndefined() + expect(icon.dataset.supermemories).toBeUndefined() + expect(popoverRemoved).toBe(true) + + let prevented = false + const accepted = acceptMemorySuggestion( + { + key: "Tab", + preventDefault() { + prevented = true + }, + stopPropagation() {}, + } as KeyboardEvent, + "chatgpt", + input as unknown as HTMLElement, + ) + expect(accepted).toBe(false) + expect(prevented).toBe(false) + }) +}) diff --git a/apps/browser-extension/entrypoints/content/memory-suggestion.ts b/apps/browser-extension/entrypoints/content/memory-suggestion.ts index 27b65b08..46e2b38f 100644 --- a/apps/browser-extension/entrypoints/content/memory-suggestion.ts +++ b/apps/browser-extension/entrypoints/content/memory-suggestion.ts @@ -4,12 +4,17 @@ const SUGGESTION_ATTR = "data-supermemory-memory-suggestion" const SUPERMEMORY_PREFIX = "Supermemories of user (only for the reference):" const SUPERMEMORY_BLUE = "#1A88FF" -export function buildSupermemoryText(memories: unknown): string { - const memoryText = Array.isArray(memories) - ? memories.join("").trim() - : String(memories || "").trim() +interface DatasetTarget { + dataset: Record +} - return `\n\n${SUPERMEMORY_PREFIX} ${memoryText}` +interface PendingMemoryIcon extends DatasetTarget { + querySelector(selectors: string): { remove(): void } | null +} + +export interface MemorySuggestionPayload { + suggestionText: string + memoriesData: string } function normalizeMemoryList(memories: unknown): string[] { @@ -29,6 +34,39 @@ export function serializeMemoriesForDataset(memories: unknown): string { return list.length > 0 ? JSON.stringify(list) : "" } +export function createMemorySuggestionPayload( + memories: unknown, +): MemorySuggestionPayload | null { + const memoriesData = serializeMemoriesForDataset(memories) + if (!memoriesData) return null + + const memoryText = Array.isArray(memories) + ? memories.join("").trim() + : String(memories ?? "").trim() + if (!memoryText) return null + + return { + suggestionText: `\n\n${SUPERMEMORY_PREFIX} ${memoryText}`, + memoriesData, + } +} + +export function buildSupermemoryText(memories: unknown): string { + return createMemorySuggestionPayload(memories)?.suggestionText ?? "" +} + +export function clearPendingMemoryState( + input: DatasetTarget | null | undefined, + icon?: PendingMemoryIcon | null, +) { + if (input) delete input.dataset.supermemories + if (icon) { + delete icon.dataset.memoriesData + delete icon.dataset.supermemories + icon.querySelector("[data-supermemory-marker-popover]")?.remove() + } +} + export function parseMemoriesFromDataset( raw: string | null | undefined, ): string[] { @@ -55,9 +93,9 @@ export function renumberIncludedMemories(memories: string[]): string[] { export function showMemorySuggestion( platform: string, input: SuggestionInput, - memories: unknown, + payload: MemorySuggestionPayload, ): string { - const suggestionText = buildSupermemoryText(memories) + const suggestionText = payload.suggestionText input.dataset.supermemories = suggestionText delete input.dataset.supermemoriesInjected @@ -185,6 +223,15 @@ export function removeMemorySuggestion(platform: string) { } } +export function clearPendingMemorySuggestion( + platform: string, + input: SuggestionInput | null, + icon?: HTMLElement | null, +) { + removeMemorySuggestion(platform) + clearPendingMemoryState(input, icon) +} + export function acceptMemorySuggestion( event: KeyboardEvent, platform: string, @@ -230,10 +277,7 @@ export function clearMemorySuggestion( platform: string, input: SuggestionInput | null, ) { - removeMemorySuggestion(platform) - if (input?.dataset.supermemories) { - delete input.dataset.supermemories - } + clearPendingMemorySuggestion(platform, input) if (input?.dataset.supermemoriesInjected) { delete input.dataset.supermemoriesInjected } diff --git a/apps/browser-extension/entrypoints/content/t3.ts b/apps/browser-extension/entrypoints/content/t3.ts index bddd83ed..908ac7bb 100644 --- a/apps/browser-extension/entrypoints/content/t3.ts +++ b/apps/browser-extension/entrypoints/content/t3.ts @@ -12,6 +12,8 @@ import { import { createT3InputBarElement, DOMUtils } from "../../utils/ui-components" import { buildSupermemoryText, + clearPendingMemoryState, + createMemorySuggestionPayload, parseMemoriesFromDataset, renumberIncludedMemories, serializeMemoriesForDataset, @@ -35,6 +37,24 @@ function disposeT3IncludedPopup() { t3IncludedPopup = null } +function getT3PromptInput(): HTMLTextAreaElement | HTMLElement | null { + const supermemoryContainer = document.querySelector( + '[data-supermemory-icon-added="true"]', + ) + const composerTextarea = + supermemoryContainer?.parentElement?.previousElementSibling?.querySelector( + "textarea", + ) as HTMLTextAreaElement | null + + return ( + composerTextarea || + (document.querySelector('div[contenteditable="true"]') as + | HTMLTextAreaElement + | HTMLElement + | null) + ) +} + export function initializeT3() { if (!DOMUtils.isOnDomain(DOMAINS.T3)) { return @@ -201,14 +221,16 @@ async function getRelatedMemoriesForT3(actionSource: string) { } } + const iconElement = document.querySelector( + '[id*="sm-t3-input-bar-element"]', + ) as HTMLElement | null + clearPendingMemoryState(getT3PromptInput(), iconElement) + disposeT3IncludedPopup() + if (!userQuery.trim()) { return } - const icon = document.querySelector('[id*="sm-t3-input-bar-element"]') - - const iconElement = icon as HTMLElement - if (!iconElement) { console.warn("T3 icon element not found, cannot update feedback") return @@ -232,32 +254,17 @@ 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", - ) - } + const memorySuggestion = response?.success + ? createMemorySuggestionPayload(response.data) + : null - if (!textareaElement) { - textareaElement = document.querySelector( - 'div[contenteditable="true"]', - ) as HTMLElement - } + if (memorySuggestion) { + const textareaElement = getT3PromptInput() if (textareaElement) { - textareaElement.dataset.supermemories = buildSupermemoryText( - response.data, - ) + textareaElement.dataset.supermemories = memorySuggestion.suggestionText - iconElement.dataset.memoriesData = serializeMemoriesForDataset( - response.data, - ) + iconElement.dataset.memoriesData = memorySuggestion.memoriesData updateT3IconFeedback("Included Memories", iconElement) } else { @@ -266,6 +273,8 @@ async function getRelatedMemoriesForT3(actionSource: string) { } } else { console.warn("No memories found or API response invalid for T3") + clearPendingMemoryState(getT3PromptInput(), iconElement) + disposeT3IncludedPopup() updateT3IconFeedback("No memories found", iconElement) } } catch (error) { @@ -273,7 +282,9 @@ async function getRelatedMemoriesForT3(actionSource: string) { try { const icon = document.querySelector( '[id*="sm-t3-input-bar-element"]', - ) as HTMLElement + ) as HTMLElement | null + clearPendingMemoryState(getT3PromptInput(), icon) + disposeT3IncludedPopup() if (icon) { updateT3IconFeedback("Error fetching memories", icon) }