@@ -2485,6 +2531,7 @@
initializeFontSizePreferences();
loadNavigationPreferences();
loadTutorialPreferences();
+ loadDesktopNotificationPreference();
loadConversationContentsPreference();
initializeProfileStatsWindowControls();
initializeFactMemoryModalInstances();
@@ -2545,6 +2592,11 @@
saveConversationContentsPreferenceButton.addEventListener('click', saveConversationContentsPreference);
}
+ const saveDesktopNotificationsButton = document.getElementById('save-desktop-notifications-btn');
+ if (saveDesktopNotificationsButton) {
+ saveDesktopNotificationsButton.addEventListener('click', saveDesktopNotificationPreference);
+ }
+
// Initialize TTS settings if enabled
{% if app_settings.enable_text_to_speech %}
loadTTSSettings();
@@ -2817,6 +2869,23 @@
statusElement.className = `preference-status small mt-3 ${classMap[type] || classMap.muted}`;
}
+function updateDesktopNotificationPreferenceStatus(message, type = 'muted') {
+ const statusElement = document.getElementById('desktop-notification-preference-status');
+ if (!statusElement) {
+ return;
+ }
+
+ const classMap = {
+ muted: 'text-muted',
+ info: 'text-info',
+ success: 'text-success',
+ danger: 'text-danger'
+ };
+
+ statusElement.textContent = message || '';
+ statusElement.className = `preference-status small mt-3 ${classMap[type] || classMap.muted}`;
+}
+
function updateConversationContentsPreferenceStatus(message, type = 'muted') {
const statusElement = document.getElementById('conversation-contents-preference-status');
if (!statusElement) {
@@ -3453,6 +3522,113 @@
});
}
+function getDesktopNotificationPermissionStatus() {
+ if (!('Notification' in window)) {
+ return {
+ permission: 'unsupported',
+ message: 'This browser does not support desktop notifications.',
+ type: 'danger'
+ };
+ }
+
+ if (Notification.permission === 'granted') {
+ return {
+ permission: 'granted',
+ message: 'Browser permission is granted. Notifications appear when SimpleChat is hidden or unfocused.',
+ type: 'success'
+ };
+ }
+
+ if (Notification.permission === 'denied') {
+ return {
+ permission: 'denied',
+ message: 'Browser permission is blocked. Allow notifications for this site in your browser settings to use this preference.',
+ type: 'danger'
+ };
+ }
+
+ return {
+ permission: 'default',
+ message: 'Browser permission has not been decided. Saving this preference will ask for permission.',
+ type: 'info'
+ };
+}
+
+function loadDesktopNotificationPreference() {
+ const toggle = document.getElementById('desktop-notifications-toggle');
+ if (!toggle) {
+ return;
+ }
+
+ fetch('/api/user/settings')
+ .then(response => {
+ if (!response.ok) {
+ throw new Error('Failed to load desktop notification preference');
+ }
+ return response.json();
+ })
+ .then(data => {
+ toggle.checked = data.settings?.desktopNotificationsEnabled !== false;
+ const permissionStatus = getDesktopNotificationPermissionStatus();
+ updateDesktopNotificationPreferenceStatus(permissionStatus.message, permissionStatus.type);
+ })
+ .catch(error => {
+ console.error('Error loading desktop notification preference:', error);
+ updateDesktopNotificationPreferenceStatus('Unable to refresh the desktop notification preference. Please reload the page and try again.', 'danger');
+ });
+}
+
+async function saveDesktopNotificationPreference() {
+ const toggle = document.getElementById('desktop-notifications-toggle');
+ const button = document.getElementById('save-desktop-notifications-btn');
+ if (!toggle || !button) {
+ return;
+ }
+
+ const originalContents = Array.from(button.childNodes, node => node.cloneNode(true));
+ button.disabled = true;
+ button.textContent = 'Saving...';
+ updateDesktopNotificationPreferenceStatus('Saving your desktop notification preference...', 'info');
+
+ try {
+ if ('Notification' in window) {
+ if (toggle.checked && Notification.permission === 'default') {
+ await Notification.requestPermission();
+ }
+ }
+
+ const response = await fetch('/api/user/settings', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({
+ settings: {
+ desktopNotificationsEnabled: toggle.checked
+ }
+ })
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to save desktop notification preference');
+ }
+
+ const permissionStatus = getDesktopNotificationPermissionStatus();
+ if (!toggle.checked) {
+ updateDesktopNotificationPreferenceStatus('Desktop conversation notifications are disabled for your account.', 'success');
+ } else {
+ updateDesktopNotificationPreferenceStatus(permissionStatus.message, permissionStatus.type);
+ }
+ showToastMessage('Desktop notification preference saved successfully', 'success');
+ } catch (error) {
+ console.error('Error saving desktop notification preference:', error);
+ updateDesktopNotificationPreferenceStatus('Failed to save the desktop notification preference. Please try again.', 'danger');
+ } finally {
+ button.disabled = false;
+ button.replaceChildren(...originalContents);
+ }
+}
+
function loadConversationContentsPreference() {
const toggle = document.getElementById('conversation-contents-drawer-toggle');
if (!toggle) {
diff --git a/docs/explanation/features/DESKTOP_CONVERSATION_NOTIFICATIONS.md b/docs/explanation/features/DESKTOP_CONVERSATION_NOTIFICATIONS.md
new file mode 100644
index 000000000..b6e6f1987
--- /dev/null
+++ b/docs/explanation/features/DESKTOP_CONVERSATION_NOTIFICATIONS.md
@@ -0,0 +1,89 @@
+# Desktop Conversation Notifications
+
+Implemented in version: **0.250.102**
+
+Related issue: **#866**
+
+## Overview and Purpose
+
+Desktop conversation notifications let users know when an AI response is ready while SimpleChat remains open in a hidden or unfocused browser tab. Administrators control whether the capability is available, and each user can enable or disable it from Profile.
+
+The operating system notification uses the configured SimpleChat application title and the conversation title. It does not include the AI response or other conversation content.
+
+## Technical Specifications
+
+### Architecture
+
+- `enable_desktop_notifications` is the disabled-by-default application setting controlled from Admin Settings.
+- `desktopNotificationsEnabled` is the per-user Cosmos setting. It defaults to enabled when no explicit preference exists.
+- The Chat route combines the administrator gate and user preference before exposing an effective boolean to the browser through sanitized settings.
+- `chat-desktop-notifications.js` owns browser support checks, permission requests, focus and visibility gating, duplicate prevention, notification creation, and click-to-focus behavior.
+- `chat-streaming.js` requests undecided permission from the next user-initiated chat send and emits a notification only for a successful terminal stream event.
+
+### Notification Conditions
+
+A notification is created only when all of these conditions are true:
+
+1. An administrator enabled desktop conversation notifications.
+2. The user did not disable the preference.
+3. The browser supports the Notifications API.
+4. The user granted browser notification permission.
+5. SimpleChat is hidden or its browser window is unfocused.
+6. The AI response completed successfully.
+
+Cancelled, failed, interrupted, and duplicate terminal stream events do not create notifications.
+
+### Security and Privacy
+
+- Application settings are sanitized before they reach Chat or Profile.
+- Notification title and body values are passed as inert strings to the browser Notifications API.
+- AI response text is never included.
+- No external JavaScript, service worker, push subscription, or third-party notification service is used.
+
+## Usage Instructions
+
+### Administrator
+
+1. Open **Admin Settings**.
+2. Locate **Desktop Conversation Notifications**.
+3. Enable **Desktop Conversation Notifications** and save settings.
+
+### User
+
+1. Open **Profile** and select the settings tab.
+2. Locate **Desktop Conversation Notifications**.
+3. Keep **Notify me when an AI response is ready** enabled or turn it off.
+4. Save the preference. If browser permission is undecided, approve the native permission request.
+
+When permission is still undecided and the preference remains enabled, SimpleChat also requests permission on the user's next chat send. If permission was denied, the user must restore it from the browser's site settings.
+
+Clicking a desktop notification focuses the existing SimpleChat tab.
+
+## Testing and Validation
+
+Coverage includes:
+
+- Application and per-user defaults
+- Administrator form persistence
+- User settings allowlist and boolean validation
+- Conditional Profile controls
+- Browser permission request behavior
+- Hidden and unfocused page gating
+- Application and conversation title content
+- Duplicate completion suppression
+- Denied permission behavior
+- Notification click-to-focus behavior
+
+Related tests:
+
+- `functional_tests/test_desktop_notification_settings.py`
+- `functional_tests/test_user_settings_allowlist_keys.py`
+- `functional_tests/test_chats_user_settings_hardening_fix.py`
+- `ui_tests/test_chat_desktop_conversation_notifications.py`
+
+## Known Limitations
+
+- SimpleChat must remain open in a browser tab. Notifications are not delivered after the tab or browser is closed.
+- Notifications require a secure browser context in production and are subject to browser and operating system notification policies.
+- Browser permission denial cannot be reversed by SimpleChat; users must change the site's permission in browser settings.
+- This feature covers completed AI conversation responses only. It does not mirror the durable SimpleChat in-app notification inbox.
diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md
index 165a7e0b0..12a486646 100644
--- a/docs/explanation/release_notes.md
+++ b/docs/explanation/release_notes.md
@@ -6,6 +6,11 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Ver
#### New Features
+* **Desktop Conversation Notifications**
+ * Administrators can enable operating system notifications for completed AI responses, and users can manage their own preference from Profile.
+ * Notifications appear only while SimpleChat is open in a hidden or unfocused tab, show the application and conversation titles without response content, and focus the existing tab when selected.
+ * (Ref: Fixes #866, `chat-desktop-notifications.js`, `chat-streaming.js`, Profile and Admin Settings)
+
* **Automatic Overnight Control Center Statistics Refresh**
* Added an enabled-by-default daily Control Center metrics refresh at 2:00 AM Eastern, with an administrator toggle and configurable time under Admin Settings > Control Center.
* The recurring schedule follows Eastern daylight-saving changes, stores concrete execution timestamps in UTC, and shows last-run and next-run values in each administrator's browser timezone.
diff --git a/functional_tests/test_desktop_notification_settings.py b/functional_tests/test_desktop_notification_settings.py
new file mode 100644
index 000000000..df21c9374
--- /dev/null
+++ b/functional_tests/test_desktop_notification_settings.py
@@ -0,0 +1,58 @@
+# test_desktop_notification_settings.py
+"""
+Functional test for desktop conversation notification settings.
+Version: 0.250.102
+Implemented in: 0.250.102
+
+This test ensures the administrator gate, per-user preference, sanitized chat
+bootstrap values, and successful stream completion hook remain connected.
+"""
+
+from pathlib import Path
+
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+
+
+def _read(relative_path):
+ return (REPO_ROOT / relative_path).read_text(encoding="utf-8")
+
+
+def test_desktop_notification_settings_are_wired_end_to_end():
+ """Verify the global and user settings are connected to Chat."""
+ functions_settings = _read("application/single_app/functions_settings.py")
+ admin_route = _read("application/single_app/route_frontend_admin_settings.py")
+ user_route = _read("application/single_app/route_backend_users.py")
+ chats_route = _read("application/single_app/route_frontend_chats.py")
+ chats_template = _read("application/single_app/templates/chats.html")
+ profile_template = _read("application/single_app/templates/profile.html")
+ admin_template = _read("application/single_app/templates/admin_settings.html")
+ streaming_script = _read("application/single_app/static/js/chat/chat-streaming.js")
+
+ assert "'enable_desktop_notifications': False" in functions_settings
+ assert '"desktopNotificationsEnabled"' in functions_settings
+ assert "doc['settings']['desktopNotificationsEnabled'] = True" in functions_settings
+
+ assert "'enable_desktop_notifications': form_data.get('enable_desktop_notifications') == 'on'" in admin_route
+ assert 'id="enable_desktop_notifications"' in admin_template
+ assert "settings.enable_desktop_notifications" in admin_template
+
+ assert "'desktopNotificationsEnabled'" in user_route
+ assert '"desktopNotificationsEnabled" in settings_to_update' in user_route
+ assert "desktop_notifications_enabled = bool(" in chats_route
+ assert 'user_settings_dict.get("desktopNotificationsEnabled", True)' in chats_route
+
+ assert "enable_desktop_notifications:" in chats_template
+ assert "desktop_notifications_enabled:" in chats_template
+ assert "app_title:" in chats_template
+ assert "{% if app_settings.enable_desktop_notifications %}" in profile_template
+ assert 'id="desktop-notifications-toggle"' in profile_template
+ assert "Notification.requestPermission()" in profile_template
+
+ assert "showDesktopConversationNotification(data);" in streaming_script
+ assert "requestDesktopNotificationPermissionIfNeeded()" in streaming_script
+
+
+if __name__ == "__main__":
+ test_desktop_notification_settings_are_wired_end_to_end()
+ print("Desktop notification settings test passed.")
diff --git a/functional_tests/test_user_settings_allowlist_keys.py b/functional_tests/test_user_settings_allowlist_keys.py
index 5d90c6a1a..bd95b7540 100644
--- a/functional_tests/test_user_settings_allowlist_keys.py
+++ b/functional_tests/test_user_settings_allowlist_keys.py
@@ -3,7 +3,7 @@
Functional test for user settings allowlist synchronization.
Version: 0.250.102
Implemented in: 0.241.077
-Updated in: 0.250.074
+Updated in: 0.250.102
AI notice dismissal updated in: 0.250.102
This test ensures that the backend user settings route accepts the
@@ -39,6 +39,7 @@ def test_user_settings_allowlist_contains_known_keys():
'deepResearchDefaultEnabled',
'fontSizePreference',
'conversationContentsDrawerEnabled',
+ 'desktopNotificationsEnabled',
'aiNoticeDismissal',
]
diff --git a/ui_tests/test_chat_desktop_conversation_notifications.py b/ui_tests/test_chat_desktop_conversation_notifications.py
new file mode 100644
index 000000000..2eed4db7f
--- /dev/null
+++ b/ui_tests/test_chat_desktop_conversation_notifications.py
@@ -0,0 +1,202 @@
+# test_chat_desktop_conversation_notifications.py
+"""
+UI test for desktop conversation notifications.
+Version: 0.250.102
+Implemented in: 0.250.102
+
+This test ensures browser permission is requested from a chat interaction and
+completed conversations notify only while SimpleChat is hidden or unfocused.
+"""
+
+from contextlib import contextmanager
+from functools import partial
+from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+import socket
+from threading import Thread
+
+import pytest
+
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+HARNESS_PATH = "ui_tests/fixtures/chat_thought_progress_harness.html"
+
+
+def _get_free_local_port():
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
+ sock.bind(("127.0.0.1", 0))
+ return sock.getsockname()[1]
+
+
+@contextmanager
+def _start_static_test_server():
+ port = _get_free_local_port()
+ handler = partial(SimpleHTTPRequestHandler, directory=str(REPO_ROOT))
+ server = ThreadingHTTPServer(("127.0.0.1", port), handler)
+ server.daemon_threads = True
+ thread = Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+
+ try:
+ yield f"http://127.0.0.1:{port}"
+ finally:
+ server.shutdown()
+ server.server_close()
+ thread.join(timeout=5)
+
+
+@pytest.mark.ui
+def test_desktop_notification_permission_visibility_and_content():
+ """Validate permission, focus gating, content, deduplication, and click behavior."""
+ playwright_sync_api = pytest.importorskip("playwright.sync_api")
+
+ with playwright_sync_api.sync_playwright() as playwright:
+ browser = playwright.chromium.launch()
+ context = browser.new_context(viewport={"width": 1440, "height": 900})
+ page = context.new_page()
+
+ try:
+ with _start_static_test_server() as server_base_url:
+ response = page.goto(f"{server_base_url}/{HARNESS_PATH}", wait_until="domcontentloaded")
+ assert response is not None and response.ok
+
+ result = page.evaluate(
+ r"""
+ async () => {
+ let visibilityState = 'hidden';
+ let hasFocus = false;
+ let focusCalls = 0;
+
+ class FakeNotification {
+ static permission = 'default';
+ static nextPermission = 'granted';
+ static requestCount = 0;
+ static instances = [];
+
+ static async requestPermission() {
+ FakeNotification.requestCount += 1;
+ FakeNotification.permission = FakeNotification.nextPermission;
+ return FakeNotification.permission;
+ }
+
+ constructor(title, options = {}) {
+ this.title = title;
+ this.options = options;
+ this.listeners = {};
+ this.closed = false;
+ FakeNotification.instances.push(this);
+ }
+
+ addEventListener(type, listener) {
+ this.listeners[type] = listener;
+ }
+
+ close() {
+ this.closed = true;
+ }
+ }
+
+ Object.defineProperty(window, 'Notification', {
+ configurable: true,
+ value: FakeNotification,
+ });
+ Object.defineProperty(document, 'visibilityState', {
+ configurable: true,
+ get: () => visibilityState,
+ });
+ document.hasFocus = () => hasFocus;
+ window.focus = () => {
+ focusCalls += 1;
+ };
+ window.appSettings = {
+ enable_desktop_notifications: true,
+ desktop_notifications_enabled: true,
+ app_title: 'Contoso Chat',
+ };
+
+ const notifications = await import(
+ '/application/single_app/static/js/chat/chat-desktop-notifications.js'
+ );
+ notifications.resetDesktopNotificationCompletionKeysForTesting();
+
+ const permission = await notifications.requestDesktopNotificationPermissionIfNeeded();
+ const finalData = {
+ message_id: 'message-1',
+ conversation_id: 'conversation-1',
+ conversation_title: 'Quarterly planning',
+ };
+
+ const first = notifications.showDesktopConversationNotification(finalData);
+ const duplicate = notifications.showDesktopConversationNotification(finalData);
+ first.listeners.click();
+
+ visibilityState = 'visible';
+ hasFocus = true;
+ const focused = notifications.showDesktopConversationNotification({
+ message_id: 'message-2',
+ conversation_id: 'conversation-2',
+ conversation_title: 'Focused conversation',
+ });
+
+ FakeNotification.permission = 'denied';
+ const deniedPermission = await notifications.requestDesktopNotificationPermissionIfNeeded();
+ const denied = notifications.showDesktopConversationNotification({
+ message_id: 'message-3',
+ conversation_id: 'conversation-3',
+ conversation_title: 'Denied conversation',
+ });
+
+ FakeNotification.permission = 'granted';
+ visibilityState = 'hidden';
+ hasFocus = false;
+ const blocked = notifications.showDesktopConversationNotification({
+ message_id: 'message-4',
+ conversation_id: 'conversation-4',
+ conversation_title: 'Blocked conversation',
+ blocked: true,
+ role: 'safety',
+ });
+
+ notifications.resetDesktopNotificationCompletionKeysForTesting();
+ FakeNotification.permission = 'default';
+ FakeNotification.nextPermission = 'default';
+ await notifications.requestDesktopNotificationPermissionIfNeeded();
+ await notifications.requestDesktopNotificationPermissionIfNeeded();
+
+ return {
+ permission,
+ requestCount: FakeNotification.requestCount,
+ notificationCount: FakeNotification.instances.length,
+ firstTitle: first?.title || '',
+ firstBody: first?.options?.body || '',
+ firstTag: first?.options?.tag || '',
+ firstClosed: first?.closed || false,
+ duplicateWasSuppressed: duplicate === null,
+ focusedWasSuppressed: focused === null,
+ deniedPermission,
+ deniedWasSuppressed: denied === null,
+ blockedWasSuppressed: blocked === null,
+ undecidedRequestCount: FakeNotification.requestCount,
+ focusCalls,
+ };
+ }
+ """
+ )
+
+ assert result["permission"] == "granted"
+ assert result["requestCount"] == 2
+ assert result["notificationCount"] == 1
+ assert result["firstTitle"] == "Contoso Chat"
+ assert result["firstBody"] == "Quarterly planning"
+ assert result["firstTag"] == "simplechat-conversation-conversation-1"
+ assert result["firstClosed"] is True
+ assert result["duplicateWasSuppressed"] is True
+ assert result["focusedWasSuppressed"] is True
+ assert result["deniedPermission"] == "denied"
+ assert result["deniedWasSuppressed"] is True
+ assert result["blockedWasSuppressed"] is True
+ assert result["undecidedRequestCount"] == 2
+ assert result["focusCalls"] == 1
+ finally:
+ context.close()
+ browser.close()