Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/chrome/src/agent/user-memory.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 = {}) {
Expand Down
12 changes: 11 additions & 1 deletion src/chrome/src/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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);
Expand Down
18 changes: 17 additions & 1 deletion src/chrome/src/ui/sidepanel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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);
}

Expand Down Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions src/chrome/styles/sidepanel.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 3 additions & 1 deletion src/firefox/src/agent/user-memory.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 = {}) {
Expand Down
12 changes: 11 additions & 1 deletion src/firefox/src/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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);
Expand Down
18 changes: 17 additions & 1 deletion src/firefox/src/ui/sidepanel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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);
}

Expand Down Expand Up @@ -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);
Expand Down
32 changes: 32 additions & 0 deletions src/firefox/styles/sidepanel.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
19 changes: 19 additions & 0 deletions test/run.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand All @@ -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.',
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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 <text> \| --forget <id>\]'/, `${label}: canonical /memory usage missing`);
assert.match(sidepanel, /value: '--add'[\s\S]*?action: 'add'[\s\S]*?takesRemainder: true/, `${label}: /memory --add metadata missing`);
Expand All @@ -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}`);
Expand Down
Loading