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

Cherry-picked via 3-way apply of net delta against stage HEAD. All 8 files
applied cleanly including the new static/pwa-startup.js.

Co-authored-by: AJV20 <abdielvc@me.com>
This commit is contained in:
hermes-agentandAJV20 committed 2026-05-24 18:28:52 +00:00
1 parent cefde78043
commit c15148f925
9 files changed
+207 -8

No files matched your search

+11
View File
@@ -1662,6 +1662,17 @@ function applyBotName(){
if(typeof fetchReasoningChip==='function') fetchReasoningChip(); if(typeof fetchReasoningChip==='function') fetchReasoningChip();
if(typeof refreshProviderQuotaIndicator==='function') refreshProviderQuotaIndicator(); if(typeof refreshProviderQuotaIndicator==='function') refreshProviderQuotaIndicator();
const urlSession=(typeof _sessionIdFromLocation==='function')?_sessionIdFromLocation():null; 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 savedLocal=localStorage.getItem('hermes-webui-session');
const saved=urlSession||savedLocal; const saved=urlSession||savedLocal;
if(saved){ if(saved){
+3 -1
View File
@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <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> <title>Hermes</title>
<!-- base href enables subpath mount support; all static paths must stay relative (no leading slash). <!-- 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 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{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{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> <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>window.__HERMES_CONFIG__={maxUploadBytes:__MAX_UPLOAD_BYTES__,csrfToken:__CSRF_TOKEN_JSON__};</script>
<script>(function(){ <script>(function(){
var cfg=window.__HERMES_CONFIG__||{},token=cfg.csrfToken||''; var cfg=window.__HERMES_CONFIG__||{},token=cfg.csrfToken||'';
+20 -1
View File
@@ -1,12 +1,31 @@
{ {
"id": "./",
"name": "Hermes", "name": "Hermes",
"short_name": "Hermes", "short_name": "Hermes",
"description": "Hermes AI Agent Web UI", "description": "Hermes AI Agent Web UI",
"start_url": "./", "start_url": "./?source=pwa",
"scope": "./",
"display": "standalone", "display": "standalone",
"display_override": ["window-controls-overlay", "standalone", "minimal-ui"],
"background_color": "#0D0D1A", "background_color": "#0D0D1A",
"theme_color": "#0D0D1A", "theme_color": "#0D0D1A",
"orientation": "portrait-primary", "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": [ "icons": [
{ {
"src": "static/favicon.svg", "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 ── */ /* ── 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;} 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;} :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)){ @supports (padding-top: env(safe-area-inset-top)){
@media (display-mode: standalone), (display-mode: fullscreen){ @media (display-mode: standalone), (display-mode: fullscreen){
:root{--app-titlebar-safe-top:env(safe-area-inset-top,0px);} :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 VQ = '?v=__WEBUI_VERSION__';
const SHELL_ASSETS = [ const SHELL_ASSETS = [
'./static/style.css' + VQ, './static/style.css' + VQ,
'./static/pwa-startup.js' + VQ,
'./static/boot.js' + VQ, './static/boot.js' + VQ,
'./static/ui.js' + VQ, './static/ui.js' + VQ,
'./static/messages.js' + VQ, './static/messages.js' + VQ,
@@ -115,7 +116,7 @@ self.addEventListener('fetch', (event) => {
// freshly set login cookie until the user manually refreshes. // freshly set login cookie until the user manually refreshes.
if (event.request.mode === 'navigate') { if (event.request.mode === 'navigate') {
event.respondWith( event.respondWith(
fetch(event.request).then((response) => { fetch(new Request(event.request, { cache: 'no-store' })).then((response) => {
if ( if (
event.request.method === 'GET' && event.request.method === 'GET' &&
response.status === 200 && response.status === 200 &&
@@ -152,7 +153,7 @@ self.addEventListener('fetch', (event) => {
// but avoids executing stale JS/CSS after a local hotfix when WEBUI_VERSION // 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). // has not changed yet (e.g. before a guarded restart updates the ?v token).
event.respondWith( event.respondWith(
fetch(event.request).then((response) => { fetch(new Request(event.request, { cache: 'no-store' })).then((response) => {
if ( if (
event.request.method === 'GET' && event.request.method === 'GET' &&
response.status === 200 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)" "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(): 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.""" """The PWA shell should not opt into cover-mode geometry for every browser surface."""
assert 'viewport-fit=cover' not in HTML 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(): def test_composer_touch_target_size():
"""Send button and composer inputs must have minimum 44px touch targets on mobile. """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 ROOT = Path(__file__).resolve().parent.parent
MANIFEST = ROOT / "static" / "manifest.json" MANIFEST = ROOT / "static" / "manifest.json"
SW = ROOT / "static" / "sw.js" SW = ROOT / "static" / "sw.js"
PWA_STARTUP = ROOT / "static" / "pwa-startup.js"
BOOT = ROOT / "static" / "boot.js"
INDEX = ROOT / "static" / "index.html" INDEX = ROOT / "static" / "index.html"
ROUTES = ROOT / "api" / "routes.py" ROUTES = ROOT / "api" / "routes.py"
AUTH = ROOT / "api" / "auth.py" AUTH = ROOT / "api" / "auth.py"
@@ -117,7 +119,7 @@ class TestServiceWorker:
""" """
src = SW.read_text(encoding="utf-8") src = SW.read_text(encoding="utf-8")
assert "Shell assets: network-first with cache fallback" in src 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 "caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone))" in src
assert ".catch(() => caches.match(event.request)" in src assert ".catch(() => caches.match(event.request)" in src
assert "if (cached) return cached;" not in src, ( assert "if (cached) return cached;" not in src, (
@@ -281,10 +283,65 @@ class TestIndexHtmlIntegration:
marker = "// Shell assets: network-first with cache fallback" marker = "// Shell assets: network-first with cache fallback"
assert marker in src assert marker in src
block = src[src.find(marker):src.find(marker) + 900] 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)" in block
assert "caches.match(event.request).then((cached)" not in block[:250] 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): def test_index_route_url_encodes_asset_version(self):
src = ROUTES.read_text(encoding="utf-8") src = ROUTES.read_text(encoding="utf-8")
idx = src.find('parsed.path in ("/", "/index.html")') 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.""" """Page navigations must hit the server before cache so expired auth redirects work."""
navigate_idx = SW_SRC.find("event.request.mode === 'navigate'") navigate_idx = SW_SRC.find("event.request.mode === 'navigate'")
assert navigate_idx != -1, "service worker must special-case page navigations" 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) 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 cache_idx != -1, "navigation branch may use cached shell only as offline fallback"
assert fetch_idx < cache_idx, ( assert fetch_idx < cache_idx, (
"navigation requests must be network-first, not cache-first, so auth redirects " "navigation requests must be network-first, not cache-first, so auth redirects "