From 68493b7b7239f05932cf328a8801f3ad57347c67 Mon Sep 17 00:00:00 2001 From: monshunter Date: Sun, 3 May 2026 16:00:39 +0800 Subject: [PATCH] feat(web): task completion sound + browser notification (#359) Merged per maintainer approval. --- apps/web/public/od-notifications-sw.js | 39 ++++ apps/web/src/components/Icon.tsx | 8 + apps/web/src/components/ProjectView.tsx | 58 ++++- apps/web/src/components/SettingsDialog.tsx | 203 ++++++++++++++++ apps/web/src/i18n/locales/ar.ts | 25 ++ apps/web/src/i18n/locales/de.ts | 25 ++ apps/web/src/i18n/locales/en.ts | 25 ++ apps/web/src/i18n/locales/es-ES.ts | 25 ++ apps/web/src/i18n/locales/fa.ts | 25 ++ apps/web/src/i18n/locales/hu.ts | 25 ++ apps/web/src/i18n/locales/ja.ts | 25 ++ apps/web/src/i18n/locales/ko.ts | 25 ++ apps/web/src/i18n/locales/pl.ts | 25 ++ apps/web/src/i18n/locales/pt-BR.ts | 25 ++ apps/web/src/i18n/locales/ru.ts | 25 ++ apps/web/src/i18n/locales/tr.ts | 25 ++ apps/web/src/i18n/locales/zh-CN.ts | 25 ++ apps/web/src/i18n/locales/zh-TW.ts | 25 ++ apps/web/src/i18n/types.ts | 26 +++ apps/web/src/state/config.ts | 37 ++- apps/web/src/types.ts | 16 ++ apps/web/src/utils/notifications.test.ts | 97 ++++++++ apps/web/src/utils/notifications.ts | 258 +++++++++++++++++++++ scripts/check-residual-js.ts | 2 + 24 files changed, 1090 insertions(+), 4 deletions(-) create mode 100644 apps/web/public/od-notifications-sw.js create mode 100644 apps/web/src/utils/notifications.test.ts create mode 100644 apps/web/src/utils/notifications.ts diff --git a/apps/web/public/od-notifications-sw.js b/apps/web/public/od-notifications-sw.js new file mode 100644 index 000000000..9b3b09460 --- /dev/null +++ b/apps/web/public/od-notifications-sw.js @@ -0,0 +1,39 @@ +// Browser service workers must be served as JavaScript files. This tiny +// runtime exists only to display task-completion notifications and focus +// the existing Open Design tab when the user clicks one. +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + + const data = event.notification.data || {}; + const targetUrl = typeof data.url === 'string' ? data.url : self.location.origin; + + event.waitUntil((async () => { + const windows = await self.clients.matchAll({ + type: 'window', + includeUncontrolled: true, + }); + const sameOrigin = windows.find((client) => { + try { + return new URL(client.url).origin === self.location.origin; + } catch { + return false; + } + }); + + if (sameOrigin) { + if ('navigate' in sameOrigin) { + try { + await sameOrigin.navigate(targetUrl); + } catch { + /* focus the existing tab below */ + } + } + return sameOrigin.focus(); + } + + if (self.clients.openWindow) { + return self.clients.openWindow(targetUrl); + } + return undefined; + })()); +}); diff --git a/apps/web/src/components/Icon.tsx b/apps/web/src/components/Icon.tsx index 8f2c9e020..261d3565e 100644 --- a/apps/web/src/components/Icon.tsx +++ b/apps/web/src/components/Icon.tsx @@ -4,6 +4,7 @@ type IconName = | 'arrow-left' | 'arrow-up' | 'attach' + | 'bell' | 'check' | 'chevron-down' | 'chevron-left' @@ -93,6 +94,13 @@ export function Icon({ name, size = 14, strokeWidth = 1.6, ...rest }: Props) { ); + case 'bell': + return ( + + + + + ); case 'check': return ( diff --git a/apps/web/src/components/ProjectView.tsx b/apps/web/src/components/ProjectView.tsx index fe42c544d..ba90ccd99 100644 --- a/apps/web/src/components/ProjectView.tsx +++ b/apps/web/src/components/ProjectView.tsx @@ -22,6 +22,8 @@ import { import { composeSystemPrompt } from '@open-design/contracts'; import { navigate } from '../router'; import { agentDisplayName } from '../utils/agentLabels'; +import { playSound, showCompletionNotification } from '../utils/notifications'; +import { DEFAULT_NOTIFICATIONS } from '../state/config'; import type { TodoItem } from '../runtime/todos'; import { createConversation, @@ -247,6 +249,56 @@ export function ProjectView({ reattachTextBuffersRef.current.clear(); }, []); + // Detect the streaming `true → false` edge so we can fire the optional + // completion sound / desktop notification exactly once per turn. Initial + // mount keeps `prevStreamingRef.current = false`, so loading historical + // conversations (where `streaming` is also false) never triggers a stray + // ding. `messages` is on the dep array so the latest assistant message's + // runStatus is visible at the moment we edge-detect; the early-return + // guarantees only the edge actually does anything. + const prevStreamingRef = useRef(false); + useEffect(() => { + const wasStreaming = prevStreamingRef.current; + prevStreamingRef.current = streaming; + if (!(wasStreaming && !streaming)) return; + + const last = [...messages].reverse().find((m) => m.role === 'assistant'); + if (!last) return; + const status = last.runStatus; + if (status !== 'succeeded' && status !== 'failed') return; + + const cfg = config.notifications ?? DEFAULT_NOTIFICATIONS; + if (cfg.soundEnabled) { + playSound(status === 'succeeded' ? cfg.successSoundId : cfg.failureSoundId); + } + + if (cfg.desktopEnabled) { + // Successes only interrupt when the user is on another tab/window. + // Failures alert regardless — losing a long agent run silently is + // worse than a small interruption when the page is in focus. + const isHidden = typeof document !== 'undefined' && document.hidden; + const isFocused = typeof document === 'undefined' ? true : document.hasFocus(); + if (status === 'failed' || isHidden || !isFocused) { + const title = status === 'succeeded' + ? t('notify.successTitle') + : t('notify.failureTitle'); + const fallbackBody = status === 'succeeded' + ? t('notify.successBody') + : t('notify.failureBody'); + const trimmed = (last.content ?? '').trim(); + const body = trimmed ? trimmed.slice(0, 80) : fallbackBody; + void showCompletionNotification({ + status, + title, + body, + onClick: () => { + if (typeof window !== 'undefined') window.focus(); + }, + }); + } + } + }, [streaming, messages, config.notifications, t]); + // Hydrate the open-tabs state once per project. After this initial // load, every mutation flows through saveTabsState() which keeps DB + // local state coherent. @@ -881,7 +933,7 @@ export function ProjectView({ updateAssistant((prev) => ({ ...prev, endedAt: Date.now(), - runStatus: prev.runId ? 'succeeded' : prev.runStatus, + runStatus: config.mode === 'api' || prev.runId ? 'succeeded' : prev.runStatus, })); if (commentAttachments.length > 0) { void patchAttachedStatuses(commentAttachments, 'needs_review'); @@ -925,7 +977,9 @@ export function ProjectView({ updateAssistant((prev) => ({ ...prev, endedAt: Date.now(), - runStatus: prev.runId || isActiveRunStatus(prev.runStatus) ? 'failed' : prev.runStatus, + runStatus: config.mode === 'api' || prev.runId || isActiveRunStatus(prev.runStatus) + ? 'failed' + : prev.runStatus, })); if (commentAttachments.length > 0) { void patchAttachedStatuses(commentAttachments, 'failed'); diff --git a/apps/web/src/components/SettingsDialog.tsx b/apps/web/src/components/SettingsDialog.tsx index 44f1e5085..d5507d7e5 100644 --- a/apps/web/src/components/SettingsDialog.tsx +++ b/apps/web/src/components/SettingsDialog.tsx @@ -19,12 +19,22 @@ import type { AgentInfo, AppConfig, AppTheme, AppVersionInfo, ExecMode } from '. import { MEDIA_PROVIDERS } from '../media/models'; import type { MediaProvider } from '../media/models'; import { PetSettings } from './pet/PetSettings'; +import { DEFAULT_NOTIFICATIONS } from '../state/config'; +import { + FAILURE_SOUNDS, + SUCCESS_SOUNDS, + notificationPermission, + playSound, + requestNotificationPermission, + showCompletionNotification, +} from '../utils/notifications'; export type SettingsSection = | 'execution' | 'media' | 'language' | 'appearance' + | 'notifications' | 'pet' | 'about'; @@ -295,6 +305,17 @@ export function SettingsDialog({ {t('settings.appearanceHint')} + + + + {notif.soundEnabled ? ( + <> +
+ +
+ {SUCCESS_SOUNDS.map((sound) => ( + + ))} +
+
+ +
+ +
+ {FAILURE_SOUNDS.map((sound) => ( + + ))} +
+
+ + ) : null} + + +
+
+
+

