mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-11 01:39:41 +02:00
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:
@@ -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).
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -23,6 +23,10 @@ type Manager = {
|
||||
getStorageKey: () => string;
|
||||
normalizePreferences: (preferences: Record<string, unknown>) => NotificationPreferences;
|
||||
notify: (notification: Record<string, unknown>) => 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<string, number>).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<string, number>).toastDurationMs;
|
||||
expect(clamp(10)).toBe(1000);
|
||||
expect(clamp(9_999_999)).toBe(300_000);
|
||||
expect(clamp('soon')).toBe(3000);
|
||||
expect(clamp(undefined)).toBe(3000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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('<!doctype html><body></body>', { url: 'http://localhost/' });
|
||||
windows.push(dom);
|
||||
const CodemanApp = function CodemanApp() {} as unknown as { prototype: Record<string, unknown> };
|
||||
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<string, any>)();
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user