From d2aeb650d8964ecc2f8f7186a8425450b39b0f29 Mon Sep 17 00:00:00 2001 From: abhay-codes07 Date: Wed, 22 Jul 2026 20:37:37 +0530 Subject: [PATCH 1/3] fix(extension): stop fragmenting memories that contain commas or newlines The Included Memories popup stores the memory list on a data-* attribute by joining it into a single string (String(response.data) / response.data), then reads it back by splitting on [,\n] in showMarkerPopover and in the T3 popup. Memory text is free-form and frequently contains commas and newlines, so a single memory was fragmented into several rows. In T3 that also desynced the per-item delete: the clicked index no longer mapped to a real memory, so removal spliced the wrong entry and rewrote the injected prompt from the mangled list. Store the list as JSON on the attribute and parse it back, so a memory with a comma or newline survives as one item. serializeMemoriesForDataset returns an empty string for an empty list so existing "memories present" truthiness checks on the attribute are unchanged, and parseMemoriesFromDataset falls back to the old delimiter split for any legacy value. This is confined to the popup's own display data: the GET_RELATED_MEMORIES response and the injected dataset.supermemories prompt text are left exactly as they were, so recall behaviour does not change. Applied consistently across ChatGPT, Claude, Gemini and T3, with unit tests for the round-trip. --- .../entrypoints/content/chatgpt.ts | 5 +- .../entrypoints/content/claude.ts | 5 +- .../entrypoints/content/gemini.ts | 5 +- .../content/memory-suggestion.test.ts | 49 ++++++++++++++++++ .../entrypoints/content/memory-suggestion.ts | 51 +++++++++++++++++-- .../entrypoints/content/t3.ts | 28 ++++++---- 6 files changed, 125 insertions(+), 18 deletions(-) create mode 100644 apps/browser-extension/entrypoints/content/memory-suggestion.test.ts diff --git a/apps/browser-extension/entrypoints/content/chatgpt.ts b/apps/browser-extension/entrypoints/content/chatgpt.ts index 444e3ac87..cf7a30028 100644 --- a/apps/browser-extension/entrypoints/content/chatgpt.ts +++ b/apps/browser-extension/entrypoints/content/chatgpt.ts @@ -17,6 +17,7 @@ import { acceptMemorySuggestion, clearMemorySuggestion, hasAcceptedSupermemoryContext, + serializeMemoriesForDataset, setMemoryMarkerStatus, showLoadingSuggestion, showMarkerPopover, @@ -212,7 +213,9 @@ async function getRelatedMemoriesForChatGPT(actionSource: string) { memoryLength: memoryText.length, }) - iconElement.dataset.memoriesData = String(response.data) + iconElement.dataset.memoriesData = serializeMemoriesForDataset( + response.data, + ) if (isAutoSearch) { setMemoryMarkerStatus(iconElement, "found") diff --git a/apps/browser-extension/entrypoints/content/claude.ts b/apps/browser-extension/entrypoints/content/claude.ts index 7bff4dfc2..f31c2bb60 100644 --- a/apps/browser-extension/entrypoints/content/claude.ts +++ b/apps/browser-extension/entrypoints/content/claude.ts @@ -17,6 +17,7 @@ import { acceptMemorySuggestion, clearMemorySuggestion, hasAcceptedSupermemoryContext, + serializeMemoriesForDataset, setMemoryMarkerStatus, showLoadingSuggestion, showMarkerPopover, @@ -459,7 +460,9 @@ async function getRelatedMemoriesForClaude(actionSource: string) { memoryLength: memoryText.length, }) - iconElement.dataset.memoriesData = String(response.data) + iconElement.dataset.memoriesData = serializeMemoriesForDataset( + response.data, + ) if (isAutoSearch) { setMemoryMarkerStatus(iconElement, "found") diff --git a/apps/browser-extension/entrypoints/content/gemini.ts b/apps/browser-extension/entrypoints/content/gemini.ts index 6ece78dff..f819d3d6f 100644 --- a/apps/browser-extension/entrypoints/content/gemini.ts +++ b/apps/browser-extension/entrypoints/content/gemini.ts @@ -17,6 +17,7 @@ import { acceptMemorySuggestion, clearMemorySuggestion, hasAcceptedSupermemoryContext, + serializeMemoriesForDataset, setMemoryMarkerStatus, showLoadingSuggestion, showMarkerPopover, @@ -417,7 +418,9 @@ async function getRelatedMemoriesForGemini(actionSource: string) { if (response?.success && response?.data && input) { const memoryText = showMemorySuggestion("gemini", input, response.data) - iconElement.dataset.memoriesData = String(response.data) + iconElement.dataset.memoriesData = serializeMemoriesForDataset( + response.data, + ) iconElement.dataset.supermemories = memoryText if (isAutoSearch) { setMemoryMarkerStatus(iconElement, "found") diff --git a/apps/browser-extension/entrypoints/content/memory-suggestion.test.ts b/apps/browser-extension/entrypoints/content/memory-suggestion.test.ts new file mode 100644 index 000000000..81d221cac --- /dev/null +++ b/apps/browser-extension/entrypoints/content/memory-suggestion.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "bun:test" +import { + parseMemoriesFromDataset, + serializeMemoriesForDataset, +} from "./memory-suggestion" + +describe("memory dataset serialization", () => { + it("round-trips a memory that contains a comma as a single item", () => { + const memories = ["Lives in Austin, Texas", "Prefers dark mode"] + const stored = serializeMemoriesForDataset(memories) + expect(parseMemoriesFromDataset(stored)).toEqual(memories) + }) + + it("round-trips a memory that contains a newline as a single item", () => { + const memories = ["Shipping address:\n123 Main St", "Likes coffee"] + const stored = serializeMemoriesForDataset(memories) + expect(parseMemoriesFromDataset(stored)).toEqual(memories) + }) + + it("trims and drops empty entries when serializing", () => { + const stored = serializeMemoriesForDataset([" keep ", "", " "]) + expect(parseMemoriesFromDataset(stored)).toEqual(["keep"]) + }) + + it("serializes an empty list to an empty string for truthiness checks", () => { + expect(serializeMemoriesForDataset([])).toBe("") + expect(serializeMemoriesForDataset(undefined)).toBe("") + }) + + it("returns an empty array for empty or missing input", () => { + expect(parseMemoriesFromDataset("")).toEqual([]) + expect(parseMemoriesFromDataset(null)).toEqual([]) + expect(parseMemoriesFromDataset(undefined)).toEqual([]) + }) + + it("falls back to the legacy comma/newline split for non-JSON values", () => { + expect(parseMemoriesFromDataset("first,second\nthird")).toEqual([ + "first", + "second", + "third", + ]) + }) + + it("wraps a single non-array value into one item", () => { + expect( + parseMemoriesFromDataset(serializeMemoriesForDataset("solo")), + ).toEqual(["solo"]) + }) +}) diff --git a/apps/browser-extension/entrypoints/content/memory-suggestion.ts b/apps/browser-extension/entrypoints/content/memory-suggestion.ts index 1722e71ef..81af3f80b 100644 --- a/apps/browser-extension/entrypoints/content/memory-suggestion.ts +++ b/apps/browser-extension/entrypoints/content/memory-suggestion.ts @@ -12,6 +12,52 @@ export function buildSupermemoryText(memories: unknown): string { 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) +} + +/** + * Serialize the memory list for storage on a `data-*` attribute. Memories are + * free text that can contain commas and newlines, so they are stored as JSON + * rather than joined into a single string, otherwise a memory with a comma in + * it is split into fragments when the popup reads it back. Returns an empty + * string for an empty list so existing truthiness checks on the attribute + * (memories present vs not) keep working. + */ +export function serializeMemoriesForDataset(memories: unknown): string { + const list = normalizeMemoryList(memories) + return list.length > 0 ? JSON.stringify(list) : "" +} + +/** + * Read back a memory list written by {@link serializeMemoriesForDataset}. + * Falls back to the legacy comma/newline split so any value written by older + * code (or a plain joined string) still renders. + */ +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 showMemorySuggestion( platform: string, input: SuggestionInput, @@ -305,10 +351,7 @@ export function showMarkerPopover( color: rgba(255, 255, 255, 0.76); ` - memories - .split(/[,\n]/) - .map((memory) => memory.trim()) - .filter((memory) => memory.length > 0 && memory !== ",") + parseMemoriesFromDataset(memories) .slice(0, 5) .forEach((memory) => { const item = document.createElement("div") diff --git a/apps/browser-extension/entrypoints/content/t3.ts b/apps/browser-extension/entrypoints/content/t3.ts index 66a11235c..39a05f03e 100644 --- a/apps/browser-extension/entrypoints/content/t3.ts +++ b/apps/browser-extension/entrypoints/content/t3.ts @@ -10,6 +10,10 @@ import { autoCapturePromptsEnabled, } from "../../utils/storage" import { createT3InputBarElement, DOMUtils } from "../../utils/ui-components" +import { + parseMemoriesFromDataset, + serializeMemoriesForDataset, +} from "./memory-suggestion" let t3DebounceTimeout: NodeJS.Timeout | null = null let t3RouteObserver: MutationObserver | null = null @@ -233,7 +237,9 @@ async function getRelatedMemoriesForT3(actionSource: string) { if (textareaElement) { textareaElement.dataset.supermemories = `\n\nSupermemories of user (only for the reference): ${response.data}` - iconElement.dataset.memoriesData = response.data + iconElement.dataset.memoriesData = serializeMemoriesForDataset( + response.data, + ) updateT3IconFeedback("Included Memories", iconElement) } else { @@ -329,11 +335,9 @@ function updateT3IconFeedback( overflow-y: auto; ` - const memoriesText = iconElement.dataset.memoriesData || "" - const individualMemories = memoriesText - .split(/[,\n]/) - .map((memory) => memory.trim()) - .filter((memory) => memory.length > 0 && memory !== ",") + const individualMemories = parseMemoriesFromDataset( + iconElement.dataset.memoriesData, + ) individualMemories.forEach((memory, index) => { const memoryItem = document.createElement("div") @@ -421,15 +425,17 @@ function updateT3IconFeedback( content.removeChild(memoryItem) } - const currentMemories = (iconElement.dataset.memoriesData || "") - .split(/[,\n]/) - .map((memory) => memory.trim()) - .filter((memory) => memory.length > 0 && memory !== ",") + const currentMemories = parseMemoriesFromDataset( + iconElement.dataset.memoriesData, + ) currentMemories.splice(index, 1) + // Injected prompt keeps its existing joined-text form; the popup's + // own data is stored as JSON so comma-bearing memories stay intact. const updatedMemories = currentMemories.join(" ,") - iconElement.dataset.memoriesData = updatedMemories + iconElement.dataset.memoriesData = + serializeMemoriesForDataset(currentMemories) const textareaElement = (document.querySelector("textarea") as HTMLTextAreaElement) || From 066626d8e286750d803fdea8ac593ecc0b4c796d Mon Sep 17 00:00:00 2001 From: James Yang Date: Thu, 6 Aug 2026 00:50:39 -0400 Subject: [PATCH 2/3] fix(extension): stop T3 Included Memories wipe/listener leaks After a removal, only clear when zero memories remain, dispose prior popup click listeners before remounting, and rebuild the injected prompt with renumbered entries instead of a comma-joined string. --- .../content/memory-suggestion.test.ts | 25 ++++++++ .../entrypoints/content/memory-suggestion.ts | 21 +++++++ .../entrypoints/content/t3.ts | 62 +++++++++++++------ 3 files changed, 90 insertions(+), 18 deletions(-) diff --git a/apps/browser-extension/entrypoints/content/memory-suggestion.test.ts b/apps/browser-extension/entrypoints/content/memory-suggestion.test.ts index 81d221cac..f2519769b 100644 --- a/apps/browser-extension/entrypoints/content/memory-suggestion.test.ts +++ b/apps/browser-extension/entrypoints/content/memory-suggestion.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from "bun:test" import { + buildSupermemoryText, parseMemoriesFromDataset, + renumberIncludedMemories, serializeMemoriesForDataset, + shouldClearIncludedMemories, } from "./memory-suggestion" describe("memory dataset serialization", () => { @@ -47,3 +50,25 @@ describe("memory dataset serialization", () => { ).toEqual(["solo"]) }) }) + +describe("included memories removal lifecycle", () => { + it("renumbers remaining memories after a removal", () => { + const remaining = renumberIncludedMemories([ + "2. Prefers TypeScript, strict mode \n", + "3. Uses Biome \n", + ]) + expect(remaining).toEqual([ + "1. Prefers TypeScript, strict mode \n", + "2. Uses Biome \n", + ]) + expect(buildSupermemoryText(remaining)).toBe( + "\n\nSupermemories of user (only for the reference): 1. Prefers TypeScript, strict mode \n2. Uses Biome", + ) + }) + + it("clears included memories only when the list is empty", () => { + expect(shouldClearIncludedMemories(2)).toBe(false) + expect(shouldClearIncludedMemories(1)).toBe(false) + expect(shouldClearIncludedMemories(0)).toBe(true) + }) +}) diff --git a/apps/browser-extension/entrypoints/content/memory-suggestion.ts b/apps/browser-extension/entrypoints/content/memory-suggestion.ts index 81af3f80b..4ddca3e25 100644 --- a/apps/browser-extension/entrypoints/content/memory-suggestion.ts +++ b/apps/browser-extension/entrypoints/content/memory-suggestion.ts @@ -58,6 +58,27 @@ export function parseMemoriesFromDataset( .filter((memory) => memory.length > 0 && memory !== ",") } +/** + * Background search labels each memory as `"N. \n"`. After a popup + * removal the surviving entries keep stale numbers, so strip the prefix and + * renumber from 1 to match a fresh fetch. + */ +export function renumberIncludedMemories(memories: string[]): string[] { + return normalizeMemoryList(memories).map((memory, index) => { + const text = memory.replace(/^\d+\.\s*/, "").replace(/\s+$/, "") + return `${index + 1}. ${text} \n` + }) +} + +/** + * After splicing one memory out of the Included Memories list, clear the + * injected prompt only when nothing remains. A `<= 1` guard incorrectly wipes + * the last kept memory when the user removes one of two. + */ +export function shouldClearIncludedMemories(remainingCount: number): boolean { + return remainingCount === 0 +} + export function showMemorySuggestion( platform: string, input: SuggestionInput, diff --git a/apps/browser-extension/entrypoints/content/t3.ts b/apps/browser-extension/entrypoints/content/t3.ts index 39a05f03e..12aec7985 100644 --- a/apps/browser-extension/entrypoints/content/t3.ts +++ b/apps/browser-extension/entrypoints/content/t3.ts @@ -11,14 +11,35 @@ import { } from "../../utils/storage" import { createT3InputBarElement, DOMUtils } from "../../utils/ui-components" import { + buildSupermemoryText, parseMemoriesFromDataset, + renumberIncludedMemories, serializeMemoriesForDataset, + shouldClearIncludedMemories, } from "./memory-suggestion" let t3DebounceTimeout: NodeJS.Timeout | null = null let t3RouteObserver: MutationObserver | null = null let t3UrlCheckInterval: NodeJS.Timeout | null = null let t3ObserverThrottle: NodeJS.Timeout | null = null +let t3IncludedMemoriesPopup: HTMLElement | null = null +let t3IncludedMemoriesClickHandler: ((event: MouseEvent) => void) | null = null +let t3IncludedMemoriesTimeout: ReturnType | null = null + +function disposeT3IncludedMemoriesPopup() { + if (t3IncludedMemoriesClickHandler) { + document.removeEventListener("click", t3IncludedMemoriesClickHandler) + t3IncludedMemoriesClickHandler = null + } + if (t3IncludedMemoriesTimeout !== null) { + clearTimeout(t3IncludedMemoriesTimeout) + t3IncludedMemoriesTimeout = null + } + if (t3IncludedMemoriesPopup?.isConnected) { + t3IncludedMemoriesPopup.remove() + } + t3IncludedMemoriesPopup = null +} export function initializeT3() { if (!DOMUtils.isOnDomain(DOMAINS.T3)) { @@ -235,7 +256,9 @@ async function getRelatedMemoriesForT3(actionSource: string) { } if (textareaElement) { - textareaElement.dataset.supermemories = `\n\nSupermemories of user (only for the reference): ${response.data}` + textareaElement.dataset.supermemories = buildSupermemoryText( + response.data, + ) iconElement.dataset.memoriesData = serializeMemoriesForDataset( response.data, @@ -274,6 +297,10 @@ function updateT3IconFeedback( iconElement.dataset.originalHtml = iconElement.innerHTML } + // Tear down any prior Included Memories popup before rendering new feedback, + // including non-success states that would otherwise leave orphaned listeners. + disposeT3IncludedMemoriesPopup() + const feedbackDiv = document.createElement("div") feedbackDiv.style.cssText = ` display: flex; @@ -296,6 +323,7 @@ function updateT3IconFeedback( if (message === "Included Memories" && iconElement.dataset.memoriesData) { const popup = document.createElement("div") + t3IncludedMemoriesPopup = popup popup.style.cssText = ` position: fixed; bottom: 80px; @@ -409,11 +437,12 @@ function updateT3IconFeedback( popup.style.display = "block" }) - document.addEventListener("click", (e) => { + t3IncludedMemoriesClickHandler = (e: MouseEvent) => { if (!popup.contains(e.target as Node)) { popup.style.display = "none" } - }) + } + document.addEventListener("click", t3IncludedMemoriesClickHandler) content.querySelectorAll("button[data-memory-index]").forEach((button) => { const htmlButton = button as HTMLButtonElement @@ -429,19 +458,17 @@ function updateT3IconFeedback( iconElement.dataset.memoriesData, ) currentMemories.splice(index, 1) - - // Injected prompt keeps its existing joined-text form; the popup's - // own data is stored as JSON so comma-bearing memories stay intact. - const updatedMemories = currentMemories.join(" ,") + const renumberedMemories = renumberIncludedMemories(currentMemories) iconElement.dataset.memoriesData = - serializeMemoriesForDataset(currentMemories) + serializeMemoriesForDataset(renumberedMemories) const textareaElement = (document.querySelector("textarea") as HTMLTextAreaElement) || (document.querySelector('div[contenteditable="true"]') as HTMLElement) if (textareaElement) { - textareaElement.dataset.supermemories = `\n\nSupermemories of user (only for the reference): ${updatedMemories}` + textareaElement.dataset.supermemories = + buildSupermemoryText(renumberedMemories) } content @@ -449,27 +476,26 @@ function updateT3IconFeedback( .forEach((btn, newIndex) => { const htmlBtn = btn as HTMLButtonElement htmlBtn.dataset.memoryIndex = newIndex.toString() + const label = htmlBtn.previousElementSibling + if (label && renumberedMemories[newIndex]) { + label.textContent = renumberedMemories[newIndex].trim() + } }) - if (currentMemories.length <= 1) { + if (shouldClearIncludedMemories(renumberedMemories.length)) { 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) - } + disposeT3IncludedMemoriesPopup() } }) }) - setTimeout(() => { - if (document.body.contains(popup)) { - document.body.removeChild(popup) - } + t3IncludedMemoriesTimeout = setTimeout(() => { + disposeT3IncludedMemoriesPopup() }, 300000) } From 0c8bfc28af465daa22658fdd4edecb0b29040ee5 Mon Sep 17 00:00:00 2001 From: James Yang Date: Thu, 6 Aug 2026 02:33:55 -0400 Subject: [PATCH 3/3] refactor(extension): trim T3 Included Memories follow-up Drop the one-line shouldClear helper, collapse popup dispose state into one object, and exit early on empty removal instead of reindexing dead UI. --- .../content/memory-suggestion.test.ts | 9 -- .../entrypoints/content/memory-suggestion.ts | 17 +--- .../entrypoints/content/t3.ts | 96 +++++++++---------- 3 files changed, 45 insertions(+), 77 deletions(-) diff --git a/apps/browser-extension/entrypoints/content/memory-suggestion.test.ts b/apps/browser-extension/entrypoints/content/memory-suggestion.test.ts index f2519769b..278cde70f 100644 --- a/apps/browser-extension/entrypoints/content/memory-suggestion.test.ts +++ b/apps/browser-extension/entrypoints/content/memory-suggestion.test.ts @@ -4,7 +4,6 @@ import { parseMemoriesFromDataset, renumberIncludedMemories, serializeMemoriesForDataset, - shouldClearIncludedMemories, } from "./memory-suggestion" describe("memory dataset serialization", () => { @@ -49,9 +48,7 @@ describe("memory dataset serialization", () => { parseMemoriesFromDataset(serializeMemoriesForDataset("solo")), ).toEqual(["solo"]) }) -}) -describe("included memories removal lifecycle", () => { it("renumbers remaining memories after a removal", () => { const remaining = renumberIncludedMemories([ "2. Prefers TypeScript, strict mode \n", @@ -65,10 +62,4 @@ describe("included memories removal lifecycle", () => { "\n\nSupermemories of user (only for the reference): 1. Prefers TypeScript, strict mode \n2. Uses Biome", ) }) - - it("clears included memories only when the list is empty", () => { - expect(shouldClearIncludedMemories(2)).toBe(false) - expect(shouldClearIncludedMemories(1)).toBe(false) - expect(shouldClearIncludedMemories(0)).toBe(true) - }) }) diff --git a/apps/browser-extension/entrypoints/content/memory-suggestion.ts b/apps/browser-extension/entrypoints/content/memory-suggestion.ts index 4ddca3e25..fd7ef0db4 100644 --- a/apps/browser-extension/entrypoints/content/memory-suggestion.ts +++ b/apps/browser-extension/entrypoints/content/memory-suggestion.ts @@ -58,27 +58,14 @@ export function parseMemoriesFromDataset( .filter((memory) => memory.length > 0 && memory !== ",") } -/** - * Background search labels each memory as `"N. \n"`. After a popup - * removal the surviving entries keep stale numbers, so strip the prefix and - * renumber from 1 to match a fresh fetch. - */ +/** Strip stale `N. ` prefixes and renumber after a popup removal. */ export function renumberIncludedMemories(memories: string[]): string[] { - return normalizeMemoryList(memories).map((memory, index) => { + return memories.map((memory, index) => { const text = memory.replace(/^\d+\.\s*/, "").replace(/\s+$/, "") return `${index + 1}. ${text} \n` }) } -/** - * After splicing one memory out of the Included Memories list, clear the - * injected prompt only when nothing remains. A `<= 1` guard incorrectly wipes - * the last kept memory when the user removes one of two. - */ -export function shouldClearIncludedMemories(remainingCount: number): boolean { - return remainingCount === 0 -} - export function showMemorySuggestion( platform: string, input: SuggestionInput, diff --git a/apps/browser-extension/entrypoints/content/t3.ts b/apps/browser-extension/entrypoints/content/t3.ts index 12aec7985..1df5b97a8 100644 --- a/apps/browser-extension/entrypoints/content/t3.ts +++ b/apps/browser-extension/entrypoints/content/t3.ts @@ -15,30 +15,24 @@ import { parseMemoriesFromDataset, renumberIncludedMemories, serializeMemoriesForDataset, - shouldClearIncludedMemories, } from "./memory-suggestion" let t3DebounceTimeout: NodeJS.Timeout | null = null let t3RouteObserver: MutationObserver | null = null let t3UrlCheckInterval: NodeJS.Timeout | null = null let t3ObserverThrottle: NodeJS.Timeout | null = null -let t3IncludedMemoriesPopup: HTMLElement | null = null -let t3IncludedMemoriesClickHandler: ((event: MouseEvent) => void) | null = null -let t3IncludedMemoriesTimeout: ReturnType | null = null - -function disposeT3IncludedMemoriesPopup() { - if (t3IncludedMemoriesClickHandler) { - document.removeEventListener("click", t3IncludedMemoriesClickHandler) - t3IncludedMemoriesClickHandler = null - } - if (t3IncludedMemoriesTimeout !== null) { - clearTimeout(t3IncludedMemoriesTimeout) - t3IncludedMemoriesTimeout = null - } - if (t3IncludedMemoriesPopup?.isConnected) { - t3IncludedMemoriesPopup.remove() - } - t3IncludedMemoriesPopup = null +let t3IncludedPopup: { + el: HTMLElement + onClick: (event: MouseEvent) => void + timer: ReturnType +} | null = null + +function disposeT3IncludedPopup() { + if (!t3IncludedPopup) return + document.removeEventListener("click", t3IncludedPopup.onClick) + clearTimeout(t3IncludedPopup.timer) + t3IncludedPopup.el.remove() + t3IncludedPopup = null } export function initializeT3() { @@ -297,9 +291,7 @@ function updateT3IconFeedback( iconElement.dataset.originalHtml = iconElement.innerHTML } - // Tear down any prior Included Memories popup before rendering new feedback, - // including non-success states that would otherwise leave orphaned listeners. - disposeT3IncludedMemoriesPopup() + disposeT3IncludedPopup() const feedbackDiv = document.createElement("div") feedbackDiv.style.cssText = ` @@ -323,7 +315,6 @@ function updateT3IconFeedback( if (message === "Included Memories" && iconElement.dataset.memoriesData) { const popup = document.createElement("div") - t3IncludedMemoriesPopup = popup popup.style.cssText = ` position: fixed; bottom: 80px; @@ -437,66 +428,65 @@ function updateT3IconFeedback( popup.style.display = "block" }) - t3IncludedMemoriesClickHandler = (e: MouseEvent) => { + const onClick = (e: MouseEvent) => { if (!popup.contains(e.target as Node)) { popup.style.display = "none" } } - document.addEventListener("click", t3IncludedMemoriesClickHandler) + document.addEventListener("click", onClick) + t3IncludedPopup = { + el: popup, + onClick, + timer: setTimeout(disposeT3IncludedPopup, 300000), + } 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) - } + htmlButton.parentElement?.remove() - const currentMemories = parseMemoriesFromDataset( + const remainingMemories = parseMemoriesFromDataset( iconElement.dataset.memoriesData, ) - currentMemories.splice(index, 1) - const renumberedMemories = renumberIncludedMemories(currentMemories) - - iconElement.dataset.memoriesData = - serializeMemoriesForDataset(renumberedMemories) + remainingMemories.splice(index, 1) + const remaining = renumberIncludedMemories(remainingMemories) const textareaElement = (document.querySelector("textarea") as HTMLTextAreaElement) || (document.querySelector('div[contenteditable="true"]') as HTMLElement) + + // Only wipe when nothing remains — `<= 1` used to discard the last kept memory. + if (remaining.length === 0) { + if (textareaElement?.dataset.supermemories) { + delete textareaElement.dataset.supermemories + } + delete iconElement.dataset.memoriesData + iconElement.innerHTML = iconElement.dataset.originalHtml || "" + delete iconElement.dataset.originalHtml + disposeT3IncludedPopup() + return + } + + iconElement.dataset.memoriesData = + serializeMemoriesForDataset(remaining) if (textareaElement) { textareaElement.dataset.supermemories = - buildSupermemoryText(renumberedMemories) + buildSupermemoryText(remaining) } content .querySelectorAll("button[data-memory-index]") .forEach((btn, newIndex) => { const htmlBtn = btn as HTMLButtonElement - htmlBtn.dataset.memoryIndex = newIndex.toString() + htmlBtn.dataset.memoryIndex = String(newIndex) const label = htmlBtn.previousElementSibling - if (label && renumberedMemories[newIndex]) { - label.textContent = renumberedMemories[newIndex].trim() + if (label) { + label.textContent = remaining[newIndex].trim() } }) - - if (shouldClearIncludedMemories(renumberedMemories.length)) { - if (textareaElement?.dataset.supermemories) { - delete textareaElement.dataset.supermemories - delete iconElement.dataset.memoriesData - iconElement.innerHTML = iconElement.dataset.originalHtml || "" - delete iconElement.dataset.originalHtml - } - disposeT3IncludedMemoriesPopup() - } }) }) - - t3IncludedMemoriesTimeout = setTimeout(() => { - disposeT3IncludedMemoriesPopup() - }, 300000) } iconElement.innerHTML = ""