supermemory/apps/browser-extension/entrypoints/content/memory-suggestion.ts
James Yang 1356affbd1
fix(extension): finish Included Memories leftovers on T3 (#1257) (#1421)
Co-authored-by: abhay-codes07 <abhaysingh0293@gmail.com>
Co-authored-by: Vedant Mahajan <vedant.04.mahajan@gmail.com>
2026-08-13 17:55:16 +05:30

446 lines
12 KiB
TypeScript

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}`
}
function normalizeMemoryList(memories: unknown): string[] {
const list = Array.isArray(memories)
? memories
: memories == null
? []
: [memories]
return list
.map((memory) => (typeof memory === "string" ? memory : String(memory)))
.map((memory) => memory.trim())
.filter((memory) => memory.length > 0)
}
export function serializeMemoriesForDataset(memories: unknown): string {
const list = normalizeMemoryList(memories)
return list.length > 0 ? JSON.stringify(list) : ""
}
export function parseMemoriesFromDataset(
raw: string | null | undefined,
): string[] {
if (!raw) return []
try {
const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) return normalizeMemoryList(parsed)
} catch {
// Not JSON — fall through to the legacy delimiter split.
}
return raw
.split(/[,\n]/)
.map((memory) => memory.trim())
.filter((memory) => memory.length > 0 && memory !== ",")
}
export function renumberIncludedMemories(memories: string[]): string[] {
return memories.map((memory, index) => {
const text = memory.replace(/^\d+\.\s*/, "").replace(/\s+$/, "")
return `${index + 1}. ${text} \n`
})
}
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);
`
parseMemoriesFromDataset(memories)
.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" }),
)
}