From db229621642b75d42d7627db0a27ebc126f0b81e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaroslav=20Bene=C5=A1?= Date: Mon, 28 Sep 2026 06:17:18 +0000 Subject: [PATCH] A reload offer only for a page that is older, and a tab switch that moves one scroller The update toast fired whenever a service worker was waiting, so after every release it appeared on pages that were already the release -- including one fetched with Ctrl+Shift+R. It now compares the waiting worker's release with the page's own, and so does the reload that follows another tab accepting it. On Admin -> Prompts, visually hidden labels were positioned against the page and made the document 6771px tall behind an overflow-hidden root; the tab handler's scrollIntoView then scrolled that root and lifted the shell 56px. Every scroll region is now a containing block, and the handler moves only the container that scrolls. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 28 +++++++++++ src/lembas/__init__.py | 2 +- src/lembas/web/static/css/app.css | 13 ++++- src/lembas/web/static/js/app.js | 79 ++++++++++++++++++++++-------- src/lembas/web/static/js/ui.js | 14 +++++- src/lembas/web/templates/base.html | 3 ++ tests/test_pwa.py | 30 ++++++++++++ tests/test_ui_js.py | 24 +++++++++ 8 files changed, 170 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 243f258..e35de89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,34 @@ for 1.0.0 have something to be assembled from. ## Unreleased +## 1.8.4 + +Two things that kept showing up after they should have gone away. + +- **"A new version is ready" no longer appears on a page that is already the + new version.** After an update the toast showed up on every page, even one just + fetched with Ctrl+Shift+R, and reloading never made it go away. It fired + whenever a new service worker was waiting. But a page loaded after the update + already *is* the update: pages always come from the server, and every + stylesheet and script they name carries the release in its address. The + worker that waits is nearly always the one from the previous release, still + holding the tab, because a reload opens the new page before the old one goes + away. The toast now compares the waiting worker's release with the page's + own, so it only appears in a tab that was opened before the update. For the + same reason, pressing Reload in one tab no longer reloads the other tabs that + are already up to date. That matters when one of them has a reply streaming + into it. + +- **Switching tabs on Administration → Prompts no longer lifts the page.** + Choosing any tab but the first pushed the whole window up by the height of + the title bar and left a blank strip along the bottom, under the sidebar too. + The prompt cards' hidden labels were positioned against the page instead of + the panel. That made the page 6,771px tall behind a window that cannot scroll + by hand, and the tab switch then scrolled it anyway. Every scrolling area now + contains what is inside it, and a tab switch moves only the panel that + scrolls. Administration → General on a small phone had the same leak and is + fixed with it. + ## 1.8.3 The model picker on a phone, which could not be read once a chat was open. diff --git a/src/lembas/__init__.py b/src/lembas/__init__.py index 58376ab..8189519 100644 --- a/src/lembas/__init__.py +++ b/src/lembas/__init__.py @@ -1,3 +1,3 @@ """LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints.""" -__version__ = "1.8.3" +__version__ = "1.8.4" diff --git a/src/lembas/web/static/css/app.css b/src/lembas/web/static/css/app.css index 4b1b4e4..4dd6b18 100644 --- a/src/lembas/web/static/css/app.css +++ b/src/lembas/web/static/css/app.css @@ -452,7 +452,17 @@ input.visually-hidden[type="checkbox"] { child will not shrink below its content without it, so a scroller missing it grows its parent instead of scrolling inside it. `.thread-scroll` relied on a scroll container's automatic minimum size to get away with omitting it, which - is true and is not something the next person should have to know. */ + is true and is not something the next person should have to know. + + `position: relative` makes each scroller the containing block for what is in + it, and without it a `.visually-hidden` label is not in it at all. That class + is `position: absolute`, so with no positioned ancestor it is placed against + the *page*, at its static position -- six thousand pixels down the Tools + panel on /admin/prompts -- and the document grew to 6771px behind a root + that is `overflow: hidden`. Nobody can scroll that by hand, but + `scrollIntoView()` and `focus()` scroll every ancestor that can scroll, + and the root can. Switching a tab there lifted the whole shell 56px: the + topbar gone off the top and a strip of bare background under everything. */ .scroll-region, .sidebar__scroll, .inspector__body, @@ -460,6 +470,7 @@ input.visually-hidden[type="checkbox"] { .thread-scroll, .admin-scroll, .main > .tabs > .tabs__body { + position: relative; flex: 1; min-height: 0; overflow-y: auto; diff --git a/src/lembas/web/static/js/app.js b/src/lembas/web/static/js/app.js index dd2f04a..74ccf32 100644 --- a/src/lembas/web/static/js/app.js +++ b/src/lembas/web/static/js/app.js @@ -1099,27 +1099,59 @@ The worker no longer takes over open pages on its own -- see sw.js -- so something has to say that one is waiting, and the reader decides. A toast rather than a reload: an application with a reply streaming into it must - not be navigated out from under somebody. */ + not be navigated out from under somebody. + + 🚨 "A worker is waiting" is not the same as "this page is out of date", + and the toast used to treat them as one. After a release it offered a + reload on every page, including one just fetched with Ctrl+Shift+R, and + reloading could not make it stop. A page is always fetched from the network + and every asset it names carries `?v=`, so a page loaded after + the update IS the update, whichever worker happens to control it. And the + worker that controls it is nearly always the previous one: a reload + creates the new page before the old one goes away, so the old worker + never runs out of pages and the new one never stops waiting. + + So the question is asked of the page. The worker's release is in its own + script URL (`/sw.js?v=`), and the page's is `window.lembasRelease` from + base.html. When the two match there is nothing newer to reload into, and + the worker is left to take over once the old tabs are closed. */ + var PAGE_RELEASE = window.lembasRelease || ""; + + function releaseOf(worker) { + try { + return new URL(worker.scriptURL).searchParams.get("v") || ""; + } catch (error) { + return ""; + } + } + + /* Unknown on either side counts as newer: better an extra offer than a + release nobody is told about. */ + function isNewerThanThisPage(worker) { + var release = releaseOf(worker); + return !PAGE_RELEASE || !release || release !== PAGE_RELEASE; + } + + function offerReload(worker) { + window.lembas.notify( + "A new version is ready. Reload to use it.", + { kind: "info", action: { label: "Reload", run: function () { + worker.postMessage({ type: "SKIP_WAITING" }); + } } } + ); + } + function watchForUpdate(registration) { function offer(worker) { if (!worker || !navigator.serviceWorker.controller) return; worker.addEventListener("statechange", function () { if (worker.state !== "installed") return; - window.lembas.notify( - "A new version is ready. Reload to use it.", - { kind: "info", action: { label: "Reload", run: function () { - worker.postMessage({ type: "SKIP_WAITING" }); - } } } - ); + if (isNewerThanThisPage(worker)) offerReload(worker); }); } - if (registration.waiting && navigator.serviceWorker.controller) { - window.lembas.notify( - "A new version is ready. Reload to use it.", - { kind: "info", action: { label: "Reload", run: function () { - registration.waiting.postMessage({ type: "SKIP_WAITING" }); - } } } - ); + if (registration.waiting && navigator.serviceWorker.controller && + isNewerThanThisPage(registration.waiting)) { + offerReload(registration.waiting); } registration.addEventListener("updatefound", function () { offer(registration.installing); @@ -1130,17 +1162,24 @@ the right answer to it -- the page is now being served by a worker whose cache it did not start from. - Two guards, and the second is the one that is easy to miss. A flag, because - `controllerchange` can fire more than once. And `hadController`, because on - a *first* visit there is no worker at all: the one that installs then calls - `clients.claim()`, which fires this event for the first time -- so without - it, the very first page anybody loads reloads itself in front of them for - no reason they could possibly work out. */ + Three guards, and the second is the one that is easy to miss. A flag, + because `controllerchange` can fire more than once. And `hadController`, + because on a *first* visit there is no worker at all: the one that + installs then calls `clients.claim()`, which fires this event for the + first time -- so without it, the very first page anybody loads reloads + itself in front of them for no reason they could possibly work out. + + The third is the same question as the toast's. Somebody pressing Reload in + one tab activates the worker for all of them, and a tab that was already + rendered by that release has nothing to gain from a reload -- and may have + a reply streaming into it. */ var reloading = false; if ("serviceWorker" in navigator) { var hadController = !!navigator.serviceWorker.controller; navigator.serviceWorker.addEventListener("controllerchange", function () { if (reloading || !hadController) return; + var controller = navigator.serviceWorker.controller; + if (controller && !isNewerThanThisPage(controller)) return; reloading = true; window.location.reload(); }); diff --git a/src/lembas/web/static/js/ui.js b/src/lembas/web/static/js/ui.js index 2b9b78f..cd42009 100644 --- a/src/lembas/web/static/js/ui.js +++ b/src/lembas/web/static/js/ui.js @@ -1133,7 +1133,19 @@ document.addEventListener("lembas:notify", function (event) { shrinks the document and scrollTop is clamped to the new maximum, which for a short panel is somewhere below everything. */ var outer = scroller(bar); - if (outer && outer !== body) bar.scrollIntoView({ block: "start" }); + if (!outer || outer === body) return; + /* Moved by hand, and only `outer`. `scrollIntoView` scrolls *every* + ancestor that can scroll, the document included -- and the document + could, by the height of whatever leaked out of the scroller, so a tab + switch lifted the whole shell and left a strip of background under it. + The containing block in app.css stops the leak; this stops a leak + anyone adds later from being turned into a visible one. + + Measured from `.tabs`, not the bar: the bar is sticky, so once the page + is scrolled past the lede it reports the scroller's own top and the + sum below would come out as nothing to do. */ + var tabs = bar.parentElement; + outer.scrollTop += tabs.getBoundingClientRect().top - outer.getBoundingClientRect().top; }); })(); diff --git a/src/lembas/web/templates/base.html b/src/lembas/web/templates/base.html index 498d89d..d8c4266 100644 --- a/src/lembas/web/templates/base.html +++ b/src/lembas/web/templates/base.html @@ -152,6 +152,9 @@ what `app.js` turns into a sentence on the settings page. #}