Stage 405: PR #2842 — feat: polish installed PWA startup by @AJV20

This commit is contained in:
nesquena-hermes
2026-05-24 18:28:53 +00:00
9 changed files with 207 additions and 8 deletions
+11
View File
@@ -1662,6 +1662,17 @@ function applyBotName(){
if(typeof fetchReasoningChip==='function') fetchReasoningChip();
if(typeof refreshProviderQuotaIndicator==='function') refreshProviderQuotaIndicator();
const urlSession=(typeof _sessionIdFromLocation==='function')?_sessionIdFromLocation():null;
const pwaLaunchAction=(window.HermesPWA&&typeof window.HermesPWA.launchAction==='function')
? window.HermesPWA.launchAction()
: null;
if(pwaLaunchAction==='new-chat'){
try{
await newSession(true);
if(S.session) await _startBootModelDropdown();
S._bootReady=true;
syncTopbar();syncWorkspacePanelState();await renderSessionList();if(typeof startGatewaySSE==='function')startGatewaySSE();return;
}catch(e){console.warn('[pwa] new-chat launch action failed', e);}
}
const savedLocal=localStorage.getItem('hermes-webui-session');
const saved=urlSession||savedLocal;
if(saved){
+3 -1
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>Hermes</title>
<!-- base href enables subpath mount support; all static paths must stay relative (no leading slash).
MUST appear before manifest/favicon links so browsers resolve relative URLs against the
@@ -26,6 +26,8 @@
<script>(function(){try{var t=localStorage.getItem('hermes-theme')||'dark';if(t==='system')t=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';var c=t==='dark'?'#141425':'#FAF7F0';document.querySelectorAll('meta[name="theme-color"]').forEach(function(m){m.setAttribute('content',c);m.removeAttribute('media');});}catch(e){}})()</script>
<script>(function(){try{document.documentElement.dataset.workspacePanel=localStorage.getItem('hermes-webui-workspace-panel')==='open'?'open':'closed';}catch(e){document.documentElement.dataset.workspacePanel='closed';}})()</script>
<script>(function(){try{if(localStorage.getItem('hermes-webui-sidebar-collapsed')==='1')document.documentElement.dataset.sidebarCollapsed='1';}catch(e){}})()</script>
<link rel="preload" href="static/pwa-startup.js?v=__WEBUI_VERSION__" as="script">
<script src="static/pwa-startup.js?v=__WEBUI_VERSION__"></script>
<script>window.__HERMES_CONFIG__={maxUploadBytes:__MAX_UPLOAD_BYTES__,csrfToken:__CSRF_TOKEN_JSON__};</script>
<script>(function(){
var cfg=window.__HERMES_CONFIG__||{},token=cfg.csrfToken||'';
+20 -1
View File
@@ -1,12 +1,31 @@
{
"id": "./",
"name": "Hermes",
"short_name": "Hermes",
"description": "Hermes AI Agent Web UI",
"start_url": "./",
"start_url": "./?source=pwa",
"scope": "./",
"display": "standalone",
"display_override": ["window-controls-overlay", "standalone", "minimal-ui"],
"background_color": "#0D0D1A",
"theme_color": "#0D0D1A",
"orientation": "portrait-primary",
"categories": ["productivity", "utilities"],
"shortcuts": [
{
"name": "New conversation",
"short_name": "New chat",
"description": "Open Hermes ready for a new chat",
"url": "./?source=pwa&action=new-chat",
"icons": [
{
"src": "static/favicon-192.png",
"sizes": "192x192",
"type": "image/png"
}
]
}
],
"icons": [
{
"src": "static/favicon.svg",
+83
View File
@@ -0,0 +1,83 @@
// Early PWA startup helpers.
// Runs before the main UI bundle so installed launches can paint with the
// correct native-like classes and capture browser install events early.
(function(){
'use strict';
var root=document.documentElement;
function mql(query){
try{return window.matchMedia&&window.matchMedia(query).matches;}catch(_){return false;}
}
function isStandalone(){
return window.navigator.standalone===true ||
mql('(display-mode: standalone)') ||
mql('(display-mode: fullscreen)') ||
mql('(display-mode: window-controls-overlay)');
}
function isIOS(){
return /iPad|iPhone|iPod/.test(window.navigator.userAgent||'') ||
(window.navigator.platform==='MacIntel' && window.navigator.maxTouchPoints>1);
}
function syncMode(){
var standalone=isStandalone();
root.classList.toggle('pwa-standalone',standalone);
root.classList.toggle('pwa-browser',!standalone);
root.classList.toggle('pwa-ios',isIOS());
root.classList.toggle('pwa-offline',window.navigator.onLine===false);
root.dataset.pwaDisplayMode=standalone?'standalone':'browser';
return standalone;
}
function dispatch(name,detail){
try{window.dispatchEvent(new CustomEvent(name,{detail:detail||{}}));}catch(_){}
}
syncMode();
window.addEventListener('online',function(){syncMode();dispatch('hermes:pwa-connection-change',{online:true});});
window.addEventListener('offline',function(){syncMode();dispatch('hermes:pwa-connection-change',{online:false});});
if(window.matchMedia){
['(display-mode: standalone)','(display-mode: fullscreen)','(display-mode: window-controls-overlay)'].forEach(function(query){
try{
var media=window.matchMedia(query);
var handler=function(){syncMode();};
if(media.addEventListener)media.addEventListener('change',handler);
else if(media.addListener)media.addListener(handler);
}catch(_){}
});
}
window.addEventListener('beforeinstallprompt',function(event){
event.preventDefault();
window.hermesDeferredInstallPrompt=event;
root.classList.add('pwa-installable');
dispatch('hermes:pwa-installable');
});
window.addEventListener('appinstalled',function(){
window.hermesDeferredInstallPrompt=null;
root.classList.remove('pwa-installable');
root.classList.add('pwa-installed');
dispatch('hermes:pwa-installed');
});
document.addEventListener('visibilitychange',function(){
if(document.visibilityState==='visible'){
syncMode();
root.classList.add('pwa-resumed');
window.setTimeout(function(){root.classList.remove('pwa-resumed');},1200);
}
});
window.HermesPWA={
isStandalone:isStandalone,
syncMode:syncMode,
launchAction:function(){
try{return new URLSearchParams(window.location.search||'').get('action')||null;}catch(_){return null;}
},
promptInstall:function(){
var prompt=window.hermesDeferredInstallPrompt;
if(!prompt||typeof prompt['prompt']!=='function')return Promise.resolve({outcome:'unavailable'});
window.hermesDeferredInstallPrompt=null;
root.classList.remove('pwa-installable');
prompt['prompt']();
return Promise.resolve(prompt.userChoice).catch(function(){return {outcome:'dismissed'};});
}
};
})();
+7
View File
@@ -641,6 +641,13 @@
/* ── Smooth dark mode transitions ── */
body,header,footer,aside,nav,main,div,button,input,textarea,select{transition-property:background-color,border-color,color;transition-duration:.15s;transition-timing-function:ease;}
:root{--app-titlebar-safe-top:0px;}
.pwa-standalone{overscroll-behavior:none;}
.pwa-standalone body{-webkit-tap-highlight-color:transparent;}
.pwa-standalone .app-titlebar-reload{display:inline-flex;}
.pwa-offline .app-titlebar::after{content:'';position:absolute;left:50%;bottom:5px;width:5px;height:5px;border-radius:999px;background:var(--warning);box-shadow:0 0 0 3px color-mix(in srgb,var(--warning) 22%,transparent);transform:translateX(-50%);}
.pwa-resumed .app-titlebar-title{animation:pwa-title-resume .6s ease-out;}
@keyframes pwa-title-resume{0%{opacity:.65;}100%{opacity:1;}}
@media (prefers-reduced-motion: reduce){.pwa-resumed .app-titlebar-title{animation:none;}}
@supports (padding-top: env(safe-area-inset-top)){
@media (display-mode: standalone), (display-mode: fullscreen){
:root{--app-titlebar-safe-top:env(safe-area-inset-top,0px);}
+3 -2
View File
@@ -23,6 +23,7 @@ const CACHE_NAME = 'hermes-shell-__WEBUI_VERSION__';
const VQ = '?v=__WEBUI_VERSION__';
const SHELL_ASSETS = [
'./static/style.css' + VQ,
'./static/pwa-startup.js' + VQ,
'./static/boot.js' + VQ,
'./static/ui.js' + VQ,
'./static/messages.js' + VQ,
@@ -115,7 +116,7 @@ self.addEventListener('fetch', (event) => {
// freshly set login cookie until the user manually refreshes.
if (event.request.mode === 'navigate') {
event.respondWith(
fetch(event.request).then((response) => {
fetch(new Request(event.request, { cache: 'no-store' })).then((response) => {
if (
event.request.method === 'GET' &&
response.status === 200 &&
@@ -152,7 +153,7 @@ self.addEventListener('fetch', (event) => {
// but avoids executing stale JS/CSS after a local hotfix when WEBUI_VERSION
// has not changed yet (e.g. before a guarded restart updates the ?v token).
event.respondWith(
fetch(event.request).then((response) => {
fetch(new Request(event.request, { cache: 'no-store' })).then((response) => {
if (
event.request.method === 'GET' &&
response.status === 200
+19
View File
@@ -665,6 +665,11 @@ def test_100dvh_viewport_height():
"style.css must use 100dvh for correct mobile viewport height (100vh hides content under address bar)"
def test_viewport_disables_page_zoom_for_native_pwa_shell():
"""Installed PWA launches should not rubber-band into browser-style page zoom."""
assert 'name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"' in HTML
def test_pwa_safe_area_top_stays_scoped_to_installed_modes():
"""The PWA shell should not opt into cover-mode geometry for every browser surface."""
assert 'viewport-fit=cover' not in HTML
@@ -701,6 +706,20 @@ def test_safe_area_variables_available_for_pwa_shell():
)
def test_pwa_startup_classes_have_native_shell_affordances():
"""The JS-startup fallback classes should mirror browser display-mode CSS.
iOS and embedded webviews do not always evaluate display-mode media queries
the same way as Chromium. pwa-startup.js adds classes early, so CSS should
provide the same native-feel affordances through those classes.
"""
assert ".pwa-standalone" in CSS
assert ".pwa-standalone .app-titlebar-reload" in CSS
assert "overscroll-behavior:none" in CSS
assert ".pwa-offline .app-titlebar::after" in CSS
assert "pwa-title-resume" in CSS
def test_composer_touch_target_size():
"""Send button and composer inputs must have minimum 44px touch targets on mobile.
+59 -2
View File
@@ -16,6 +16,8 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
MANIFEST = ROOT / "static" / "manifest.json"
SW = ROOT / "static" / "sw.js"
PWA_STARTUP = ROOT / "static" / "pwa-startup.js"
BOOT = ROOT / "static" / "boot.js"
INDEX = ROOT / "static" / "index.html"
ROUTES = ROOT / "api" / "routes.py"
AUTH = ROOT / "api" / "auth.py"
@@ -117,7 +119,7 @@ class TestServiceWorker:
"""
src = SW.read_text(encoding="utf-8")
assert "Shell assets: network-first with cache fallback" in src
assert "fetch(event.request).then((response)" in src
assert "fetch(new Request(event.request, { cache: 'no-store' })).then((response)" in src
assert "caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone))" in src
assert ".catch(() => caches.match(event.request)" in src
assert "if (cached) return cached;" not in src, (
@@ -281,10 +283,65 @@ class TestIndexHtmlIntegration:
marker = "// Shell assets: network-first with cache fallback"
assert marker in src
block = src[src.find(marker):src.find(marker) + 900]
assert "fetch(event.request).then" in block
assert "fetch(new Request(event.request, { cache: 'no-store' })).then" in block
assert "caches.match(event.request)" in block
assert "caches.match(event.request).then((cached)" not in block[:250]
def test_index_loads_pwa_startup_helper_early(self):
"""The installed-app shell should classify standalone/offline mode before
the main UI bundle hydrates, so native chrome and safe-area affordances
are present on first paint.
"""
src = INDEX.read_text(encoding="utf-8")
preload_pos = src.find('href="static/pwa-startup.js?v=__WEBUI_VERSION__"')
script_pos = src.find('src="static/pwa-startup.js?v=__WEBUI_VERSION__"')
ui_pos = src.find('static/ui.js?v=__WEBUI_VERSION__')
assert preload_pos != -1, "index.html must preload the PWA startup helper"
assert script_pos != -1, "index.html must load the PWA startup helper"
assert ui_pos != -1, "index.html must load the main UI bundle"
assert preload_pos < ui_pos and script_pos < ui_pos, (
"pwa-startup.js must run before ui.js so standalone/offline classes "
"are available before the app shell paints"
)
def test_sw_precaches_pwa_startup_helper(self):
src = SW.read_text(encoding="utf-8")
assert "pwa-startup.js' + VQ" in src or 'pwa-startup.js" + VQ' in src, (
"sw.js SHELL_ASSETS must pre-cache pwa-startup.js with the same "
"version query used by index.html"
)
def test_manifest_has_native_launch_fields(self):
data = json.loads(MANIFEST.read_text(encoding="utf-8"))
assert data.get("id") == "./"
assert data.get("scope") == "./"
assert data.get("start_url") == "./?source=pwa"
assert "standalone" in data.get("display_override", []), (
"manifest.display_override should preserve standalone as a native "
"launch fallback"
)
shortcuts = data.get("shortcuts") or []
assert any(shortcut.get("url") == "./?source=pwa&action=new-chat" for shortcut in shortcuts), (
"manifest should expose a native shortcut for starting a new chat"
)
def test_pwa_startup_detects_standalone_and_install_events(self):
src = PWA_STARTUP.read_text(encoding="utf-8")
assert "pwa-standalone" in src
assert "pwa-browser" in src
assert "beforeinstallprompt" in src
assert "appinstalled" in src
assert "HermesPWA" in src
assert "launchAction" in src
assert "promptInstall" in src
def test_pwa_new_chat_shortcut_is_handled_at_boot(self):
src = BOOT.read_text(encoding="utf-8")
assert "pwaLaunchAction" in src
assert "launchAction()" in src
assert "pwaLaunchAction==='new-chat'" in src
assert "await newSession(true)" in src
def test_index_route_url_encodes_asset_version(self):
src = ROUTES.read_text(encoding="utf-8")
idx = src.find('parsed.path in ("/", "/index.html")')
+2 -2
View File
@@ -41,9 +41,9 @@ def test_service_worker_uses_network_first_for_page_navigation():
"""Page navigations must hit the server before cache so expired auth redirects work."""
navigate_idx = SW_SRC.find("event.request.mode === 'navigate'")
assert navigate_idx != -1, "service worker must special-case page navigations"
fetch_idx = SW_SRC.find("fetch(event.request)", navigate_idx)
fetch_idx = SW_SRC.find("fetch(new Request(event.request, { cache: 'no-store' }))", navigate_idx)
cache_idx = SW_SRC.find("caches.match", navigate_idx)
assert fetch_idx != -1, "navigation branch must try the live server first"
assert fetch_idx != -1, "navigation branch must try the live server first while bypassing HTTP cache"
assert cache_idx != -1, "navigation branch may use cached shell only as offline fallback"
assert fetch_idx < cache_idx, (
"navigation requests must be network-first, not cache-first, so auth redirects "