diff --git a/server/modules/database/repositories/notification-preferences.ts b/server/modules/database/repositories/notification-preferences.ts index 6ba21976..469b58f0 100644 --- a/server/modules/database/repositories/notification-preferences.ts +++ b/server/modules/database/repositories/notification-preferences.ts @@ -10,6 +10,7 @@ type NotificationPreferences = { channels: { inApp: boolean; webPush: boolean; + sound: boolean; }; events: { actionRequired: boolean; @@ -22,6 +23,7 @@ const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = { channels: { inApp: false, webPush: false, + sound: true, }, events: { actionRequired: true, @@ -37,6 +39,7 @@ function normalizeNotificationPreferences(value: unknown): NotificationPreferenc channels: { inApp: source.channels?.inApp === true, webPush: source.channels?.webPush === true, + sound: source.channels?.sound !== false, }, events: { actionRequired: source.events?.actionRequired !== false, diff --git a/src/components/chat/hooks/useChatRealtimeHandlers.ts b/src/components/chat/hooks/useChatRealtimeHandlers.ts index 6a5a1b81..c2683933 100644 --- a/src/components/chat/hooks/useChatRealtimeHandlers.ts +++ b/src/components/chat/hooks/useChatRealtimeHandlers.ts @@ -2,6 +2,8 @@ import { useEffect, useRef } from 'react'; import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; import { usePaletteOps } from '../../../contexts/PaletteOpsContext'; +import { showCompletionTitleIndicator } from '../../../utils/pageTitleNotification'; +import { playChatCompletionSound } from '../../../utils/notificationSound'; import type { PendingPermissionRequest, SessionNavigationOptions } from '../types/types'; import type { ProjectSession, LLMProvider } from '../../../types/app'; import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore'; @@ -285,6 +287,9 @@ export function useChatRealtimeHandlers({ break; } + showCompletionTitleIndicator(); + void playChatCompletionSound(); + const actualSessionId = typeof msg.actualSessionId === 'string' && msg.actualSessionId.trim().length > 0 ? msg.actualSessionId diff --git a/src/components/settings/hooks/useSettingsController.ts b/src/components/settings/hooks/useSettingsController.ts index c81a7c04..70e9ed1c 100644 --- a/src/components/settings/hooks/useSettingsController.ts +++ b/src/components/settings/hooks/useSettingsController.ts @@ -1,6 +1,8 @@ import { useCallback, useEffect, useRef, useState } from 'react'; + import { useTheme } from '../../../contexts/ThemeContext'; import { authenticatedFetch } from '../../../utils/api'; +import { setNotificationSoundEnabled } from '../../../utils/notificationSound'; import { useProviderAuthStatus } from '../../provider-auth/hooks/useProviderAuthStatus'; import { DEFAULT_CODE_EDITOR_SETTINGS, @@ -107,6 +109,7 @@ const createDefaultNotificationPreferences = (): NotificationPreferencesState => channels: { inApp: true, webPush: false, + sound: true, }, events: { actionRequired: true, @@ -115,6 +118,25 @@ const createDefaultNotificationPreferences = (): NotificationPreferencesState => }, }); +const normalizeNotificationPreferences = ( + preferences?: Partial | null, +): NotificationPreferencesState => { + const defaults = createDefaultNotificationPreferences(); + + return { + channels: { + inApp: preferences?.channels?.inApp ?? defaults.channels.inApp, + webPush: preferences?.channels?.webPush ?? defaults.channels.webPush, + sound: preferences?.channels?.sound ?? defaults.channels.sound, + }, + events: { + actionRequired: preferences?.events?.actionRequired ?? defaults.events.actionRequired, + stop: preferences?.events?.stop ?? defaults.events.stop, + error: preferences?.events?.error ?? defaults.events.error, + }, + }; +}; + export function useSettingsController({ isOpen, initialTab }: UseSettingsControllerArgs) { const { isDarkMode, toggleDarkMode } = useTheme() as ThemeContextValue; const closeTimerRef = useRef(null); @@ -186,7 +208,7 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl if (notificationResponse.ok) { const notificationData = await toResponseJson(notificationResponse); if (notificationData.success && notificationData.preferences) { - setNotificationPreferences(notificationData.preferences); + setNotificationPreferences(normalizeNotificationPreferences(notificationData.preferences)); } else { setNotificationPreferences(createDefaultNotificationPreferences()); } @@ -301,6 +323,10 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl void refreshProviderAuthStatuses(); }, [initialTab, isOpen, loadSettings, refreshProviderAuthStatuses]); + useEffect(() => { + setNotificationSoundEnabled(notificationPreferences.channels.sound); + }, [notificationPreferences.channels.sound]); + useEffect(() => { localStorage.setItem('codeEditorTheme', codeEditorSettings.theme); localStorage.setItem('codeEditorWordWrap', String(codeEditorSettings.wordWrap)); diff --git a/src/components/settings/types/types.ts b/src/components/settings/types/types.ts index 8fe3b7ff..f68cacc4 100644 --- a/src/components/settings/types/types.ts +++ b/src/components/settings/types/types.ts @@ -1,4 +1,5 @@ import type { Dispatch, SetStateAction } from 'react'; + import type { LLMProvider } from '../../../types/app'; import type { ProviderAuthStatus } from '../../provider-auth/types'; @@ -29,6 +30,7 @@ export type NotificationPreferencesState = { channels: { inApp: boolean; webPush: boolean; + sound: boolean; }; events: { actionRequired: boolean; diff --git a/src/components/settings/view/tabs/NotificationsSettingsTab.tsx b/src/components/settings/view/tabs/NotificationsSettingsTab.tsx index a9607407..0519d5e9 100644 --- a/src/components/settings/view/tabs/NotificationsSettingsTab.tsx +++ b/src/components/settings/view/tabs/NotificationsSettingsTab.tsx @@ -1,5 +1,8 @@ -import { Bell, BellOff, BellRing, Loader2 } from 'lucide-react'; +import { Bell, BellOff, BellRing, Loader2, Play, Volume2 } from 'lucide-react'; import { useTranslation } from 'react-i18next'; + +import { Button } from '../../../../shared/view/ui'; +import { playChatCompletionSound } from '../../../../utils/notificationSound'; import type { NotificationPreferencesState } from '../../types/types'; type NotificationsSettingsTabProps = { @@ -82,6 +85,54 @@ export default function NotificationsSettingsTab({ )} +
+
+
+
+ +

+ {t('notifications.sound.title', { defaultValue: 'Sound' })} +

+
+

+ {t('notifications.sound.description', { + defaultValue: 'Play a short tone when a chat run finishes.', + })} +

+
+ + +
+ + +
+

{t('notifications.events.title')}

diff --git a/src/i18n/locales/de/settings.json b/src/i18n/locales/de/settings.json index 6358c708..237e5950 100644 --- a/src/i18n/locales/de/settings.json +++ b/src/i18n/locales/de/settings.json @@ -94,9 +94,35 @@ "git": "Git", "apiTokens": "API & Token", "tasks": "Aufgaben", + "notifications": "Benachrichtigungen", "plugins": "Plugins", "about": "Info" }, + "notifications": { + "title": "Benachrichtigungen", + "description": "Lege fest, welche Benachrichtigungen du erhältst.", + "webPush": { + "title": "Web-Push-Benachrichtigungen", + "enable": "Push-Benachrichtigungen aktivieren", + "disable": "Push-Benachrichtigungen deaktivieren", + "enabled": "Push-Benachrichtigungen sind aktiviert", + "loading": "Wird aktualisiert...", + "unsupported": "Push-Benachrichtigungen werden in diesem Browser nicht unterstützt.", + "denied": "Push-Benachrichtigungen sind blockiert. Bitte erlaube sie in den Browsereinstellungen." + }, + "sound": { + "title": "Ton", + "description": "Spielt einen kurzen Ton ab, wenn ein Chat-Lauf abgeschlossen ist.", + "enabled": "Aktiviert", + "test": "Ton testen" + }, + "events": { + "title": "Ereignistypen", + "actionRequired": "Aktion erforderlich", + "stop": "Lauf gestoppt", + "error": "Lauf fehlgeschlagen" + } + }, "appearanceSettings": { "darkMode": { "label": "Darkmode", diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 6b550530..93d9a537 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -110,6 +110,12 @@ "unsupported": "Push notifications are not supported in this browser.", "denied": "Push notifications are blocked. Please allow them in your browser settings." }, + "sound": { + "title": "Sound", + "description": "Play a short tone when a chat run finishes.", + "enabled": "Enabled", + "test": "Test sound" + }, "events": { "title": "Event Types", "actionRequired": "Action required", diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json index 670798ec..d283bdb9 100644 --- a/src/i18n/locales/it/settings.json +++ b/src/i18n/locales/it/settings.json @@ -110,6 +110,12 @@ "unsupported": "Le notifiche push non sono supportate in questo browser.", "denied": "Le notifiche push sono bloccate. Abilitale nelle impostazioni del browser." }, + "sound": { + "title": "Suono", + "description": "Riproduci un breve tono quando termina un'esecuzione della chat.", + "enabled": "Attivato", + "test": "Prova suono" + }, "events": { "title": "Tipi di evento", "actionRequired": "Azione richiesta", diff --git a/src/i18n/locales/ja/settings.json b/src/i18n/locales/ja/settings.json index 53cec8d1..0ad10c46 100644 --- a/src/i18n/locales/ja/settings.json +++ b/src/i18n/locales/ja/settings.json @@ -110,6 +110,12 @@ "unsupported": "このブラウザではプッシュ通知がサポートされていません。", "denied": "プッシュ通知がブロックされています。ブラウザの設定で許可してください。" }, + "sound": { + "title": "サウンド", + "description": "チャット実行が完了したときに短い音を再生します。", + "enabled": "有効", + "test": "サウンドをテスト" + }, "events": { "title": "イベント種別", "actionRequired": "対応が必要", diff --git a/src/i18n/locales/ko/settings.json b/src/i18n/locales/ko/settings.json index 0d3d2d30..3fd7a285 100644 --- a/src/i18n/locales/ko/settings.json +++ b/src/i18n/locales/ko/settings.json @@ -110,6 +110,12 @@ "unsupported": "이 브라우저에서는 푸시 알림이 지원되지 않습니다.", "denied": "푸시 알림이 차단되었습니다. 브라우저 설정에서 허용해 주세요." }, + "sound": { + "title": "소리", + "description": "채팅 실행이 완료되면 짧은 알림음을 재생합니다.", + "enabled": "사용", + "test": "소리 테스트" + }, "events": { "title": "이벤트 유형", "actionRequired": "작업 필요", diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index 98944876..94e88f37 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -94,9 +94,35 @@ "git": "Git", "apiTokens": "API и токены", "tasks": "Задачи", + "notifications": "Уведомления", "plugins": "Плагины", "about": "О программе" }, + "notifications": { + "title": "Уведомления", + "description": "Управляйте тем, какие события уведомлений вы получаете.", + "webPush": { + "title": "Web Push уведомления", + "enable": "Включить Push уведомления", + "disable": "Отключить Push уведомления", + "enabled": "Push уведомления включены", + "loading": "Обновление...", + "unsupported": "Push уведомления не поддерживаются в этом браузере.", + "denied": "Push уведомления заблокированы. Разрешите их в настройках браузера." + }, + "sound": { + "title": "Звук", + "description": "Воспроизводить короткий сигнал при завершении запуска чата.", + "enabled": "Включено", + "test": "Проверить звук" + }, + "events": { + "title": "Типы событий", + "actionRequired": "Требуется действие", + "stop": "Запуск остановлен", + "error": "Запуск завершился с ошибкой" + } + }, "appearanceSettings": { "darkMode": { "label": "Темная тема", diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json index 662b177c..01763542 100644 --- a/src/i18n/locales/tr/settings.json +++ b/src/i18n/locales/tr/settings.json @@ -110,6 +110,12 @@ "unsupported": "Bu tarayıcıda push bildirimleri desteklenmiyor.", "denied": "Push bildirimleri engellendi. Lütfen tarayıcı ayarlarından izin ver." }, + "sound": { + "title": "Ses", + "description": "Sohbet çalışması tamamlandığında kısa bir ton çal.", + "enabled": "Etkin", + "test": "Sesi test et" + }, "events": { "title": "Etkinlik Türleri", "actionRequired": "Aksiyon gerekli", diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index cfcc8ee2..5567695c 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -110,6 +110,12 @@ "unsupported": "此浏览器不支持推送通知。", "denied": "推送通知已被阻止,请在浏览器设置中允许。" }, + "sound": { + "title": "声音", + "description": "聊天运行完成时播放短提示音。", + "enabled": "已启用", + "test": "测试声音" + }, "events": { "title": "事件类型", "actionRequired": "需要处理", diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index d38aa931..4fe469bd 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -110,6 +110,12 @@ "unsupported": "此瀏覽器不支援推播通知。", "denied": "推播通知已被封鎖,請在瀏覽器設定中允許。" }, + "sound": { + "title": "聲音", + "description": "聊天執行完成時播放短提示音。", + "enabled": "已啟用", + "test": "測試聲音" + }, "events": { "title": "事件類型", "actionRequired": "需要處理", diff --git a/src/utils/notificationSound.ts b/src/utils/notificationSound.ts new file mode 100644 index 00000000..78af2d99 --- /dev/null +++ b/src/utils/notificationSound.ts @@ -0,0 +1,83 @@ +const NOTIFICATION_SOUND_ENABLED_STORAGE_KEY = 'notificationSoundEnabled'; +const AudioContextConstructor = + typeof window !== 'undefined' + ? window.AudioContext || (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext + : undefined; + +let audioContext: AudioContext | null = null; + +export const isNotificationSoundEnabled = (): boolean => { + if (typeof localStorage === 'undefined') { + return true; + } + + return localStorage.getItem(NOTIFICATION_SOUND_ENABLED_STORAGE_KEY) !== 'false'; +}; + +export const setNotificationSoundEnabled = (enabled: boolean): void => { + if (typeof localStorage === 'undefined') { + return; + } + + localStorage.setItem(NOTIFICATION_SOUND_ENABLED_STORAGE_KEY, String(enabled)); +}; + +const getAudioContext = (): AudioContext | null => { + if (!AudioContextConstructor) { + return null; + } + + if (!audioContext) { + audioContext = new AudioContextConstructor(); + } + + return audioContext; +}; + +const playTone = ( + context: AudioContext, + frequency: number, + startsAt: number, + duration: number, + peakVolume: number, +): void => { + const oscillator = context.createOscillator(); + const gain = context.createGain(); + + oscillator.type = 'sine'; + oscillator.frequency.setValueAtTime(frequency, startsAt); + + // Shape the volume so the synthesized tone starts and stops cleanly. + gain.gain.setValueAtTime(0.0001, startsAt); + gain.gain.exponentialRampToValueAtTime(peakVolume, startsAt + 0.015); + gain.gain.exponentialRampToValueAtTime(0.0001, startsAt + duration); + + oscillator.connect(gain); + gain.connect(context.destination); + oscillator.start(startsAt); + oscillator.stop(startsAt + duration + 0.02); +}; + +export const playChatCompletionSound = async ({ force = false } = {}): Promise => { + if (!force && !isNotificationSoundEnabled()) { + return; + } + + const context = getAudioContext(); + if (!context) { + return; + } + + try { + if (context.state === 'suspended') { + await context.resume(); + } + + const now = context.currentTime; + playTone(context, 740, now, 0.12, 0.075); + playTone(context, 988, now + 0.11, 0.16, 0.06); + } catch (error) { + // Browsers may block audio until the page receives a user gesture. + console.warn('Unable to play notification sound:', error); + } +}; diff --git a/src/utils/pageTitleNotification.ts b/src/utils/pageTitleNotification.ts new file mode 100644 index 00000000..dd786a0b --- /dev/null +++ b/src/utils/pageTitleNotification.ts @@ -0,0 +1,112 @@ +const COMPLETION_TITLE_INDICATOR = '[Done]'; +const TITLE_INDICATOR_CLEAR_DELAY_MS = 2000; + +let clearTimer: number | null = null; +let returnListenersAttached = false; + +const getIndicatorPrefix = () => `${COMPLETION_TITLE_INDICATOR} `; + +const stripIndicator = (title: string): string => { + const prefix = getIndicatorPrefix(); + return title.startsWith(prefix) ? title.slice(prefix.length) : title; +}; + +const pageIsActive = (): boolean => ( + document.visibilityState === 'visible' && document.hasFocus() +); + +const removeReturnListeners = (): void => { + if (!returnListenersAttached || typeof window === 'undefined') { + return; + } + + document.removeEventListener('visibilitychange', handleUserReturn); + window.removeEventListener('focus', handleUserReturn, true); + window.removeEventListener('click', handleUserReturn, true); + returnListenersAttached = false; +}; + +const clearTitleIndicator = (): void => { + if (clearTimer !== null) { + window.clearTimeout(clearTimer); + clearTimer = null; + } + + removeReturnListeners(); + removePageInactiveListener(); + + if (document.title.startsWith(getIndicatorPrefix())) { + document.title = stripIndicator(document.title); + } +}; + +const removePageInactiveListener = (): void => { + document.removeEventListener('visibilitychange', handlePageInactive); +}; + +const scheduleClear = (): void => { + if (clearTimer !== null) { + window.clearTimeout(clearTimer); + } + + clearTimer = window.setTimeout(() => { + clearTitleIndicator(); + }, TITLE_INDICATOR_CLEAR_DELAY_MS); + + removePageInactiveListener(); + document.addEventListener('visibilitychange', handlePageInactive, { once: true }); +}; + +function handleUserReturn(): void { + if (!pageIsActive()) { + return; + } + + // Background completions keep the marker indefinitely. A tab click normally + // surfaces as visibility/focus, while an in-page click is a useful fallback. + scheduleClear(); +} + +function handlePageInactive(): void { + if (document.visibilityState !== 'hidden') { + return; + } + + if (clearTimer !== null) { + window.clearTimeout(clearTimer); + clearTimer = null; + } + + if (!returnListenersAttached) { + document.addEventListener('visibilitychange', handleUserReturn); + window.addEventListener('focus', handleUserReturn, true); + window.addEventListener('click', handleUserReturn, true); + returnListenersAttached = true; + } +} + +export const showCompletionTitleIndicator = (): void => { + if (typeof document === 'undefined' || typeof window === 'undefined') { + return; + } + + const baseTitle = stripIndicator(document.title || 'CloudCLI UI'); + document.title = `${getIndicatorPrefix()}${baseTitle}`; + + if (pageIsActive()) { + scheduleClear(); + return; + } + + if (clearTimer !== null) { + window.clearTimeout(clearTimer); + clearTimer = null; + } + + if (!returnListenersAttached) { + document.addEventListener('visibilitychange', handleUserReturn); + window.addEventListener('focus', handleUserReturn, true); + window.addEventListener('click', handleUserReturn, true); + returnListenersAttached = true; + } +};