mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-07 16:09:43 +02:00
Merge pull request #178 from Lint111/agent/split-notification-noise
fix(notifications): quiet lifecycle hook noise
This commit is contained in:
@@ -1848,7 +1848,7 @@
|
|||||||
<input type="checkbox" id="eventIdleAudio">
|
<input type="checkbox" id="eventIdleAudio">
|
||||||
|
|
||||||
<div class="event-label">Response complete</div>
|
<div class="event-label">Response complete</div>
|
||||||
<input type="checkbox" id="eventStopEnabled" checked>
|
<input type="checkbox" id="eventStopEnabled">
|
||||||
<input type="checkbox" id="eventStopBrowser">
|
<input type="checkbox" id="eventStopBrowser">
|
||||||
<input type="checkbox" id="eventStopPush">
|
<input type="checkbox" id="eventStopPush">
|
||||||
<input type="checkbox" id="eventStopAudio">
|
<input type="checkbox" id="eventStopAudio">
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
* 5. Audio alerts (Web Audio API beep, user-opt-in)
|
* 5. Audio alerts (Web Audio API beep, user-opt-in)
|
||||||
*
|
*
|
||||||
* Features:
|
* Features:
|
||||||
* - Per-event-type preferences (enabled, browser, audio, push) with v1→v4 migration
|
* - Per-event-type preferences (enabled, browser, audio, push) with v1→v5 migration
|
||||||
* - Device-specific defaults (notifications disabled on mobile by default)
|
* - Device-specific defaults (notifications disabled on mobile by default)
|
||||||
* - 5s notification grouping window to batch rapid-fire events
|
* - 5s notification grouping window to batch rapid-fire events
|
||||||
* - 100-notification cap with oldest eviction
|
* - 100-notification cap with oldest eviction
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
* @param {CodemanApp} app - Reference to the main app instance
|
* @param {CodemanApp} app - Reference to the main app instance
|
||||||
*
|
*
|
||||||
* @dependency constants.js (STUCK_THRESHOLD_DEFAULT_MS, timing constants)
|
* @dependency constants.js (STUCK_THRESHOLD_DEFAULT_MS, timing constants)
|
||||||
* @dependency mobile-handlers.js (MobileDetection.getDeviceType for device-specific defaults)
|
* @dependency mobile-handlers.js (MobileDetection stable handheld identity/device type)
|
||||||
* @loadorder 4 of 15 — loaded after voice-input.js, before keyboard-accessory.js
|
* @loadorder 4 of 15 — loaded after voice-input.js, before keyboard-accessory.js
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -65,12 +65,19 @@ class NotificationManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
loadPreferences() {
|
_usesMobilePreferences() {
|
||||||
|
return (
|
||||||
|
MobileDetection.isHandheldDevice?.() ??
|
||||||
|
MobileDetection.getDeviceType() === 'mobile'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getDefaultPreferences() {
|
||||||
const defaultEventTypes = {
|
const defaultEventTypes = {
|
||||||
permission_prompt: { enabled: true, browser: true, audio: true, push: false },
|
permission_prompt: { enabled: true, browser: true, audio: true, push: false },
|
||||||
elicitation_dialog: { enabled: true, browser: true, audio: true, push: false },
|
elicitation_dialog: { enabled: true, browser: true, audio: true, push: false },
|
||||||
idle_prompt: { enabled: true, browser: true, audio: false, push: false },
|
idle_prompt: { enabled: true, browser: true, audio: false, push: false },
|
||||||
stop: { enabled: true, browser: false, audio: false, push: false },
|
stop: { enabled: false, browser: false, audio: false, push: false },
|
||||||
session_error: { enabled: true, browser: true, audio: false, push: false },
|
session_error: { enabled: true, browser: true, audio: false, push: false },
|
||||||
respawn_cycle: { enabled: true, browser: false, audio: false, push: false },
|
respawn_cycle: { enabled: true, browser: false, audio: false, push: false },
|
||||||
token_milestone: { enabled: true, browser: false, audio: false, push: false },
|
token_milestone: { enabled: true, browser: false, audio: false, push: false },
|
||||||
@@ -80,8 +87,8 @@ class NotificationManager {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Device-specific defaults: mobile has notifications disabled by default
|
// Device-specific defaults: mobile has notifications disabled by default
|
||||||
const isMobile = MobileDetection.getDeviceType() === 'mobile';
|
const isMobile = this._usesMobilePreferences();
|
||||||
const defaults = {
|
return {
|
||||||
enabled: !isMobile, // Disabled on mobile by default
|
enabled: !isMobile, // Disabled on mobile by default
|
||||||
browserNotifications: !isMobile,
|
browserNotifications: !isMobile,
|
||||||
audioAlerts: false,
|
audioAlerts: false,
|
||||||
@@ -92,51 +99,97 @@ class NotificationManager {
|
|||||||
muteInfo: false,
|
muteInfo: false,
|
||||||
// Per-event-type preferences
|
// Per-event-type preferences
|
||||||
eventTypes: defaultEventTypes,
|
eventTypes: defaultEventTypes,
|
||||||
_version: 4,
|
_version: 5,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the complete v1→v5 migration to either local or server-hydrated
|
||||||
|
* preferences. Keeping one normalization path prevents fresh browsers from
|
||||||
|
* reviving retired drawer-only hook defaults.
|
||||||
|
*/
|
||||||
|
normalizePreferences(rawPreferences) {
|
||||||
|
const defaults = this.getDefaultPreferences();
|
||||||
|
if (
|
||||||
|
!rawPreferences ||
|
||||||
|
typeof rawPreferences !== 'object' ||
|
||||||
|
Array.isArray(rawPreferences)
|
||||||
|
) {
|
||||||
|
return defaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefs = {
|
||||||
|
...rawPreferences,
|
||||||
|
eventTypes:
|
||||||
|
rawPreferences.eventTypes &&
|
||||||
|
typeof rawPreferences.eventTypes === 'object' &&
|
||||||
|
!Array.isArray(rawPreferences.eventTypes)
|
||||||
|
? Object.fromEntries(
|
||||||
|
Object.entries(rawPreferences.eventTypes).map(([key, value]) => [
|
||||||
|
key,
|
||||||
|
value && typeof value === 'object' ? { ...value } : value,
|
||||||
|
])
|
||||||
|
)
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
const version = Number.isInteger(prefs._version) ? prefs._version : 0;
|
||||||
|
|
||||||
|
// Migrate: v1 had browserNotifications defaulting to false
|
||||||
|
if (version < 2) {
|
||||||
|
prefs.browserNotifications = true;
|
||||||
|
}
|
||||||
|
// Migrate: v2 -> v3 adds eventTypes
|
||||||
|
if (version < 3) {
|
||||||
|
prefs.eventTypes = { ...defaults.eventTypes };
|
||||||
|
}
|
||||||
|
// Migrate: v3 -> v4 adds push field to all eventTypes
|
||||||
|
if (version < 4 && prefs.eventTypes) {
|
||||||
|
for (const key of Object.keys(prefs.eventTypes)) {
|
||||||
|
if (prefs.eventTypes[key] && prefs.eventTypes[key].push === undefined) {
|
||||||
|
prefs.eventTypes[key].push = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Migrate: v4 -> v5 removes the drawer-only Response Complete default.
|
||||||
|
// Preserve users who opted into any external delivery channel.
|
||||||
|
if (version < 5) {
|
||||||
|
const stopPref = prefs.eventTypes?.stop;
|
||||||
|
if (
|
||||||
|
stopPref?.enabled === true &&
|
||||||
|
!stopPref.browser &&
|
||||||
|
!stopPref.audio &&
|
||||||
|
!stopPref.push
|
||||||
|
) {
|
||||||
|
stopPref.enabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...defaults,
|
||||||
|
...prefs,
|
||||||
|
eventTypes: { ...defaults.eventTypes, ...prefs.eventTypes },
|
||||||
|
_version: 5,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
loadPreferences() {
|
||||||
try {
|
try {
|
||||||
const storageKey = this.getStorageKey();
|
const storageKey = this.getStorageKey();
|
||||||
const saved = localStorage.getItem(storageKey);
|
const saved = localStorage.getItem(storageKey);
|
||||||
if (saved) {
|
if (saved) {
|
||||||
const prefs = JSON.parse(saved);
|
const normalized = this.normalizePreferences(JSON.parse(saved));
|
||||||
// Migrate: v1 had browserNotifications defaulting to false
|
localStorage.setItem(storageKey, JSON.stringify(normalized));
|
||||||
if (!prefs._version || prefs._version < 2) {
|
return normalized;
|
||||||
prefs.browserNotifications = true;
|
|
||||||
prefs._version = 2;
|
|
||||||
}
|
|
||||||
// Migrate: v2 -> v3 adds eventTypes
|
|
||||||
if (prefs._version < 3) {
|
|
||||||
prefs.eventTypes = defaultEventTypes;
|
|
||||||
prefs._version = 3;
|
|
||||||
localStorage.setItem(storageKey, JSON.stringify(prefs));
|
|
||||||
}
|
|
||||||
// Migrate: v3 -> v4 adds push field to all eventTypes
|
|
||||||
if (prefs._version < 4) {
|
|
||||||
if (prefs.eventTypes) {
|
|
||||||
for (const key of Object.keys(prefs.eventTypes)) {
|
|
||||||
if (prefs.eventTypes[key] && prefs.eventTypes[key].push === undefined) {
|
|
||||||
prefs.eventTypes[key].push = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
prefs._version = 4;
|
|
||||||
localStorage.setItem(storageKey, JSON.stringify(prefs));
|
|
||||||
}
|
|
||||||
// Merge with defaults to ensure all eventTypes exist
|
|
||||||
return {
|
|
||||||
...defaults,
|
|
||||||
...prefs,
|
|
||||||
eventTypes: { ...defaultEventTypes, ...prefs.eventTypes },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
} catch (_e) { /* ignore */ }
|
} catch (_e) { /* ignore */ }
|
||||||
return defaults;
|
return this.getDefaultPreferences();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get storage key for notification prefs (device-specific)
|
// Get storage key for notification prefs (device-specific)
|
||||||
getStorageKey() {
|
getStorageKey() {
|
||||||
const isMobile = MobileDetection.getDeviceType() === 'mobile';
|
return this._usesMobilePreferences()
|
||||||
return isMobile ? 'codeman-notification-prefs-mobile' : 'codeman-notification-prefs';
|
? 'codeman-notification-prefs-mobile'
|
||||||
|
: 'codeman-notification-prefs';
|
||||||
}
|
}
|
||||||
|
|
||||||
savePreferences() {
|
savePreferences() {
|
||||||
@@ -163,8 +216,10 @@ class NotificationManager {
|
|||||||
'exit-gate': 'ralph_complete',
|
'exit-gate': 'ralph_complete',
|
||||||
'subagent-spawn': 'subagent_spawn',
|
'subagent-spawn': 'subagent_spawn',
|
||||||
'subagent-complete': 'subagent_complete',
|
'subagent-complete': 'subagent_complete',
|
||||||
'hook-teammate-idle': 'idle_prompt',
|
// Team lifecycle hooks are agent activity, not session-idle/stop alerts.
|
||||||
'hook-task-completed': 'stop',
|
// Reuse the existing opt-in agent categories instead of making them noisy.
|
||||||
|
'hook-teammate-idle': 'subagent_spawn',
|
||||||
|
'hook-task-completed': 'subagent_complete',
|
||||||
};
|
};
|
||||||
const eventTypeKey = categoryToEventType[category] || category;
|
const eventTypeKey = categoryToEventType[category] || category;
|
||||||
|
|
||||||
|
|||||||
@@ -410,7 +410,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
document.getElementById('eventIdleAudio').checked = idlePref.audio ?? false;
|
document.getElementById('eventIdleAudio').checked = idlePref.audio ?? false;
|
||||||
// Response complete (stop)
|
// Response complete (stop)
|
||||||
const stopPref = eventTypes.stop || {};
|
const stopPref = eventTypes.stop || {};
|
||||||
document.getElementById('eventStopEnabled').checked = stopPref.enabled ?? true;
|
document.getElementById('eventStopEnabled').checked = stopPref.enabled ?? false;
|
||||||
document.getElementById('eventStopBrowser').checked = stopPref.browser ?? false;
|
document.getElementById('eventStopBrowser').checked = stopPref.browser ?? false;
|
||||||
document.getElementById('eventStopPush').checked = stopPref.push ?? false;
|
document.getElementById('eventStopPush').checked = stopPref.push ?? false;
|
||||||
document.getElementById('eventStopAudio').checked = stopPref.audio ?? false;
|
document.getElementById('eventStopAudio').checked = stopPref.audio ?? false;
|
||||||
@@ -1589,7 +1589,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
audio: document.getElementById('eventSubagentAudio').checked,
|
audio: document.getElementById('eventSubagentAudio').checked,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
_version: 4,
|
_version: 5,
|
||||||
};
|
};
|
||||||
if (this.notificationManager) {
|
if (this.notificationManager) {
|
||||||
this.notificationManager.preferences = notifPrefsToSave;
|
this.notificationManager.preferences = notifPrefsToSave;
|
||||||
@@ -2295,7 +2295,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (notificationPreferences && this.notificationManager) {
|
if (notificationPreferences && this.notificationManager) {
|
||||||
const localNotifPrefs = localStorage.getItem(this.notificationManager.getStorageKey());
|
const localNotifPrefs = localStorage.getItem(this.notificationManager.getStorageKey());
|
||||||
if (!localNotifPrefs) {
|
if (!localNotifPrefs) {
|
||||||
this.notificationManager.preferences = notificationPreferences;
|
this.notificationManager.preferences =
|
||||||
|
this.notificationManager.normalizePreferences(notificationPreferences);
|
||||||
this.notificationManager.savePreferences();
|
this.notificationManager.savePreferences();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { JSDOM } from 'jsdom';
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
const SOURCE = readFileSync(new URL('../src/web/public/notification-manager.js', import.meta.url), 'utf8');
|
||||||
|
|
||||||
|
type EventPreference = {
|
||||||
|
enabled: boolean;
|
||||||
|
browser: boolean;
|
||||||
|
audio: boolean;
|
||||||
|
push: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NotificationPreferences = {
|
||||||
|
enabled: boolean;
|
||||||
|
eventTypes: Record<string, EventPreference>;
|
||||||
|
_version: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Manager = {
|
||||||
|
preferences: NotificationPreferences;
|
||||||
|
notifications: unknown[];
|
||||||
|
getStorageKey: () => string;
|
||||||
|
normalizePreferences: (preferences: Record<string, unknown>) => NotificationPreferences;
|
||||||
|
notify: (notification: Record<string, unknown>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openWindows: JSDOM[] = [];
|
||||||
|
|
||||||
|
function loadManager(
|
||||||
|
saved?: Record<string, unknown>,
|
||||||
|
device: { deviceType?: string; handheld?: boolean } = {}
|
||||||
|
): { dom: JSDOM; manager: Manager } {
|
||||||
|
const dom = new JSDOM(
|
||||||
|
'<!doctype html><body><span id="notifBadge"></span><div id="notifList"></div><div id="notifEmpty"></div></body>',
|
||||||
|
{
|
||||||
|
url: 'http://localhost/',
|
||||||
|
runScripts: 'outside-only',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
openWindows.push(dom);
|
||||||
|
const win = dom.window as unknown as Window &
|
||||||
|
typeof globalThis & {
|
||||||
|
MobileDetection: {
|
||||||
|
getDeviceType: () => string;
|
||||||
|
isHandheldDevice?: () => boolean;
|
||||||
|
};
|
||||||
|
STUCK_THRESHOLD_DEFAULT_MS: number;
|
||||||
|
GROUPING_TIMEOUT_MS: number;
|
||||||
|
NOTIFICATION_LIST_CAP: number;
|
||||||
|
};
|
||||||
|
win.MobileDetection = {
|
||||||
|
getDeviceType: () => device.deviceType ?? 'desktop',
|
||||||
|
...(typeof device.handheld === 'boolean' ? { isHandheldDevice: () => device.handheld === true } : {}),
|
||||||
|
};
|
||||||
|
win.STUCK_THRESHOLD_DEFAULT_MS = 600_000;
|
||||||
|
win.GROUPING_TIMEOUT_MS = 5_000;
|
||||||
|
win.NOTIFICATION_LIST_CAP = 100;
|
||||||
|
win.requestAnimationFrame = ((callback: FrameRequestCallback) => {
|
||||||
|
callback(0);
|
||||||
|
return 1;
|
||||||
|
}) as typeof requestAnimationFrame;
|
||||||
|
|
||||||
|
if (saved) {
|
||||||
|
win.localStorage.setItem('codeman-notification-prefs', JSON.stringify(saved));
|
||||||
|
}
|
||||||
|
|
||||||
|
win.eval(`
|
||||||
|
${SOURCE}
|
||||||
|
window.__testNotificationManager = NotificationManager;
|
||||||
|
`);
|
||||||
|
const NotificationManager = (
|
||||||
|
win as unknown as {
|
||||||
|
__testNotificationManager: new (app: { sessions: Map<unknown, unknown> }) => Manager;
|
||||||
|
}
|
||||||
|
).__testNotificationManager;
|
||||||
|
const manager = new NotificationManager({ sessions: new Map() }) as Manager;
|
||||||
|
return { dom, manager };
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const dom of openWindows.splice(0)) dom.window.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('notification noise defaults', () => {
|
||||||
|
it('keeps response-complete and team lifecycle drawer entries opt-in', () => {
|
||||||
|
const { manager } = loadManager();
|
||||||
|
expect(manager.preferences.eventTypes.stop.enabled).toBe(false);
|
||||||
|
|
||||||
|
for (const category of ['hook-stop', 'hook-teammate-idle', 'hook-task-completed']) {
|
||||||
|
manager.notify({
|
||||||
|
urgency: 'info',
|
||||||
|
category,
|
||||||
|
sessionId: 'session-1',
|
||||||
|
sessionName: 'session',
|
||||||
|
title: category,
|
||||||
|
message: category,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(manager.notifications).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('migrates the old drawer-only Stop default but preserves explicit delivery', () => {
|
||||||
|
const quietV4 = {
|
||||||
|
enabled: true,
|
||||||
|
eventTypes: {
|
||||||
|
stop: { enabled: true, browser: false, audio: false, push: false },
|
||||||
|
},
|
||||||
|
_version: 4,
|
||||||
|
};
|
||||||
|
const { manager: quietManager } = loadManager(quietV4);
|
||||||
|
expect(quietManager.preferences.eventTypes.stop.enabled).toBe(false);
|
||||||
|
expect(quietManager.preferences._version).toBe(5);
|
||||||
|
|
||||||
|
const browserV4 = {
|
||||||
|
enabled: true,
|
||||||
|
eventTypes: {
|
||||||
|
stop: { enabled: true, browser: true, audio: false, push: false },
|
||||||
|
},
|
||||||
|
_version: 4,
|
||||||
|
};
|
||||||
|
const { manager: browserManager } = loadManager(browserV4);
|
||||||
|
expect(browserManager.preferences.eventTypes.stop.enabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes server-hydrated v4 preferences through the same quiet migration', () => {
|
||||||
|
const { manager } = loadManager();
|
||||||
|
manager.preferences = manager.normalizePreferences({
|
||||||
|
enabled: true,
|
||||||
|
eventTypes: {
|
||||||
|
stop: { enabled: true, browser: false, audio: false, push: false },
|
||||||
|
},
|
||||||
|
_version: 4,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(manager.preferences.eventTypes.stop.enabled).toBe(false);
|
||||||
|
expect(manager.preferences._version).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps mobile notification defaults and storage on an unfolded handheld', () => {
|
||||||
|
const { manager } = loadManager(undefined, {
|
||||||
|
deviceType: 'desktop',
|
||||||
|
handheld: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(manager.preferences.enabled).toBe(false);
|
||||||
|
expect(manager.getStorageKey()).toBe('codeman-notification-prefs-mobile');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user