From a44d02689efec1899c45ef33469596a036965286 Mon Sep 17 00:00:00 2001 From: fly1d Date: Tue, 11 Aug 2026 10:43:09 +0800 Subject: [PATCH 1/9] feat(memory): track newly formed memories --- src/chrome/src/agent/user-memory.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/chrome/src/agent/user-memory.js b/src/chrome/src/agent/user-memory.js index 7c90a100b..b7fe65ee3 100644 --- a/src/chrome/src/agent/user-memory.js +++ b/src/chrome/src/agent/user-memory.js @@ -290,6 +290,7 @@ export function applyUserMemoryExtractionOperations(storeInput, operations, opts const threshold = Number.isFinite(Number(opts.threshold)) ? Number(opts.threshold) : USER_MEMORY_EXTRACTION_CONFIDENCE_THRESHOLD; let store = normalizeUserMemoryStore(storeInput, { now: ts }); let changed = false; + let created = false; const applied = []; for (const op of Array.isArray(operations) ? operations : []) { if (!op || op.confidence < threshold) continue; @@ -314,10 +315,11 @@ export function applyUserMemoryExtractionOperations(storeInput, operations, opts if (result?.changed) { store = result.store; changed = true; + if (op.op === 'add' && !result.deduped) created = true; applied.push({ op: op.op, id: result.record?.id || op.id }); } } - return { store, changed, applied }; + return { store, changed, created, applied }; } export function createUserMemoryStore(storageArea, opts = {}) { From 6f915d378c950120a4a9f0e209c23fe4061d7fcc Mon Sep 17 00:00:00 2001 From: fly1d Date: Tue, 11 Aug 2026 10:44:19 +0800 Subject: [PATCH 2/9] feat(memory): notify the Chrome side panel --- src/chrome/src/background.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/chrome/src/background.js b/src/chrome/src/background.js index bf3b0d3d0..8dce31837 100644 --- a/src/chrome/src/background.js +++ b/src/chrome/src/background.js @@ -609,6 +609,13 @@ async function applyUserMemoryExtractionOperationsToCurrentStore(jobId, operatio }); } +function notifyUserMemoryCreated() { + chrome.runtime.sendMessage({ + target: 'sidepanel', + action: 'user_memory_created', + }).catch(() => {}); +} + function scheduleUserMemoryExtractionDrain(delayMs = USER_MEMORY_EXTRACTION_DELAY_MS) { if (userMemoryExtractionTimer) clearTimeout(userMemoryExtractionTimer); userMemoryExtractionTimer = setTimeout(() => { @@ -701,7 +708,10 @@ async function drainUserMemoryExtractionQueue() { }); const operations = parseUserMemoryExtractionResult(result?.content || ''); const applied = await applyUserMemoryExtractionOperationsToCurrentStore(job.id, operations); - if (applied.changed) await syncAgentUserMemoryFromStorage(); + if (applied.changed) { + await syncAgentUserMemoryFromStorage(); + if (applied.created) notifyUserMemoryCreated(); + } } catch (error) { if (agent._isCostAllowanceError?.(error)) { await removeUserMemoryExtractionJob(job.id); From 1e0cd27f1c449224087bf4da3e4bc9685d95eb29 Mon Sep 17 00:00:00 2001 From: fly1d Date: Tue, 11 Aug 2026 10:44:27 +0800 Subject: [PATCH 3/9] feat(memory): render the Chrome memory cue --- src/chrome/src/ui/sidepanel.js | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/chrome/src/ui/sidepanel.js b/src/chrome/src/ui/sidepanel.js index af3b94534..5005aa22a 100644 --- a/src/chrome/src/ui/sidepanel.js +++ b/src/chrome/src/ui/sidepanel.js @@ -6879,7 +6879,7 @@ function showBusySlashCommandNotice() { showComposerToast(t('sp.slash.busy_only_oob'), { duration: 5000 }); } -function showComposerToast(message, { duration = 2600 } = {}) { +function showComposerToast(message, { duration = 2600, effect = '' } = {}) { if (!message) return; let toast = document.getElementById('composer-toast'); if (!toast) { @@ -6892,10 +6892,19 @@ function showComposerToast(message, { duration = 2600 } = {}) { } if (isSystemHtml(message)) toast.innerHTML = message.__systemHtml; else toast.textContent = message; + toast.classList.remove('memory-update-cue', 'memory-update-cue-enter'); + if (effect === 'memory') { + toast.classList.add('memory-update-cue'); + if (!globalThis.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches) { + void toast.offsetWidth; + toast.classList.add('memory-update-cue-enter'); + } + } toast.classList.remove('hidden'); clearTimeout(composerToastTimer); composerToastTimer = setTimeout(() => { toast.classList.add('hidden'); + toast.classList.remove('memory-update-cue', 'memory-update-cue-enter'); }, duration); } @@ -8261,6 +8270,13 @@ hydrateRecordingFromBackground(); // --- Listen for Agent Updates --- +chrome.runtime.onMessage.addListener((msg) => { + if (msg?.target !== 'sidepanel' + || msg.action !== 'user_memory_created' + || document.visibilityState === 'hidden') return; + showComposerToast(t('sp.memory.remembered'), { duration: 3200, effect: 'memory' }); +}); + // Recorder broadcasts — independent of the per-tab agent_update flow. // These are intentionally NOT scoped by tabId because the recording banner // is global (a panel on any tab in the window should reflect that a record From 4e27905c43639873f11cba5b8001f80a78695aaf Mon Sep 17 00:00:00 2001 From: fly1d Date: Tue, 11 Aug 2026 10:44:36 +0800 Subject: [PATCH 4/9] style(memory): animate the Chrome memory cue --- src/chrome/styles/sidepanel.css | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/chrome/styles/sidepanel.css b/src/chrome/styles/sidepanel.css index c49448919..78f19a2d7 100644 --- a/src/chrome/styles/sidepanel.css +++ b/src/chrome/styles/sidepanel.css @@ -1170,6 +1170,38 @@ body { display: none; } +.composer-toast.memory-update-cue { + border-color: color-mix(in srgb, var(--success) 48%, var(--border)); + background: color-mix(in srgb, var(--success) 10%, var(--bg-input)); + color: var(--text-primary); +} + +.composer-toast.memory-update-cue-enter { + animation: memory-update-cue-enter 480ms cubic-bezier(0.2, 0.8, 0.2, 1); +} + +@keyframes memory-update-cue-enter { + 0% { + opacity: 0; + transform: translateY(6px); + box-shadow: 0 0 0 0 color-mix(in srgb, var(--success) 34%, transparent); + } + 55% { + opacity: 1; + box-shadow: 0 0 0 4px color-mix(in srgb, var(--success) 8%, transparent); + } + 100% { + transform: translateY(0); + box-shadow: var(--shadow); + } +} + +@media (prefers-reduced-motion: reduce) { + .composer-toast.memory-update-cue-enter { + animation: none; + } +} + /* Page Inspection Banner */ #inspection-banner { display: flex; From 1b2d94dd24b99ef83a4abcb5229b2e1818f44d3d Mon Sep 17 00:00:00 2001 From: fly1d Date: Tue, 11 Aug 2026 10:44:45 +0800 Subject: [PATCH 5/9] feat(memory): mirror new-memory tracking in Firefox --- src/firefox/src/agent/user-memory.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/firefox/src/agent/user-memory.js b/src/firefox/src/agent/user-memory.js index 7c90a100b..b7fe65ee3 100644 --- a/src/firefox/src/agent/user-memory.js +++ b/src/firefox/src/agent/user-memory.js @@ -290,6 +290,7 @@ export function applyUserMemoryExtractionOperations(storeInput, operations, opts const threshold = Number.isFinite(Number(opts.threshold)) ? Number(opts.threshold) : USER_MEMORY_EXTRACTION_CONFIDENCE_THRESHOLD; let store = normalizeUserMemoryStore(storeInput, { now: ts }); let changed = false; + let created = false; const applied = []; for (const op of Array.isArray(operations) ? operations : []) { if (!op || op.confidence < threshold) continue; @@ -314,10 +315,11 @@ export function applyUserMemoryExtractionOperations(storeInput, operations, opts if (result?.changed) { store = result.store; changed = true; + if (op.op === 'add' && !result.deduped) created = true; applied.push({ op: op.op, id: result.record?.id || op.id }); } } - return { store, changed, applied }; + return { store, changed, created, applied }; } export function createUserMemoryStore(storageArea, opts = {}) { From f57ffbc3777413122659b5285f47194cadbb1425 Mon Sep 17 00:00:00 2001 From: fly1d Date: Tue, 11 Aug 2026 10:45:06 +0800 Subject: [PATCH 6/9] feat(memory): notify the Firefox side panel --- src/firefox/src/background.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/firefox/src/background.js b/src/firefox/src/background.js index fe96b32b2..8f26a523c 100644 --- a/src/firefox/src/background.js +++ b/src/firefox/src/background.js @@ -572,6 +572,13 @@ async function applyUserMemoryExtractionOperationsToCurrentStore(jobId, operatio }); } +function notifyUserMemoryCreated() { + browser.runtime.sendMessage({ + target: 'sidepanel', + action: 'user_memory_created', + }).catch(() => {}); +} + function scheduleUserMemoryExtractionDrain(delayMs = USER_MEMORY_EXTRACTION_DELAY_MS) { if (userMemoryExtractionTimer) clearTimeout(userMemoryExtractionTimer); userMemoryExtractionTimer = setTimeout(() => { @@ -664,7 +671,10 @@ async function drainUserMemoryExtractionQueue() { }); const operations = parseUserMemoryExtractionResult(result?.content || ''); const applied = await applyUserMemoryExtractionOperationsToCurrentStore(job.id, operations); - if (applied.changed) await syncAgentUserMemoryFromStorage(); + if (applied.changed) { + await syncAgentUserMemoryFromStorage(); + if (applied.created) notifyUserMemoryCreated(); + } } catch (error) { if (agent._isCostAllowanceError?.(error)) { await removeUserMemoryExtractionJob(job.id); From 6b4af4adc6dbadaa0ed2f1b0654d6728caf5fa40 Mon Sep 17 00:00:00 2001 From: fly1d Date: Tue, 11 Aug 2026 10:45:15 +0800 Subject: [PATCH 7/9] feat(memory): render the Firefox memory cue --- src/firefox/src/ui/sidepanel.js | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/firefox/src/ui/sidepanel.js b/src/firefox/src/ui/sidepanel.js index 32864b946..5594607a4 100644 --- a/src/firefox/src/ui/sidepanel.js +++ b/src/firefox/src/ui/sidepanel.js @@ -6725,7 +6725,7 @@ function showBusySlashCommandNotice() { showComposerToast(t('sp.slash.busy_only_oob'), { duration: 5000 }); } -function showComposerToast(message, { duration = 2600 } = {}) { +function showComposerToast(message, { duration = 2600, effect = '' } = {}) { if (!message) return; let toast = document.getElementById('composer-toast'); if (!toast) { @@ -6738,10 +6738,19 @@ function showComposerToast(message, { duration = 2600 } = {}) { } if (isSystemHtml(message)) toast.innerHTML = message.__systemHtml; else toast.textContent = message; + toast.classList.remove('memory-update-cue', 'memory-update-cue-enter'); + if (effect === 'memory') { + toast.classList.add('memory-update-cue'); + if (!globalThis.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches) { + void toast.offsetWidth; + toast.classList.add('memory-update-cue-enter'); + } + } toast.classList.remove('hidden'); clearTimeout(composerToastTimer); composerToastTimer = setTimeout(() => { toast.classList.add('hidden'); + toast.classList.remove('memory-update-cue', 'memory-update-cue-enter'); }, duration); } @@ -7889,6 +7898,13 @@ async function sendMessage(extraChatParams = {}) { // --- Listen for Agent Updates --- +browser.runtime.onMessage.addListener((msg) => { + if (msg?.target !== 'sidepanel' + || msg.action !== 'user_memory_created' + || document.visibilityState === 'hidden') return; + showComposerToast(t('sp.memory.remembered'), { duration: 3200, effect: 'memory' }); +}); + browser.runtime.onMessage.addListener((msg) => { if (msg?.target !== 'sidepanel' || msg.action !== 'context_menu_prompt') return; acceptContextMenuPrompt(msg.prompt || msg); From e863994a64b0dc869f5d49757e8dc464ebf8989a Mon Sep 17 00:00:00 2001 From: fly1d Date: Tue, 11 Aug 2026 10:45:24 +0800 Subject: [PATCH 8/9] style(memory): animate the Firefox memory cue --- src/firefox/styles/sidepanel.css | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/firefox/styles/sidepanel.css b/src/firefox/styles/sidepanel.css index dbb1e058a..d3357b1af 100644 --- a/src/firefox/styles/sidepanel.css +++ b/src/firefox/styles/sidepanel.css @@ -1048,6 +1048,38 @@ body { display: none; } +.composer-toast.memory-update-cue { + border-color: color-mix(in srgb, var(--success) 48%, var(--border)); + background: color-mix(in srgb, var(--success) 10%, var(--bg-input)); + color: var(--text-primary); +} + +.composer-toast.memory-update-cue-enter { + animation: memory-update-cue-enter 480ms cubic-bezier(0.2, 0.8, 0.2, 1); +} + +@keyframes memory-update-cue-enter { + 0% { + opacity: 0; + transform: translateY(6px); + box-shadow: 0 0 0 0 color-mix(in srgb, var(--success) 34%, transparent); + } + 55% { + opacity: 1; + box-shadow: 0 0 0 4px color-mix(in srgb, var(--success) 8%, transparent); + } + 100% { + transform: translateY(0); + box-shadow: var(--shadow); + } +} + +@media (prefers-reduced-motion: reduce) { + .composer-toast.memory-update-cue-enter { + animation: none; + } +} + /* Page Inspection Banner */ #inspection-banner { display: flex; From 0c02fc6d87ffcb30c6102fad1277c26eee0d657d Mon Sep 17 00:00:00 2001 From: fly1d Date: Tue, 11 Aug 2026 10:45:32 +0800 Subject: [PATCH 9/9] test(memory): cover newly formed memory cues --- test/run.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/test/run.js b/test/run.js index cdfb069f5..c9335e7dd 100644 --- a/test/run.js +++ b/test/run.js @@ -2467,6 +2467,7 @@ test('user memory extraction applies only high-confidence safe operations', () = assert.equal(parsed.length, 3, `${label}: parser should drop none operations`); const applied = memory.applyUserMemoryExtractionOperations(base, parsed, { now: 200, threshold: 0.85 }); assert.equal(applied.changed, true, `${label}: high-confidence update should apply`); + assert.equal(applied.created, false, `${label}: updates should not report a newly formed memory`); assert.equal(applied.store.records.length, 1, `${label}: low-confidence and sensitive adds should not apply`); assert.equal(applied.store.records[0].text, 'Prefer concise explanations.', `${label}: update text`); assert.equal(applied.store.records[0].kind, 'workflow_preference', `${label}: update kind`); @@ -2475,6 +2476,17 @@ test('user memory extraction applies only high-confidence safe operations', () = { op: 'archive', id: 'm1', text: '', kind: 'preference', confidence: 0.9 }, ], { now: 300 }); assert.equal(memory.activeUserMemoryRecords(archived.store).length, 0, `${label}: archive op should remove active memory`); + assert.equal(archived.created, false, `${label}: archive should not report a newly formed memory`); + + const created = memory.applyUserMemoryExtractionOperations(base, [ + { op: 'add', text: 'Use numbered implementation steps.', kind: 'workflow_preference', confidence: 0.95 }, + ], { now: 400 }); + assert.equal(created.created, true, `${label}: a new add should report a newly formed memory`); + const deduped = memory.applyUserMemoryExtractionOperations(created.store, [ + { op: 'add', text: 'Use numbered implementation steps.', kind: 'workflow_preference', confidence: 0.96 }, + ], { now: 500 }); + assert.equal(deduped.changed, true, `${label}: duplicate adds may refresh the existing record`); + assert.equal(deduped.created, false, `${label}: duplicate adds should not report a newly formed memory`); const extractionMessages = memory.buildUserMemoryExtractionMessages({ userText: 'Remember that I prefer terse replies.', @@ -2507,6 +2519,7 @@ test('user memory browser wiring is mirrored and non-blocking', () => { ]) { const background = fs.readFileSync(path.join(ROOT, prefix, 'src/background.js'), 'utf8'); const sidepanel = fs.readFileSync(path.join(ROOT, prefix, 'src/ui/sidepanel.js'), 'utf8'); + const sidepanelCss = fs.readFileSync(path.join(ROOT, prefix, 'styles/sidepanel.css'), 'utf8'); const settingsHtml = fs.readFileSync(path.join(ROOT, prefix, 'src/ui/settings.html'), 'utf8'); const settingsJs = fs.readFileSync(path.join(ROOT, prefix, 'src/ui/settings.js'), 'utf8'); const locale = fs.readFileSync(path.join(ROOT, prefix, 'src/ui/locales/en.js'), 'utf8'); @@ -2558,6 +2571,8 @@ test('user memory browser wiring is mirrored and non-blocking', () => { assert.match(background, /agent\._isCostAllowanceError\?\.\(error\)/, `${label}: extraction cost limit should be silent`); assert.match(background, /async function markUserMemoryExtractionJobFailed\(jobId\)[\s\S]*attempts: attempts \+ 1/, `${label}: extraction jobs should retry once`); assert.match(background, /await markUserMemoryExtractionJobFailed\(job\.id\);\s*scheduleUserMemoryExtractionDrain\(USER_MEMORY_EXTRACTION_RETRY_DELAY_MS\);\s*return;/, `${label}: retryable extraction failures should reschedule the drain with a backoff delay`); + assert.match(background, /function notifyUserMemoryCreated\(\)[\s\S]*target: 'sidepanel',[\s\S]*action: 'user_memory_created'/, `${label}: background should publish a sidepanel cue for newly formed memory`); + assert.match(background, /if \(applied\.changed\) \{\s*await syncAgentUserMemoryFromStorage\(\);\s*if \(applied\.created\) notifyUserMemoryCreated\(\);/, `${label}: the visual cue should only follow a persisted new memory`); assert.match(sidepanel, /usage: '\/memory \[--add \| --forget \]'/, `${label}: canonical /memory usage missing`); assert.match(sidepanel, /value: '--add'[\s\S]*?action: 'add'[\s\S]*?takesRemainder: true/, `${label}: /memory --add metadata missing`); @@ -2571,6 +2586,10 @@ test('user memory browser wiring is mirrored and non-blocking', () => { assert.match(sidepanel, /command\.value === '\/memory' && action === 'forget'[\s\S]*?await forgetUserMemory\(payload, tabId\)/, `${label}: /memory --forget handler missing`); assert.match(sidepanel, /card\.dataset\.memorySource = scheduledJobId[\s\S]*'scheduled_clarification'[\s\S]*'form_confirmation'[\s\S]*'clarification_response'/, `${label}: clarify cards should tag memory source`); assert.match(sidepanel, /clarifyPayload\.memorySource = card\.dataset\.memorySource/, `${label}: clarify responses should include memory source metadata`); + assert.match(sidepanel, /msg\.action !== 'user_memory_created'[\s\S]*document\.visibilityState === 'hidden'[\s\S]*showComposerToast\(t\('sp\.memory\.remembered'\), \{ duration: 3200, effect: 'memory' \}\)/, `${label}: visible sidepanels should show the localized memory cue`); + assert.match(sidepanel, /function showComposerToast\(message, \{ duration = 2600, effect = '' \} = \{\}\)[\s\S]*toast\.classList\.add\('memory-update-cue'\)/, `${label}: composer toast should support the memory effect`); + assert.match(sidepanelCss, /\.composer-toast\.memory-update-cue-enter[\s\S]*@keyframes memory-update-cue-enter/, `${label}: memory cue animation missing`); + assert.match(sidepanelCss, /@media \(prefers-reduced-motion: reduce\) \{[\s\S]*?\.composer-toast\.memory-update-cue-enter \{[\s\S]*?animation: none;/, `${label}: memory cue should respect reduced motion`); for (const id of ['toggle-user-memory-enabled', 'toggle-user-memory-auto', 'toggle-user-memory-form', 'input-user-memory-max-chars', 'user-memory-list', 'btn-export-user-memory', 'btn-clear-user-memory', 'user-memory-import-text', 'btn-import-user-memory']) { assert.match(settingsHtml, new RegExp(`id="${id}"`), `${label}: settings HTML missing ${id}`);