{t('settings.notifyDesktop')}

+

{t('settings.notifyDesktopHint')}

+
+
+
+ +
+ {permission === 'unsupported' ? ( +

{t('settings.notifyDesktopUnsupported')}

+ ) : null} + {permission === 'denied' ? ( +

{t('settings.notifyDesktopBlocked')}

+ ) : null} + {notif.desktopEnabled && permission === 'granted' ? ( + <> + + {testStatus ?

{t(testStatus)}

: null} + + ) : null} +
+ + ); +} + +function testNotificationStatusText( + result: Awaited>, +): + | 'settings.notifyTestSent' + | 'settings.notifyDesktopBlocked' + | 'settings.notifyDesktopUnsupported' + | 'settings.notifyTestFailed' { + if (result === 'shown') return 'settings.notifyTestSent'; + if (result === 'permission-denied') return 'settings.notifyDesktopBlocked'; + if (result === 'unsupported') return 'settings.notifyDesktopUnsupported'; + return 'settings.notifyTestFailed'; +} diff --git a/apps/web/src/i18n/locales/ar.ts b/apps/web/src/i18n/locales/ar.ts index 5a5309bc4..a9aebc084 100644 --- a/apps/web/src/i18n/locales/ar.ts +++ b/apps/web/src/i18n/locales/ar.ts @@ -786,4 +786,29 @@ export const ar: Dict = { 'pet.communitySyncFailed': 'فشلت المزامنة: {error}', 'pet.codexBundled': 'مدمج', 'pet.codexBundledTitle': 'يأتي مع Open Design - لا حاجة للتحميل.', + + 'settings.notifications': 'الإشعارات', + 'settings.notificationsHint': 'صوت وإشعار سطح المكتب عند اكتمال المهمة', + 'settings.notifyCompletionSound': 'صوت الاكتمال', + 'settings.notifyCompletionSoundHint': 'يُشغَّل عند انتهاء الجولة. متوقف افتراضيًا.', + 'settings.notifySuccessSound': 'صوت النجاح', + 'settings.notifyFailureSound': 'صوت الفشل', + 'settings.notifyDesktop': 'إشعار سطح المكتب', + 'settings.notifyDesktopHint': 'يُرسَل عندما لا تكون النافذة في المقدمة.', + 'settings.notifyDesktopBlocked': 'تم حظر الإشعارات من المتصفح. اسمح بها من إعدادات الموقع.', + 'settings.notifyDesktopUnsupported': 'إشعارات سطح المكتب غير متاحة في هذه البيئة.', + 'settings.notifyTest': 'إرسال اختبار', + 'settings.notifyTestSent': 'تم إرسال إشعار الاختبار. إذا لم تظهر نافذة، فتحقق من إعدادات إشعارات المتصفح والنظام.', + 'settings.notifyTestFailed': 'فشل استدعاء الإشعار. تحقق من إعدادات إشعارات المتصفح والنظام.', + 'settings.notifySoundDing': 'دينج', + 'settings.notifySoundChime': 'جرس', + 'settings.notifySoundTwoToneUp': 'نغمتان صاعدتان', + 'settings.notifySoundPluck': 'نقرة وتر', + 'settings.notifySoundBuzz': 'طنين', + 'settings.notifySoundTwoToneDown': 'نغمتان هابطتان', + 'settings.notifySoundThud': 'دمدمة', + 'notify.successTitle': 'اكتملت المهمة', + 'notify.failureTitle': 'فشلت المهمة', + 'notify.successBody': 'انتهت جولة.', + 'notify.failureBody': 'انتهت المهمة بخطأ.', }; diff --git a/apps/web/src/i18n/locales/de.ts b/apps/web/src/i18n/locales/de.ts index eecbf4417..9ec0a6c57 100644 --- a/apps/web/src/i18n/locales/de.ts +++ b/apps/web/src/i18n/locales/de.ts @@ -786,4 +786,29 @@ export const de: Dict = { 'pet.communitySyncFailed': 'Sync fehlgeschlagen: {error}', 'pet.codexBundled': 'Mitgeliefert', 'pet.codexBundledTitle': 'Wird mit Open Design ausgeliefert — kein Download nötig.', + + 'settings.notifications': 'Benachrichtigungen', + 'settings.notificationsHint': 'Ton und Desktop-Benachrichtigung beim Abschluss von Aufgaben', + 'settings.notifyCompletionSound': 'Abschluss-Ton', + 'settings.notifyCompletionSoundHint': 'Wird abgespielt, wenn eine Runde endet. Standardmäßig aus.', + 'settings.notifySuccessSound': 'Erfolgs-Ton', + 'settings.notifyFailureSound': 'Fehler-Ton', + 'settings.notifyDesktop': 'Desktop-Benachrichtigung', + 'settings.notifyDesktopHint': 'Wird gesendet, wenn das Fenster nicht im Vordergrund ist.', + 'settings.notifyDesktopBlocked': 'Vom Browser blockiert. Bitte in den Website-Einstellungen erlauben.', + 'settings.notifyDesktopUnsupported': 'Desktop-Benachrichtigungen werden in dieser Umgebung nicht unterstützt.', + 'settings.notifyTest': 'Test senden', + 'settings.notifyTestSent': 'Testbenachrichtigung gesendet. Wenn kein Banner erscheint, Browser- und Systembenachrichtigungen prüfen.', + 'settings.notifyTestFailed': 'Benachrichtigungsaufruf fehlgeschlagen. Browser- und Systembenachrichtigungen prüfen.', + 'settings.notifySoundDing': 'Ding', + 'settings.notifySoundChime': 'Glockenspiel', + 'settings.notifySoundTwoToneUp': 'Zweiton aufwärts', + 'settings.notifySoundPluck': 'Zupfen', + 'settings.notifySoundBuzz': 'Summen', + 'settings.notifySoundTwoToneDown': 'Zweiton abwärts', + 'settings.notifySoundThud': 'Dumpfer Schlag', + 'notify.successTitle': 'Aufgabe abgeschlossen', + 'notify.failureTitle': 'Aufgabe fehlgeschlagen', + 'notify.successBody': 'Eine Runde ist abgeschlossen.', + 'notify.failureBody': 'Die Aufgabe wurde mit einem Fehler beendet.', }; diff --git a/apps/web/src/i18n/locales/en.ts b/apps/web/src/i18n/locales/en.ts index 01b703878..beaa1abd7 100644 --- a/apps/web/src/i18n/locales/en.ts +++ b/apps/web/src/i18n/locales/en.ts @@ -786,4 +786,29 @@ export const en: Dict = { 'pet.communitySyncFailed': 'Sync failed: {error}', 'pet.codexBundled': 'Bundled', 'pet.codexBundledTitle': 'Ships with Open Design — no download needed.', + + 'settings.notifications': 'Notifications', + 'settings.notificationsHint': 'Sound and desktop notification on task completion', + 'settings.notifyCompletionSound': 'Completion sound', + 'settings.notifyCompletionSoundHint': 'Plays when a turn finishes. Off by default.', + 'settings.notifySuccessSound': 'Success sound', + 'settings.notifyFailureSound': 'Failure sound', + 'settings.notifyDesktop': 'Desktop notification', + 'settings.notifyDesktopHint': 'Sent when the window is not focused.', + 'settings.notifyDesktopBlocked': 'Notifications blocked by the browser. Allow them in site settings.', + 'settings.notifyDesktopUnsupported': 'Desktop notifications unavailable in this environment.', + 'settings.notifyTest': 'Send test', + 'settings.notifyTestSent': 'Test notification sent. If no banner appears, check browser and system notification settings.', + 'settings.notifyTestFailed': 'Notification call failed. Check browser and system notification settings.', + 'settings.notifySoundDing': 'Ding', + 'settings.notifySoundChime': 'Chime', + 'settings.notifySoundTwoToneUp': 'Two-tone up', + 'settings.notifySoundPluck': 'Pluck', + 'settings.notifySoundBuzz': 'Buzz', + 'settings.notifySoundTwoToneDown': 'Two-tone down', + 'settings.notifySoundThud': 'Thud', + 'notify.successTitle': 'Task completed', + 'notify.failureTitle': 'Task failed', + 'notify.successBody': 'A turn has finished.', + 'notify.failureBody': 'The task ended with an error.', }; diff --git a/apps/web/src/i18n/locales/es-ES.ts b/apps/web/src/i18n/locales/es-ES.ts index 085471ccf..1646be377 100644 --- a/apps/web/src/i18n/locales/es-ES.ts +++ b/apps/web/src/i18n/locales/es-ES.ts @@ -787,4 +787,29 @@ export const esES: Dict = { 'pet.communitySyncFailed': 'Error al sincronizar: {error}', 'pet.codexBundled': 'Incluida', 'pet.codexBundledTitle': 'Viene con Open Design — sin descarga.', + + 'settings.notifications': 'Notificaciones', + 'settings.notificationsHint': 'Sonido y notificación al completar la tarea', + 'settings.notifyCompletionSound': 'Sonido al completar', + 'settings.notifyCompletionSoundHint': 'Se reproduce al terminar un turno. Desactivado por defecto.', + 'settings.notifySuccessSound': 'Sonido de éxito', + 'settings.notifyFailureSound': 'Sonido de error', + 'settings.notifyDesktop': 'Notificación de escritorio', + 'settings.notifyDesktopHint': 'Se envía cuando la ventana no está en primer plano.', + 'settings.notifyDesktopBlocked': 'Bloqueadas por el navegador. Habilítalas en la configuración del sitio.', + 'settings.notifyDesktopUnsupported': 'Las notificaciones de escritorio no están disponibles en este entorno.', + 'settings.notifyTest': 'Enviar prueba', + 'settings.notifyTestSent': 'Notificación de prueba enviada. Si no aparece el aviso, revisa las notificaciones del navegador y del sistema.', + 'settings.notifyTestFailed': 'Falló la llamada de notificación. Revisa las notificaciones del navegador y del sistema.', + 'settings.notifySoundDing': 'Tilín', + 'settings.notifySoundChime': 'Carillón', + 'settings.notifySoundTwoToneUp': 'Dos tonos ascendente', + 'settings.notifySoundPluck': 'Pulsación', + 'settings.notifySoundBuzz': 'Zumbido', + 'settings.notifySoundTwoToneDown': 'Dos tonos descendente', + 'settings.notifySoundThud': 'Golpe', + 'notify.successTitle': 'Tarea completada', + 'notify.failureTitle': 'La tarea falló', + 'notify.successBody': 'Un turno ha terminado.', + 'notify.failureBody': 'La tarea terminó con un error.', }; diff --git a/apps/web/src/i18n/locales/fa.ts b/apps/web/src/i18n/locales/fa.ts index d3dd6fd4d..9747f4420 100644 --- a/apps/web/src/i18n/locales/fa.ts +++ b/apps/web/src/i18n/locales/fa.ts @@ -787,4 +787,29 @@ export const fa: Dict = { 'pet.communitySyncFailed': 'خطا در همگام‌سازی: {error}', 'pet.codexBundled': 'همراه', 'pet.codexBundledTitle': 'همراه Open Design ارائه می‌شود — نیازی به دانلود نیست.', + + 'settings.notifications': 'اعلان‌ها', + 'settings.notificationsHint': 'صدا و اعلان دسکتاپ هنگام تکمیل وظیفه', + 'settings.notifyCompletionSound': 'صدای تکمیل', + 'settings.notifyCompletionSoundHint': 'پس از اتمام یک نوبت پخش می‌شود. به‌طور پیش‌فرض خاموش است.', + 'settings.notifySuccessSound': 'صدای موفقیت', + 'settings.notifyFailureSound': 'صدای خطا', + 'settings.notifyDesktop': 'اعلان دسکتاپ', + 'settings.notifyDesktopHint': 'هنگامی که پنجره فعال نیست ارسال می‌شود.', + 'settings.notifyDesktopBlocked': 'مرورگر اعلان‌ها را مسدود کرده است. در تنظیمات سایت آن‌ها را مجاز کنید.', + 'settings.notifyDesktopUnsupported': 'اعلان‌های دسکتاپ در این محیط در دسترس نیستند.', + 'settings.notifyTest': 'ارسال آزمایشی', + 'settings.notifyTestSent': 'اعلان آزمایشی ارسال شد. اگر بنری نمایش داده نشد، تنظیمات اعلان مرورگر و سیستم را بررسی کنید.', + 'settings.notifyTestFailed': 'فراخوانی اعلان ناموفق بود. تنظیمات اعلان مرورگر و سیستم را بررسی کنید.', + 'settings.notifySoundDing': 'دینگ', + 'settings.notifySoundChime': 'زنگ', + 'settings.notifySoundTwoToneUp': 'دو نوای بالارونده', + 'settings.notifySoundPluck': 'مضراب', + 'settings.notifySoundBuzz': 'وزوز', + 'settings.notifySoundTwoToneDown': 'دو نوای پایین‌رونده', + 'settings.notifySoundThud': 'تالاپ', + 'notify.successTitle': 'وظیفه تکمیل شد', + 'notify.failureTitle': 'وظیفه ناموفق بود', + 'notify.successBody': 'یک نوبت به پایان رسید.', + 'notify.failureBody': 'وظیفه با خطا پایان یافت.', }; diff --git a/apps/web/src/i18n/locales/hu.ts b/apps/web/src/i18n/locales/hu.ts index f393701c7..17c6e613f 100644 --- a/apps/web/src/i18n/locales/hu.ts +++ b/apps/web/src/i18n/locales/hu.ts @@ -796,4 +796,29 @@ export const hu: Dict = { 'pet.communitySyncFailed': 'A szinkronizálás sikertelen: {error}', 'pet.codexBundled': 'Beépített', 'pet.codexBundledTitle': 'Az Open Designgal érkezik — letöltés nem szükséges.', + + 'settings.notifications': 'Értesítések', + 'settings.notificationsHint': 'Hang és asztali értesítés a feladat befejezésekor', + 'settings.notifyCompletionSound': 'Befejezési hang', + 'settings.notifyCompletionSoundHint': 'Megszólal egy kör végén. Alapértelmezetten kikapcsolva.', + 'settings.notifySuccessSound': 'Sikerhang', + 'settings.notifyFailureSound': 'Hibahang', + 'settings.notifyDesktop': 'Asztali értesítés', + 'settings.notifyDesktopHint': 'Akkor érkezik, ha az ablak nem aktív.', + 'settings.notifyDesktopBlocked': 'A böngésző letiltotta. Engedélyezd a webhely beállításaiban.', + 'settings.notifyDesktopUnsupported': 'Az asztali értesítések ebben a környezetben nem elérhetők.', + 'settings.notifyTest': 'Tesztküldés', + 'settings.notifyTestSent': 'Tesztértesítés elküldve. Ha nem jelenik meg banner, ellenőrizd a böngésző és a rendszer értesítési beállításait.', + 'settings.notifyTestFailed': 'Az értesítéshívás sikertelen. Ellenőrizd a böngésző és a rendszer értesítési beállításait.', + 'settings.notifySoundDing': 'Csilingelés', + 'settings.notifySoundChime': 'Csengő', + 'settings.notifySoundTwoToneUp': 'Kétszólamú emelkedő', + 'settings.notifySoundPluck': 'Pengetés', + 'settings.notifySoundBuzz': 'Zümmögés', + 'settings.notifySoundTwoToneDown': 'Kétszólamú ereszkedő', + 'settings.notifySoundThud': 'Tompa puffanás', + 'notify.successTitle': 'Feladat befejezve', + 'notify.failureTitle': 'A feladat meghiúsult', + 'notify.successBody': 'Egy kör befejeződött.', + 'notify.failureBody': 'A feladat hibával ért véget.', }; diff --git a/apps/web/src/i18n/locales/ja.ts b/apps/web/src/i18n/locales/ja.ts index 2918e6464..6d3d49c48 100644 --- a/apps/web/src/i18n/locales/ja.ts +++ b/apps/web/src/i18n/locales/ja.ts @@ -785,4 +785,29 @@ export const ja: Dict = { 'pet.communitySyncFailed': '同期に失敗しました: {error}', 'pet.codexBundled': '同梱', 'pet.codexBundledTitle': 'Open Design に同梱 — ダウンロード不要。', + + 'settings.notifications': '通知', + 'settings.notificationsHint': 'タスク完了時の効果音とデスクトップ通知', + 'settings.notifyCompletionSound': '完了サウンド', + 'settings.notifyCompletionSoundHint': '1ターン終了時に再生されます。既定ではオフです。', + 'settings.notifySuccessSound': '成功サウンド', + 'settings.notifyFailureSound': '失敗サウンド', + 'settings.notifyDesktop': 'デスクトップ通知', + 'settings.notifyDesktopHint': 'ウィンドウが非アクティブのときに送信されます。', + 'settings.notifyDesktopBlocked': 'ブラウザにブロックされています。サイト設定で許可してください。', + 'settings.notifyDesktopUnsupported': 'この環境ではデスクトップ通知に対応していません。', + 'settings.notifyTest': 'テスト送信', + 'settings.notifyTestSent': 'テスト通知を送信しました。バナーが出ない場合は、ブラウザとシステムの通知設定を確認してください。', + 'settings.notifyTestFailed': '通知の呼び出しに失敗しました。ブラウザとシステムの通知設定を確認してください。', + 'settings.notifySoundDing': 'ティン', + 'settings.notifySoundChime': 'チャイム', + 'settings.notifySoundTwoToneUp': '上昇2音', + 'settings.notifySoundPluck': 'プラック', + 'settings.notifySoundBuzz': 'ブザー', + 'settings.notifySoundTwoToneDown': '下降2音', + 'settings.notifySoundThud': 'ドスン', + 'notify.successTitle': 'タスクが完了しました', + 'notify.failureTitle': 'タスクが失敗しました', + 'notify.successBody': '1ターンが終了しました。', + 'notify.failureBody': 'タスクはエラーで終了しました。', }; diff --git a/apps/web/src/i18n/locales/ko.ts b/apps/web/src/i18n/locales/ko.ts index 2e4282658..ebd7d3166 100644 --- a/apps/web/src/i18n/locales/ko.ts +++ b/apps/web/src/i18n/locales/ko.ts @@ -786,4 +786,29 @@ export const ko: Dict = { 'pet.communitySyncFailed': '동기화 실패: {error}', 'pet.codexBundled': '내장', 'pet.codexBundledTitle': 'Open Design 에 내장 — 다운로드 불필요.', + + 'settings.notifications': '알림', + 'settings.notificationsHint': '작업 완료 시 효과음 및 데스크톱 알림', + 'settings.notifyCompletionSound': '완료 사운드', + 'settings.notifyCompletionSoundHint': '한 턴이 끝나면 재생됩니다. 기본값은 꺼짐입니다.', + 'settings.notifySuccessSound': '성공 사운드', + 'settings.notifyFailureSound': '실패 사운드', + 'settings.notifyDesktop': '데스크톱 알림', + 'settings.notifyDesktopHint': '창이 활성화되지 않았을 때 전송됩니다.', + 'settings.notifyDesktopBlocked': '브라우저에서 차단되었습니다. 사이트 설정에서 허용하세요.', + 'settings.notifyDesktopUnsupported': '이 환경에서는 데스크톱 알림을 사용할 수 없습니다.', + 'settings.notifyTest': '테스트 보내기', + 'settings.notifyTestSent': '테스트 알림을 보냈습니다. 배너가 보이지 않으면 브라우저와 시스템 알림 설정을 확인하세요.', + 'settings.notifyTestFailed': '알림 호출에 실패했습니다. 브라우저와 시스템 알림 설정을 확인하세요.', + 'settings.notifySoundDing': '딩', + 'settings.notifySoundChime': '차임벨', + 'settings.notifySoundTwoToneUp': '상승 2음', + 'settings.notifySoundPluck': '플럭', + 'settings.notifySoundBuzz': '버즈', + 'settings.notifySoundTwoToneDown': '하강 2음', + 'settings.notifySoundThud': '쿵', + 'notify.successTitle': '작업 완료', + 'notify.failureTitle': '작업 실패', + 'notify.successBody': '한 턴이 끝났습니다.', + 'notify.failureBody': '작업이 오류로 종료되었습니다.', }; diff --git a/apps/web/src/i18n/locales/pl.ts b/apps/web/src/i18n/locales/pl.ts index 088cb0813..7152b34c5 100644 --- a/apps/web/src/i18n/locales/pl.ts +++ b/apps/web/src/i18n/locales/pl.ts @@ -786,4 +786,29 @@ export const pl: Dict = { 'pet.communitySyncFailed': 'Błąd synchronizacji: {error}', 'pet.codexBundled': 'W zestawie', 'pet.codexBundledTitle': 'Dostarczany z Open Design — bez pobierania.', + + 'settings.notifications': 'Powiadomienia', + 'settings.notificationsHint': 'Dźwięk i powiadomienie pulpitu po zakończeniu zadania', + 'settings.notifyCompletionSound': 'Dźwięk zakończenia', + 'settings.notifyCompletionSoundHint': 'Odtwarzane po zakończeniu tury. Domyślnie wyłączone.', + 'settings.notifySuccessSound': 'Dźwięk sukcesu', + 'settings.notifyFailureSound': 'Dźwięk błędu', + 'settings.notifyDesktop': 'Powiadomienie pulpitu', + 'settings.notifyDesktopHint': 'Wysyłane, gdy okno nie jest aktywne.', + 'settings.notifyDesktopBlocked': 'Zablokowane przez przeglądarkę. Włącz je w ustawieniach witryny.', + 'settings.notifyDesktopUnsupported': 'Powiadomienia pulpitu są niedostępne w tym środowisku.', + 'settings.notifyTest': 'Wyślij test', + 'settings.notifyTestSent': 'Powiadomienie testowe wysłane. Jeśli baner się nie pojawi, sprawdź ustawienia powiadomień przeglądarki i systemu.', + 'settings.notifyTestFailed': 'Wywołanie powiadomienia nie powiodło się. Sprawdź ustawienia powiadomień przeglądarki i systemu.', + 'settings.notifySoundDing': 'Dzyń', + 'settings.notifySoundChime': 'Dzwonek', + 'settings.notifySoundTwoToneUp': 'Dwuton rosnący', + 'settings.notifySoundPluck': 'Szarpnięcie', + 'settings.notifySoundBuzz': 'Brzęczenie', + 'settings.notifySoundTwoToneDown': 'Dwuton malejący', + 'settings.notifySoundThud': 'Łomot', + 'notify.successTitle': 'Zadanie ukończone', + 'notify.failureTitle': 'Zadanie nieudane', + 'notify.successBody': 'Tura zakończona.', + 'notify.failureBody': 'Zadanie zakończyło się błędem.', }; diff --git a/apps/web/src/i18n/locales/pt-BR.ts b/apps/web/src/i18n/locales/pt-BR.ts index dad910703..279e4065f 100644 --- a/apps/web/src/i18n/locales/pt-BR.ts +++ b/apps/web/src/i18n/locales/pt-BR.ts @@ -785,4 +785,29 @@ export const ptBR: Dict = { 'pet.communitySyncFailed': 'Falha na sincronização: {error}', 'pet.codexBundled': 'Incluído', 'pet.codexBundledTitle': 'Já vem com o Open Design — sem download.', + + 'settings.notifications': 'Notificações', + 'settings.notificationsHint': 'Som e notificação na conclusão da tarefa', + 'settings.notifyCompletionSound': 'Som de conclusão', + 'settings.notifyCompletionSoundHint': 'Toca ao terminar uma rodada. Desativado por padrão.', + 'settings.notifySuccessSound': 'Som de sucesso', + 'settings.notifyFailureSound': 'Som de falha', + 'settings.notifyDesktop': 'Notificação na área de trabalho', + 'settings.notifyDesktopHint': 'Enviada quando a janela não está em foco.', + 'settings.notifyDesktopBlocked': 'Bloqueadas pelo navegador. Habilite nas configurações do site.', + 'settings.notifyDesktopUnsupported': 'Notificações na área de trabalho indisponíveis neste ambiente.', + 'settings.notifyTest': 'Enviar teste', + 'settings.notifyTestSent': 'Notificação de teste enviada. Se nenhum banner aparecer, verifique as notificações do navegador e do sistema.', + 'settings.notifyTestFailed': 'Falha ao chamar a notificação. Verifique as notificações do navegador e do sistema.', + 'settings.notifySoundDing': 'Tilim', + 'settings.notifySoundChime': 'Carrilhão', + 'settings.notifySoundTwoToneUp': 'Dois tons crescente', + 'settings.notifySoundPluck': 'Dedilhado', + 'settings.notifySoundBuzz': 'Zumbido', + 'settings.notifySoundTwoToneDown': 'Dois tons descendente', + 'settings.notifySoundThud': 'Baque', + 'notify.successTitle': 'Tarefa concluída', + 'notify.failureTitle': 'Tarefa falhou', + 'notify.successBody': 'Uma rodada foi concluída.', + 'notify.failureBody': 'A tarefa terminou com erro.', }; diff --git a/apps/web/src/i18n/locales/ru.ts b/apps/web/src/i18n/locales/ru.ts index b342f1a97..ac7cff77e 100644 --- a/apps/web/src/i18n/locales/ru.ts +++ b/apps/web/src/i18n/locales/ru.ts @@ -785,4 +785,29 @@ export const ru: Dict = { 'pet.communitySyncFailed': 'Ошибка синхронизации: {error}', 'pet.codexBundled': 'Встроен', 'pet.codexBundledTitle': 'Поставляется с Open Design — загрузка не нужна.', + + 'settings.notifications': 'Уведомления', + 'settings.notificationsHint': 'Звук и уведомление при завершении задачи', + 'settings.notifyCompletionSound': 'Звук завершения', + 'settings.notifyCompletionSoundHint': 'Воспроизводится по завершении хода. По умолчанию выключено.', + 'settings.notifySuccessSound': 'Звук успеха', + 'settings.notifyFailureSound': 'Звук ошибки', + 'settings.notifyDesktop': 'Уведомление на рабочем столе', + 'settings.notifyDesktopHint': 'Отправляется, когда окно не активно.', + 'settings.notifyDesktopBlocked': 'Уведомления заблокированы браузером. Разрешите их в настройках сайта.', + 'settings.notifyDesktopUnsupported': 'Уведомления на рабочем столе недоступны в этой среде.', + 'settings.notifyTest': 'Отправить тест', + 'settings.notifyTestSent': 'Тестовое уведомление отправлено. Если баннер не появился, проверьте настройки уведомлений браузера и системы.', + 'settings.notifyTestFailed': 'Вызов уведомления не удался. Проверьте настройки уведомлений браузера и системы.', + 'settings.notifySoundDing': 'Динь', + 'settings.notifySoundChime': 'Колокольчик', + 'settings.notifySoundTwoToneUp': 'Двухтон вверх', + 'settings.notifySoundPluck': 'Щипок', + 'settings.notifySoundBuzz': 'Жужжание', + 'settings.notifySoundTwoToneDown': 'Двухтон вниз', + 'settings.notifySoundThud': 'Глухой удар', + 'notify.successTitle': 'Задача выполнена', + 'notify.failureTitle': 'Задача завершилась с ошибкой', + 'notify.successBody': 'Ход завершён.', + 'notify.failureBody': 'Задача завершилась с ошибкой.', }; diff --git a/apps/web/src/i18n/locales/tr.ts b/apps/web/src/i18n/locales/tr.ts index 64e4f0294..c8d0a477a 100644 --- a/apps/web/src/i18n/locales/tr.ts +++ b/apps/web/src/i18n/locales/tr.ts @@ -785,4 +785,29 @@ export const tr: Dict = { 'pet.communitySyncFailed': 'Eşitleme başarısız: {error}', 'pet.codexBundled': 'Yerleşik', 'pet.codexBundledTitle': 'Open Design ile birlikte gelir — indirme gerekmez.', + + 'settings.notifications': 'Bildirimler', + 'settings.notificationsHint': 'Görev tamamlandığında ses ve masaüstü bildirimi', + 'settings.notifyCompletionSound': 'Tamamlanma sesi', + 'settings.notifyCompletionSoundHint': 'Bir tur sona erdiğinde çalar. Varsayılan olarak kapalı.', + 'settings.notifySuccessSound': 'Başarı sesi', + 'settings.notifyFailureSound': 'Hata sesi', + 'settings.notifyDesktop': 'Masaüstü bildirimi', + 'settings.notifyDesktopHint': 'Pencere etkin değilken gönderilir.', + 'settings.notifyDesktopBlocked': 'Tarayıcı bildirimleri engelledi. Site ayarlarından izin verin.', + 'settings.notifyDesktopUnsupported': 'Bu ortamda masaüstü bildirimleri kullanılamıyor.', + 'settings.notifyTest': 'Test gönder', + 'settings.notifyTestSent': 'Test bildirimi gönderildi. Banner görünmezse tarayıcı ve sistem bildirim ayarlarını kontrol edin.', + 'settings.notifyTestFailed': 'Bildirim çağrısı başarısız oldu. Tarayıcı ve sistem bildirim ayarlarını kontrol edin.', + 'settings.notifySoundDing': 'Çın', + 'settings.notifySoundChime': 'Çıngırak', + 'settings.notifySoundTwoToneUp': 'Yükselen iki ton', + 'settings.notifySoundPluck': 'Tıngırtı', + 'settings.notifySoundBuzz': 'Vızıltı', + 'settings.notifySoundTwoToneDown': 'Alçalan iki ton', + 'settings.notifySoundThud': 'Boğuk vuruş', + 'notify.successTitle': 'Görev tamamlandı', + 'notify.failureTitle': 'Görev başarısız oldu', + 'notify.successBody': 'Bir tur tamamlandı.', + 'notify.failureBody': 'Görev bir hata ile sona erdi.', }; diff --git a/apps/web/src/i18n/locales/zh-CN.ts b/apps/web/src/i18n/locales/zh-CN.ts index b6931484e..779feb1c2 100644 --- a/apps/web/src/i18n/locales/zh-CN.ts +++ b/apps/web/src/i18n/locales/zh-CN.ts @@ -768,4 +768,29 @@ export const zhCN: Dict = { 'pet.communitySyncFailed': '同步失败:{error}', 'pet.codexBundled': '内置', 'pet.codexBundledTitle': 'Open Design 内置宠物,无需下载。', + + 'settings.notifications': '通知', + 'settings.notificationsHint': '任务完成时的声音和桌面通知', + 'settings.notifyCompletionSound': '完成提示音', + 'settings.notifyCompletionSoundHint': '一轮任务结束时播放,默认关闭', + 'settings.notifySuccessSound': '成功音色', + 'settings.notifyFailureSound': '失败音色', + 'settings.notifyDesktop': '桌面通知', + 'settings.notifyDesktopHint': '窗口不在前台时弹出系统通知', + 'settings.notifyDesktopBlocked': '浏览器已拒绝通知权限,请在站点设置中开启', + 'settings.notifyDesktopUnsupported': '当前环境不支持桌面通知', + 'settings.notifyTest': '测试通知', + 'settings.notifyTestSent': '测试通知已发送;如果没有弹窗,请检查浏览器和系统通知设置。', + 'settings.notifyTestFailed': '通知调用失败,请检查浏览器和系统通知设置。', + 'settings.notifySoundDing': '叮', + 'settings.notifySoundChime': '风铃', + 'settings.notifySoundTwoToneUp': '上行双音', + 'settings.notifySoundPluck': '拨弦', + 'settings.notifySoundBuzz': '蜂鸣', + 'settings.notifySoundTwoToneDown': '下行双音', + 'settings.notifySoundThud': '低响', + 'notify.successTitle': '任务已完成', + 'notify.failureTitle': '任务失败', + 'notify.successBody': '一轮回答已经写完。', + 'notify.failureBody': '本轮任务出错,请查看错误信息。', }; diff --git a/apps/web/src/i18n/locales/zh-TW.ts b/apps/web/src/i18n/locales/zh-TW.ts index 1967dc46d..03c5db7bd 100644 --- a/apps/web/src/i18n/locales/zh-TW.ts +++ b/apps/web/src/i18n/locales/zh-TW.ts @@ -768,4 +768,29 @@ export const zhTW: Dict = { 'pet.communitySyncFailed': '同步失敗:{error}', 'pet.codexBundled': '內建', 'pet.codexBundledTitle': 'Open Design 內建寵物,無需下載。', + + 'settings.notifications': '通知', + 'settings.notificationsHint': '任務完成時的音效和桌面通知', + 'settings.notifyCompletionSound': '完成提示音', + 'settings.notifyCompletionSoundHint': '一輪任務結束時播放,預設關閉', + 'settings.notifySuccessSound': '成功音色', + 'settings.notifyFailureSound': '失敗音色', + 'settings.notifyDesktop': '桌面通知', + 'settings.notifyDesktopHint': '視窗不在前景時跳出系統通知', + 'settings.notifyDesktopBlocked': '瀏覽器已拒絕通知權限,請在網站設定中開啟', + 'settings.notifyDesktopUnsupported': '當前環境不支援桌面通知', + 'settings.notifyTest': '測試通知', + 'settings.notifyTestSent': '測試通知已送出;如果沒有彈窗,請檢查瀏覽器和系統通知設定。', + 'settings.notifyTestFailed': '通知呼叫失敗,請檢查瀏覽器和系統通知設定。', + 'settings.notifySoundDing': '叮', + 'settings.notifySoundChime': '風鈴', + 'settings.notifySoundTwoToneUp': '上行雙音', + 'settings.notifySoundPluck': '撥弦', + 'settings.notifySoundBuzz': '蜂鳴', + 'settings.notifySoundTwoToneDown': '下行雙音', + 'settings.notifySoundThud': '低響', + 'notify.successTitle': '任務已完成', + 'notify.failureTitle': '任務失敗', + 'notify.successBody': '一輪回答已經寫完。', + 'notify.failureBody': '本輪任務出錯,請查看錯誤訊息。', }; diff --git a/apps/web/src/i18n/types.ts b/apps/web/src/i18n/types.ts index 0247b60f9..50eed98fc 100644 --- a/apps/web/src/i18n/types.ts +++ b/apps/web/src/i18n/types.ts @@ -137,6 +137,32 @@ export interface Dict { 'settings.runtimeDevelopment': string; 'settings.versionUnavailable': string; + // Notifications (settings + system notifications) + 'settings.notifications': string; + 'settings.notificationsHint': string; + 'settings.notifyCompletionSound': string; + 'settings.notifyCompletionSoundHint': string; + 'settings.notifySuccessSound': string; + 'settings.notifyFailureSound': string; + 'settings.notifyDesktop': string; + 'settings.notifyDesktopHint': string; + 'settings.notifyDesktopBlocked': string; + 'settings.notifyDesktopUnsupported': string; + 'settings.notifyTest': string; + 'settings.notifyTestSent': string; + 'settings.notifyTestFailed': string; + 'settings.notifySoundDing': string; + 'settings.notifySoundChime': string; + 'settings.notifySoundTwoToneUp': string; + 'settings.notifySoundPluck': string; + 'settings.notifySoundBuzz': string; + 'settings.notifySoundTwoToneDown': string; + 'settings.notifySoundThud': string; + 'notify.successTitle': string; + 'notify.failureTitle': string; + 'notify.successBody': string; + 'notify.failureBody': string; + // Entry view / tabs 'entry.tabDesigns': string; 'entry.tabExamples': string; diff --git a/apps/web/src/state/config.ts b/apps/web/src/state/config.ts index 3b1a93c57..79009b00e 100644 --- a/apps/web/src/state/config.ts +++ b/apps/web/src/state/config.ts @@ -4,8 +4,13 @@ import type { ApiProtocol, AppConfig, MediaProviderCredentials, + NotificationsConfig, PetConfig, } from '../types'; +import { + DEFAULT_FAILURE_SOUND_ID, + DEFAULT_SUCCESS_SOUND_ID, +} from '../utils/notifications'; const STORAGE_KEY = 'open-design:config'; const CONFIG_MIGRATION_VERSION = 1; @@ -13,6 +18,16 @@ const CONFIG_MIGRATION_VERSION = 1; // Hatched out of the box, but tucked away — the user has to go through // either the entry-view "adopt a pet" callout or Settings → Pets to // summon them. Keeps the workspace quiet for first-run users. +// Both switches default off so first-run users are not greeted by a +// surprise sound or a permission prompt; they can opt in from Settings → +// Notifications when they want it. +export const DEFAULT_NOTIFICATIONS: NotificationsConfig = { + soundEnabled: false, + successSoundId: DEFAULT_SUCCESS_SOUND_ID, + failureSoundId: DEFAULT_FAILURE_SOUND_ID, + desktopEnabled: false, +}; + export const DEFAULT_PET: PetConfig = { adopted: false, enabled: false, @@ -44,6 +59,7 @@ export const DEFAULT_CONFIG: AppConfig = { mediaProviders: {}, agentModels: {}, pet: DEFAULT_PET, + notifications: DEFAULT_NOTIFICATIONS, }; /** Well-known providers with pre-filled base URLs. */ @@ -163,6 +179,12 @@ function normalizePet(input: Partial | undefined): PetConfig { }; } +function normalizeNotifications( + input: Partial | undefined, +): NotificationsConfig { + return { ...DEFAULT_NOTIFICATIONS, ...(input ?? {}) }; +} + function inferApiProtocol(model: string, baseUrl: string): ApiProtocol { try { return isOpenAICompatible(model, baseUrl) ? 'openai' : 'anthropic'; @@ -177,7 +199,13 @@ function inferApiProtocol(model: string, baseUrl: string): ApiProtocol { export function loadConfig(): AppConfig { try { const raw = localStorage.getItem(STORAGE_KEY); - if (!raw) return { ...DEFAULT_CONFIG, pet: normalizePet(DEFAULT_PET) }; + if (!raw) { + return { + ...DEFAULT_CONFIG, + pet: normalizePet(DEFAULT_PET), + notifications: normalizeNotifications(DEFAULT_NOTIFICATIONS), + }; + } const parsed = JSON.parse(raw) as Partial; const parsedHasApiProtocol = Object.prototype.hasOwnProperty.call( parsed, @@ -189,6 +217,7 @@ export function loadConfig(): AppConfig { mediaProviders: { ...(parsed.mediaProviders ?? {}) }, agentModels: { ...(parsed.agentModels ?? {}) }, pet: normalizePet(parsed.pet), + notifications: normalizeNotifications(parsed.notifications), }; if (parsed.configMigrationVersion !== CONFIG_MIGRATION_VERSION) { @@ -212,7 +241,11 @@ export function loadConfig(): AppConfig { return merged; } catch { - return { ...DEFAULT_CONFIG, pet: normalizePet(DEFAULT_PET) }; + return { + ...DEFAULT_CONFIG, + pet: normalizePet(DEFAULT_PET), + notifications: normalizeNotifications(DEFAULT_NOTIFICATIONS), + }; } } diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index b865b4416..ea7eafd46 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -112,6 +112,18 @@ export interface PetCustom { atlas?: PetAtlasLayout; } +export interface NotificationsConfig { + // Master switch for the completion sound. Default false — first-run users + // hear nothing until they opt in. + soundEnabled: boolean; + // Sound id played when a turn ends with `runStatus === 'succeeded'`. + successSoundId: string; + // Sound id played when a turn ends with `runStatus === 'failed'`. + failureSoundId: string; + // Master switch for the browser Notification API banner. Default false. + desktopEnabled: boolean; +} + export interface PetConfig { // True once the user has explicitly picked a pet (built-in or custom). // Until then, the entry view shows an "adopt" callout to drive discovery. @@ -157,6 +169,10 @@ export interface AppConfig { // the feature land at `undefined`, which the loader normalizes to a // safe default (un-adopted, hidden until the user opts in). pet?: PetConfig; + // Optional task-completion sound + browser notification settings. Older + // configs that pre-date the feature land at `undefined`, which the loader + // normalizes to a safe default (everything off). + notifications?: NotificationsConfig; } export type AgentEvent = PersistedAgentEvent; diff --git a/apps/web/src/utils/notifications.test.ts b/apps/web/src/utils/notifications.test.ts new file mode 100644 index 000000000..21bdb1fbe --- /dev/null +++ b/apps/web/src/utils/notifications.test.ts @@ -0,0 +1,97 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { showCompletionNotification } from './notifications'; + +type NotificationOptionsWithRenotify = NotificationOptions & { renotify?: boolean }; + +class MockNotification { + static permission: NotificationPermission = 'granted'; + static instances: MockNotification[] = []; + + onclose: (() => void) | null = null; + onclick: (() => void) | null = null; + onerror: (() => void) | null = null; + + constructor( + public title: string, + public options?: NotificationOptionsWithRenotify, + ) { + MockNotification.instances.push(this); + } + + close(): void { + // Fire synchronously so tests can observe cleanup without browser events. + this.onclose?.(); + } +} + +afterEach(() => { + vi.unstubAllGlobals(); + MockNotification.permission = 'granted'; + MockNotification.instances = []; +}); + +describe('showCompletionNotification', () => { + it('creates a renotifying desktop notification when permission is granted', async () => { + vi.stubGlobal('Notification', MockNotification as unknown as typeof Notification); + + const result = await showCompletionNotification({ + status: 'succeeded', + title: 'Task completed', + body: 'Done', + }); + + expect(result).toBe('shown'); + expect(MockNotification.instances).toHaveLength(1); + expect(MockNotification.instances[0]!.title).toBe('Task completed'); + expect(MockNotification.instances[0]!.options).toMatchObject({ + body: 'Done', + tag: 'od-task-succeeded', + renotify: true, + }); + }); + + it('uses the service worker notification API when available', async () => { + const showNotification = vi.fn().mockResolvedValue(undefined); + const registration = { showNotification }; + const register = vi.fn().mockResolvedValue(registration); + vi.stubGlobal('Notification', MockNotification as unknown as typeof Notification); + vi.stubGlobal('navigator', { + serviceWorker: { + register, + ready: Promise.resolve(registration), + }, + }); + + const result = await showCompletionNotification({ + status: 'succeeded', + title: 'Task completed', + body: 'Done', + }); + + expect(result).toBe('shown'); + expect(register).toHaveBeenCalledWith('/od-notifications-sw.js'); + expect(showNotification).toHaveBeenCalledWith( + 'Task completed', + expect.objectContaining({ + body: 'Done', + tag: 'od-task-succeeded', + renotify: true, + }), + ); + expect(MockNotification.instances).toHaveLength(0); + }); + + it('does not create a notification when permission is not granted', async () => { + MockNotification.permission = 'denied'; + vi.stubGlobal('Notification', MockNotification as unknown as typeof Notification); + + const result = await showCompletionNotification({ + status: 'failed', + title: 'Task failed', + body: 'Error', + }); + + expect(result).toBe('permission-denied'); + expect(MockNotification.instances).toHaveLength(0); + }); +}); diff --git a/apps/web/src/utils/notifications.ts b/apps/web/src/utils/notifications.ts new file mode 100644 index 000000000..ecd820310 --- /dev/null +++ b/apps/web/src/utils/notifications.ts @@ -0,0 +1,258 @@ +import type { Dict } from '../i18n/types'; + +export type SoundId = string; + +export interface SoundOption { + id: SoundId; + labelKey: keyof Dict; +} + +export const SUCCESS_SOUNDS: SoundOption[] = [ + { id: 'ding', labelKey: 'settings.notifySoundDing' }, + { id: 'chime', labelKey: 'settings.notifySoundChime' }, + { id: 'two-tone-up', labelKey: 'settings.notifySoundTwoToneUp' }, + { id: 'pluck', labelKey: 'settings.notifySoundPluck' }, +]; + +export const FAILURE_SOUNDS: SoundOption[] = [ + { id: 'buzz', labelKey: 'settings.notifySoundBuzz' }, + { id: 'two-tone-down', labelKey: 'settings.notifySoundTwoToneDown' }, + { id: 'thud', labelKey: 'settings.notifySoundThud' }, +]; + +export const DEFAULT_SUCCESS_SOUND_ID: SoundId = 'ding'; +export const DEFAULT_FAILURE_SOUND_ID: SoundId = 'buzz'; + +type AudioCtxCtor = typeof AudioContext; +type NotificationOptionsWithBrowserExtensions = NotificationOptions & { + renotify?: boolean; +}; + +let ctx: AudioContext | null = null; +const activeNotifications = new Set(); +const SERVICE_WORKER_URL = '/od-notifications-sw.js'; + +function getCtx(): AudioContext | null { + if (typeof window === 'undefined') return null; + const Ctor: AudioCtxCtor | undefined = + window.AudioContext ?? + (window as unknown as { webkitAudioContext?: AudioCtxCtor }).webkitAudioContext; + if (!Ctor) return null; + if (!ctx) { + try { + ctx = new Ctor(); + } catch { + return null; + } + } + if (ctx && ctx.state === 'suspended') { + void ctx.resume().catch(() => { + // Autoplay policy can refuse — fall through silently. The next + // user-gesture-driven call will retry. + }); + } + return ctx; +} + +interface ToneSpec { + freq: number; + type: OscillatorType; + start: number; + duration: number; + gain?: number; + // Optional lowpass cutoff applied via a BiquadFilter for plucky textures. + lowpass?: number; +} + +function playTones(c: AudioContext, tones: ToneSpec[]): void { + const now = c.currentTime; + for (const tone of tones) { + const osc = c.createOscillator(); + const gain = c.createGain(); + osc.type = tone.type; + osc.frequency.value = tone.freq; + const peak = tone.gain ?? 0.18; + const startAt = now + tone.start; + const endAt = startAt + tone.duration; + // Short attack to avoid clicks; exponential-ish decay via linear ramp + // to a near-zero value (exponentialRamp can't reach 0). + gain.gain.setValueAtTime(0.0001, startAt); + gain.gain.linearRampToValueAtTime(peak, startAt + Math.min(0.005, tone.duration * 0.2)); + gain.gain.exponentialRampToValueAtTime(0.0001, endAt); + + let last: AudioNode = osc; + if (tone.lowpass) { + const lp = c.createBiquadFilter(); + lp.type = 'lowpass'; + lp.frequency.value = tone.lowpass; + osc.connect(lp); + last = lp; + } + last.connect(gain); + gain.connect(c.destination); + osc.start(startAt); + osc.stop(endAt + 0.02); + } +} + +const SOUND_PLAYERS: Record void> = { + ding: (c) => { + playTones(c, [{ freq: 880, type: 'sine', start: 0, duration: 0.25, gain: 0.22 }]); + }, + chime: (c) => { + playTones(c, [ + { freq: 880, type: 'triangle', start: 0, duration: 0.4, gain: 0.18 }, + { freq: 1320, type: 'triangle', start: 0, duration: 0.4, gain: 0.12 }, + ]); + }, + 'two-tone-up': (c) => { + playTones(c, [ + { freq: 660, type: 'square', start: 0, duration: 0.08, gain: 0.16 }, + { freq: 990, type: 'square', start: 0.09, duration: 0.08, gain: 0.16 }, + ]); + }, + pluck: (c) => { + playTones(c, [ + { freq: 220, type: 'sawtooth', start: 0, duration: 0.15, gain: 0.22, lowpass: 1200 }, + ]); + }, + buzz: (c) => { + playTones(c, [ + { freq: 165, type: 'square', start: 0, duration: 0.06, gain: 0.2 }, + { freq: 165, type: 'square', start: 0.1, duration: 0.06, gain: 0.2 }, + { freq: 165, type: 'square', start: 0.2, duration: 0.06, gain: 0.2 }, + ]); + }, + 'two-tone-down': (c) => { + playTones(c, [ + { freq: 880, type: 'sine', start: 0, duration: 0.12, gain: 0.2 }, + { freq: 440, type: 'sine', start: 0.13, duration: 0.12, gain: 0.2 }, + ]); + }, + thud: (c) => { + playTones(c, [{ freq: 80, type: 'sine', start: 0, duration: 0.12, gain: 0.32 }]); + }, +}; + +export function playSound(id: SoundId): void { + const c = getCtx(); + if (!c) return; + const player = SOUND_PLAYERS[id]; + if (!player) return; + try { + player(c); + } catch { + // A node creation / connection failure should never throw out to UI code. + } +} + +export function previewSuccess(id: SoundId): void { + playSound(id); +} + +export function previewFailure(id: SoundId): void { + playSound(id); +} + +export function notificationPermission(): NotificationPermission | 'unsupported' { + if (typeof Notification === 'undefined') return 'unsupported'; + return Notification.permission; +} + +export async function requestNotificationPermission(): Promise< + NotificationPermission | 'unsupported' +> { + if (typeof Notification === 'undefined') return 'unsupported'; + if (Notification.permission === 'granted' || Notification.permission === 'denied') { + return Notification.permission; + } + try { + return await Notification.requestPermission(); + } catch { + return 'denied'; + } +} + +export interface CompletionNotificationOpts { + status: 'succeeded' | 'failed'; + title: string; + body: string; + onClick?: () => void; +} + +export type CompletionNotificationResult = + | 'shown' + | 'unsupported' + | 'permission-denied' + | 'failed'; + +function notificationOptionsFor( + opts: CompletionNotificationOpts, +): NotificationOptionsWithBrowserExtensions { + const tag = `od-task-${opts.status}`; + return { + body: opts.body, + tag, + renotify: true, + data: { + status: opts.status, + url: typeof window === 'undefined' ? '/' : window.location.href, + }, + }; +} + +async function showViaServiceWorker( + opts: CompletionNotificationOpts, +): Promise { + if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return null; + try { + const registration = await navigator.serviceWorker.register(SERVICE_WORKER_URL); + const readyRegistration = await navigator.serviceWorker.ready.catch(() => registration); + if (!readyRegistration.showNotification) return null; + await readyRegistration.showNotification(opts.title, notificationOptionsFor(opts)); + return 'shown'; + } catch { + return null; + } +} + +function showViaConstructor(opts: CompletionNotificationOpts): CompletionNotificationResult { + if (typeof Notification === 'undefined') return 'unsupported'; + if (Notification.permission !== 'granted') return 'permission-denied'; + try { + const note = new Notification(opts.title, notificationOptionsFor(opts)); + activeNotifications.add(note); + const release = () => { + note.onclick = null; + note.onclose = null; + note.onerror = null; + activeNotifications.delete(note); + }; + note.onclick = () => { + try { + if (typeof window !== 'undefined') window.focus(); + } catch { + /* ignore */ + } + opts.onClick?.(); + try { + note.close(); + } catch { + /* ignore */ + } + }; + note.onclose = release; + note.onerror = release; + return 'shown'; + } catch { + return 'failed'; + } +} + +export async function showCompletionNotification( + opts: CompletionNotificationOpts, +): Promise { + if (typeof Notification === 'undefined') return 'unsupported'; + if (Notification.permission !== 'granted') return 'permission-denied'; + return (await showViaServiceWorker(opts)) ?? showViaConstructor(opts); +} diff --git a/scripts/check-residual-js.ts b/scripts/check-residual-js.ts index 98c815387..0e4111b9d 100644 --- a/scripts/check-residual-js.ts +++ b/scripts/check-residual-js.ts @@ -31,6 +31,8 @@ const allowedExactPaths = new Set([ "scripts/import-prompt-templates.mjs", "scripts/postinstall.mjs", "apps/packaged/esbuild.config.mjs", + // Browser service workers must be served as JavaScript files. + "apps/web/public/od-notifications-sw.js", "scripts/bake-html-ppt-examples.mjs", "scripts/scaffold-html-ppt-skills.mjs", "scripts/sync-hyperframes-skill.mjs",