mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-27 04:00:37 +00:00
Stage 405: PR #2842 — feat: polish installed PWA startup by @AJV20
This commit is contained in:
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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'};});
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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")')
|
||||
|
||||
@@ -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 "
|
||||
|
||||
Reference in New Issue
Block a user