feat(notifications): configurable toast and browser-notification display time (#564)

Squash-merged so the toast-history half, dropped during review, stays out of master's history.
This commit is contained in:
Devvyn authored and GitHub committed 2026-10-10 02:52:38 +02:00
1 parent 1b3f40bba7
commit b3d3c647cf
10 files changed
+165 -7

No files matched your search

+3
View File
@@ -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)
+20
View File
@@ -2723,6 +2723,26 @@
<span class="set-unit">min</span>
</div>
</div>
<div class="set-row" data-search="toast display time popup dismiss seconds notification">
<div class="set-row-text">
<span class="set-row-label">Toast display time</span>
<span class="set-row-desc">How long the corner pop-ups stay on screen.</span>
</div>
<div class="set-row-actions">
<input type="number" id="appSettingsNotifToastSecs" class="set-num" value="3" min="1" max="300">
<span class="set-unit">sec</span>
</div>
</div>
<div class="set-row" data-search="browser notification auto close dismiss seconds">
<div class="set-row-text">
<span class="set-row-label">Browser notification display time</span>
<span class="set-row-desc">How long a desktop notification stays up before Codeman closes it. Your OS may close it sooner.</span>
</div>
<div class="set-row-actions">
<input type="number" id="appSettingsNotifBrowserSecs" class="set-num" value="8" min="1" max="300">
<span class="set-unit">sec</span>
</div>
</div>
</div>
</div>
+20 -3
View File
@@ -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:<host>" / "codeman:<host>" 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)) {
+3 -2
View File
@@ -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: <the actual reason>")
* 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}`;
+9 -1
View File
@@ -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();
}
+2
View File
@@ -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(),