Release v0.51.346 — Release LJ (PWA notification controls #3229) (#3897)
Some checks failed
Release & Docker / release (push) Has been cancelled

* Add PWA notification controls (#3229, #3196)

Rebased onto fresh master (#3229 was 281 commits behind). Routes browser
notifications through the service worker (reg.showNotification) with a direct
Notification fallback, threads the originating session deep link/tag into the
payload, and adds Enable/Send-test/permission-status controls in Settings.
notificationclick prefers an already-open target-session tab before opening a
new window. Added the 6 new i18n keys to the Polish (pl) locale block, which
the original PR missed.

Co-authored-by: AJV20 <AJV20@users.noreply.github.com>

* Apply Opus review fixes to #3229 PWA notifications

- MUST-FIX: _showPwaNotification used navigator.serviceWorker.ready, which
  never settles when no SW registration ever activates (reverse-proxy MIME
  mishap, SW disabled) — every notification was silently dropped, a regression
  vs master. Switch to getRegistration() raced against a 2s timeout, falling
  back to new Notification().
- SHOULD-FIX: notificationclick matched client.url === targetUrl exactly, but
  _sessionUrlForSid copies the current query/hash into the deep link, so an
  already-open session tab missed the match and spawned a duplicate window.
  Compare pathnames instead.
- SHOULD-FIX: "Send test" with permission denied was a silent no-op — now
  surfaces the notifications_denied toast on the force path.
- SHOULD-FIX: permission-status span went stale after granting via the button
  — refresh it in requestNotificationPermission().then.
- Consistency: the request-permission grant branch now has the same
  new Notification fallback as the granted branch.
- Update test_pwa_notification_controls.py to assert the pathname match.

* Stamp v0.51.346 — Release LJ (PWA notification controls #3229/#3196)

---------

Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: AJV20 <AJV20@users.noreply.github.com>
This commit is contained in:
nesquena-hermes
2026-06-09 15:54:21 -07:00
committed by GitHub
parent cc7f8a274a
commit aa5cd6e8ff
7 changed files with 250 additions and 12 deletions

View File

@@ -3,6 +3,12 @@
## [Unreleased]
## [v0.51.346] — 2026-06-09 — Release LJ (PWA notification controls)
### Added
- **PWA notifications now use the service worker for reliable delivery, with explicit controls in Settings.** Browser notifications prefer `ServiceWorkerRegistration.showNotification()` (falling back to a direct `Notification` when no service worker is registered), which is the only notification path that works in an installed standalone PWA — notably iOS, where `new Notification()` is unavailable. Notification payloads now carry the originating session's deep link, icon, badge, and a stable per-session tag, and clicking a notification focuses an already-open tab for that session (or opens a new window) instead of yanking an unrelated tab away from what you were reading. Settings → Preferences gains **Enable notifications**, **Send test**, and a live permission-status readout. (#3196, #3229)
## [v0.51.345] — 2026-06-09 — Release LI (model override picker for scheduled jobs)
### Added

View File

@@ -908,6 +908,12 @@ const LOCALES = {
settings_desc_tts_engine: 'Choose speech engine. Edge TTS uses Microsoft neural voices via the server.',
settings_label_notifications: 'Browser notifications',
settings_desc_notifications: 'Show a system notification when a response completes while the app is in the background.',
notifications_enable_btn: 'Enable notifications',
notifications_test_btn: 'Send test',
notifications_permission_status: (status) => `Permission: ${status}`,
notifications_enabled_toast: 'Notifications enabled.',
notifications_denied: 'Notifications are blocked in this browser. Enable them in browser settings.',
notifications_unsupported: 'Notifications are not supported in this browser.',
settings_desc_token_usage: 'Displays input/output token count below each assistant reply. Also toggled with /usage.',
settings_label_api_redact: 'Redact sensitive data in API responses',
settings_desc_api_redact: 'Self-hosted users can disable for transparency (not recommended for shared instances).',
@@ -2298,6 +2304,12 @@ const LOCALES = {
settings_desc_tts_engine: 'Scegli il motore vocale. Edge TTS utilizza le voci neurali Microsoft tramite il server.',
settings_label_notifications: 'Notifiche browser',
settings_desc_notifications: 'Mostra una notifica di sistema quando una risposta termina mentre l\'app è in background.',
notifications_enable_btn: 'Enable notifications',
notifications_test_btn: 'Send test',
notifications_permission_status: (status) => `Permission: ${status}`,
notifications_enabled_toast: 'Notifications enabled.',
notifications_denied: 'Notifications are blocked in this browser. Enable them in browser settings.',
notifications_unsupported: 'Notifications are not supported in this browser.',
settings_desc_token_usage: 'Mostra il conteggio token input/output sotto ogni risposta dell\'assistente. Attivabile anche con /usage.',
settings_label_api_redact: 'Oscura dati sensibili nelle risposte API',
settings_desc_api_redact: 'Gli utenti self-hosted possono disabilitare per trasparenza (sconsigliato per istanze condivise).',
@@ -3693,6 +3705,12 @@ const LOCALES = {
settings_desc_tts_engine: '音声エンジンを選択してください。Edge TTSはサーバー経由でMicrosoftのニューラル音声を使用します。',
settings_label_notifications: 'ブラウザ通知',
settings_desc_notifications: 'アプリがバックグラウンドの間に応答が完了したらシステム通知を表示します。',
notifications_enable_btn: 'Enable notifications',
notifications_test_btn: 'Send test',
notifications_permission_status: (status) => `Permission: ${status}`,
notifications_enabled_toast: 'Notifications enabled.',
notifications_denied: 'Notifications are blocked in this browser. Enable them in browser settings.',
notifications_unsupported: 'Notifications are not supported in this browser.',
settings_desc_token_usage: 'アシスタント応答の下に入力/出力トークン数を表示します。/usage でも切替可能。',
settings_label_api_redact: 'API 応答内の機密データをマスク',
settings_desc_api_redact: 'セルフホスト利用者は透明性のため無効化できます (共有インスタンスでは非推奨)。',
@@ -4808,6 +4826,12 @@ const LOCALES = {
settings_desc_sound: 'Проигрывать звук, когда помощник завершает ответ.',
settings_label_notifications: 'Уведомления браузера',
settings_desc_notifications: 'Показывать системное уведомление, когда ответ готов, а вкладка находится в фоне.',
notifications_enable_btn: 'Enable notifications',
notifications_test_btn: 'Send test',
notifications_permission_status: (status) => `Permission: ${status}`,
notifications_enabled_toast: 'Notifications enabled.',
notifications_denied: 'Notifications are blocked in this browser. Enable them in browser settings.',
notifications_unsupported: 'Notifications are not supported in this browser.',
settings_desc_token_usage: 'Показывает количество входных и выходных токенов под каждым ответом помощника. Также переключается через /usage.',
settings_sidebar_density_compact: 'Компактно',
settings_sidebar_density_detailed: 'Подробно',
@@ -6136,6 +6160,12 @@ const LOCALES = {
settings_desc_sound: 'Reproduce un sonido cuando el asistente termina una respuesta.',
settings_label_notifications: 'Notificaciones del navegador',
settings_desc_notifications: 'Muestra una notificación del sistema cuando una respuesta termina mientras la pestaña está en segundo plano.',
notifications_enable_btn: 'Enable notifications',
notifications_test_btn: 'Send test',
notifications_permission_status: (status) => `Permission: ${status}`,
notifications_enabled_toast: 'Notifications enabled.',
notifications_denied: 'Notifications are blocked in this browser. Enable them in browser settings.',
notifications_unsupported: 'Notifications are not supported in this browser.',
settings_desc_token_usage: 'Muestra el conteo de tokens de entrada/salida debajo de cada respuesta del asistente. También se puede alternar con /usage.',
settings_sidebar_density_compact: 'Compacta',
settings_sidebar_density_detailed: 'Detallada',
@@ -7437,6 +7467,12 @@ const LOCALES = {
settings_desc_sound: 'Spielt einen Ton ab, wenn der Assistent eine Antwort beendet.',
settings_label_notifications: 'Browser-Benachrichtigungen',
settings_desc_notifications: 'Zeigt eine Systembenachrichtigung an, wenn eine Antwort fertiggestellt wird, während der Tab im Hintergrund ist.',
notifications_enable_btn: 'Enable notifications',
notifications_test_btn: 'Send test',
notifications_permission_status: (status) => `Permission: ${status}`,
notifications_enabled_toast: 'Notifications enabled.',
notifications_denied: 'Notifications are blocked in this browser. Enable them in browser settings.',
notifications_unsupported: 'Notifications are not supported in this browser.',
settings_desc_token_usage: 'Zeigt die Anzahl der Input/Output-Token unter jeder Antwort des Assistenten an. Auch umschaltbar mit /usage.',
settings_sidebar_density_compact: 'Kompakt',
settings_sidebar_density_detailed: 'Detailliert',
@@ -8884,6 +8920,12 @@ const LOCALES = {
settings_label_notifications: '浏览器通知',
settings_desc_sound: '助手完成回复时播放提示音。',
settings_desc_notifications: '当标签页在后台时,回复完成后显示系统通知。',
notifications_enable_btn: 'Enable notifications',
notifications_test_btn: 'Send test',
notifications_permission_status: (status) => `Permission: ${status}`,
notifications_enabled_toast: 'Notifications enabled.',
notifications_denied: 'Notifications are blocked in this browser. Enable them in browser settings.',
notifications_unsupported: 'Notifications are not supported in this browser.',
settings_desc_token_usage: '在助手每次回复下方显示输入/输出 token 数量。也可以用 /usage 切换。',
settings_sidebar_density_compact: '紧凑',
settings_sidebar_density_detailed: '详细',
@@ -10395,6 +10437,12 @@ const LOCALES = {
settings_desc_tts_engine: '選擇語音引擎。Edge TTS 會透過伺服器使用 Microsoft 神經語音。',
settings_label_notifications: '瀏覽器通知',
settings_desc_notifications: '分頁在背景中時,回覆完成後顯示系統通知。',
notifications_enable_btn: 'Enable notifications',
notifications_test_btn: 'Send test',
notifications_permission_status: (status) => `Permission: ${status}`,
notifications_enabled_toast: 'Notifications enabled.',
notifications_denied: 'Notifications are blocked in this browser. Enable them in browser settings.',
notifications_unsupported: 'Notifications are not supported in this browser.',
settings_desc_token_usage: '在每則助理回覆下方顯示輸入/輸出 token 數量。也可以使用 /usage 切換。',
settings_label_api_redact: '在 API 回應中遮蔽敏感資料',
settings_desc_api_redact: '自行託管使用者可以為了透明度停用此功能(不建議在共用執行個體中停用)。',
@@ -11609,6 +11657,12 @@ const LOCALES = {
settings_desc_sound: 'Tocar som quando assistente finalizar resposta.',
settings_label_notifications: 'Notificações do navegador',
settings_desc_notifications: 'Mostrar notificação quando resposta completar com app em background.',
notifications_enable_btn: 'Enable notifications',
notifications_test_btn: 'Send test',
notifications_permission_status: (status) => `Permission: ${status}`,
notifications_enabled_toast: 'Notifications enabled.',
notifications_denied: 'Notifications are blocked in this browser. Enable them in browser settings.',
notifications_unsupported: 'Notifications are not supported in this browser.',
settings_desc_token_usage: 'Exibe contagem de tokens abaixo de cada resposta. Também com /usage.',
settings_sidebar_density_compact: 'Compacto',
settings_sidebar_density_detailed: 'Detalhado',
@@ -12899,6 +12953,12 @@ const LOCALES = {
settings_desc_sound: 'Assistant 응답이 끝나면 소리를 재생합니다.',
settings_label_notifications: '브라우저 알림',
settings_desc_notifications: '앱이 백그라운드에 있을 때 응답이 완료되면 시스템 알림을 표시합니다.',
notifications_enable_btn: 'Enable notifications',
notifications_test_btn: 'Send test',
notifications_permission_status: (status) => `Permission: ${status}`,
notifications_enabled_toast: 'Notifications enabled.',
notifications_denied: 'Notifications are blocked in this browser. Enable them in browser settings.',
notifications_unsupported: 'Notifications are not supported in this browser.',
settings_desc_token_usage: '각 Assistant 응답 아래에 입력/출력 토큰 수를 표시합니다. /usage로도 전환할 수 있습니다.',
settings_sidebar_density_compact: '간결',
settings_sidebar_density_detailed: '자세히',
@@ -14233,6 +14293,12 @@ const LOCALES = {
settings_desc_tts_engine: 'Choisissez le moteur de synthèse vocale. Edge TTS utilise les voix neuronales Microsoft via le serveur.',
settings_label_notifications: 'Notifications du navigateur',
settings_desc_notifications: 'Afficher une notification système lorsqu\'une réponse est terminée alors que l\'application est en arrière-plan.',
notifications_enable_btn: 'Enable notifications',
notifications_test_btn: 'Send test',
notifications_permission_status: (status) => `Permission: ${status}`,
notifications_enabled_toast: 'Notifications enabled.',
notifications_denied: 'Notifications are blocked in this browser. Enable them in browser settings.',
notifications_unsupported: 'Notifications are not supported in this browser.',
settings_desc_token_usage: 'Affiche le nombre de jetons dentrée/sortie sous chaque réponse de lassistant. Également basculé avec /usage.',
settings_label_api_redact: 'Expurger les données sensibles dans les réponses API',
settings_desc_api_redact: 'Les utilisateurs auto-hébergés peuvent désactiver pour des raisons de transparence (non recommandé pour les instances partagées).',
@@ -15615,6 +15681,12 @@ const LOCALES = {
settings_desc_sound: 'Asistan bir yanıtı bitirdiğinde bir ses çalın.',
settings_label_notifications: 'Tarayıcı bildirimleri',
settings_desc_notifications: 'Uygulama arka plandayken bir yanıt tamamlandığında bir sistem bildirimi gösterin.',
notifications_enable_btn: 'Enable notifications',
notifications_test_btn: 'Send test',
notifications_permission_status: (status) => `Permission: ${status}`,
notifications_enabled_toast: 'Notifications enabled.',
notifications_denied: 'Notifications are blocked in this browser. Enable them in browser settings.',
notifications_unsupported: 'Notifications are not supported in this browser.',
settings_desc_token_usage: 'Her Asistan yanıtının altında giriş/çıkış jeton sayılarını gösterir. /usage ile de değiştirilebilir.',
settings_sidebar_density_compact: 'Kompakt',
settings_sidebar_density_detailed: 'Ayrıntılı',
@@ -17073,6 +17145,12 @@ const LOCALES = {
settings_desc_tts_engine: 'Wybierz silnik mowy. Edge TTS korzysta z głosów neuronowych Microsoft za pośrednictwem serwera.',
settings_label_notifications: 'Powiadomienia przeglądarki',
settings_desc_notifications: 'Pokaż powiadomienie systemowe, gdy odpowiedź zostanie ukończona, podczas gdy aplikacja działa w tle.',
notifications_enable_btn: 'Włącz powiadomienia',
notifications_test_btn: 'Wyślij test',
notifications_permission_status: (status) => `Uprawnienie: ${status}`,
notifications_enabled_toast: 'Powiadomienia włączone.',
notifications_denied: 'Powiadomienia są zablokowane w tej przeglądarce. Włącz je w ustawieniach przeglądarki.',
notifications_unsupported: 'Powiadomienia nie są obsługiwane w tej przeglądarce.',
settings_desc_token_usage: 'Wyświetla liczbę tokenów wejściowych/wyjściowych pod każdą odpowiedzią asystenta. Można też przełączyć za pomocą /usage.',
settings_label_api_redact: 'Ukrywaj poufne dane w odpowiedziach API',
settings_desc_api_redact: 'Użytkownicy korzystający z hostingu własnego mogą wyłączyć to dla przejrzystości (niezalecane dla współdzielonych instancji).',

View File

@@ -1125,7 +1125,12 @@
<input type="checkbox" id="settingsNotificationsEnabled" style="width:15px;height:15px;accent-color:var(--accent)">
<span data-i18n="settings_label_notifications">Browser notifications</span>
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_notifications">Show a system notification when a response completes while the tab is in the background.</div>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_notifications">Show a system notification when a response completes while the app is in the background.</div>
<div style="display:flex;gap:8px;align-items:center;margin-top:8px;flex-wrap:wrap">
<button type="button" class="sm-btn" onclick="requestNotificationPermission()" style="padding:5px 10px;font-size:12px" data-i18n="notifications_enable_btn">Enable notifications</button>
<button type="button" class="sm-btn" onclick="sendBrowserNotification('Hermes test','Notifications are ready.',{force:true});" style="padding:5px 10px;font-size:12px" data-i18n="notifications_test_btn">Send test</button>
<span id="notificationPermissionStatus" style="font-size:11px;color:var(--muted)"></span>
</div>
</div>
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">

View File

@@ -2911,14 +2911,14 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
const d=JSON.parse(e.data);
showApprovalForSession(activeSid, d, 1);
playAttentionSound(_attentionSoundKey(activeSid,'approval',1));
sendBrowserNotification('Approval required',d.description||'Tool approval needed');
sendBrowserNotification('Approval required',d.description||'Tool approval needed',{sid:activeSid});
});
source.addEventListener('clarify',e=>{
const d=JSON.parse(e.data);
showClarifyForSession(activeSid, d);
playAttentionSound(_attentionSoundKey(activeSid,'clarify',1));
sendBrowserNotification('Clarification needed',d.question||'Tool clarification needed');
sendBrowserNotification('Clarification needed',d.question||'Tool clarification needed',{sid:activeSid});
});
source.addEventListener('state_saved',e=>{
@@ -3251,7 +3251,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
renderSessionList();
_setActivePaneIdleIfOwner();
playNotificationSound();
sendBrowserNotification('Response complete',assistantText?assistantText.slice(0,100):'Task finished');
sendBrowserNotification('Response complete',assistantText?assistantText.slice(0,100):'Task finished',{sid:activeSid});
};
if(_shouldUseStreamFade()&&assistantBody){
_cancelAnimationFramePendingStreamRender();
@@ -4946,16 +4946,59 @@ function playAttentionSound(key){
}catch(e){console.warn('Attention sound failed:',e);}
}
function sendBrowserNotification(title,body){
if(!window._notificationsEnabled||!document.hidden) return;
if(!('Notification' in window)) return;
function _notificationOptions(body,options={}){
const sid=(options&&options.sid)||(S&&S.session&&S.session.session_id);
const url=sid?`${location.origin}${_sessionUrlForSid(sid)}`:location.href;
return {body:body||'',tag:sid?`hermes-${sid}`:'hermes-webui',renotify:false,icon:'static/favicon-192.png',badge:'static/favicon-32.png',data:{url}};
}
function _showPwaNotification(title,body,options={}){
const botName=assistantDisplayName();
const opts=_notificationOptions(body,options);
const direct=()=>new Notification(title||botName,opts);
// Prefer the service worker (the only path that works in a standalone PWA,
// notably iOS). Use getRegistration() + a short timeout race rather than
// navigator.serviceWorker.ready, because `.ready` NEVER settles when no
// registration ever activates for the scope (e.g. a reverse proxy serving
// sw.js with the wrong MIME type, or SW disabled in the browser) — which
// would silently drop every notification instead of falling back.
if(navigator.serviceWorker&&navigator.serviceWorker.getRegistration){
const reg$=Promise.race([
navigator.serviceWorker.getRegistration().catch(()=>null),
new Promise(res=>setTimeout(()=>res(null),2000))
]);
return reg$.then(reg=>(reg&&reg.active&&reg.showNotification)
? reg.showNotification(title||botName,opts)
: direct());
}
return Promise.resolve(direct());
}
function requestNotificationPermission(){
if(!('Notification' in window)){
if(typeof showToast==='function') showToast(t('notifications_unsupported'),3000,'error');
return Promise.resolve('unsupported');
}
if(Notification.permission==='granted') return Promise.resolve('granted');
if(Notification.permission==='denied'){
if(typeof showToast==='function') showToast(t('notifications_denied'),3500,'error');
return Promise.resolve('denied');
}
return Notification.requestPermission().then(p=>{
if(typeof showToast==='function') showToast(p==='granted'?t('notifications_enabled_toast'):t('notifications_denied'),3000,p==='granted'?undefined:'error');
if(typeof updateNotificationPermissionStatus==='function') updateNotificationPermissionStatus();
return p;
});
}
function sendBrowserNotification(title,body,options={}){
const force=!!(options&&options.force);
if(!force&&(!window._notificationsEnabled||!document.hidden)) return;
if(!('Notification' in window)) return;
if(Notification.permission==='granted'){
new Notification(title||botName,{body:body});
}else if(Notification.permission!=='denied'){
Notification.requestPermission().then(p=>{
if(p==='granted') new Notification(title||botName,{body:body});
});
_showPwaNotification(title,body,options).catch(()=>{try{new Notification(title||assistantDisplayName(),_notificationOptions(body,options));}catch(_err){}});
}else if(Notification.permission==='denied'){
// Explicit "Send test" (force) deserves feedback instead of a silent no-op.
if(force&&typeof showToast==='function') showToast(t('notifications_denied'),3500,'error');
}else{
requestNotificationPermission().then(p=>{if(p==='granted') _showPwaNotification(title,body,options).catch(()=>{try{new Notification(title||assistantDisplayName(),_notificationOptions(body,options));}catch(_err){}});});
}
}

View File

@@ -8518,6 +8518,7 @@ function loadGatewayStatus(){
const _origSwitchSettings=switchSettingsSection;
switchSettingsSection=function(name){
_origSwitchSettings(name);
if(name==='preferences') updateNotificationPermissionStatus();
if(name==='system'){loadMcpServers();loadMcpTools();loadGatewayStatus();}
};
@@ -8628,3 +8629,12 @@ async function _restoreCheckpoint(workspace,checkpoint,message){
showToast(t('checkpoint_restore')+': '+e.message,'error');
}
}
function updateNotificationPermissionStatus(){
const el=$('notificationPermissionStatus');
if(!el) return;
if(!('Notification' in window)){el.textContent=t('notifications_unsupported');return;}
const perm=Notification.permission||'default';
el.textContent=t('notifications_permission_status', perm);
}

View File

@@ -171,3 +171,31 @@ self.addEventListener('fetch', (event) => {
})))
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const rawUrl = (event.notification.data && event.notification.data.url) || './';
const targetUrl = new URL(rawUrl, self.registration.scope || './').href;
const targetPath = new URL(targetUrl).pathname;
const samePath = (clientUrl) => {
try { return new URL(clientUrl).pathname === targetPath; } catch (_e) { return false; }
};
event.waitUntil(
self.clients.matchAll({type: 'window', includeUncontrolled: true}).then((clientList) => {
// Match on pathname, not the full href: _sessionUrlForSid copies the
// current page's query string + hash into the deep link, so an open tab
// already on /session/<sid> would fail an exact-href match and spawn a
// duplicate window.
const targetClient = clientList.find((client) => samePath(client.url) && 'focus' in client);
if (targetClient) return targetClient.focus();
if (self.clients.openWindow) return self.clients.openWindow(targetUrl);
const focusableClient = clientList.find((client) => 'focus' in client);
if (focusableClient && 'navigate' in focusableClient) {
return focusableClient.navigate(targetUrl)
.then((client) => (client && 'focus' in client ? client.focus() : focusableClient.focus()))
.catch(() => focusableClient.focus());
}
})
);
});

View File

@@ -0,0 +1,68 @@
"""Regression coverage for PWA-backed browser notifications (#3196)."""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MESSAGES_JS = (ROOT / "static" / "messages.js").read_text(encoding="utf-8")
SW_JS = (ROOT / "static" / "sw.js").read_text(encoding="utf-8")
INDEX_HTML = (ROOT / "static" / "index.html").read_text(encoding="utf-8")
PANELS_JS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8")
I18N_JS = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
CHANGELOG = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8")
def test_browser_notifications_use_service_worker_when_available():
assert "function _showPwaNotification" in MESSAGES_JS
assert "navigator.serviceWorker.ready" in MESSAGES_JS
assert "reg.showNotification" in MESSAGES_JS
assert "new Notification" in MESSAGES_JS
assert "function sendBrowserNotification" in MESSAGES_JS
def test_notification_payload_uses_completion_session_when_provided():
assert "function _notificationOptions" in MESSAGES_JS
assert "const sid=(options&&options.sid)||(S&&S.session&&S.session.session_id);" in MESSAGES_JS
assert "_sessionUrlForSid(sid)" in MESSAGES_JS
assert "data:{url}" in MESSAGES_JS
assert "tag:sid?`hermes-${sid}`" in MESSAGES_JS
assert "sendBrowserNotification('Response complete',assistantText?assistantText.slice(0,100):'Task finished',{sid:activeSid})" in MESSAGES_JS
assert "sendBrowserNotification('Approval required',d.description||'Tool approval needed',{sid:activeSid})" in MESSAGES_JS
assert "sendBrowserNotification('Clarification needed',d.question||'Tool clarification needed',{sid:activeSid})" in MESSAGES_JS
def test_service_worker_handles_notification_clicks_without_hijacking_other_sessions():
assert "notificationclick" in SW_JS
assert "event.notification.close()" in SW_JS
assert "clients.matchAll" in SW_JS
assert "clients.openWindow" in SW_JS
# Match the open tab on pathname, not the full href (query/hash differ).
assert "samePath(client.url)" in SW_JS
assert "new URL(clientUrl).pathname === targetPath" in SW_JS
assert "targetClient.focus()" in SW_JS
exact_idx = SW_JS.index("targetClient.focus()")
open_idx = SW_JS.index("self.clients.openWindow(targetUrl)")
navigate_idx = SW_JS.index("focusableClient.navigate(targetUrl)")
assert exact_idx < open_idx < navigate_idx
def test_settings_expose_permission_and_test_controls():
assert "notificationPermissionStatus" in INDEX_HTML
assert "requestNotificationPermission()" in INDEX_HTML
assert "sendBrowserNotification('Hermes test'" in INDEX_HTML
assert "{force:true}" in INDEX_HTML
assert "function updateNotificationPermissionStatus" in PANELS_JS
assert "notifications_permission_status" in PANELS_JS
def test_notification_i18n_and_changelog_entries_exist():
for key in [
"notifications_enable_btn",
"notifications_test_btn",
"notifications_permission_status",
"notifications_enabled_toast",
"notifications_denied",
"notifications_unsupported",
]:
assert key in I18N_JS
assert "PWA notifications now use the service worker" in CHANGELOG
assert "#3196" in CHANGELOG