feat(web): task completion sound + browser notification (#359)

Merged per maintainer approval.
This commit is contained in:
monshunter 2026-05-03 16:00:39 +08:00 committed by GitHub
parent 61cdc3fe4b
commit 68493b7b72
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1090 additions and 4 deletions

View file

@ -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;
})());
});

View file

@ -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) {
<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
</svg>
);
case 'bell':
return (
<svg {...common}>
<path d="M6 8a6 6 0 1 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
</svg>
);
case 'check':
return (
<svg {...common}>

View file

@ -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');

View file

@ -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({
<small>{t('settings.appearanceHint')}</small>
</span>
</button>
<button
type="button"
className={`settings-nav-item${activeSection === 'notifications' ? ' active' : ''}`}
onClick={() => setActiveSection('notifications')}
>
<Icon name="bell" size={18} />
<span>
<strong>{t('settings.notifications')}</strong>
<small>{t('settings.notificationsHint')}</small>
</span>
</button>
<button
type="button"
className={`settings-nav-item${activeSection === 'pet' ? ' active' : ''}`}
@ -726,6 +747,10 @@ export function SettingsDialog({
<AppearanceSection cfg={cfg} setCfg={setCfg} />
) : null}
{activeSection === 'notifications' ? (
<NotificationsSection cfg={cfg} setCfg={setCfg} />
) : null}
{activeSection === 'pet' ? (
<PetSettings cfg={cfg} setCfg={setCfg} />
) : null}
@ -942,3 +967,181 @@ function AppearanceSection({
</section>
);
}
function NotificationsSection({
cfg,
setCfg,
}: {
cfg: AppConfig;
setCfg: Dispatch<SetStateAction<AppConfig>>;
}) {
const { t } = useI18n();
const notif = cfg.notifications ?? DEFAULT_NOTIFICATIONS;
const [permission, setPermission] = useState<NotificationPermission | 'unsupported'>(
() => notificationPermission(),
);
const [testStatus, setTestStatus] = useState<ReturnType<typeof testNotificationStatusText> | null>(null);
const updateNotif = (
patch: Partial<NonNullable<AppConfig['notifications']>>,
) => {
setCfg((c) => ({
...c,
notifications: { ...DEFAULT_NOTIFICATIONS, ...(c.notifications ?? {}), ...patch },
}));
};
const toggleSound = () => {
const next = !notif.soundEnabled;
updateNotif({ soundEnabled: next });
// Give the user immediate audible feedback when turning the master
// switch on so they know which sound they're signing up for. Resuming
// the AudioContext also bakes in their gesture for later auto-plays.
if (next) playSound(notif.successSoundId);
};
const toggleDesktop = async () => {
if (notif.desktopEnabled) {
updateNotif({ desktopEnabled: false });
return;
}
const result = await requestNotificationPermission();
setPermission(result);
if (result === 'granted') {
updateNotif({ desktopEnabled: true });
} else {
updateNotif({ desktopEnabled: false });
}
};
const sendTestNotification = async () => {
const result = await showCompletionNotification({
status: 'succeeded',
title: t('notify.successTitle'),
body: t('notify.successBody'),
});
setPermission(notificationPermission());
setTestStatus(testNotificationStatusText(result));
};
return (
<section className="settings-section">
<div className="section-head">
<div>
<h3>{t('settings.notifications')}</h3>
<p className="hint">{t('settings.notificationsHint')}</p>
</div>
</div>
<div className="settings-subsection">
<div className="section-head">
<div>
<h4>{t('settings.notifyCompletionSound')}</h4>
<p className="hint">{t('settings.notifyCompletionSoundHint')}</p>
</div>
</div>
<div className="seg-control" role="group" aria-label={t('settings.notifyCompletionSound')} style={{ '--seg-cols': 1 } as React.CSSProperties}>
<button
type="button"
className={'seg-btn' + (notif.soundEnabled ? ' active' : '')}
aria-pressed={notif.soundEnabled}
onClick={toggleSound}
>
<span className="seg-title">{notif.soundEnabled ? t('common.active') : t('common.offline')}</span>
</button>
</div>
{notif.soundEnabled ? (
<>
<div className="settings-field">
<label>{t('settings.notifySuccessSound')}</label>
<div className="seg-control" role="group" aria-label={t('settings.notifySuccessSound')} style={{ '--seg-cols': SUCCESS_SOUNDS.length } as React.CSSProperties}>
{SUCCESS_SOUNDS.map((sound) => (
<button
key={sound.id}
type="button"
className={'seg-btn' + (notif.successSoundId === sound.id ? ' active' : '')}
aria-pressed={notif.successSoundId === sound.id}
onClick={() => {
updateNotif({ successSoundId: sound.id });
playSound(sound.id);
}}
>
<span className="seg-title">{t(sound.labelKey)}</span>
</button>
))}
</div>
</div>
<div className="settings-field">
<label>{t('settings.notifyFailureSound')}</label>
<div className="seg-control" role="group" aria-label={t('settings.notifyFailureSound')} style={{ '--seg-cols': FAILURE_SOUNDS.length } as React.CSSProperties}>
{FAILURE_SOUNDS.map((sound) => (
<button
key={sound.id}
type="button"
className={'seg-btn' + (notif.failureSoundId === sound.id ? ' active' : '')}
aria-pressed={notif.failureSoundId === sound.id}
onClick={() => {
updateNotif({ failureSoundId: sound.id });
playSound(sound.id);
}}
>
<span className="seg-title">{t(sound.labelKey)}</span>
</button>
))}
</div>
</div>
</>
) : null}
</div>
<div className="settings-subsection">
<div className="section-head">
<div>
<h4>{t('settings.notifyDesktop')}</h4>
<p className="hint">{t('settings.notifyDesktopHint')}</p>
</div>
</div>
<div className="seg-control" role="group" aria-label={t('settings.notifyDesktop')} style={{ '--seg-cols': 1 } as React.CSSProperties}>
<button
type="button"
className={'seg-btn' + (notif.desktopEnabled ? ' active' : '')}
aria-pressed={notif.desktopEnabled}
disabled={permission === 'unsupported'}
onClick={() => { void toggleDesktop(); }}
>
<span className="seg-title">{notif.desktopEnabled ? t('common.active') : t('common.offline')}</span>
</button>
</div>
{permission === 'unsupported' ? (
<p className="hint">{t('settings.notifyDesktopUnsupported')}</p>
) : null}
{permission === 'denied' ? (
<p className="hint">{t('settings.notifyDesktopBlocked')}</p>
) : null}
{notif.desktopEnabled && permission === 'granted' ? (
<>
<button type="button" className="ghost" onClick={() => { void sendTestNotification(); }}>
{t('settings.notifyTest')}
</button>
{testStatus ? <p className="hint" role="status">{t(testStatus)}</p> : null}
</>
) : null}
</div>
</section>
);
}
function testNotificationStatusText(
result: Awaited<ReturnType<typeof showCompletionNotification>>,
):
| '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';
}

View file

@ -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': 'انتهت المهمة بخطأ.',
};

View file

@ -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.',
};

View file

@ -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.',
};

View file

@ -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.',
};

View file

@ -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': 'وظیفه با خطا پایان یافت.',
};

View file

@ -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.',
};

View file

@ -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': 'タスクはエラーで終了しました。',
};

View file

@ -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': '작업이 오류로 종료되었습니다.',
};

View file

@ -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.',
};

View file

@ -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.',
};

View file

@ -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': 'Задача завершилась с ошибкой.',
};

View file

@ -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.',
};

View file

@ -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': '本轮任务出错,请查看错误信息。',
};

View file

@ -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': '本輪任務出錯,請查看錯誤訊息。',
};

View file

@ -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;

View file

@ -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<PetConfig> | undefined): PetConfig {
};
}
function normalizeNotifications(
input: Partial<NotificationsConfig> | 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<AppConfig>;
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),
};
}
}

View file

@ -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;

View file

@ -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);
});
});

View file

@ -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<Notification>();
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<SoundId, (c: AudioContext) => 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<CompletionNotificationResult | null> {
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<CompletionNotificationResult> {
if (typeof Notification === 'undefined') return 'unsupported';
if (Notification.permission !== 'granted') return 'permission-denied';
return (await showViaServiceWorker(opts)) ?? showViaConstructor(opts);
}

View file

@ -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",