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();
+ });
+});