diff --git a/apps/browser-extension/entrypoints/background.ts b/apps/browser-extension/entrypoints/background.ts index e6dba3eb6..2066cf161 100644 --- a/apps/browser-extension/entrypoints/background.ts +++ b/apps/browser-extension/entrypoints/background.ts @@ -21,6 +21,50 @@ import type { MemoryPayload, } from "../utils/types" +const PLATFORM_LABELS: Record = { + chatgpt: "ChatGPT", + claude: "Claude", + gemini: "Gemini", + t3: "T3 Chat", + twitter: "X / Twitter", +} + +function normalizePlatform(value?: string): string | undefined { + if (!value) return undefined + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, "") +} + +function inferPlatformFromActionSource( + actionSource: string, +): string | undefined { + const source = actionSource.toLowerCase() + if (source.includes("chatgpt")) return "chatgpt" + if (source.includes("claude")) return "claude" + if (source.includes("gemini")) return "gemini" + if (source.includes("t3")) return "t3" + if (source.includes("twitter") || source.includes("x_")) return "twitter" + return undefined +} + +function inferPlatformFromUrl(url?: string): string | undefined { + if (!url) return undefined + try { + const hostname = new URL(url).hostname + if (hostname === "chatgpt.com" || hostname === "chat.openai.com") { + return "chatgpt" + } + if (hostname === "claude.ai") return "claude" + if (hostname === "gemini.google.com") return "gemini" + if (hostname === "t3.chat") return "t3" + if (hostname === "x.com" || hostname === "twitter.com") return "twitter" + } catch { + return undefined + } +} + export default defineBackground(() => { let twitterImporter: TwitterImporter | null = null @@ -107,11 +151,33 @@ export default defineBackground(() => { content = data?.url || "" } + const platform = + normalizePlatform(data.sourcePlatform) || + inferPlatformFromUrl(data.url) || + inferPlatformFromActionSource(actionSource) + const platformLabel = platform + ? data.sourcePlatformLabel || PLATFORM_LABELS[platform] || platform + : undefined + const metadata: MemoryPayload["metadata"] = { sm_source: "consumer", + sm_origin: "browser_extension", + sm_origin_action: actionSource, website_url: data.url, } + if (platform) { + metadata.sm_origin_platform = platform + } + + if (platformLabel) { + metadata.sm_origin_platform_label = platformLabel + } + + if (data.sourceSurface) { + metadata.sm_origin_surface = data.sourceSurface + } + if (data.ogImage) { metadata.website_og_image = data.ogImage } @@ -242,6 +308,9 @@ export default defineBackground(() => { const memoryData: MemoryData = { content: messageData.prompt, + url: messageData.source, + sourcePlatform: messageData.platform, + sourceSurface: "prompt_capture", } const result = await saveMemoryToSupermemory( diff --git a/apps/browser-extension/entrypoints/content/chatgpt.ts b/apps/browser-extension/entrypoints/content/chatgpt.ts index 7c3d28aaa..0ec50d926 100644 --- a/apps/browser-extension/entrypoints/content/chatgpt.ts +++ b/apps/browser-extension/entrypoints/content/chatgpt.ts @@ -13,18 +13,37 @@ import { createChatGPTInputBarElement, DOMUtils, } from "../../utils/ui-components" +import { + acceptMemorySuggestion, + clearMemorySuggestion, + hasAcceptedSupermemoryContext, + setMemoryMarkerStatus, + showLoadingSuggestion, + showMarkerPopover, + showMemorySuggestion, + syncAcceptedSupermemoryState, +} from "./memory-suggestion" let chatGPTDebounceTimeout: NodeJS.Timeout | null = null let chatGPTRouteObserver: MutationObserver | null = null let chatGPTUrlCheckInterval: NodeJS.Timeout | null = null let chatGPTObserverThrottle: NodeJS.Timeout | null = null +const CHATGPT_DEBUG = true +const CHATGPT_LOG_PREFIX = "[supermemory:chatgpt]" export function initializeChatGPT() { + debugChatGPT("initializeChatGPT called", { + host: window.location.hostname, + href: window.location.href, + }) + if (!DOMUtils.isOnDomain(DOMAINS.CHATGPT)) { + debugChatGPT("not on ChatGPT domain, skipping") return } if (document.body.hasAttribute("data-chatgpt-initialized")) { + debugChatGPT("already initialized") return } @@ -39,6 +58,18 @@ export function initializeChatGPT() { setupChatGPTRouteChangeDetection() document.body.setAttribute("data-chatgpt-initialized", "true") + debugChatGPT("initialized listeners") +} + +function debugChatGPT(message: string, data?: unknown) { + if (!CHATGPT_DEBUG) return + + if (data === undefined) { + console.log(CHATGPT_LOG_PREFIX, message) + return + } + + console.log(CHATGPT_LOG_PREFIX, message, data) } function setupChatGPTRouteChangeDetection() { @@ -58,7 +89,7 @@ function setupChatGPTRouteChangeDetection() { const checkForRouteChange = () => { if (window.location.href !== currentUrl) { currentUrl = window.location.href - console.log("ChatGPT route changed, re-adding supermemory elements") + debugChatGPT("route changed, re-adding supermemory elements", currentUrl) setTimeout(() => { addSupermemoryButtonToMemoriesDialog() addSaveChatGPTElementBeforeComposerBtn() @@ -83,8 +114,10 @@ function setupChatGPTRouteChangeDetection() { if ( element.querySelector?.("#prompt-textarea") || element.querySelector?.("button.composer-btn") || + element.querySelector?.("button") || element.querySelector?.('[role="dialog"]') || element.matches?.("#prompt-textarea") || + element.matches?.("button") || element.id === "prompt-textarea" ) { shouldRecheck = true @@ -98,6 +131,7 @@ function setupChatGPTRouteChangeDetection() { chatGPTObserverThrottle = setTimeout(() => { try { chatGPTObserverThrottle = null + debugChatGPT("DOM changed near composer, rechecking UI") addSupermemoryButtonToMemoriesDialog() addSaveChatGPTElementBeforeComposerBtn() setupChatGPTAutoFetch() @@ -124,6 +158,8 @@ function setupChatGPTRouteChangeDetection() { async function getRelatedMemoriesForChatGPT(actionSource: string) { try { + const isAutoSearch = + actionSource === POSTHOG_EVENT_KEY.CHATGPT_CHAT_MEMORIES_AUTO_SEARCHED const userQuery = document.getElementById("prompt-textarea")?.textContent || "" @@ -138,7 +174,15 @@ async function getRelatedMemoriesForChatGPT(actionSource: string) { return } - updateChatGPTIconFeedback("Searching memories...", iconElement) + if (isAutoSearch) { + const promptElement = document.getElementById("prompt-textarea") + if (promptElement) { + showLoadingSuggestion("chatgpt", promptElement) + } + setMemoryMarkerStatus(iconElement, "searching") + } else { + updateChatGPTIconFeedback("Searching memories...", iconElement) + } const timeoutPromise = new Promise((_, reject) => setTimeout( @@ -159,24 +203,37 @@ async function getRelatedMemoriesForChatGPT(actionSource: string) { if (response?.success && response?.data) { const promptElement = document.getElementById("prompt-textarea") if (promptElement) { - promptElement.dataset.supermemories = `\n\nSupermemories of user (only for the reference): ${response.data}` - console.log( - "Prompt element dataset:", - promptElement.dataset.supermemories, + const memoryText = showMemorySuggestion( + "chatgpt", + promptElement, + response.data, ) + console.log("Prompt element dataset:", memoryText) - iconElement.dataset.memoriesData = response.data + iconElement.dataset.memoriesData = String(response.data) - updateChatGPTIconFeedback("Included Memories", iconElement) + if (isAutoSearch) { + setMemoryMarkerStatus(iconElement, "found") + } else { + updateChatGPTIconFeedback("Included Memories", iconElement) + } } else { console.warn( "ChatGPT prompt element not found after successful memory fetch", ) - updateChatGPTIconFeedback("Memories found", iconElement) + if (isAutoSearch) { + setMemoryMarkerStatus(iconElement, "found") + } else { + updateChatGPTIconFeedback("Memories found", iconElement) + } } } else { console.warn("No memories found or API response invalid") - updateChatGPTIconFeedback("No memories found", iconElement) + if (isAutoSearch) { + setMemoryMarkerStatus(iconElement, "none") + } else { + updateChatGPTIconFeedback("No memories found", iconElement) + } } } catch (error) { console.error("Error getting related memories:", error) @@ -185,7 +242,13 @@ async function getRelatedMemoriesForChatGPT(actionSource: string) { '[id*="sm-chatgpt-input-bar-element-before-composer"]', )[0] as HTMLElement if (icon) { - updateChatGPTIconFeedback("Error fetching memories", icon) + if ( + actionSource === POSTHOG_EVENT_KEY.CHATGPT_CHAT_MEMORIES_AUTO_SEARCHED + ) { + setMemoryMarkerStatus(icon, "error") + } else { + updateChatGPTIconFeedback("Error fetching memories", icon) + } } } catch (feedbackError) { console.error("Failed to update error feedback:", feedbackError) @@ -218,7 +281,7 @@ function addSupermemoryButtonToMemoriesDialog() { supermemoryButton.id = "supermemory-save-button" supermemoryButton.className = "btn relative btn-primary-outline mr-2" - const iconUrl = browser.runtime.getURL("/icon-16.png") + const iconUrl = browser.runtime.getURL("/new_logo.png") supermemoryButton.innerHTML = `
@@ -278,6 +341,9 @@ async function saveMemoriesToSupermemory() { action: MESSAGE_TYPES.SAVE_MEMORY, data: { html: combinedContent, + sourcePlatform: "chatgpt", + sourceSurface: "memories_dialog", + url: window.location.href, }, actionSource: "chatgpt_memories_dialog", }) @@ -300,272 +366,242 @@ function updateChatGPTIconFeedback( iconElement: HTMLElement, resetAfter = 0, ) { - if (!iconElement.dataset.originalHtml) { - iconElement.dataset.originalHtml = iconElement.innerHTML + const memories = iconElement.dataset.memoriesData + const fallbackReset = + resetAfter || (message === "Included Memories" ? 0 : 2200) + + if (message === "Included Memories" || message === "Memories found") { + setMemoryMarkerStatus(iconElement, "found") + showMarkerPopover(iconElement, "Included Memories", memories) + return } - const feedbackDiv = document.createElement("div") - feedbackDiv.style.cssText = ` - display: flex; - align-items: center; - gap: 6px; - padding: 4px 8px; - background: #513EA9; - border-radius: 12px; - color: white; - font-size: 12px; - font-weight: 500; - cursor: ${message === "Included Memories" ? "pointer" : "default"}; - position: relative; - ` - - feedbackDiv.innerHTML = ` - - ${message} - ` - - if (message === "Included Memories" && iconElement.dataset.memoriesData) { - const popup = document.createElement("div") - popup.style.cssText = ` - position: fixed; - bottom: 80px; - left: 50%; - transform: translateX(-50%); - background: #1a1a1a; - color: white; - padding: 0; - border-radius: 12px; - font-size: 13px; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif; - max-width: 500px; - max-height: 400px; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); - z-index: 999999; - display: none; - border: 1px solid #333; - ` - - const header = document.createElement("div") - header.style.cssText = ` - display: flex; - justify-content: space-between; - align-items: center; - padding: 8px; - border-bottom: 1px solid #333; - opacity: 0.8; - ` - header.innerHTML = ` - Included Memories - ` - - const content = document.createElement("div") - content.style.cssText = ` - padding: 0; - max-height: 300px; - overflow-y: auto; - ` - - const memoriesText = iconElement.dataset.memoriesData || "" - console.log("Memories text:", memoriesText) - const individualMemories = memoriesText - .split(/[,\n]/) - .map((memory) => memory.trim()) - .filter((memory) => memory.length > 0 && memory !== ",") - console.log("Individual memories:", individualMemories) - - individualMemories.forEach((memory, index) => { - const memoryItem = document.createElement("div") - memoryItem.style.cssText = ` - display: flex; - align-items: center; - gap: 6px; - padding: 10px; - font-size: 13px; - line-height: 1.4; - ` - - const memoryText = document.createElement("div") - memoryText.style.cssText = ` - flex: 1; - color: #e5e5e5; - ` - memoryText.textContent = memory.trim() - - const removeBtn = document.createElement("button") - removeBtn.style.cssText = ` - background: transparent; - color: #9ca3af; - border: none; - padding: 4px; - border-radius: 4px; - cursor: pointer; - flex-shrink: 0; - height: fit-content; - display: flex; - align-items: center; - justify-content: center; - ` - removeBtn.innerHTML = `` - removeBtn.dataset.memoryIndex = index.toString() - - removeBtn.addEventListener("mouseenter", () => { - removeBtn.style.color = "#ef4444" - }) - removeBtn.addEventListener("mouseleave", () => { - removeBtn.style.color = "#9ca3af" - }) - - memoryItem.appendChild(memoryText) - memoryItem.appendChild(removeBtn) - content.appendChild(memoryItem) - }) + if (message.toLowerCase().includes("searching")) { + setMemoryMarkerStatus(iconElement, "searching") + showMarkerPopover(iconElement, message) + return + } - popup.appendChild(header) - popup.appendChild(content) - document.body.appendChild(popup) + setMemoryMarkerStatus( + iconElement, + message.toLowerCase().includes("error") ? "error" : "none", + ) + showMarkerPopover(iconElement, message, undefined, fallbackReset) +} - feedbackDiv.addEventListener("mouseenter", () => { - const textSpan = feedbackDiv.querySelector("span:last-child") - if (textSpan) { - textSpan.textContent = "Click to see memories" - } - }) +function addSaveChatGPTElementBeforeComposerBtn() { + const promptInput = getChatGPTPromptInput() + if (!promptInput) { + debugChatGPT("prompt input not found", getChatGPTDomSnapshot()) + return + } - feedbackDiv.addEventListener("mouseleave", () => { - const textSpan = feedbackDiv.querySelector("span:last-child") - if (textSpan) { - textSpan.textContent = "Included Memories" - } - }) + const composer = findChatGPTComposerRoot(promptInput) + if (!composer?.querySelector) { + debugChatGPT("composer root not found", describeElement(promptInput)) + return + } - feedbackDiv.addEventListener("click", (e) => { - e.stopPropagation() - popup.style.display = "block" - }) + const existingMarkers = Array.from( + document.querySelectorAll( + `[id*="${ELEMENT_IDS.CHATGPT_INPUT_BAR_ELEMENT}-before-composer"]`, + ), + ) + if (existingMarkers.length > 1) { + debugChatGPT("removed duplicate markers", existingMarkers.length) + for (const marker of existingMarkers) { + marker.remove() + } + } else if (existingMarkers.length === 1) { + debugChatGPT("marker already exists") + return + } - document.addEventListener("click", (e) => { - if (!popup.contains(e.target as Node)) { - popup.style.display = "none" - } + const buttons = findChatGPTComposerButtons(promptInput, composer) + debugChatGPT("candidate ChatGPT buttons", { + input: describeElement(promptInput), + composer: describeElement(composer), + buttons: buttons.map((button) => ({ + label: buttonLabel(button), + element: describeElement(button), + })), + }) + + const micButton = buttons.find((button) => isChatGPTMicButton(button)) + const voiceButton = buttons.find((button) => isChatGPTVoiceButton(button)) + const sendButton = buttons.find((button) => isChatGPTSendButton(button)) + const anchorButton = + micButton || voiceButton || sendButton || buttons[buttons.length - 1] + const anchorSlot = findChatGPTButtonSlot(anchorButton, composer) + const speechContainer = composer.querySelector( + '[data-testid="composer-speech-button-container"]', + ) as HTMLElement | null + const targetContainer = + anchorSlot?.parentElement || + speechContainer?.parentElement || + promptInput.parentElement + + if (!targetContainer) { + debugChatGPT("could not find insertion target", { + anchor: anchorButton ? describeElement(anchorButton) : null, + input: describeElement(promptInput), }) + return + } - content.querySelectorAll("button[data-memory-index]").forEach((button) => { - const htmlButton = button as HTMLButtonElement - htmlButton.addEventListener("click", () => { - const index = Number.parseInt(htmlButton.dataset.memoryIndex || "0", 10) - const memoryItem = htmlButton.parentElement + const saveChatGPTElement = createChatGPTInputBarElement(async () => { + await getRelatedMemoriesForChatGPT( + POSTHOG_EVENT_KEY.CHATGPT_CHAT_MEMORIES_SEARCHED, + ) + }) - if (memoryItem) { - content.removeChild(memoryItem) - } + saveChatGPTElement.id = `${ELEMENT_IDS.CHATGPT_INPUT_BAR_ELEMENT}-before-composer-${Date.now()}-${Math.random().toString(36).substring(2, 11)}` - const currentMemories = (iconElement.dataset.memoriesData || "") - .split(/[,\n]/) - .map((memory) => memory.trim()) - .filter((memory) => memory.length > 0 && memory !== ",") - currentMemories.splice(index, 1) + if (anchorSlot?.parentElement === targetContainer) { + targetContainer.insertBefore(saveChatGPTElement, anchorSlot) + debugChatGPT("inserted marker before anchor button", { + anchorLabel: anchorButton ? buttonLabel(anchorButton) : null, + anchorSlot: describeElement(anchorSlot), + target: describeElement(targetContainer), + }) + } else { + targetContainer.appendChild(saveChatGPTElement) + debugChatGPT("inserted marker into fallback target", { + target: describeElement(targetContainer), + }) + } - const updatedMemories = currentMemories.join(" ,") + setupChatGPTAutoFetch() +} - iconElement.dataset.memoriesData = updatedMemories +function getChatGPTPromptInput(): HTMLElement | null { + return document.querySelector( + '#prompt-textarea, [data-testid="prompt-textarea"], div[contenteditable="true"]', + ) as HTMLElement | null +} - const promptElement = document.getElementById("prompt-textarea") - if (promptElement) { - promptElement.dataset.supermemories = `\n\nSupermemories of user (only for the reference): ${updatedMemories}` - } +function findChatGPTComposerRoot(input: HTMLElement): HTMLElement { + const form = input.closest("form") as HTMLElement | null + if (form) return form - content - .querySelectorAll("button[data-memory-index]") - .forEach((btn, newIndex) => { - const htmlBtn = btn as HTMLButtonElement - htmlBtn.dataset.memoryIndex = newIndex.toString() - }) + let current: HTMLElement | null = input + for (let depth = 0; current && depth < 8; depth += 1) { + if (current.querySelectorAll("button").length >= 2) { + return current + } + current = current.parentElement + } - if (currentMemories.length <= 1) { - if (promptElement?.dataset.supermemories) { - delete promptElement.dataset.supermemories - delete iconElement.dataset.memoriesData - iconElement.innerHTML = iconElement.dataset.originalHtml || "" - delete iconElement.dataset.originalHtml - } - popup.style.display = "none" - if (document.body.contains(popup)) { - document.body.removeChild(popup) - } - } - }) - }) + return input.parentElement || document.body +} - setTimeout(() => { - if (document.body.contains(popup)) { - document.body.removeChild(popup) - } - }, 300000) +function findChatGPTComposerButtons( + input: HTMLElement, + composer: HTMLElement, +): HTMLButtonElement[] { + const composerButtons = Array.from(composer.querySelectorAll("button")) + if (composerButtons.length > 0) { + return composerButtons } - iconElement.innerHTML = "" - iconElement.appendChild(feedbackDiv) + const inputRect = input.getBoundingClientRect() + const allButtons = Array.from(document.querySelectorAll("button")) - if (resetAfter > 0) { - setTimeout(() => { - iconElement.innerHTML = iconElement.dataset.originalHtml || "" - delete iconElement.dataset.originalHtml - }, resetAfter) - } + return allButtons.filter((button) => { + const rect = button.getBoundingClientRect() + const verticallyNear = + Math.abs( + rect.top + rect.height / 2 - (inputRect.top + inputRect.height / 2), + ) < 120 + const horizontallyNear = + rect.left > inputRect.left - 80 && rect.left < inputRect.right + 260 + + return verticallyNear && horizontallyNear + }) } -function addSaveChatGPTElementBeforeComposerBtn() { - const composerButtons = document.querySelectorAll("button.composer-btn") +function buttonLabel(button: HTMLButtonElement): string { + return [ + button.id, + button.getAttribute("aria-label"), + button.getAttribute("title"), + button.getAttribute("data-testid"), + button.getAttribute("data-test-id"), + button.textContent, + ] + .filter(Boolean) + .join(" ") +} - composerButtons.forEach((button) => { - if (button.hasAttribute("data-supermemory-icon-added-before")) { - return - } +function isChatGPTMicButton(button: HTMLButtonElement): boolean { + return /mic|microphone|dictate/i.test(buttonLabel(button)) +} - const parent = button.parentElement - if (!parent) return +function isChatGPTVoiceButton(button: HTMLButtonElement): boolean { + return /voice|audio|speech/i.test(buttonLabel(button)) +} - const parentSiblings = parent.parentElement?.children - if (!parentSiblings) return +function isChatGPTSendButton(button: HTMLButtonElement): boolean { + const label = buttonLabel(button) + return /composer-submit-button|send|submit/i.test(label) +} - let hasSpeechButtonSibling = false - for (const sibling of parentSiblings) { - if ( - sibling.getAttribute("data-testid") === - "composer-speech-button-container" - ) { - hasSpeechButtonSibling = true - break - } +function findChatGPTButtonSlot( + button: HTMLButtonElement | undefined, + composer: HTMLElement, +): HTMLElement | null { + if (!button) return null + + let current: HTMLElement | null = button + while (current?.parentElement && current.parentElement !== composer) { + const parent: HTMLElement = current.parentElement + const parentStyle = window.getComputedStyle(parent) + const hasSiblingControls = parent.children.length > 1 + const isRow = + parentStyle.display.includes("flex") && + parentStyle.flexDirection !== "column" + + if (hasSiblingControls && isRow) { + return current } - if (!hasSpeechButtonSibling) return - - const grandParent = parent.parentElement - if (!grandParent) return + current = parent + } - const existingIcon = grandParent.querySelector( - `#${ELEMENT_IDS.CHATGPT_INPUT_BAR_ELEMENT}-before-composer`, - ) - if (existingIcon) { - button.setAttribute("data-supermemory-icon-added-before", "true") - return - } + return current || button +} - const saveChatGPTElement = createChatGPTInputBarElement(async () => { - await getRelatedMemoriesForChatGPT( - POSTHOG_EVENT_KEY.CHATGPT_CHAT_MEMORIES_SEARCHED, - ) - }) +function describeElement(element: Element | null): string | null { + if (!element) return null - saveChatGPTElement.id = `${ELEMENT_IDS.CHATGPT_INPUT_BAR_ELEMENT}-before-composer-${Date.now()}-${Math.random().toString(36).substring(2, 11)}` + const parts = [element.tagName.toLowerCase()] + if (element.id) parts.push(`#${element.id}`) + if (element.className && typeof element.className === "string") { + parts.push( + `.${element.className.trim().split(/\s+/).slice(0, 4).join(".")}`, + ) + } - button.setAttribute("data-supermemory-icon-added-before", "true") + for (const attr of ["aria-label", "data-testid", "data-test-id", "role"]) { + const value = element.getAttribute(attr) + if (value) parts.push(`[${attr}="${value}"]`) + } - grandParent.insertBefore(saveChatGPTElement, parent) + return parts.join("") +} - setupChatGPTAutoFetch() - }) +function getChatGPTDomSnapshot() { + return { + promptTextareas: document.querySelectorAll("#prompt-textarea").length, + contenteditables: document.querySelectorAll('[contenteditable="true"]') + .length, + textareas: document.querySelectorAll("textarea").length, + buttons: document.querySelectorAll("button").length, + composerButtons: document.querySelectorAll("button.composer-btn").length, + speechContainers: document.querySelectorAll( + '[data-testid="composer-speech-button-container"]', + ).length, + } } async function setupChatGPTAutoFetch() { @@ -586,12 +622,29 @@ async function setupChatGPTAutoFetch() { promptTextarea.setAttribute("data-supermemory-auto-fetch", "true") 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) } chatGPTDebounceTimeout = setTimeout(async () => { - const content = promptTextarea.textContent?.trim() || "" + if (hasAcceptedSupermemoryContext(promptTextarea)) { + clearMemorySuggestion("chatgpt", promptTextarea) + return + } if (content.length > 2) { await getRelatedMemoriesForChatGPT( @@ -604,6 +657,7 @@ async function setupChatGPTAutoFetch() { icons.forEach((icon) => { const iconElement = icon as HTMLElement + setMemoryMarkerStatus(iconElement, "neutral") if (iconElement.dataset.originalHtml) { iconElement.innerHTML = iconElement.dataset.originalHtml delete iconElement.dataset.originalHtml @@ -612,7 +666,7 @@ async function setupChatGPTAutoFetch() { }) if (promptTextarea.dataset.supermemories) { - delete promptTextarea.dataset.supermemories + clearMemorySuggestion("chatgpt", promptTextarea) } } }, UI_CONFIG.AUTO_SEARCH_DEBOUNCE_DELAY) @@ -641,16 +695,6 @@ function setupChatGPTPromptCapture() { promptContent = promptTextarea.textContent || "" } - const storedMemories = promptTextarea?.dataset.supermemories - if ( - storedMemories && - promptTextarea && - !promptContent.includes("Supermemories of user") - ) { - promptTextarea.appendChild(document.createTextNode(storedMemories)) - promptContent = promptTextarea.textContent || "" - } - if (promptTextarea && promptContent.trim()) { console.log(`ChatGPT prompt submitted via ${source}:`, promptContent) @@ -660,7 +704,7 @@ function setupChatGPTPromptCapture() { data: { prompt: promptContent, platform: "chatgpt", - source: source, + source: window.location.href, }, }) } catch (error) { @@ -682,7 +726,7 @@ function setupChatGPTPromptCapture() { }) if (promptTextarea?.dataset.supermemories) { - delete promptTextarea.dataset.supermemories + clearMemorySuggestion("chatgpt", promptTextarea) } } @@ -705,6 +749,18 @@ function setupChatGPTPromptCapture() { async (event) => { const target = event.target as HTMLElement + if ( + (target.id === "prompt-textarea" || + target.closest("#prompt-textarea")) && + acceptMemorySuggestion( + event, + "chatgpt", + document.getElementById("prompt-textarea"), + ) + ) { + return + } + if ( target.id === "prompt-textarea" && event.key === "Enter" && diff --git a/apps/browser-extension/entrypoints/content/claude.ts b/apps/browser-extension/entrypoints/content/claude.ts index d124c84a0..c88fe70a6 100644 --- a/apps/browser-extension/entrypoints/content/claude.ts +++ b/apps/browser-extension/entrypoints/content/claude.ts @@ -13,18 +13,37 @@ import { createClaudeInputBarElement, DOMUtils, } from "../../utils/ui-components" +import { + acceptMemorySuggestion, + clearMemorySuggestion, + hasAcceptedSupermemoryContext, + setMemoryMarkerStatus, + showLoadingSuggestion, + showMarkerPopover, + showMemorySuggestion, + syncAcceptedSupermemoryState, +} from "./memory-suggestion" let claudeDebounceTimeout: NodeJS.Timeout | null = null let claudeRouteObserver: MutationObserver | null = null let claudeUrlCheckInterval: NodeJS.Timeout | null = null let claudeObserverThrottle: NodeJS.Timeout | null = null +const CLAUDE_DEBUG = true +const CLAUDE_LOG_PREFIX = "[supermemory:claude]" export function initializeClaude() { + debugClaude("initializeClaude called", { + host: window.location.hostname, + href: window.location.href, + }) + if (!DOMUtils.isOnDomain(DOMAINS.CLAUDE)) { + debugClaude("not on Claude domain, skipping") return } if (document.body.hasAttribute("data-claude-initialized")) { + debugClaude("already initialized") return } @@ -38,6 +57,18 @@ export function initializeClaude() { setupClaudeRouteChangeDetection() document.body.setAttribute("data-claude-initialized", "true") + debugClaude("initialized listeners") +} + +function debugClaude(message: string, data?: unknown) { + if (!CLAUDE_DEBUG) return + + if (data === undefined) { + console.log(CLAUDE_LOG_PREFIX, message) + return + } + + console.log(CLAUDE_LOG_PREFIX, message, data) } function setupClaudeRouteChangeDetection() { @@ -57,7 +88,7 @@ function setupClaudeRouteChangeDetection() { const checkForRouteChange = () => { if (window.location.href !== currentUrl) { currentUrl = window.location.href - console.log("Claude route changed, re-adding supermemory icon") + debugClaude("route changed, re-adding supermemory icon", currentUrl) setTimeout(() => { addSupermemoryIconToClaudeInput() setupClaudeAutoFetch() @@ -81,8 +112,10 @@ function setupClaudeRouteChangeDetection() { if ( element.querySelector?.('div[contenteditable="true"]') || element.querySelector?.("textarea") || + element.querySelector?.("button") || element.matches?.('div[contenteditable="true"]') || - element.matches?.("textarea") + element.matches?.("textarea") || + element.matches?.("button") ) { shouldRecheck = true } @@ -95,6 +128,7 @@ function setupClaudeRouteChangeDetection() { claudeObserverThrottle = setTimeout(() => { try { claudeObserverThrottle = null + debugClaude("DOM changed near composer, rechecking UI") addSupermemoryIconToClaudeInput() setupClaudeAutoFetch() } catch (error) { @@ -119,39 +153,207 @@ function setupClaudeRouteChangeDetection() { } function addSupermemoryIconToClaudeInput() { - const targetContainers = document.querySelectorAll( - ".relative.flex-1.flex.items-center.gap-2.shrink.min-w-0", - ) + const input = getClaudePromptInput() + if (!input) { + debugClaude("prompt input not found", getClaudeDomSnapshot()) + return + } - targetContainers.forEach((container) => { - if (container.hasAttribute("data-supermemory-icon-added")) { - return + const composer = findComposerRoot(input) + if (!composer?.querySelector) { + debugClaude("composer root not found", describeElement(input)) + return + } + + const existingMarkers = Array.from( + document.querySelectorAll( + `[id*="${ELEMENT_IDS.CLAUDE_INPUT_BAR_ELEMENT}"]`, + ), + ) + if (existingMarkers.length > 1) { + debugClaude("removed duplicate markers", existingMarkers.length) + for (const marker of existingMarkers) { + marker.remove() } + } else if (existingMarkers.length === 1) { + debugClaude("marker already exists") + return + } + + const buttons = findClaudeComposerButtons(input, composer) + debugClaude("candidate Claude buttons", { + input: describeElement(input), + composer: describeElement(composer), + buttons: buttons.map((button) => ({ + label: buttonLabel(button), + element: describeElement(button), + })), + }) + + const micButton = buttons.find((button) => isClaudeMicButton(button)) + const voiceButton = buttons.find((button) => isClaudeVoiceButton(button)) + const sendButton = buttons.find((button) => isClaudeSendButton(button)) + const anchorButton = + micButton || voiceButton || sendButton || buttons[buttons.length - 1] + const anchorSlot = findClaudeButtonSlot(anchorButton, composer) + const targetContainer = anchorSlot?.parentElement || input.parentElement + + if (!targetContainer) { + debugClaude("could not find insertion target", { + anchor: anchorButton ? describeElement(anchorButton) : null, + input: describeElement(input), + }) + return + } - const existingIcon = container.querySelector( - `#${ELEMENT_IDS.CLAUDE_INPUT_BAR_ELEMENT}`, + const supermemoryIcon = createClaudeInputBarElement(async () => { + await getRelatedMemoriesForClaude( + POSTHOG_EVENT_KEY.CLAUDE_CHAT_MEMORIES_SEARCHED, ) - if (existingIcon) { - container.setAttribute("data-supermemory-icon-added", "true") - return - } + }) - const supermemoryIcon = createClaudeInputBarElement(async () => { - await getRelatedMemoriesForClaude( - POSTHOG_EVENT_KEY.CLAUDE_CHAT_MEMORIES_SEARCHED, - ) + supermemoryIcon.id = `${ELEMENT_IDS.CLAUDE_INPUT_BAR_ELEMENT}-${Date.now()}-${Math.random().toString(36).substring(2, 11)}` + + if (anchorSlot?.parentElement === targetContainer) { + targetContainer.insertBefore(supermemoryIcon, anchorSlot) + debugClaude("inserted marker before anchor button", { + anchorLabel: anchorButton ? buttonLabel(anchorButton) : null, + anchorSlot: describeElement(anchorSlot), + target: describeElement(targetContainer), }) + return + } - supermemoryIcon.id = `${ELEMENT_IDS.CLAUDE_INPUT_BAR_ELEMENT}-${Date.now()}-${Math.random().toString(36).substring(2, 11)}` + targetContainer.appendChild(supermemoryIcon) + debugClaude("inserted marker into fallback target", { + target: describeElement(targetContainer), + }) +} + +function getClaudePromptInput(): HTMLElement | null { + return document.querySelector( + '.ProseMirror[contenteditable="true"], div[contenteditable="true"], textarea', + ) as HTMLElement | null +} + +function findComposerRoot(input: HTMLElement): HTMLElement { + return ( + (input.closest("form") as HTMLElement | null) || + (input.closest('[data-testid*="composer"]') as HTMLElement | null) || + (input.closest('[class*="composer"]') as HTMLElement | null) || + (input.closest(".relative") as HTMLElement | null) || + input.parentElement || + document.body + ) +} + +function buttonLabel(button: HTMLButtonElement): string { + return [ + button.getAttribute("aria-label"), + button.getAttribute("title"), + button.getAttribute("data-testid"), + button.getAttribute("data-test-id"), + button.textContent, + ] + .filter(Boolean) + .join(" ") +} + +function findClaudeComposerButtons( + input: HTMLElement, + composer: HTMLElement, +): HTMLButtonElement[] { + const composerButtons = Array.from(composer.querySelectorAll("button")) + if (composerButtons.length > 0) { + return composerButtons + } + + const inputRect = input.getBoundingClientRect() + const allButtons = Array.from(document.querySelectorAll("button")) - container.setAttribute("data-supermemory-icon-added", "true") + return allButtons.filter((button) => { + const rect = button.getBoundingClientRect() + const verticallyNear = + Math.abs( + rect.top + rect.height / 2 - (inputRect.top + inputRect.height / 2), + ) < 120 + const horizontallyNear = + rect.left > inputRect.left - 80 && rect.left < inputRect.right + 260 - container.insertBefore(supermemoryIcon, container.firstChild) + return verticallyNear && horizontallyNear }) } +function isClaudeMicButton(button: HTMLButtonElement): boolean { + return /mic|microphone|dictate/i.test(buttonLabel(button)) +} + +function isClaudeVoiceButton(button: HTMLButtonElement): boolean { + return /voice|audio|speech/i.test(buttonLabel(button)) +} + +function isClaudeSendButton(button: HTMLButtonElement): boolean { + return /send|submit/i.test(buttonLabel(button)) +} + +function findClaudeButtonSlot( + button: HTMLButtonElement | undefined, + composer: HTMLElement, +): HTMLElement | null { + if (!button) return null + + let current: HTMLElement | null = button + while (current?.parentElement && current.parentElement !== composer) { + const parent: HTMLElement = current.parentElement + const parentStyle = window.getComputedStyle(parent) + const hasSiblingControls = parent.children.length > 1 + const isRow = + parentStyle.display.includes("flex") && + parentStyle.flexDirection !== "column" + + if (hasSiblingControls && isRow) { + return current + } + + current = parent + } + + return current || button +} + +function describeElement(element: Element | null): string | null { + if (!element) return null + + const parts = [element.tagName.toLowerCase()] + if (element.id) parts.push(`#${element.id}`) + if (element.className && typeof element.className === "string") { + parts.push( + `.${element.className.trim().split(/\s+/).slice(0, 4).join(".")}`, + ) + } + + for (const attr of ["aria-label", "data-testid", "data-test-id", "role"]) { + const value = element.getAttribute(attr) + if (value) parts.push(`[${attr}="${value}"]`) + } + + return parts.join("") +} + +function getClaudeDomSnapshot() { + return { + proseMirrors: document.querySelectorAll(".ProseMirror").length, + contenteditables: document.querySelectorAll('[contenteditable="true"]') + .length, + textareas: document.querySelectorAll("textarea").length, + buttons: document.querySelectorAll("button").length, + } +} + async function getRelatedMemoriesForClaude(actionSource: string) { try { + const isAutoSearch = + actionSource === POSTHOG_EVENT_KEY.CLAUDE_CHAT_MEMORIES_AUTO_SEARCHED let userQuery = "" const supermemoryContainer = document.querySelector( @@ -204,7 +406,15 @@ async function getRelatedMemoriesForClaude(actionSource: string) { return } - updateClaudeIconFeedback("Searching memories...", iconElement) + if (isAutoSearch) { + const input = getClaudePromptInput() + if (input) { + showLoadingSuggestion("claude", input) + } + setMemoryMarkerStatus(iconElement, "searching") + } else { + updateClaudeIconFeedback("Searching memories...", iconElement) + } const timeoutPromise = new Promise((_, reject) => setTimeout( @@ -230,24 +440,37 @@ async function getRelatedMemoriesForClaude(actionSource: string) { ) as HTMLElement if (textareaElement) { - textareaElement.dataset.supermemories = `\n\nSupermemories of user (only for the reference): ${response.data}` - console.log( - "Text element dataset:", - textareaElement.dataset.supermemories, + const memoryText = showMemorySuggestion( + "claude", + textareaElement, + response.data, ) + console.log("Text element dataset:", memoryText) - iconElement.dataset.memoriesData = response.data + iconElement.dataset.memoriesData = String(response.data) - updateClaudeIconFeedback("Included Memories", iconElement) + if (isAutoSearch) { + setMemoryMarkerStatus(iconElement, "found") + } else { + updateClaudeIconFeedback("Included Memories", iconElement) + } } else { console.warn( "Claude input area not found after successful memory fetch", ) - updateClaudeIconFeedback("Memories found", iconElement) + if (isAutoSearch) { + setMemoryMarkerStatus(iconElement, "found") + } else { + updateClaudeIconFeedback("Memories found", iconElement) + } } } else { console.warn("No memories found or API response invalid for Claude") - updateClaudeIconFeedback("No memories found", iconElement) + if (isAutoSearch) { + setMemoryMarkerStatus(iconElement, "none") + } else { + updateClaudeIconFeedback("No memories found", iconElement) + } } } catch (error) { console.error("Error getting related memories for Claude:", error) @@ -256,7 +479,13 @@ async function getRelatedMemoriesForClaude(actionSource: string) { '[id*="sm-claude-input-bar-element"]', ) as HTMLElement if (icon) { - updateClaudeIconFeedback("Error fetching memories", icon) + if ( + actionSource === POSTHOG_EVENT_KEY.CLAUDE_CHAT_MEMORIES_AUTO_SEARCHED + ) { + setMemoryMarkerStatus(icon, "error") + } else { + updateClaudeIconFeedback("Error fetching memories", icon) + } } } catch (feedbackError) { console.error("Failed to update Claude error feedback:", feedbackError) @@ -269,220 +498,27 @@ function updateClaudeIconFeedback( iconElement: HTMLElement, resetAfter = 0, ) { - if (!iconElement.dataset.originalHtml) { - iconElement.dataset.originalHtml = iconElement.innerHTML - } - - const feedbackDiv = document.createElement("div") - feedbackDiv.style.cssText = ` - display: flex; - align-items: center; - gap: 6px; - padding: 6px 8px; - background: #513EA9; - border-radius: 6px; - color: white; - font-size: 12px; - font-weight: 500; - cursor: ${message === "Included Memories" ? "pointer" : "default"}; - position: relative; - ` - - feedbackDiv.innerHTML = ` - - ${message} - ` - - if (message === "Included Memories" && iconElement.dataset.memoriesData) { - const popup = document.createElement("div") - popup.style.cssText = ` - position: fixed; - bottom: 80px; - left: 50%; - transform: translateX(-50%); - background: #1a1a1a; - color: white; - padding: 0; - border-radius: 12px; - font-size: 13px; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif; - max-width: 500px; - max-height: 400px; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); - z-index: 999999; - display: none; - border: 1px solid #333; - ` - - const header = document.createElement("div") - header.style.cssText = ` - display: flex; - justify-content: space-between; - align-items: center; - padding: 8px; - border-bottom: 1px solid #333; - opacity: 0.8; - ` - header.innerHTML = ` - Included Memories - ` - - const content = document.createElement("div") - content.style.cssText = ` - padding: 0; - max-height: 300px; - overflow-y: auto; - ` - - const memoriesText = iconElement.dataset.memoriesData || "" - console.log("Memories text:", memoriesText) - const individualMemories = memoriesText - .split(/[,\n]/) - .map((memory) => memory.trim()) - .filter((memory) => memory.length > 0 && memory !== ",") - console.log("Individual memories:", individualMemories) - - individualMemories.forEach((memory, index) => { - const memoryItem = document.createElement("div") - memoryItem.style.cssText = ` - display: flex; - align-items: center; - gap: 6px; - padding: 10px; - font-size: 13px; - line-height: 1.4; - ` - - const memoryText = document.createElement("div") - memoryText.style.cssText = ` - flex: 1; - color: #e5e5e5; - ` - memoryText.textContent = memory.trim() - - const removeBtn = document.createElement("button") - removeBtn.style.cssText = ` - background: transparent; - color: #9ca3af; - border: none; - padding: 4px; - border-radius: 4px; - cursor: pointer; - flex-shrink: 0; - height: fit-content; - display: flex; - align-items: center; - justify-content: center; - ` - removeBtn.innerHTML = `` - removeBtn.dataset.memoryIndex = index.toString() - - removeBtn.addEventListener("mouseenter", () => { - removeBtn.style.color = "#ef4444" - }) - removeBtn.addEventListener("mouseleave", () => { - removeBtn.style.color = "#9ca3af" - }) - - memoryItem.appendChild(memoryText) - memoryItem.appendChild(removeBtn) - content.appendChild(memoryItem) - }) - - popup.appendChild(header) - popup.appendChild(content) - document.body.appendChild(popup) - - feedbackDiv.addEventListener("mouseenter", () => { - const textSpan = feedbackDiv.querySelector("span:last-child") - if (textSpan) { - textSpan.textContent = "Click to see memories" - } - }) - - feedbackDiv.addEventListener("mouseleave", () => { - const textSpan = feedbackDiv.querySelector("span:last-child") - if (textSpan) { - textSpan.textContent = "Included Memories" - } - }) - - feedbackDiv.addEventListener("click", (e) => { - e.stopPropagation() - popup.style.display = "block" - }) - - document.addEventListener("click", (e) => { - if (!popup.contains(e.target as Node)) { - popup.style.display = "none" - } - }) - - content.querySelectorAll("button[data-memory-index]").forEach((button) => { - const htmlButton = button as HTMLButtonElement - htmlButton.addEventListener("click", () => { - const index = Number.parseInt(htmlButton.dataset.memoryIndex || "0", 10) - const memoryItem = htmlButton.parentElement - - if (memoryItem) { - content.removeChild(memoryItem) - } - - const currentMemories = (iconElement.dataset.memoriesData || "") - .split(/[,\n]/) - .map((memory) => memory.trim()) - .filter((memory) => memory.length > 0 && memory !== ",") - currentMemories.splice(index, 1) - - const updatedMemories = currentMemories.join(" ,") - - iconElement.dataset.memoriesData = updatedMemories + const memories = iconElement.dataset.memoriesData + const fallbackReset = + resetAfter || (message === "Included Memories" ? 0 : 2200) - const textareaElement = document.querySelector( - 'div[contenteditable="true"]', - ) as HTMLElement - if (textareaElement) { - textareaElement.dataset.supermemories = `\n\nSupermemories of user (only for the reference): ${updatedMemories}` - } - - content - .querySelectorAll("button[data-memory-index]") - .forEach((btn, newIndex) => { - const htmlBtn = btn as HTMLButtonElement - htmlBtn.dataset.memoryIndex = newIndex.toString() - }) - - if (currentMemories.length <= 1) { - if (textareaElement?.dataset.supermemories) { - delete textareaElement.dataset.supermemories - delete iconElement.dataset.memoriesData - iconElement.innerHTML = iconElement.dataset.originalHtml || "" - delete iconElement.dataset.originalHtml - } - popup.style.display = "none" - if (document.body.contains(popup)) { - document.body.removeChild(popup) - } - } - }) - }) - - setTimeout(() => { - if (document.body.contains(popup)) { - document.body.removeChild(popup) - } - }, 300000) + if (message === "Included Memories" || message === "Memories found") { + setMemoryMarkerStatus(iconElement, "found") + showMarkerPopover(iconElement, "Included Memories", memories) + return } - iconElement.innerHTML = "" - iconElement.appendChild(feedbackDiv) - - if (resetAfter > 0) { - setTimeout(() => { - iconElement.innerHTML = iconElement.dataset.originalHtml || "" - delete iconElement.dataset.originalHtml - }, resetAfter) + if (message.toLowerCase().includes("searching")) { + setMemoryMarkerStatus(iconElement, "searching") + showMarkerPopover(iconElement, message) + return } + + setMemoryMarkerStatus( + iconElement, + message.toLowerCase().includes("error") ? "error" : "none", + ) + showMarkerPopover(iconElement, message, undefined, fallbackReset) } function setupClaudePromptCapture() { @@ -514,17 +550,6 @@ function setupClaudePromptCapture() { } } - const storedMemories = contentEditableDiv?.dataset.supermemories - if ( - storedMemories && - contentEditableDiv && - !promptContent.includes("Supermemories of user") - ) { - contentEditableDiv.appendChild(document.createTextNode(storedMemories)) - promptContent = - contentEditableDiv.textContent || contentEditableDiv.innerText || "" - } - if (promptContent.trim()) { console.log(`Claude prompt submitted via ${source}:`, promptContent) @@ -534,7 +559,7 @@ function setupClaudePromptCapture() { data: { prompt: promptContent, platform: "claude", - source: source, + source: window.location.href, }, }) } catch (error) { @@ -556,7 +581,7 @@ function setupClaudePromptCapture() { }) if (contentEditableDiv?.dataset.supermemories) { - delete contentEditableDiv.dataset.supermemories + clearMemorySuggestion("claude", contentEditableDiv) } } @@ -564,14 +589,16 @@ function setupClaudePromptCapture() { "click", async (event) => { const target = event.target as HTMLElement - const sendButton = - target.closest( - "button.inline-flex.items-center.justify-center.relative.shrink-0.can-focus.select-none", - ) || - target.closest('button[class*="bg-accent-main-000"]') || - target.closest('button[class*="rounded-lg"]') - - if (sendButton) { + if (target.closest('[data-supermemory-connected-indicator="true"]')) { + return + } + + const sendButton = target.closest("button") + + if ( + sendButton && + buttonLabel(sendButton as HTMLButtonElement).match(/send|submit/i) + ) { await captureClaudePromptContent("button click") } }, @@ -583,10 +610,18 @@ function setupClaudePromptCapture() { async (event) => { const target = event.target as HTMLElement + const activeInput = + (target.closest('div[contenteditable="true"]') as HTMLElement | null) || + (target.matches("textarea") ? (target as HTMLTextAreaElement) : null) + if (acceptMemorySuggestion(event, "claude", activeInput)) { + return + } + if ( (target.matches('div[contenteditable="true"]') || target.matches(".ProseMirror") || target.matches("textarea") || + target.closest('div[contenteditable="true"]') || target.closest(".ProseMirror")) && event.key === "Enter" && !event.shiftKey @@ -618,12 +653,27 @@ 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") + }) + } + if (claudeDebounceTimeout) { clearTimeout(claudeDebounceTimeout) } claudeDebounceTimeout = setTimeout(async () => { - const content = textareaElement.textContent?.trim() || "" + if (hasAcceptedSupermemoryContext(textareaElement)) { + clearMemorySuggestion("claude", textareaElement) + return + } if (content.length > 2) { await getRelatedMemoriesForClaude( @@ -636,6 +686,7 @@ async function setupClaudeAutoFetch() { icons.forEach((icon) => { const iconElement = icon as HTMLElement + setMemoryMarkerStatus(iconElement, "neutral") if (iconElement.dataset.originalHtml) { iconElement.innerHTML = iconElement.dataset.originalHtml delete iconElement.dataset.originalHtml @@ -644,7 +695,7 @@ async function setupClaudeAutoFetch() { }) if (textareaElement.dataset.supermemories) { - delete 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 new file mode 100644 index 000000000..cf763c108 --- /dev/null +++ b/apps/browser-extension/entrypoints/content/gemini.ts @@ -0,0 +1,676 @@ +import { + DOMAINS, + ELEMENT_IDS, + MESSAGE_TYPES, + POSTHOG_EVENT_KEY, + UI_CONFIG, +} from "../../utils/constants" +import { + autoCapturePromptsEnabled, + autoSearchEnabled, +} from "../../utils/storage" +import { + createGeminiInputBarElement, + DOMUtils, +} from "../../utils/ui-components" +import { + acceptMemorySuggestion, + clearMemorySuggestion, + hasAcceptedSupermemoryContext, + setMemoryMarkerStatus, + showLoadingSuggestion, + showMarkerPopover, + showMemorySuggestion, + syncAcceptedSupermemoryState, +} from "./memory-suggestion" + +let geminiDebounceTimeout: NodeJS.Timeout | null = null +let geminiRouteObserver: MutationObserver | null = null +let geminiUrlCheckInterval: NodeJS.Timeout | null = null +let geminiObserverThrottle: NodeJS.Timeout | null = null +const GEMINI_DEBUG = true +const GEMINI_LOG_PREFIX = "[supermemory:gemini]" + +type GeminiInput = HTMLElement | HTMLTextAreaElement + +export function initializeGemini() { + debugGemini("initializeGemini called", { + host: window.location.hostname, + href: window.location.href, + }) + + if (!DOMUtils.isOnDomain(DOMAINS.GEMINI)) { + debugGemini("not on Gemini domain, skipping") + return + } + + if (document.body.hasAttribute("data-gemini-initialized")) { + debugGemini("already initialized") + return + } + + setTimeout(() => { + addSupermemoryIconToGeminiInput() + setupGeminiAutoFetch() + }, 2000) + + setupGeminiPromptCapture() + setupGeminiRouteChangeDetection() + + document.body.setAttribute("data-gemini-initialized", "true") + debugGemini("initialized listeners") +} + +function debugGemini(message: string, data?: unknown) { + if (!GEMINI_DEBUG) return + + if (data === undefined) { + console.log(GEMINI_LOG_PREFIX, message) + return + } + + console.log(GEMINI_LOG_PREFIX, message, data) +} + +function setupGeminiRouteChangeDetection() { + if (geminiRouteObserver) { + geminiRouteObserver.disconnect() + } + if (geminiUrlCheckInterval) { + clearInterval(geminiUrlCheckInterval) + } + if (geminiObserverThrottle) { + clearTimeout(geminiObserverThrottle) + geminiObserverThrottle = null + } + + let currentUrl = window.location.href + + const recheckGeminiUI = () => { + addSupermemoryIconToGeminiInput() + setupGeminiAutoFetch() + } + + const checkForRouteChange = () => { + if (window.location.href !== currentUrl) { + currentUrl = window.location.href + debugGemini("route changed, rechecking UI", currentUrl) + setTimeout(recheckGeminiUI, 1000) + } + } + + geminiUrlCheckInterval = setInterval(checkForRouteChange, 2000) + + geminiRouteObserver = new MutationObserver((mutations) => { + if (geminiObserverThrottle) { + return + } + + const shouldRecheck = mutations.some((mutation) => + Array.from(mutation.addedNodes).some((node) => { + if (node.nodeType !== Node.ELEMENT_NODE) { + return false + } + + const element = node as Element + return ( + element.matches?.("rich-textarea, textarea, button") || + element.matches?.('[contenteditable="true"]') || + !!element.querySelector?.( + 'rich-textarea, textarea, button, [contenteditable="true"]', + ) + ) + }), + ) + + if (shouldRecheck) { + geminiObserverThrottle = setTimeout(() => { + geminiObserverThrottle = null + debugGemini("DOM changed near Gemini composer, rechecking UI") + recheckGeminiUI() + }, 300) + } + }) + + try { + geminiRouteObserver.observe(document.body, { + childList: true, + subtree: true, + }) + } catch (error) { + console.error("Failed to set up Gemini route observer:", error) + if (geminiUrlCheckInterval) { + clearInterval(geminiUrlCheckInterval) + } + geminiUrlCheckInterval = setInterval(checkForRouteChange, 1000) + } +} + +function addSupermemoryIconToGeminiInput() { + const input = getGeminiPromptInput() + if (!input) { + debugGemini("prompt input not found", getGeminiDomSnapshot()) + return + } + + const composer = findGeminiComposerRoot(input) + if (!composer?.querySelector) { + debugGemini("composer root not found", describeElement(input)) + return + } + + const existingMarkers = Array.from( + document.querySelectorAll( + `[id*="${ELEMENT_IDS.GEMINI_INPUT_BAR_ELEMENT}"]`, + ), + ) + if (existingMarkers.length > 1) { + debugGemini("removed duplicate markers", existingMarkers.length) + for (const marker of existingMarkers) { + marker.remove() + } + } else if (existingMarkers.length === 1) { + debugGemini("marker already exists") + return + } + + const buttons = findGeminiComposerButtons(input, composer) + debugGemini("candidate Gemini buttons", { + input: describeElement(input), + composer: describeElement(composer), + buttons: buttons.map((button) => ({ + label: buttonLabel(button), + element: describeElement(button), + })), + }) + + const micButton = buttons.find((button) => isGeminiMicButton(button)) + const sendButton = buttons.find((button) => isGeminiSendButton(button)) + const anchorButton = micButton || sendButton || buttons[buttons.length - 1] + const anchorSlot = findGeminiButtonSlot(anchorButton, composer) + const targetContainer = + anchorSlot?.parentElement || + (input.closest("rich-textarea") as HTMLElement | null)?.parentElement || + input.parentElement + + if (!targetContainer) { + debugGemini("could not find insertion target", { + anchor: anchorButton ? describeElement(anchorButton) : null, + input: describeElement(input), + }) + return + } + + const supermemoryIcon = createGeminiInputBarElement(async () => { + await getRelatedMemoriesForGemini( + POSTHOG_EVENT_KEY.GEMINI_CHAT_MEMORIES_SEARCHED, + ) + }) + + supermemoryIcon.id = `${ELEMENT_IDS.GEMINI_INPUT_BAR_ELEMENT}-${Date.now()}-${Math.random().toString(36).substring(2, 11)}` + + if (anchorSlot?.parentElement === targetContainer) { + targetContainer.insertBefore(supermemoryIcon, anchorSlot) + debugGemini("inserted marker before anchor button", { + anchorLabel: anchorButton ? buttonLabel(anchorButton) : null, + anchorSlot: describeElement(anchorSlot), + target: describeElement(targetContainer), + }) + return + } + + targetContainer.appendChild(supermemoryIcon) + debugGemini("inserted marker into fallback target", { + target: describeElement(targetContainer), + }) +} + +function getGeminiPromptInput(): GeminiInput | null { + return document.querySelector( + 'rich-textarea .ql-editor[contenteditable="true"], rich-textarea [contenteditable="true"], .ql-editor[contenteditable="true"], div[contenteditable="true"], textarea', + ) as GeminiInput | null +} + +function findGeminiComposerRoot(input: GeminiInput): HTMLElement { + const form = input.closest("form") as HTMLElement | null + if (form) return form + + let current: HTMLElement | null = input + for (let depth = 0; current && depth < 8; depth += 1) { + if (current.querySelectorAll("button").length >= 2) { + return current + } + current = current.parentElement + } + + return input.parentElement || document.body +} + +function findGeminiComposerButtons( + input: GeminiInput, + composer: HTMLElement, +): HTMLButtonElement[] { + const composerButtons = Array.from(composer.querySelectorAll("button")) + if (composerButtons.length > 0) { + return composerButtons + } + + const inputRect = input.getBoundingClientRect() + const allButtons = Array.from(document.querySelectorAll("button")) + + return allButtons.filter((button) => { + const rect = button.getBoundingClientRect() + const verticallyNear = + Math.abs( + rect.top + rect.height / 2 - (inputRect.top + inputRect.height / 2), + ) < 120 + const horizontallyNear = + rect.left > inputRect.left - 80 && rect.left < inputRect.right + 240 + + return verticallyNear && horizontallyNear + }) +} + +function buttonLabel(button: HTMLButtonElement): string { + return [ + button.getAttribute("aria-label"), + button.getAttribute("title"), + button.getAttribute("data-testid"), + button.getAttribute("data-test-id"), + button.getAttribute("jsname"), + button.textContent, + ] + .filter(Boolean) + .join(" ") +} + +function isGeminiMicButton(button: HTMLButtonElement): boolean { + return /mic|microphone|voice|dictate|audio/i.test(buttonLabel(button)) +} + +function isGeminiSendButton(button: HTMLButtonElement): boolean { + const label = buttonLabel(button) + if (/send|submit/i.test(label)) { + return true + } + + return !!button.querySelector( + 'mat-icon[fonticon="send"], mat-icon[data-mat-icon-name="send"], [data-icon-name="send"]', + ) +} + +function findGeminiButtonSlot( + button: HTMLButtonElement | undefined, + composer: HTMLElement, +): HTMLElement | null { + if (!button) return null + + let current: HTMLElement | null = button + while (current?.parentElement && current.parentElement !== composer) { + const parent: HTMLElement = current.parentElement + const parentStyle = window.getComputedStyle(parent) + const hasSiblingControls = parent.children.length > 1 + const isRow = + parentStyle.display.includes("flex") && + parentStyle.flexDirection !== "column" + + if (hasSiblingControls && isRow) { + return current + } + + current = parent + } + + return current || button +} + +function describeElement(element: Element | null): string | null { + if (!element) return null + + const parts = [element.tagName.toLowerCase()] + if (element.id) parts.push(`#${element.id}`) + if (element.className && typeof element.className === "string") { + parts.push( + `.${element.className.trim().split(/\s+/).slice(0, 4).join(".")}`, + ) + } + + for (const attr of ["aria-label", "data-testid", "data-test-id", "role"]) { + const value = element.getAttribute(attr) + if (value) parts.push(`[${attr}="${value}"]`) + } + + return parts.join("") +} + +function getGeminiDomSnapshot() { + return { + richTextareas: document.querySelectorAll("rich-textarea").length, + qlEditors: document.querySelectorAll(".ql-editor").length, + contenteditables: document.querySelectorAll('[contenteditable="true"]') + .length, + textareas: document.querySelectorAll("textarea").length, + buttons: document.querySelectorAll("button").length, + } +} + +function getInputText(input: GeminiInput | null): string { + if (!input) return "" + if (input instanceof HTMLTextAreaElement) { + return input.value || "" + } + + return input.innerText || input.textContent || "" +} + +function _appendStoredMemories(input: GeminiInput, storedMemories: string) { + if (input instanceof HTMLTextAreaElement) { + const promptContent = input.value || "" + input.value = `${promptContent}${storedMemories}` + input.dispatchEvent(new Event("input", { bubbles: true })) + return input.value + } + + input.appendChild(document.createTextNode(storedMemories)) + input.dispatchEvent( + new InputEvent("input", { bubbles: true, inputType: "insertText" }), + ) + return input.innerText || input.textContent || "" +} + +async function getRelatedMemoriesForGemini(actionSource: string) { + try { + const isAutoSearch = + actionSource === POSTHOG_EVENT_KEY.GEMINI_CHAT_MEMORIES_AUTO_SEARCHED + const input = getGeminiPromptInput() + const userQuery = getInputText(input).trim() + debugGemini("manual/auto memory search requested", { + actionSource, + hasInput: !!input, + queryLength: userQuery.length, + }) + + 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 + } + + if (input && isAutoSearch) { + showLoadingSuggestion("gemini", input) + } + setMemoryMarkerStatus(iconElement, "searching") + if (!isAutoSearch) { + updateGeminiIconFeedback("Searching memories...", iconElement) + } + + const timeoutPromise = new Promise((_, reject) => + setTimeout( + () => reject(new Error("Memory search timeout")), + UI_CONFIG.API_REQUEST_TIMEOUT, + ), + ) + + const response = (await Promise.race([ + browser.runtime.sendMessage({ + action: MESSAGE_TYPES.GET_RELATED_MEMORIES, + data: userQuery, + actionSource, + }), + timeoutPromise, + ])) as { success?: boolean; data?: string } + + debugGemini("memory search response", response) + + if (response?.success && response?.data && input) { + const memoryText = showMemorySuggestion("gemini", input, response.data) + iconElement.dataset.memoriesData = String(response.data) + iconElement.dataset.supermemories = memoryText + if (isAutoSearch) { + setMemoryMarkerStatus(iconElement, "found") + } else { + updateGeminiIconFeedback("Included Memories", iconElement) + } + return + } + + if (isAutoSearch) { + setMemoryMarkerStatus(iconElement, "none") + } else { + updateGeminiIconFeedback("No memories found", iconElement, 1800) + } + } catch (error) { + console.error("Error getting related memories for Gemini:", error) + const iconElement = document.querySelector( + `[id*="${ELEMENT_IDS.GEMINI_INPUT_BAR_ELEMENT}"]`, + ) as HTMLElement | null + if (iconElement) { + if ( + actionSource === POSTHOG_EVENT_KEY.GEMINI_CHAT_MEMORIES_AUTO_SEARCHED + ) { + setMemoryMarkerStatus(iconElement, "error") + } else { + updateGeminiIconFeedback("Error fetching memories", iconElement, 1800) + } + } + } +} + +function updateGeminiIconFeedback( + message: string, + iconElement: HTMLElement, + resetAfter = 0, +) { + const memories = iconElement.dataset.memoriesData + const fallbackReset = + resetAfter || (message === "Included Memories" ? 0 : 2200) + + if (message === "Included Memories" || message === "Memories found") { + setMemoryMarkerStatus(iconElement, "found") + showMarkerPopover(iconElement, "Included Memories", memories) + return + } + + if (message.toLowerCase().includes("searching")) { + setMemoryMarkerStatus(iconElement, "searching") + showMarkerPopover(iconElement, message) + return + } + + setMemoryMarkerStatus( + iconElement, + message.toLowerCase().includes("error") ? "error" : "none", + ) + showMarkerPopover(iconElement, message, undefined, fallbackReset) +} + +function setupGeminiPromptCapture() { + if (document.body.hasAttribute("data-gemini-prompt-capture-setup")) { + return + } + + document.body.setAttribute("data-gemini-prompt-capture-setup", "true") + + const captureGeminiPromptContent = async (source: string) => { + const autoCapture = (await autoCapturePromptsEnabled.getValue()) ?? false + debugGemini("capture requested", { source, autoCapture }) + + if (!autoCapture) { + console.log("Auto capture prompts is disabled, skipping prompt capture") + return + } + + const input = getGeminiPromptInput() + const promptContent = getInputText(input) + debugGemini("capture input state", { + hasInput: !!input, + promptLength: promptContent.length, + hasStoredMemories: !!input?.dataset.supermemories, + }) + + if (promptContent.trim()) { + try { + const response = await browser.runtime.sendMessage({ + action: MESSAGE_TYPES.CAPTURE_PROMPT, + data: { + prompt: promptContent, + platform: "gemini", + source: window.location.href, + }, + }) + debugGemini("capture response", response) + } catch (error) { + console.error("Error sending Gemini prompt to background:", error) + } + } else { + debugGemini("capture skipped because prompt is empty") + } + + 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) + } + } + + document.addEventListener( + "click", + async (event) => { + const target = event.target as HTMLElement + if (target.closest('[data-supermemory-connected-indicator="true"]')) { + return + } + + const sendButton = target.closest("button") + if (sendButton && isGeminiSendButton(sendButton as HTMLButtonElement)) { + debugGemini("send button click detected", { + label: buttonLabel(sendButton as HTMLButtonElement), + element: describeElement(sendButton), + }) + await captureGeminiPromptContent("button click") + } + }, + true, + ) + + document.addEventListener( + "keydown", + async (event) => { + const target = event.target as HTMLElement + + const activeInput = + (target.closest('[contenteditable="true"]') as GeminiInput | null) || + (target.matches("textarea") ? (target as HTMLTextAreaElement) : null) + if (acceptMemorySuggestion(event, "gemini", activeInput)) { + return + } + + if ( + (target.matches("textarea") || + target.matches('[contenteditable="true"]') || + target.closest('[contenteditable="true"]')) && + event.key === "Enter" && + !event.shiftKey + ) { + debugGemini("Enter submit detected", { + target: describeElement(target), + }) + await captureGeminiPromptContent("Enter key") + } + }, + true, + ) +} + +async function setupGeminiAutoFetch() { + const autoSearch = (await autoSearchEnabled.getValue()) ?? false + debugGemini("setup auto fetch", { autoSearch }) + if (!autoSearch) { + return + } + + const input = getGeminiPromptInput() + if (!input || input.hasAttribute("data-supermemory-auto-fetch")) { + debugGemini("auto fetch skipped", { + hasInput: !!input, + alreadyAttached: input?.hasAttribute("data-supermemory-auto-fetch"), + }) + return + } + + input.setAttribute("data-supermemory-auto-fetch", "true") + debugGemini("auto fetch attached", describeElement(input)) + + 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) + } + + geminiDebounceTimeout = setTimeout(async () => { + if (hasAcceptedSupermemoryContext(input)) { + clearMemorySuggestion("gemini", input) + return + } + + if (content.length > 2) { + 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) + } + + input.addEventListener("input", handleInput) +} diff --git a/apps/browser-extension/entrypoints/content/index.ts b/apps/browser-extension/entrypoints/content/index.ts index cba33ce28..9d4b3cb28 100644 --- a/apps/browser-extension/entrypoints/content/index.ts +++ b/apps/browser-extension/entrypoints/content/index.ts @@ -2,6 +2,7 @@ import { DOMAINS, MESSAGE_TYPES } from "../../utils/constants" import { DOMUtils } from "../../utils/ui-components" import { initializeChatGPT } from "./chatgpt" import { initializeClaude } from "./claude" +import { initializeGemini } from "./gemini" import { saveMemory, setupGlobalKeyboardShortcut, @@ -19,13 +20,13 @@ export default defineContentScript({ matches: [""], main() { // Setup global event listeners - browser.runtime.onMessage.addListener(async (message) => { + browser.runtime.onMessage.addListener((message) => { if (message.action === MESSAGE_TYPES.SHOW_TOAST) { DOMUtils.showToast(message.state) } else if (message.action === MESSAGE_TYPES.SAVE_MEMORY) { - await saveMemory() + return saveMemory(message.actionSource || "content_script") } else if (message.action === MESSAGE_TYPES.TWITTER_IMPORT_OPEN_MODAL) { - await openImportModal() + return openImportModal() } else if (message.type === MESSAGE_TYPES.IMPORT_UPDATE) { updateTwitterImportUI(message) } else if (message.type === MESSAGE_TYPES.IMPORT_DONE) { @@ -48,6 +49,9 @@ export default defineContentScript({ if (DOMUtils.isOnDomain(DOMAINS.CLAUDE)) { initializeClaude() } + if (DOMUtils.isOnDomain(DOMAINS.GEMINI)) { + initializeGemini() + } if (DOMUtils.isOnDomain(DOMAINS.T3)) { initializeT3() } @@ -65,6 +69,7 @@ export default defineContentScript({ // Initialize platform-specific functionality initializeChatGPT() initializeClaude() + initializeGemini() initializeT3() initializeTwitter() diff --git a/apps/browser-extension/entrypoints/content/memory-suggestion.ts b/apps/browser-extension/entrypoints/content/memory-suggestion.ts new file mode 100644 index 000000000..1722e71ef --- /dev/null +++ b/apps/browser-extension/entrypoints/content/memory-suggestion.ts @@ -0,0 +1,409 @@ +type SuggestionInput = HTMLElement | HTMLTextAreaElement + +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() + + return `\n\n${SUPERMEMORY_PREFIX} ${memoryText}` +} + +export function showMemorySuggestion( + platform: string, + input: SuggestionInput, + memories: unknown, +): string { + const suggestionText = buildSupermemoryText(memories) + input.dataset.supermemories = suggestionText + delete input.dataset.supermemoriesInjected + + removeMemorySuggestion(platform) + + const anchor = getSuggestionAnchor(input) + if (!anchor) return suggestionText + + const previousPosition = window.getComputedStyle(anchor).position + if (previousPosition === "static") { + anchor.dataset.supermemoryPreviousPosition = "static" + anchor.style.position = "relative" + } + + const suggestion = createSuggestionContainer(platform, input, anchor) + suggestion.dataset.supermemorySuggestionState = "ready" + suggestion.style.gap = "8px" + suggestion.style.alignItems = "center" + + const text = document.createElement("span") + text.style.cssText = ` + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + ` + text.textContent = suggestionText.trim() + + const tabKey = document.createElement("span") + tabKey.style.cssText = ` + display: inline-flex; + align-items: center; + justify-content: center; + height: 20px; + padding: 0 8px; + border-radius: 999px; + background: ${SUPERMEMORY_BLUE}; + color: #FFFFFF; + font-size: 11px; + font-weight: 700; + line-height: 1; + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.16) inset, 0 6px 18px rgba(26, 136, 255, 0.24); + flex-shrink: 0; + ` + tabKey.textContent = "Tab" + + suggestion.appendChild(text) + suggestion.appendChild(tabKey) + anchor.appendChild(suggestion) + + return suggestionText +} + +export function showLoadingSuggestion( + platform: string, + input: SuggestionInput, +) { + removeMemorySuggestion(platform) + + const anchor = getSuggestionAnchor(input) + if (!anchor) return + + const previousPosition = window.getComputedStyle(anchor).position + if (previousPosition === "static") { + anchor.dataset.supermemoryPreviousPosition = "static" + anchor.style.position = "relative" + } + + ensureSuggestionAnimationStyle() + + const suggestion = createSuggestionContainer(platform, input, anchor) + suggestion.dataset.supermemorySuggestionState = "loading" + suggestion.style.gap = "4px" + suggestion.setAttribute("aria-label", "supermemory searching memories") + + for (let index = 0; index < 3; index += 1) { + const dot = document.createElement("span") + dot.style.cssText = ` + width: 5px; + height: 5px; + border-radius: 999px; + background: ${SUPERMEMORY_BLUE}; + animation: supermemorySuggestionDot 1s ease-in-out infinite; + animation-delay: ${index * 0.14}s; + ` + suggestion.appendChild(dot) + } + + anchor.appendChild(suggestion) +} + +function createSuggestionContainer( + platform: string, + input: SuggestionInput, + anchor: HTMLElement, +): HTMLDivElement { + const suggestion = document.createElement("div") + suggestion.setAttribute(SUGGESTION_ATTR, platform) + const position = getCaretPosition(input, anchor) + const verticalOffset = platform === "gemini" ? -10 : 0 + suggestion.style.cssText = ` + position: absolute; + left: ${position.left + 6}px; + top: ${position.top + verticalOffset}px; + max-width: min(540px, calc(100% - ${position.left + 220}px)); + display: inline-flex; + align-items: center; + height: 22px; + color: rgba(255, 255, 255, 0.34); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 14px; + line-height: 1.35; + pointer-events: none; + z-index: 2147483646; + ` + return suggestion +} + +export function removeMemorySuggestion(platform: string) { + const elements = document.querySelectorAll( + `[${SUGGESTION_ATTR}="${platform}"]`, + ) + for (const element of elements) { + element.remove() + } +} + +export function acceptMemorySuggestion( + event: KeyboardEvent, + platform: string, + input: SuggestionInput | null, +): boolean { + if (event.key !== "Tab" || !input?.dataset.supermemories) { + return false + } + + event.preventDefault() + event.stopPropagation() + + const text = input.dataset.supermemories + appendTextToInput(input, text) + delete input.dataset.supermemories + input.dataset.supermemoriesInjected = "true" + removeMemorySuggestion(platform) + + return true +} + +export function hasAcceptedSupermemoryContext( + input: SuggestionInput | null, +): boolean { + if (!input) return false + const text = + input instanceof HTMLTextAreaElement + ? input.value + : input.innerText || input.textContent || "" + + return text.includes(SUPERMEMORY_PREFIX) +} + +export function syncAcceptedSupermemoryState(input: SuggestionInput | null) { + if (!input?.dataset.supermemoriesInjected) return + + if (!hasAcceptedSupermemoryContext(input)) { + delete input.dataset.supermemoriesInjected + } +} + +export function clearMemorySuggestion( + platform: string, + input: SuggestionInput | null, +) { + removeMemorySuggestion(platform) + if (input?.dataset.supermemories) { + delete input.dataset.supermemories + } + if (input?.dataset.supermemoriesInjected) { + delete input.dataset.supermemoriesInjected + } +} + +export function setMemoryMarkerStatus( + iconElement: HTMLElement | null, + status: "neutral" | "searching" | "found" | "none" | "error", +) { + if (!iconElement) return + + iconElement.querySelector("[data-supermemory-status-badge]")?.remove() + + if (status === "neutral" || status === "none") { + delete iconElement.dataset.supermemoryStatus + return + } + + iconElement.dataset.supermemoryStatus = status + const badge = document.createElement("span") + badge.dataset.supermemoryStatusBadge = "true" + badge.style.cssText = ` + position: absolute; + top: 3px; + right: 3px; + width: ${status === "searching" ? "7px" : "8px"}; + height: ${status === "searching" ? "7px" : "8px"}; + border-radius: 999px; + background: ${status === "found" ? "#36F3D7" : status === "searching" ? SUPERMEMORY_BLUE : status === "error" ? "#EF4444" : "rgba(255, 255, 255, 0.55)"}; + border: 1px solid rgba(5, 7, 10, 0.9); + box-shadow: ${status === "found" ? "0 0 0 2px rgba(54, 243, 215, 0.18)" : "none"}; + pointer-events: none; + ` + iconElement.appendChild(badge) +} + +export function showMarkerPopover( + iconElement: HTMLElement, + message: string, + memories?: string, + resetAfter = 0, +) { + iconElement.querySelector("[data-supermemory-marker-popover]")?.remove() + ensureSuggestionAnimationStyle() + + const popover = document.createElement("div") + popover.dataset.supermemoryMarkerPopover = "true" + popover.style.cssText = ` + position: absolute; + right: 0; + bottom: calc(100% + 10px); + min-width: 168px; + max-width: 280px; + padding: 10px; + border-radius: 12px; + background: rgba(10, 14, 20, 0.96); + border: 1px solid rgba(255, 255, 255, 0.12); + color: #FAFAFA; + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.32); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 12px; + line-height: 1.35; + text-align: left; + z-index: 2147483647; + pointer-events: auto; + ` + + const title = document.createElement("div") + title.style.cssText = ` + display: flex; + align-items: center; + gap: 6px; + font-weight: 700; + margin-bottom: ${memories ? "8px" : "0"}; + ` + + if (message.toLowerCase().includes("searching")) { + const dots = document.createElement("span") + dots.style.cssText = "display: inline-flex; gap: 3px; align-items: center;" + for (let index = 0; index < 3; index += 1) { + const dot = document.createElement("span") + dot.style.cssText = ` + width: 4px; + height: 4px; + border-radius: 999px; + background: ${SUPERMEMORY_BLUE}; + animation: supermemorySuggestionDot 1s ease-in-out infinite; + animation-delay: ${index * 0.14}s; + ` + dots.appendChild(dot) + } + title.appendChild(dots) + } + + const titleText = document.createElement("span") + titleText.textContent = + message === "Included Memories" ? "Included memories" : message + title.appendChild(titleText) + popover.appendChild(title) + + if (memories) { + const list = document.createElement("div") + list.style.cssText = ` + display: flex; + flex-direction: column; + gap: 6px; + max-height: 160px; + overflow-y: auto; + color: rgba(255, 255, 255, 0.76); + ` + + memories + .split(/[,\n]/) + .map((memory) => memory.trim()) + .filter((memory) => memory.length > 0 && memory !== ",") + .slice(0, 5) + .forEach((memory) => { + const item = document.createElement("div") + item.textContent = memory + list.appendChild(item) + }) + + popover.appendChild(list) + } + + iconElement.appendChild(popover) + + if (resetAfter > 0) { + setTimeout(() => { + popover.remove() + }, resetAfter) + } +} + +function ensureSuggestionAnimationStyle() { + if (document.getElementById("supermemory-suggestion-animation-style")) { + return + } + + const style = document.createElement("style") + style.id = "supermemory-suggestion-animation-style" + style.textContent = ` + @keyframes supermemorySuggestionDot { + 0%, 80%, 100% { opacity: 0.3; transform: translateY(0); } + 40% { opacity: 1; transform: translateY(-1px); } + } + ` + document.head.appendChild(style) +} + +function getSuggestionAnchor(input: SuggestionInput): HTMLElement | null { + return ( + (input.closest("form") as HTMLElement | null) || + (input.closest('[role="textbox"]') as HTMLElement | null)?.parentElement || + input.parentElement + ) +} + +function getCaretPosition(input: SuggestionInput, anchor: HTMLElement) { + const anchorRect = anchor.getBoundingClientRect() + + if (!(input instanceof HTMLTextAreaElement)) { + const selection = window.getSelection() + if (selection?.rangeCount) { + const range = selection.getRangeAt(0).cloneRange() + if (input.contains(range.startContainer)) { + range.collapse(true) + let rect = range.getBoundingClientRect() + if (rect.width === 0 && rect.height === 0) { + const marker = document.createElement("span") + marker.textContent = "\u200b" + range.insertNode(marker) + rect = marker.getBoundingClientRect() + marker.remove() + } + + if (rect.width || rect.height) { + return { + left: Math.max(18, rect.right - anchorRect.left + 4), + top: Math.max(10, rect.top - anchorRect.top), + } + } + } + } + } + + const inputRect = input.getBoundingClientRect() + return { + left: Math.max(18, inputRect.left - anchorRect.left + 18), + top: Math.max(10, inputRect.top - anchorRect.top + 8), + } +} + +function appendTextToInput(input: SuggestionInput, text: string) { + if (input instanceof HTMLTextAreaElement) { + input.value = `${input.value}${text}` + input.dispatchEvent(new Event("input", { bubbles: true })) + return + } + + input.focus() + const selection = window.getSelection() + const range = document.createRange() + range.selectNodeContents(input) + range.collapse(false) + range.insertNode(document.createTextNode(text)) + range.collapse(false) + selection?.removeAllRanges() + selection?.addRange(range) + input.dispatchEvent( + new InputEvent("input", { bubbles: true, inputType: "insertText" }), + ) +} diff --git a/apps/browser-extension/entrypoints/content/shared.ts b/apps/browser-extension/entrypoints/content/shared.ts index 68d117a1a..07fdcebde 100644 --- a/apps/browser-extension/entrypoints/content/shared.ts +++ b/apps/browser-extension/entrypoints/content/shared.ts @@ -1,9 +1,12 @@ import { MESSAGE_TYPES } from "../../utils/constants" import { bearerToken, userData } from "../../utils/storage" +import type { APIResponse } from "../../utils/types" import { DOMUtils } from "../../utils/ui-components" import { default as TurndownService } from "turndown" -export async function saveMemory() { +export async function saveMemory( + actionSource = "content_script", +): Promise { try { DOMUtils.showToast("loading") @@ -64,21 +67,29 @@ export async function saveMemory() { data.markdown = markdown } - const response = await browser.runtime.sendMessage({ + const response = (await browser.runtime.sendMessage({ action: MESSAGE_TYPES.SAVE_MEMORY, data, - actionSource: "context_menu", - }) + actionSource, + })) as APIResponse - console.log("Response from enxtension:", response) - if (response.success) { + console.log("Response from extension:", response) + if (response?.success) { DOMUtils.showToast("success") - } else { - DOMUtils.showToast("error") + return response + } + DOMUtils.showToast("error") + return { + success: false, + error: response?.error || "Failed to save memory", } } catch (error) { console.error("Error saving memory:", error) DOMUtils.showToast("error") + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error", + } } } @@ -90,7 +101,7 @@ export function setupGlobalKeyboardShortcut() { event.key === "m" ) { event.preventDefault() - await saveMemory() + await saveMemory("keyboard_shortcut") } }) } diff --git a/apps/browser-extension/entrypoints/content/t3.ts b/apps/browser-extension/entrypoints/content/t3.ts index c7bdb09a1..13ce233d9 100644 --- a/apps/browser-extension/entrypoints/content/t3.ts +++ b/apps/browser-extension/entrypoints/content/t3.ts @@ -546,7 +546,7 @@ function setupT3PromptCapture() { data: { prompt: promptContent, platform: "t3", - source: source, + source: window.location.href, }, }) } catch (error) { diff --git a/apps/browser-extension/entrypoints/content/twitter.ts b/apps/browser-extension/entrypoints/content/twitter.ts index ffa138af9..875f4bab3 100644 --- a/apps/browser-extension/entrypoints/content/twitter.ts +++ b/apps/browser-extension/entrypoints/content/twitter.ts @@ -253,7 +253,7 @@ async function showOnboardingToast() { header.style.cssText = "display: flex; align-items: flex-start; gap: 12px; position: relative;" - const iconUrl = browser.runtime.getURL("/icon-16.png") + const iconUrl = browser.runtime.getURL("/new_logo.png") const icon = document.createElement("img") icon.src = iconUrl icon.alt = "Supermemory" @@ -512,7 +512,7 @@ function showOrUpdateImportProgressToast(message: string, isComplete = false) { animation: smSlideInUp 0.3s ease-out; ` - const iconUrl = browser.runtime.getURL("/icon-16.png") + const iconUrl = browser.runtime.getURL("/new_logo.png") const icon = document.createElement("img") icon.src = iconUrl icon.alt = "Supermemory" diff --git a/apps/browser-extension/entrypoints/popup/App.tsx b/apps/browser-extension/entrypoints/popup/App.tsx index 498a75a2c..2bb3b3e2e 100644 --- a/apps/browser-extension/entrypoints/popup/App.tsx +++ b/apps/browser-extension/entrypoints/popup/App.tsx @@ -2,7 +2,12 @@ import { useQueryClient } from "@tanstack/react-query" import { useEffect, useState } from "react" import "./App.css" import { validateAuthToken } from "../../utils/api" -import { MESSAGE_TYPES, STORAGE_KEYS, UI_CONFIG } from "../../utils/constants" +import { + getSupermemoryLoginUrl, + MESSAGE_TYPES, + STORAGE_KEYS, + UI_CONFIG, +} from "../../utils/constants" import { useDefaultProject, useProjects, @@ -84,6 +89,7 @@ function App() { const [autoCapturePromptsEnabled, setAutoCapturePromptsEnabled] = useState(false) const [authInvalidated, setAuthInvalidated] = useState(false) + const [saveError, setSaveError] = useState(null) const queryClient = useQueryClient() const { data: projects = [], isLoading: loadingProjects } = useProjects({ @@ -189,29 +195,70 @@ function App() { const handleSaveCurrentPage = async () => { setSaving(true) + setSaveError(null) try { const tabs = await chrome.tabs.query({ active: true, currentWindow: true, }) - if (tabs.length > 0 && tabs[0].id) { - const response = await chrome.tabs.sendMessage(tabs[0].id, { + const tab = tabs[0] + let response: { success?: boolean; error?: string } | undefined + + if (tab?.id) { + try { + response = await chrome.tabs.sendMessage(tab.id, { + action: MESSAGE_TYPES.SAVE_MEMORY, + actionSource: "popup", + }) + } catch (contentScriptError) { + console.warn("Content script save failed:", contentScriptError) + } + } + + if (response && !response.success) { + throw new Error(response.error || "Failed to save current page") + } + + if (!response) { + const fallbackUrl = tab?.url || currentUrl + const fallbackTitle = tab?.title || currentTitle || "Current Page" + + if (!fallbackUrl) { + throw new Error("No active page URL found") + } + + response = await chrome.runtime.sendMessage({ action: MESSAGE_TYPES.SAVE_MEMORY, - actionSource: "popup", + actionSource: "popup_fallback", + data: { + url: fallbackUrl, + title: fallbackTitle, + content: `${fallbackTitle}\n\n${fallbackUrl}`, + }, }) + } - if (response?.success) { - await chrome.tabs.sendMessage(tabs[0].id, { - action: MESSAGE_TYPES.SHOW_TOAST, - state: "success", - }) + if (response?.success) { + if (tab?.id) { + await chrome.tabs + .sendMessage(tab.id, { + action: MESSAGE_TYPES.SHOW_TOAST, + state: "success", + }) + .catch(() => undefined) } window.close() + return } + + throw new Error(response?.error || "Failed to save current page") } catch (error) { console.error("Failed to save current page:", error) + setSaveError( + error instanceof Error ? error.message : "Could not save page", + ) try { const tabs = await chrome.tabs.query({ @@ -227,8 +274,6 @@ function App() { } catch (toastError) { console.error("Failed to show error toast:", toastError) } - - window.close() } finally { setSaving(false) } @@ -287,7 +332,7 @@ function App() { > supermemory
@@ -295,11 +340,9 @@ function App() { Your - supermemory + + supermemory + @@ -353,7 +396,7 @@ function App() { > supermemory @@ -367,11 +410,9 @@ function App() { return name.endsWith("s") ? `${name}'` : `${name}'s` })()} - supermemory + + supermemory + {userSignedIn && ( @@ -626,6 +667,11 @@ function App() { {saving ? "Saving..." : "Add to supermemory"} + {saveError && ( +

+ {saveError} +

+ )} ) : activeTab === "imports" ? ( @@ -862,13 +908,13 @@ function App() { @@ -893,9 +939,7 @@ function App() { className="w-full py-3 px-6 bg-[#2d3f5c] text-white border-none rounded-3xl text-base font-medium cursor-pointer transition-colors duration-200 hover:bg-[#3d5270] disabled:bg-neutral-600 disabled:cursor-not-allowed" onClick={() => { chrome.tabs.create({ - url: import.meta.env.PROD - ? "https://app.supermemory.ai/login" - : "http://localhost:3000/login", + url: getSupermemoryLoginUrl(), }) }} type="button" diff --git a/apps/browser-extension/entrypoints/popup/index.html b/apps/browser-extension/entrypoints/popup/index.html index ed4cb9494..d0cca15e9 100644 --- a/apps/browser-extension/entrypoints/popup/index.html +++ b/apps/browser-extension/entrypoints/popup/index.html @@ -3,7 +3,7 @@ - Default Popup Title + supermemory diff --git a/apps/browser-extension/entrypoints/welcome/Welcome.tsx b/apps/browser-extension/entrypoints/welcome/Welcome.tsx index 9463eba4d..4ef72b813 100644 --- a/apps/browser-extension/entrypoints/welcome/Welcome.tsx +++ b/apps/browser-extension/entrypoints/welcome/Welcome.tsx @@ -1,105 +1,93 @@ +import { getSupermemoryLoginUrl } from "../../utils/constants" + function Welcome() { return ( -
-
- {/* Header */} -
- supermemory -

- Your AI second brain for saving and organizing everything that - matters. Supermemory learns and remembers everything you save, your - preferences, and understands you. -

-
+
+
+
+
- {/* Features Section */} -
-

- What can you do with supermemory ? -

+
+
+
+ + + supermemory + +
+ + Chrome extension + +
-
-
-
💾
-

- Save Any Page -

-

- Instantly save web pages, articles, and content to your personal - knowledge base -

+
+
+
+ + Browser memory connected
-
-
🐦
-

- Import Twitter/X Bookmarks -

-

- Bring all your saved tweets and bookmarks into one organized - place -

-
+

+ Your browser now has{" "} + + supermemory. + +

-
-
🤖
-

- Import ChatGPT Memories -

-

- Keep your important AI conversations and insights accessible -

-
+

+ Sign in once, then use the extension to save pages, import + bookmarks, and bring your memory into supported chat apps. +

-
-
🔍
-

- Your context, everywhere. -

-

- You can connect chatbots with MCP, chat with your personal - assistant, and more. -

+
+ +
-
- - {/* Actions */} -
- -
+
- {/* Footer */} -
-

- Learn more at{" "} - - supermemory.ai - -

-
-
+
+ supermemory stores your extension session locally in Chrome. +
+
) } diff --git a/apps/browser-extension/entrypoints/welcome/index.html b/apps/browser-extension/entrypoints/welcome/index.html index 92bb26e0d..30ac32140 100644 --- a/apps/browser-extension/entrypoints/welcome/index.html +++ b/apps/browser-extension/entrypoints/welcome/index.html @@ -2,7 +2,7 @@ - + Welcome to supermemory @@ -10,4 +10,4 @@
- \ No newline at end of file + diff --git a/apps/browser-extension/public/new_logo.png b/apps/browser-extension/public/new_logo.png new file mode 100644 index 000000000..dbabef62a Binary files /dev/null and b/apps/browser-extension/public/new_logo.png differ diff --git a/apps/browser-extension/utils/constants.ts b/apps/browser-extension/utils/constants.ts index 20977642d..3d1346a71 100644 --- a/apps/browser-extension/utils/constants.ts +++ b/apps/browser-extension/utils/constants.ts @@ -10,6 +10,17 @@ export const API_ENDPOINTS = { : "http://localhost:3000", } as const +export function getSupermemoryLoginUrl(): string { + const baseUrl = API_ENDPOINTS.SUPERMEMORY_WEB + const loginUrl = new URL("/login", baseUrl) + const redirectUrl = new URL("/", baseUrl) + + redirectUrl.searchParams.set("extension-auth-success", "true") + loginUrl.searchParams.set("redirect", redirectUrl.toString()) + + return loginUrl.toString() +} + /** * DOM Element IDs */ @@ -22,6 +33,7 @@ export const ELEMENT_IDS = { SAVE_TWEET_ELEMENT: "sm-save-tweet-element", CHATGPT_INPUT_BAR_ELEMENT: "sm-chatgpt-input-bar-element", CLAUDE_INPUT_BAR_ELEMENT: "sm-claude-input-bar-element", + GEMINI_INPUT_BAR_ELEMENT: "sm-gemini-input-bar-element", T3_INPUT_BAR_ELEMENT: "sm-t3-input-bar-element", PROJECT_SELECTION_MODAL: "sm-project-selection-modal", } as const @@ -58,6 +70,7 @@ export const DOMAINS = { TWITTER: ["x.com", "twitter.com"], CHATGPT: ["chatgpt.com", "chat.openai.com"], CLAUDE: ["claude.ai"], + GEMINI: ["gemini.google.com"], T3: ["t3.chat"], SUPERMEMORY: ["localhost", "supermemory.ai", "app.supermemory.ai"], } as const @@ -94,6 +107,8 @@ export const POSTHOG_EVENT_KEY = { T3_CHAT_MEMORIES_AUTO_SEARCHED: "t3_chat_memories_auto_searched", CLAUDE_CHAT_MEMORIES_SEARCHED: "claude_chat_memories_searched", CLAUDE_CHAT_MEMORIES_AUTO_SEARCHED: "claude_chat_memories_auto_searched", + GEMINI_CHAT_MEMORIES_SEARCHED: "gemini_chat_memories_searched", + GEMINI_CHAT_MEMORIES_AUTO_SEARCHED: "gemini_chat_memories_auto_searched", CHATGPT_CHAT_MEMORIES_SEARCHED: "chatgpt_chat_memories_searched", CHATGPT_CHAT_MEMORIES_AUTO_SEARCHED: "chatgpt_chat_memories_auto_searched", } as const diff --git a/apps/browser-extension/utils/types.ts b/apps/browser-extension/utils/types.ts index 8cec22411..e17f4ff9f 100644 --- a/apps/browser-extension/utils/types.ts +++ b/apps/browser-extension/utils/types.ts @@ -38,6 +38,9 @@ export interface MemoryData { url?: string ogImage?: string title?: string + sourcePlatform?: string + sourcePlatformLabel?: string + sourceSurface?: string } /** diff --git a/apps/browser-extension/utils/ui-components.ts b/apps/browser-extension/utils/ui-components.ts index 99f96cbbd..2ed576907 100644 --- a/apps/browser-extension/utils/ui-components.ts +++ b/apps/browser-extension/utils/ui-components.ts @@ -117,7 +117,7 @@ export function createToast(state: ToastState): HTMLElement { break case "success": { - const iconUrl = browser.runtime.getURL("/icon-16.png") + const iconUrl = browser.runtime.getURL("/new_logo.png") icon.innerHTML = `Success` textElement.textContent = "Added to Memory" break @@ -184,7 +184,7 @@ export function createTwitterImportButton(onClick: () => void): HTMLElement { font-family: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; ` - const iconUrl = browser.runtime.getURL("/icon-16.png") + const iconUrl = browser.runtime.getURL("/new_logo.png") button.style.backgroundImage = `url("${iconUrl}")` button.style.backgroundRepeat = "no-repeat" @@ -232,7 +232,7 @@ export function createSaveTweetElement(onClick: () => void): HTMLElement { z-index: 1000; ` - const iconFileName = "/icon-16.png" + const iconFileName = "/new_logo.png" const iconUrl = browser.runtime.getURL(iconFileName) iconButton.innerHTML = ` Save to Memory @@ -261,31 +261,72 @@ export function createSaveTweetElement(onClick: () => void): HTMLElement { * @returns HTMLElement - The save button element */ export function createChatGPTInputBarElement(onClick: () => void): HTMLElement { - const iconButton = document.createElement("div") + return createConnectedIndicator(onClick) +} + +export function createConnectedIndicator(onClick: () => void): HTMLElement { + const iconButton = document.createElement("button") + iconButton.type = "button" + iconButton.setAttribute("aria-label", "supermemory connected") + iconButton.dataset.supermemoryConnectedIndicator = "true" iconButton.style.cssText = ` display: inline-flex; align-items: center; justify-content: center; - width: auto; - height: 24px; + width: 32px; + height: 32px; + min-width: 32px; cursor: pointer; - transition: opacity 0.2s ease; + transition: opacity 0.2s ease, background-color 0.2s ease, transform 0.2s ease; border-radius: 50%; + border: none; + background: transparent; + padding: 0; + position: relative; + flex-shrink: 0; ` - // Use appropriate icon based on theme - const iconFileName = "/icon-16.png" + const iconFileName = "/new_logo.png" const iconUrl = browser.runtime.getURL(iconFileName) iconButton.innerHTML = ` - Save to Memory + ` + const tooltip = document.createElement("div") + tooltip.textContent = "supermemory connected" + tooltip.style.cssText = ` + position: absolute; + bottom: calc(100% + 8px); + left: 50%; + transform: translateX(-50%) translateY(2px); + background: #0A0E14; + color: #FAFAFA; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + padding: 6px 8px; + font-family: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 12px; + font-weight: 500; + line-height: 1; + white-space: nowrap; + pointer-events: none; + opacity: 0; + transition: opacity 0.16s ease, transform 0.16s ease; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28); + z-index: 2147483647; + ` + iconButton.appendChild(tooltip) + iconButton.addEventListener("mouseenter", () => { - iconButton.style.opacity = "0.8" + iconButton.style.backgroundColor = "rgba(255, 255, 255, 0.08)" + tooltip.style.opacity = "1" + tooltip.style.transform = "translateX(-50%) translateY(0)" }) iconButton.addEventListener("mouseleave", () => { - iconButton.style.opacity = "1" + iconButton.style.backgroundColor = "transparent" + tooltip.style.opacity = "0" + tooltip.style.transform = "translateX(-50%) translateY(2px)" }) iconButton.addEventListener("click", (event) => { @@ -303,42 +344,11 @@ export function createChatGPTInputBarElement(onClick: () => void): HTMLElement { * @returns HTMLElement - The save button element */ export function createClaudeInputBarElement(onClick: () => void): HTMLElement { - const iconButton = document.createElement("div") - iconButton.style.cssText = ` - display: inline-flex; - align-items: center; - justify-content: center; - width: auto; - height: 32px; - cursor: pointer; - transition: all 0.2s ease; - border-radius: 6px; - background: transparent; - ` - - const iconFileName = "/icon-16.png" - const iconUrl = browser.runtime.getURL(iconFileName) - iconButton.innerHTML = ` - Get Related Memories from supermemory - ` - - iconButton.addEventListener("mouseenter", () => { - iconButton.style.backgroundColor = "rgba(0, 0, 0, 0.05)" - iconButton.style.borderColor = "rgba(0, 0, 0, 0.2)" - }) - - iconButton.addEventListener("mouseleave", () => { - iconButton.style.backgroundColor = "transparent" - iconButton.style.borderColor = "rgba(0, 0, 0, 0.1)" - }) - - iconButton.addEventListener("click", (event) => { - event.stopPropagation() - event.preventDefault() - onClick() - }) + return createConnectedIndicator(onClick) +} - return iconButton +export function createGeminiInputBarElement(onClick: () => void): HTMLElement { + return createConnectedIndicator(onClick) } /** @@ -360,7 +370,7 @@ export function createT3InputBarElement(onClick: () => void): HTMLElement { background: transparent; ` - const iconFileName = "/icon-16.png" + const iconFileName = "/new_logo.png" const iconUrl = browser.runtime.getURL(iconFileName) iconButton.innerHTML = ` Get Related Memories from supermemory @@ -433,7 +443,7 @@ export function createProjectSelectionModal( margin-bottom: 20px; ` - const iconUrl = browser.runtime.getURL("/icon-16.png") + const iconUrl = browser.runtime.getURL("/new_logo.png") header.innerHTML = `

@@ -702,7 +712,7 @@ export const DOMUtils = { if (icon && text) { if (state === "success") { - const iconUrl = browser.runtime.getURL("/icon-16.png") + const iconUrl = browser.runtime.getURL("/new_logo.png") icon.innerHTML = `Success` icon.style.animation = "" text.textContent = "Added to Memory" diff --git a/apps/browser-extension/wxt.config.ts b/apps/browser-extension/wxt.config.ts index c810e73f5..f3514b97c 100644 --- a/apps/browser-extension/wxt.config.ts +++ b/apps/browser-extension/wxt.config.ts @@ -38,11 +38,14 @@ export default defineConfig({ "*://api.supermemory.ai/*", "*://chatgpt.com/*", "*://chat.openai.com/*", + "*://claude.ai/*", + "*://gemini.google.com/*", + "*://t3.chat/*", "https://*.posthog.com/*", ], web_accessible_resources: [ { - resources: ["icon-16.png", "fonts/*.ttf"], + resources: ["new_logo.png", "fonts/*.ttf"], matches: [""], }, ], diff --git a/apps/web/components/memories-grid.tsx b/apps/web/components/memories-grid.tsx index d2f93d544..9c77e0f41 100644 --- a/apps/web/components/memories-grid.tsx +++ b/apps/web/components/memories-grid.tsx @@ -90,6 +90,34 @@ type OgData = { image?: string } +const EXTENSION_PLATFORM_LABELS: Record = { + chatgpt: "ChatGPT", + claude: "Claude", + gemini: "Gemini", + t3: "T3 Chat", + twitter: "X / Twitter", +} + +function getExtensionSourceLabel( + document: DocumentWithMemories, +): string | null { + const metadata = document.metadata + if (!metadata || typeof metadata !== "object") return null + + const label = metadata.sm_origin_platform_label + if (typeof label === "string" && label.trim()) { + return label.trim() + } + + const platform = metadata.sm_origin_platform + if (typeof platform === "string" && platform.trim()) { + const normalized = platform.trim().toLowerCase() + return EXTENSION_PLATFORM_LABELS[normalized] || platform.trim() + } + + return null +} + const ogCache = new Map() const ogInflight = new Map>() const ogFailures = new Map() @@ -1147,6 +1175,7 @@ const DocumentCard = memo( pluginDocument?.kind === "claude-code-doc" ? claudeCodeTokenBadge(document) : null + const sourceLabel = getExtensionSourceLabel(document) const date = new Date( document.createdAt, ).toLocaleDateString("en-US", { @@ -1154,7 +1183,9 @@ const DocumentCard = memo( day: "numeric", year: "numeric", }) - return badge ? `${badge} · ${date}` : date + return [sourceLabel, badge, date] + .filter(Boolean) + .join(" - ") })()}