diff --git a/static/index.html b/static/index.html
index 08ebb423..b972611f 100644
--- a/static/index.html
+++ b/static/index.html
@@ -18,7 +18,7 @@
-
+
diff --git a/static/sw.js b/static/sw.js
index 58fa2c46..9e43db66 100644
--- a/static/sw.js
+++ b/static/sw.js
@@ -9,21 +9,30 @@
// Bumps automatically whenever the git commit changes — no manual edits needed.
const CACHE_NAME = 'hermes-shell-__CACHE_VERSION__';
-// Static assets that form the app shell
+// Static assets that form the app shell.
+//
+// Versioned assets (CSS + JS) include `?v=__CACHE_VERSION__` to match the
+// query string the page sends — see index.html. Without the version query
+// here, every cache lookup against `?v=...` URLs would miss and fall through
+// to network, defeating the pre-cache.
+//
+// Unversioned assets (`./`, manifest.json, favicons) are referenced from
+// index.html without a cache-bust query, so they stay unversioned here too.
+const VQ = '?v=__CACHE_VERSION__';
const SHELL_ASSETS = [
'./',
- './static/style.css',
- './static/boot.js',
- './static/ui.js',
- './static/messages.js',
- './static/sessions.js',
- './static/panels.js',
- './static/commands.js',
- './static/icons.js',
- './static/i18n.js',
- './static/workspace.js',
- './static/terminal.js',
- './static/onboarding.js',
+ './static/style.css' + VQ,
+ './static/boot.js' + VQ,
+ './static/ui.js' + VQ,
+ './static/messages.js' + VQ,
+ './static/sessions.js' + VQ,
+ './static/panels.js' + VQ,
+ './static/commands.js' + VQ,
+ './static/icons.js' + VQ,
+ './static/i18n.js' + VQ,
+ './static/workspace.js' + VQ,
+ './static/terminal.js' + VQ,
+ './static/onboarding.js' + VQ,
'./static/favicon.svg',
'./static/favicon-32.png',
'./manifest.json',
diff --git a/tests/test_pwa_manifest_sw.py b/tests/test_pwa_manifest_sw.py
index f2d0e5db..40220dec 100644
--- a/tests/test_pwa_manifest_sw.py
+++ b/tests/test_pwa_manifest_sw.py
@@ -160,6 +160,63 @@ class TestIndexHtmlIntegration:
assert "sw.js?v=__WEBUI_VERSION__" in src
assert "static/ui.js?v=__WEBUI_VERSION__" in src
+ def test_index_versions_stylesheet(self):
+ """Regression for #1507: the `` for style.css MUST
+ carry the same `?v=__WEBUI_VERSION__` cache-bust query as the JS files.
+
+ Without the version, a stale service worker controlling a tab across a
+ version upgrade would intercept `static/style.css`, find an exact URL
+ match in its old shell cache, and return OLD CSS — while the new JS
+ URLs (which DO carry `?v=`) miss the cache and load fresh. The mismatch
+ breaks the layout until a force-refresh bypasses the SW.
+ """
+ src = INDEX.read_text(encoding="utf-8")
+ assert "static/style.css?v=__WEBUI_VERSION__" in src, (
+ "static/style.css must carry ?v=__WEBUI_VERSION__ so an old service "
+ "worker controlling the tab across a version upgrade does not return "
+ "stale CSS — see #1507"
+ )
+ # And the unversioned form must NOT appear (defensive — catches accidental
+ # reverts that leave both lines).
+ assert 'href="static/style.css"' not in src, (
+ "unversioned static/style.css link found — must include "
+ "?v=__WEBUI_VERSION__ for cache busting"
+ )
+
+ def test_sw_shell_assets_match_versioned_asset_urls(self):
+ """The service worker's SHELL_ASSETS pre-cache list must use the same
+ `?v=__CACHE_VERSION__` suffix on JS+CSS that index.html sends, so that
+ the pre-cached entries actually serve when the page requests them.
+
+ Without this, every `cache.match()` for a versioned asset URL (e.g.
+ `static/style.css?v=vN`) would miss against the unversioned pre-cached
+ entry (`static/style.css`), defeating the pre-cache.
+ """
+ src = SW.read_text(encoding="utf-8")
+ # Versioned shell assets must include the cache version query.
+ for asset in (
+ "style.css",
+ "boot.js",
+ "ui.js",
+ "messages.js",
+ "sessions.js",
+ "panels.js",
+ "commands.js",
+ "icons.js",
+ "i18n.js",
+ "workspace.js",
+ "terminal.js",
+ "onboarding.js",
+ ):
+ # Either inline `?v=__CACHE_VERSION__` or via the VQ constant
+ # produces a URL string the cache lookup can match.
+ has_inline = f"{asset}?v=__CACHE_VERSION__" in src
+ has_concat = f"{asset}' + VQ" in src or f"{asset}\" + VQ" in src
+ assert has_inline or has_concat, (
+ f"sw.js SHELL_ASSETS entry for {asset} must carry "
+ "?v=__CACHE_VERSION__ to match the URL the page requests"
+ )
+
def test_index_route_url_encodes_asset_version(self):
src = ROUTES.read_text(encoding="utf-8")
idx = src.find('parsed.path in ("/", "/index.html")')
diff --git a/tests/test_sprint37.py b/tests/test_sprint37.py
index cf8c3de8..b16a253d 100644
--- a/tests/test_sprint37.py
+++ b/tests/test_sprint37.py
@@ -94,7 +94,10 @@ def test_workspace_panel_restore_before_sync():
def test_workspace_panel_preload_marker_restored_in_head():
"""index.html must preload the workspace panel state before the main stylesheet paints."""
marker = "document.documentElement.dataset.workspacePanel"
- css_link = ''
+ # The stylesheet href carries `?v=__WEBUI_VERSION__` for SW cache busting (#1507).
+ # We just need the link's start position; the version query doesn't change the
+ # ordering invariant this test enforces.
+ css_link = '