From 3e0ce7af1125bcc16002968e164464b33e2b9968 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Thu, 30 Jul 2026 15:41:02 -0400 Subject: [PATCH] Add desktop conversation notifications Add an admin feature gate, per-user preference, browser permission handling, and completion-only OS notifications for hidden or unfocused chat tabs.\n\nCo-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- application/single_app/config.py | 2 +- application/single_app/functions_settings.py | 6 + application/single_app/route_backend_users.py | 6 + .../route_frontend_admin_settings.py | 3 + .../single_app/route_frontend_chats.py | 5 + .../js/chat/chat-desktop-notifications.js | 95 ++++++++ .../static/js/chat/chat-streaming.js | 3 + .../single_app/templates/admin_settings.html | 24 +++ application/single_app/templates/chats.html | 3 + application/single_app/templates/profile.html | 176 +++++++++++++++ .../DESKTOP_CONVERSATION_NOTIFICATIONS.md | 89 ++++++++ docs/explanation/release_notes.md | 9 + .../test_desktop_notification_settings.py | 58 +++++ .../test_user_settings_allowlist_keys.py | 5 +- ...chat_desktop_conversation_notifications.py | 202 ++++++++++++++++++ 15 files changed, 683 insertions(+), 3 deletions(-) create mode 100644 application/single_app/static/js/chat/chat-desktop-notifications.js create mode 100644 docs/explanation/features/DESKTOP_CONVERSATION_NOTIFICATIONS.md create mode 100644 functional_tests/test_desktop_notification_settings.py create mode 100644 ui_tests/test_chat_desktop_conversation_notifications.py diff --git a/application/single_app/config.py b/application/single_app/config.py index 86e9a5c32..4b6fef4b6 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -95,7 +95,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.250.101" +VERSION = "0.250.102" IS_DEVELOPMENT = is_development_env_enabled() SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax') diff --git a/application/single_app/functions_settings.py b/application/single_app/functions_settings.py index bcebb15e1..f04c1258b 100644 --- a/application/single_app/functions_settings.py +++ b/application/single_app/functions_settings.py @@ -35,6 +35,7 @@ "navLayout", "darkModeEnabled", "showTutorialButtons", + "desktopNotificationsEnabled", "chatLayout", "streamingEnabled", "notifications_per_page", @@ -1099,6 +1100,7 @@ def get_settings(use_cosmos=False, include_source=False): # User Feedback / Conversation Archiving 'enable_user_feedback': True, + 'enable_desktop_notifications': False, 'require_member_of_feedback_admin': False, 'enable_conversation_archiving': False, @@ -2078,6 +2080,9 @@ def get_user_settings(user_id, allow_cross_user=False): if 'showTutorialButtons' not in doc['settings']: doc['settings']['showTutorialButtons'] = True updated = True + if 'desktopNotificationsEnabled' not in doc['settings']: + doc['settings']['desktopNotificationsEnabled'] = True + updated = True if should_sync_session_profile: # Try to update email/display_name if missing and available in session @@ -2121,6 +2126,7 @@ def get_user_settings(user_id, allow_cross_user=False): doc = {"id": user_id, "settings": {}} doc["settings"]["personal_model_endpoints"] = [] doc["settings"]["showTutorialButtons"] = True + doc["settings"]["desktopNotificationsEnabled"] = True if should_sync_session_profile: user = session.get("user", {}) email = user.get("preferred_username") or user.get("email") diff --git a/application/single_app/route_backend_users.py b/application/single_app/route_backend_users.py index 20d5cf1f9..f8c18f45e 100644 --- a/application/single_app/route_backend_users.py +++ b/application/single_app/route_backend_users.py @@ -515,6 +515,8 @@ def user_settings(): 'ttsEnabled', 'ttsVoice', 'ttsSpeed', 'ttsAutoplay', # Tutorial visibility settings 'showTutorialButtons', + # Desktop conversation notification settings + 'desktopNotificationsEnabled', 'recentCollaborators', # Personal workspace settings managed by other backend/frontend flows 'personal_model_endpoints', 'tag_definitions', @@ -555,6 +557,10 @@ def user_settings(): if not isinstance(settings_to_update["conversationContentsDrawerEnabled"], bool): return jsonify({"error": "Invalid conversation contents drawer preference"}), 400 + if "desktopNotificationsEnabled" in settings_to_update: + if not isinstance(settings_to_update["desktopNotificationsEnabled"], bool): + return jsonify({"error": "Invalid desktop notification preference"}), 400 + if "sidebarMenuState" in settings_to_update: sidebar_menu_state = settings_to_update.get("sidebarMenuState") allowed_sidebar_menu_keys = { diff --git a/application/single_app/route_frontend_admin_settings.py b/application/single_app/route_frontend_admin_settings.py index a2b8f6b08..2c87bdfb9 100644 --- a/application/single_app/route_frontend_admin_settings.py +++ b/application/single_app/route_frontend_admin_settings.py @@ -571,6 +571,8 @@ def admin_settings(): settings['require_member_of_create_public_workspace'] = False if 'enable_chat_file_uploads' not in settings: settings['enable_chat_file_uploads'] = True + if 'enable_desktop_notifications' not in settings: + settings['enable_desktop_notifications'] = False if 'enable_conversation_contents_drawer' not in settings: settings['enable_conversation_contents_drawer'] = True if 'require_member_of_chat_file_upload_user' not in settings: @@ -2527,6 +2529,7 @@ def is_valid_url(url): # Feedback, Archiving & Thoughts 'enable_user_feedback': form_data.get('enable_user_feedback') == 'on', + 'enable_desktop_notifications': form_data.get('enable_desktop_notifications') == 'on', 'enable_conversation_archiving': form_data.get('enable_conversation_archiving') == 'on', 'enable_thoughts': form_data.get('enable_thoughts') == 'on', diff --git a/application/single_app/route_frontend_chats.py b/application/single_app/route_frontend_chats.py index 8f81fcc23..c6ac2c040 100644 --- a/application/single_app/route_frontend_chats.py +++ b/application/single_app/route_frontend_chats.py @@ -716,6 +716,10 @@ def chats(): enable_document_classification = public_settings.get("enable_document_classification", False) enable_extract_meta_data = public_settings.get("enable_extract_meta_data", False) enable_multi_model_endpoints = public_settings.get("enable_multi_model_endpoints", False) + desktop_notifications_enabled = bool( + public_settings.get("enable_desktop_notifications", False) + and user_settings_dict.get("desktopNotificationsEnabled", True) + ) active_group_id = user_settings_dict.get("activeGroupOid", "") active_group_name = "" if active_group_id: @@ -874,6 +878,7 @@ def chats(): chat_model_options=chat_model_options, initial_chat_model_selection=initial_chat_model_selection, conversation_contents_drawer_enabled=conversation_contents_drawer_enabled, + desktop_notifications_enabled=desktop_notifications_enabled, ) @bp.route('/workflow-activity', methods=['GET']) diff --git a/application/single_app/static/js/chat/chat-desktop-notifications.js b/application/single_app/static/js/chat/chat-desktop-notifications.js new file mode 100644 index 000000000..6015f7a00 --- /dev/null +++ b/application/single_app/static/js/chat/chat-desktop-notifications.js @@ -0,0 +1,95 @@ +// chat-desktop-notifications.js + +const notifiedCompletionKeys = new Set(); +let permissionRequestAttempted = false; + +function getDesktopNotificationConfig() { + const appSettings = window.appSettings || {}; + return { + enabled: appSettings.enable_desktop_notifications === true + && appSettings.desktop_notifications_enabled === true, + appTitle: String(appSettings.app_title || 'Simple Chat').trim() || 'Simple Chat' + }; +} + +function getConversationTitle(finalData = {}) { + const eventTitle = String(finalData.conversation_title || '').trim(); + if (eventTitle) { + return eventTitle; + } + + const currentTitle = document.getElementById('current-conversation-title')?.textContent?.trim(); + return currentTitle || 'Conversation'; +} + +function getCompletionKey(finalData = {}) { + const messageId = String(finalData.message_id || '').trim(); + if (messageId) { + return `message:${messageId}`; + } + + const conversationId = String(finalData.conversation_id || '').trim(); + return conversationId ? `conversation:${conversationId}` : ''; +} + +export function requestDesktopNotificationPermissionIfNeeded() { + const config = getDesktopNotificationConfig(); + if (!config.enabled || !('Notification' in window) || Notification.permission !== 'default') { + return Promise.resolve(window.Notification?.permission || 'unsupported'); + } + if (permissionRequestAttempted) { + return Promise.resolve(Notification.permission); + } + + permissionRequestAttempted = true; + return Notification.requestPermission().catch(error => { + console.warn('Desktop notification permission request failed:', error); + return Notification.permission; + }); +} + +export function showDesktopConversationNotification(finalData = {}) { + const config = getDesktopNotificationConfig(); + if ( + !config.enabled + || finalData.blocked === true + || finalData.role === 'safety' + || !('Notification' in window) + || Notification.permission !== 'granted' + || (document.visibilityState !== 'hidden' && document.hasFocus()) + ) { + return null; + } + + const completionKey = getCompletionKey(finalData); + if (completionKey && notifiedCompletionKeys.has(completionKey)) { + return null; + } + + try { + const conversationId = String(finalData.conversation_id || '').trim(); + const notification = new Notification(config.appTitle, { + body: getConversationTitle(finalData), + tag: conversationId ? `simplechat-conversation-${conversationId}` : undefined + }); + + if (completionKey) { + notifiedCompletionKeys.add(completionKey); + } + + notification.addEventListener('click', () => { + window.focus(); + notification.close(); + }); + + return notification; + } catch (error) { + console.warn('Desktop conversation notification could not be shown:', error); + return null; + } +} + +export function resetDesktopNotificationCompletionKeysForTesting() { + notifiedCompletionKeys.clear(); + permissionRequestAttempted = false; +} diff --git a/application/single_app/static/js/chat/chat-streaming.js b/application/single_app/static/js/chat/chat-streaming.js index c08a1cf05..6380e1143 100644 --- a/application/single_app/static/js/chat/chat-streaming.js +++ b/application/single_app/static/js/chat/chat-streaming.js @@ -8,6 +8,7 @@ import { beginStreamingThoughtSession, clearStreamingThoughtSession, handleStrea import { destroyInlineCharts, hydrateInlineCharts } from './chat-inline-charts.js'; import { hydrateInlineImageProposals } from './chat-inline-image-proposals.js'; import { escapeHtml } from './chat-utils.js'; +import { requestDesktopNotificationPermissionIfNeeded, showDesktopConversationNotification } from './chat-desktop-notifications.js'; let currentStreamController = null; let currentStreamContext = null; @@ -667,6 +668,7 @@ function consumeStreamingResponse(requestFactory, tempAiMessageId, tempUserMessa data, fallbackAgentInfo ); + showDesktopConversationNotification(data); if (typeof onDone === 'function') { onDone(data); @@ -913,6 +915,7 @@ function consumeStreamingResponse(requestFactory, tempAiMessageId, tempUserMessa export function sendMessageWithStreaming(messageData, tempUserMessageId, currentConversationId, options = {}) { const { endpoint = '/api/chat/stream' } = options; + void requestDesktopNotificationPermissionIfNeeded(); const tempAiMessageId = createStreamingPlaceholder(); const recoveryConversationId = currentConversationId || messageData?.conversation_id || window.currentConversationId || null; diff --git a/application/single_app/templates/admin_settings.html b/application/single_app/templates/admin_settings.html index 4f2c9a9cf..a808db892 100644 --- a/application/single_app/templates/admin_settings.html +++ b/application/single_app/templates/admin_settings.html @@ -9922,6 +9922,30 @@
+
+
+ Desktop Conversation Notifications +
+

+ Allow users to receive an operating system notification when an AI response finishes while SimpleChat is open in a hidden or unfocused browser tab. +

+
+ + +
+

+ Users can turn notifications off from Profile. Browser permission is required, and notifications stop when the SimpleChat tab is closed. +

+
+
diff --git a/application/single_app/templates/chats.html b/application/single_app/templates/chats.html index 17c7ae3b4..14cef6f27 100644 --- a/application/single_app/templates/chats.html +++ b/application/single_app/templates/chats.html @@ -1557,6 +1557,9 @@
Tutorial Preferences
+ {% if app_settings.enable_desktop_notifications %} +
+
+
+ +
+
Desktop Conversation Notifications
+

Receive an operating system notification when an AI response finishes while SimpleChat is open in a hidden or unfocused tab.

+

Notifications include the application and conversation titles. They stop when the SimpleChat tab is closed.

+
+
+
+ +
+
+
+ + + Your browser controls the final permission. If access is blocked, allow notifications in this site's browser settings. +
+
+
+ +
+
+ +
+
+ {% endif %} + {% if app_settings.enable_conversation_contents_drawer %}
@@ -2485,6 +2531,7 @@