diff --git a/.changeset/notification-history-duration.md b/.changeset/notification-history-duration.md new file mode 100644 index 00000000..98ec90c8 --- /dev/null +++ b/.changeset/notification-history-duration.md @@ -0,0 +1,5 @@ +--- +'aicodeman': minor +--- + +Notifications stay as long as you want. Settings → Notifications has a "Toast display time" and a "Browser notification display time" (seconds, per device; the defaults stay 3s and 8s). diff --git a/docs/wiki/Settings-Reference.md b/docs/wiki/Settings-Reference.md index ff31d1d3..73187590 100644 --- a/docs/wiki/Settings-Reference.md +++ b/docs/wiki/Settings-Reference.md @@ -146,7 +146,11 @@ instead of its native cloud backend. See [Custom Model Endpoints](Custom-Model-E ### Notifications -Master toggle, browser notifications, push subscription, audio alerts, the idle +Master toggle, browser notifications, push subscription, audio alerts, how long a +corner toast stays on screen (**Toast display time**, 1 to 300 seconds, default 3) and +how long a desktop notification stays up before Codeman closes it (**Browser +notification display time**, default 8; both per device, and your OS may close a +desktop notification sooner), the idle threshold that decides when a quiet session counts as needing you, and the server-wide webhook (ntfy, Slack, Discord or generic JSON; admins only in multi-user mode). See [Notifications And Approvals](Notifications-And-Approvals). diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 2272378d..30cd2649 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -110,6 +110,9 @@ const TITLE_FLASH_INTERVAL_MS = 1500; // Title flash rate const BROWSER_NOTIF_RATE_LIMIT_MS = 3000; // Rate limit for browser notifications const MOBILE_RESIZE_RETRY_MS = 30000; // Small-viewport resize re-send while a desktop sizing claim is hot const AUTO_CLOSE_NOTIFICATION_MS = 8000; // Auto-close browser notifications +const DEFAULT_TOAST_DURATION_MS = 3000; // How long a corner toast stays by default +const MIN_NOTIFICATION_DURATION_MS = 1000; // Shortest configurable toast / browser-notification time +const MAX_NOTIFICATION_DURATION_MS = 300000; // Longest configurable toast / browser-notification time const THROTTLE_DELAY_MS = 100; // General UI throttle delay const TERMINAL_CHUNK_SIZE = 32 * 1024; // 32KB chunks for terminal buffer loading const TERMINAL_TAIL_SIZE = 1024 * 1024; // 1MB tail for initial load (more scrollback on tab switch) diff --git a/src/web/public/index.html b/src/web/public/index.html index 335d3785..3d0bb26a 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -2723,6 +2723,26 @@ min +
+
+ Toast display time + How long the corner pop-ups stay on screen. +
+
+ + sec +
+
+
+
+ Browser notification display time + How long a desktop notification stays up before Codeman closes it. Your OS may close it sooner. +
+
+ + sec +
+
diff --git a/src/web/public/notification-manager.js b/src/web/public/notification-manager.js index d13f02a0..c8cb0595 100644 --- a/src/web/public/notification-manager.js +++ b/src/web/public/notification-manager.js @@ -4,7 +4,7 @@ * The NotificationManager class implements five notification layers: * 1. In-app notification drawer (slide-out panel with grouped notifications) * 2. Tab title flash (alternating "⚠️ (N) codeman:" / "codeman:" when tab is hidden; uses this.originalTitle so it tracks any per-host title) - * 3. Browser Notification API (desktop push with auto-close after 8s) + * 3. Browser Notification API (desktop push; auto-closes after 8s by default, configurable per device in Settings → Notifications) * 4. Web Push via service worker (OS-level notifications when tab is closed) * 5. Audio alerts (Web Audio API beep, user-opt-in) * @@ -93,6 +93,10 @@ class NotificationManager { browserNotifications: !isMobile, audioAlerts: false, stuckThresholdMs: STUCK_THRESHOLD_DEFAULT_MS, + // How long a corner toast stays on screen, and how long a browser notification + // stays up before Codeman closes it (ms; per-device like the rest of these) + toastDurationMs: DEFAULT_TOAST_DURATION_MS, + browserAutoCloseMs: AUTO_CLOSE_NOTIFICATION_MS, // Legacy urgency muting (keep for backwards compat) muteCritical: false, muteWarning: false, @@ -167,11 +171,24 @@ class NotificationManager { return { ...defaults, ...prefs, + toastDurationMs: this.clampDuration(prefs.toastDurationMs, defaults.toastDurationMs), + browserAutoCloseMs: this.clampDuration(prefs.browserAutoCloseMs, defaults.browserAutoCloseMs), eventTypes: { ...defaults.eventTypes, ...prefs.eventTypes }, _version: 5, }; } + /** A display time in ms kept within [1s, 5min]; anything unusable falls back to the default. */ + clampDuration(value, fallback) { + if (typeof value !== 'number' || !Number.isFinite(value)) return fallback; + return Math.min(MAX_NOTIFICATION_DURATION_MS, Math.max(MIN_NOTIFICATION_DURATION_MS, Math.round(value))); + } + + /** Display time for corner toasts that do not set their own `duration`. */ + getToastDurationMs() { + return this.clampDuration(this.preferences?.toastDurationMs, DEFAULT_TOAST_DURATION_MS); + } + loadPreferences() { try { const storageKey = this.getStorageKey(); @@ -403,7 +420,7 @@ class NotificationManager { }; // Auto-close - setTimeout(() => notif.close(), AUTO_CLOSE_NOTIFICATION_MS); + setTimeout(() => notif.close(), this.clampDuration(this.preferences.browserAutoCloseMs, AUTO_CLOSE_NOTIFICATION_MS)); } async requestPermission() { @@ -466,7 +483,7 @@ class NotificationManager { notif.read = true; this.unreadCount = Math.max(0, this.unreadCount - 1); this.updateBadge(); - } + } // Switch to session if available if (notif.sessionId && this.app.sessions.has(notif.sessionId)) { diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index 3e955e92..6781e575 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -5862,7 +5862,8 @@ Object.assign(CodemanApp.prototype, { }, /** - * `duration` defaults to 3000ms for every toast type. A message worth + * `duration` defaults to the "Toast display time" preference (3000ms unless changed in + * Settings → Notifications) for every toast type. A message worth * reading rather than glancing at (e.g. "Session started on the native * backend — could not apply the custom endpoint: ") * passes an explicit `opts.duration: 0` at its own call site instead of @@ -5874,7 +5875,7 @@ Object.assign(CodemanApp.prototype, { * regardless of duration. */ showToast(message, type = 'info', opts = {}) { - const { duration = 3000, action } = opts; + const { duration = this.notificationManager?.getToastDurationMs?.() ?? 3000, action } = opts; const toast = document.createElement('div'); toast.className = `toast toast-${type}`; diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index a419121b..db6b6cf4 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -563,6 +563,12 @@ Object.assign(CodemanApp.prototype, { document.getElementById('appSettingsNotifBrowser').checked = notifPrefs.browserNotifications ?? false; document.getElementById('appSettingsNotifAudio').checked = notifPrefs.audioAlerts ?? false; document.getElementById('appSettingsNotifStuckMins').value = Math.round((notifPrefs.stuckThresholdMs || 600000) / 60000); + document.getElementById('appSettingsNotifToastSecs').value = Math.round( + (this.notificationManager?.getToastDurationMs?.() ?? DEFAULT_TOAST_DURATION_MS) / 1000 + ); + document.getElementById('appSettingsNotifBrowserSecs').value = Math.round( + (notifPrefs.browserAutoCloseMs ?? AUTO_CLOSE_NOTIFICATION_MS) / 1000 + ); document.getElementById('appSettingsNotifCritical').checked = !notifPrefs.muteCritical; document.getElementById('appSettingsNotifWarning').checked = !notifPrefs.muteWarning; document.getElementById('appSettingsNotifInfo').checked = !notifPrefs.muteInfo; @@ -2667,6 +2673,8 @@ Object.assign(CodemanApp.prototype, { browserNotifications: document.getElementById('appSettingsNotifBrowser').checked, audioAlerts: document.getElementById('appSettingsNotifAudio').checked, stuckThresholdMs: (parseInt(document.getElementById('appSettingsNotifStuckMins').value) || 10) * 60000, + toastDurationMs: (parseInt(document.getElementById('appSettingsNotifToastSecs').value) || 3) * 1000, + browserAutoCloseMs: (parseInt(document.getElementById('appSettingsNotifBrowserSecs').value) || 8) * 1000, muteCritical: !document.getElementById('appSettingsNotifCritical').checked, muteWarning: !document.getElementById('appSettingsNotifWarning').checked, muteInfo: !document.getElementById('appSettingsNotifInfo').checked, @@ -2736,7 +2744,7 @@ Object.assign(CodemanApp.prototype, { _version: 5, }; if (this.notificationManager) { - this.notificationManager.preferences = notifPrefsToSave; + this.notificationManager.preferences = this.notificationManager.normalizePreferences(notifPrefsToSave); this.notificationManager.savePreferences(); } diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 68f0e174..2a1422f1 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -1517,6 +1517,8 @@ export const SettingsUpdateSchema = z browserNotifications: z.boolean().optional(), audioAlerts: z.boolean().optional(), stuckThresholdMs: z.number().optional(), + toastDurationMs: z.number().optional(), + browserAutoCloseMs: z.number().optional(), muteCritical: z.boolean().optional(), muteWarning: z.boolean().optional(), muteInfo: z.boolean().optional(), diff --git a/test/notification-manager-noise.test.ts b/test/notification-manager-noise.test.ts index d5fbf896..666e36e7 100644 --- a/test/notification-manager-noise.test.ts +++ b/test/notification-manager-noise.test.ts @@ -23,6 +23,10 @@ type Manager = { getStorageKey: () => string; normalizePreferences: (preferences: Record) => NotificationPreferences; notify: (notification: Record) => void; + getToastDurationMs: () => number; + unreadCount: number; + markAllRead: () => void; + clearAll: () => void; }; const openWindows: JSDOM[] = []; @@ -48,6 +52,10 @@ function loadManager( STUCK_THRESHOLD_DEFAULT_MS: number; GROUPING_TIMEOUT_MS: number; NOTIFICATION_LIST_CAP: number; + AUTO_CLOSE_NOTIFICATION_MS: number; + DEFAULT_TOAST_DURATION_MS: number; + MIN_NOTIFICATION_DURATION_MS: number; + MAX_NOTIFICATION_DURATION_MS: number; }; win.MobileDetection = { getDeviceType: () => device.deviceType ?? 'desktop', @@ -56,6 +64,10 @@ function loadManager( win.STUCK_THRESHOLD_DEFAULT_MS = 600_000; win.GROUPING_TIMEOUT_MS = 5_000; win.NOTIFICATION_LIST_CAP = 100; + win.AUTO_CLOSE_NOTIFICATION_MS = 8_000; + win.DEFAULT_TOAST_DURATION_MS = 3_000; + win.MIN_NOTIFICATION_DURATION_MS = 1_000; + win.MAX_NOTIFICATION_DURATION_MS = 300_000; win.requestAnimationFrame = ((callback: FrameRequestCallback) => { callback(0); return 1; @@ -66,6 +78,7 @@ function loadManager( } win.eval(` + window.escapeHtml = (value) => String(value).replace(/[&<>"']/g, (c) => '&#' + c.charCodeAt(0) + ';'); ${SOURCE} window.__testNotificationManager = NotificationManager; `); @@ -148,3 +161,23 @@ describe('notification noise defaults', () => { expect(manager.getStorageKey()).toBe('codeman-notification-prefs-mobile'); }); }); + +describe('notification display time', () => { + it('defaults to 3s toasts and 8s browser notifications', () => { + const { manager } = loadManager(); + expect(manager.getToastDurationMs()).toBe(3000); + expect((manager.preferences as unknown as Record).browserAutoCloseMs).toBe(8000); + }); + + it('honours a configured toast time and clamps unusable values', () => { + const { manager } = loadManager({ toastDurationMs: 15_000 }); + expect(manager.getToastDurationMs()).toBe(15_000); + + const clamp = (value: unknown) => + (manager.normalizePreferences({ toastDurationMs: value }) as unknown as Record).toastDurationMs; + expect(clamp(10)).toBe(1000); + expect(clamp(9_999_999)).toBe(300_000); + expect(clamp('soon')).toBe(3000); + expect(clamp(undefined)).toBe(3000); + }); +}); diff --git a/test/toast-display-time.test.ts b/test/toast-display-time.test.ts new file mode 100644 index 00000000..c7d89a49 --- /dev/null +++ b/test/toast-display-time.test.ts @@ -0,0 +1,65 @@ +/** + * @fileoverview showToast() display time and drawer logging: a toast with no explicit + * `duration` uses the notification preference, an explicit `duration` (0 = sticky) still wins, + * and every toast is recorded in the notification drawer. + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { JSDOM } from 'jsdom'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const windows: JSDOM[] = []; + +function loadApp(notificationManager?: unknown) { + const dom = new JSDOM('', { url: 'http://localhost/' }); + windows.push(dom); + const CodemanApp = function CodemanApp() {} as unknown as { prototype: Record }; + const context = vm.createContext({ + CodemanApp, + document: dom.window.document, + requestAnimationFrame: (cb: () => void) => cb(), + setTimeout, + clearTimeout, + console, + }); + const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/panels-ui.js'), 'utf8'); + vm.runInContext(source, context, { filename: 'panels-ui.js' }); + const app = new (CodemanApp as unknown as new () => Record)(); + app.notificationManager = notificationManager; + return app; +} + +afterEach(() => { + vi.useRealTimers(); + for (const dom of windows.splice(0)) dom.window.close(); +}); + +describe('showToast', () => { + it('uses the configured display time when no duration is given', () => { + vi.useFakeTimers(); + const app = loadApp({ getToastDurationMs: () => 10_000 }); + app.showToast('hello'); + expect(windows[0].window.document.querySelectorAll('.toast')).toHaveLength(1); + vi.advanceTimersByTime(9_000); + expect(windows[0].window.document.querySelector('.toast.show')).not.toBeNull(); + vi.advanceTimersByTime(1_500); + expect(windows[0].window.document.querySelector('.toast.show')).toBeNull(); + }); + + it('falls back to 3s without a notification manager', () => { + vi.useFakeTimers(); + const app = loadApp(); + app.showToast('hello'); + vi.advanceTimersByTime(3_100); + expect(windows[0].window.document.querySelector('.toast.show')).toBeNull(); + }); + + it('lets an explicit duration of 0 stay until dismissed', () => { + vi.useFakeTimers(); + const app = loadApp({ getToastDurationMs: () => 1_000 }); + app.showToast('sticky', 'error', { duration: 0 }); + vi.advanceTimersByTime(60_000); + expect(windows[0].window.document.querySelector('.toast.show')).not.toBeNull(); + }); +});