Merge pull request #4155 from nesquena/stage-4143
Some checks failed
Release & Docker / release (push) Has been cancelled
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:
@@ -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
|
||||
|
||||
11
static/sw.js
11
static/sw.js
@@ -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();
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
83
tests/test_4109_notification_focus_existing_tab.py
Normal file
83
tests/test_4109_notification_focus_existing_tab.py
Normal 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
|
||||
Reference in New Issue
Block a user