Merge pull request #4155 from nesquena/stage-4143
Some checks failed
Release & Docker / release (push) Has been cancelled

Release NP (v0.51.403): notification click reuses existing chat tab (#4109)
This commit is contained in:
nesquena-hermes
2026-06-13 17:47:27 -07:00
committed by GitHub
3 changed files with 98 additions and 2 deletions

View File

@@ -3,6 +3,12 @@
## [Unreleased]
## [v0.51.403] — 2026-06-13 — Release NP (notification click reuses the existing chat tab, #4109)
### Fixed
- **Clicking a notification now focuses (and navigates) the existing chat tab instead of opening a new one (#4109).** The service-worker `notificationclick` handler still focuses an exact-path tab when one is open; when the notification points at a different session, it now reuses a same-origin focusable/navigable client (`navigate(targetUrl)` then `focus()`) rather than spawning a duplicate window, and only falls back to `openWindow` if no reusable tab exists. The new `sameOrigin` guard prevents navigating an unrelated cross-origin window. (#4109)
## [v0.51.400] — 2026-06-13 — Release NM (skill bundles in WebUI slash commands, #4087)
### Fixed

View File

@@ -182,6 +182,9 @@ self.addEventListener('notificationclick', (event) => {
const samePath = (clientUrl) => {
try { return new URL(clientUrl).pathname === targetPath; } catch (_e) { return false; }
};
const sameOrigin = (clientUrl) => {
try { return new URL(clientUrl).origin === self.location.origin; } 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
@@ -190,13 +193,17 @@ self.addEventListener('notificationclick', (event) => {
// 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);
const openNotificationWindow = () => (
self.clients.openWindow ? self.clients.openWindow(targetUrl) : undefined
);
const focusableClient = clientList.find((client) => sameOrigin(client.url) && 'focus' in client && 'navigate' in client);
if (focusableClient && 'navigate' in focusableClient) {
return focusableClient.navigate(targetUrl)
.then((client) => (client && 'focus' in client ? client.focus() : focusableClient.focus()))
.catch(() => focusableClient.focus());
}
return openNotificationWindow();
})
);
});

View File

@@ -0,0 +1,83 @@
"""Regression coverage for notification clicks reusing an open WebUI tab (#4109)."""
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
SW_SRC = (ROOT / "static" / "sw.js").read_text(encoding="utf-8")
MESSAGES_SRC = (ROOT / "static" / "messages.js").read_text(encoding="utf-8")
ROUTES_SRC = (ROOT / "api" / "routes.py").read_text(encoding="utf-8")
@pytest.fixture(scope="session", autouse=True)
def test_server():
"""This module only reads static source; it does not need the HTTP fixture."""
def _notification_click_handler() -> str:
start = SW_SRC.index("self.addEventListener('notificationclick'")
return SW_SRC[start:]
def test_notification_click_keeps_exact_path_focus_fast_path():
handler = _notification_click_handler()
target_idx = handler.index("const targetClient = clientList.find")
exact_focus_idx = handler.index("if (targetClient) return targetClient.focus();")
reusable_idx = handler.index("const focusableClient = clientList.find")
assert "samePath(client.url)" in handler
assert "new URL(clientUrl).pathname === targetPath" in handler
assert target_idx < exact_focus_idx < reusable_idx
def test_notification_click_navigates_reusable_client_before_opening_window():
handler = _notification_click_handler()
reusable_idx = handler.index("const focusableClient = clientList.find")
navigate_idx = handler.index("focusableClient.navigate(targetUrl)")
open_fallback_idx = handler.index("return openNotificationWindow();")
assert "sameOrigin(client.url) && 'focus' in client && 'navigate' in client" in handler
assert reusable_idx < navigate_idx < open_fallback_idx
assert "if (self.clients.openWindow) return self.clients.openWindow(targetUrl)" not in handler
def test_notification_click_focuses_after_navigation_or_navigation_failure():
handler = _notification_click_handler()
assert (
".then((client) => (client && 'focus' in client ? "
"client.focus() : focusableClient.focus()))"
) in handler
assert ".catch(() => focusableClient.focus())" in handler
def test_notification_click_open_window_remains_no_reusable_client_fallback():
handler = _notification_click_handler()
assert "const openNotificationWindow = () => (" in handler
assert "self.clients.openWindow ? self.clients.openWindow(targetUrl) : undefined" in handler
assert handler.index("return openNotificationWindow();") > handler.index(
"focusableClient.navigate(targetUrl)"
)
def test_test_notification_without_sid_still_targets_current_page_for_reuse():
assert "const url=sid?`${location.origin}${_sessionUrlForSid(sid)}`:location.href;" in MESSAGES_SRC
assert "sendBrowserNotification('Hermes test','Notifications are ready.',{force:true});" in (
ROOT / "static" / "index.html"
).read_text(encoding="utf-8")
def test_service_worker_update_delivery_keeps_versioned_no_store_route():
assert "const CACHE_NAME = 'hermes-shell-__WEBUI_VERSION__';" in SW_SRC
assert "self.skipWaiting();" in SW_SRC
assert "self.clients.claim();" in SW_SRC
route_idx = ROUTES_SRC.index('"/sw.js"')
route_block = ROUTES_SRC[route_idx : route_idx + 1200]
assert 'replace(\n "__WEBUI_VERSION__", version_token\n )' in route_block
assert 'handler.send_header("Cache-Control", "no-store")' in route_block