From f7259b4e27cfe21a8629bb2708a3123e558447d7 Mon Sep 17 00:00:00 2001 From: 0xKT Date: Mon, 21 Sep 2026 14:04:20 +0800 Subject: [PATCH 1/3] fix(rpc): serve the page with no-cache like its assets `raven serve` sent dist/index.html from `/` with an ETag and a Last-Modified date but no Cache-Control, so a browser guessed a freshness lifetime from that date and answered a navigation to `/` from its cache without asking. The sign-in page at /auth ends by navigating the tab to `/`, so a rebuilt or upgraded page kept opening as the build before it until a hard reload. The assets already carry `Cache-Control: no-cache` for the same reason. Extend that one hook to the page: it now registers whenever the page is served, not only when an assets directory exists, and matches `/` as well as `/assets/`. `no-cache` keeps the copy and revalidates it by ETag, so an unchanged page still costs a 304 and no bytes. The asset test no longer says the page is a different question; a page test pins the directive with no assets directory at all. Co-authored-by: Claude (claude-fable-5-1) --- raven/rpc/transports/ws.py | 39 ++++++++++++++++++++----------------- tests/test_rpc_transport.py | 34 ++++++++++++++++++++++++++++++-- 2 files changed, 53 insertions(+), 20 deletions(-) diff --git a/raven/rpc/transports/ws.py b/raven/rpc/transports/ws.py index fba8626b1..4458e3fd3 100644 --- a/raven/rpc/transports/ws.py +++ b/raven/rpc/transports/ws.py @@ -547,24 +547,27 @@ async def index(_request: web.Request) -> web.FileResponse: if assets.is_dir(): app.router.add_static("/assets", assets) - async def revalidate(_request: web.Request, response: web.StreamResponse) -> None: - """Make the browser ask before reusing an asset it already has. - - These files are served from one unversioned path each, so a - rebuilt icon lands at the URL its predecessor is cached under. - Without a directive the browser is free to guess a lifetime from - the last-modified date and keep the old drawing for hours -- a - provider logo replaced in the bundle went on rendering as the - one it replaced. - - ``no-cache`` is not "do not store": the copy is kept and offered - back with its etag, so an unchanged file costs a 304 and no - bytes. Only the guessing is switched off. - """ - if _request.path.startswith("/assets/"): - response.headers.setdefault("Cache-Control", "no-cache") - - app.on_response_prepare.append(revalidate) + async def revalidate(_request: web.Request, response: web.StreamResponse) -> None: + """Make the browser ask before reusing a dist file it already has. + + The page and its assets are served from one unversioned path each, + so a rebuilt file lands at the URL its predecessor is cached under. + Without a directive the browser is free to guess a lifetime from + the last-modified date and keep the old copy for hours -- a + provider logo replaced in the bundle went on rendering as the one + it replaced, and a rebuilt page went on opening as the build + before it: the sign-in page at ``/auth`` ends by navigating the + tab to ``/``, and a browser answers that navigation from a copy + it still guesses fresh. + + ``no-cache`` is not "do not store": the copy is kept and offered + back with its etag, so an unchanged file costs a 304 and no + bytes. Only the guessing is switched off. + """ + if _request.path == "/" or _request.path.startswith("/assets/"): + response.headers.setdefault("Cache-Control", "no-cache") + + app.on_response_prepare.append(revalidate) else: async def placeholder(_request: web.Request) -> web.Response: diff --git a/tests/test_rpc_transport.py b/tests/test_rpc_transport.py index 519a831da..2c6bf80e3 100644 --- a/tests/test_rpc_transport.py +++ b/tests/test_rpc_transport.py @@ -609,8 +609,38 @@ async def test_a_rebuilt_asset_is_not_served_from_a_stale_browser_cache(tmp_path again = await client.get("/assets/providers/acme.svg", headers={"If-None-Match": etag}) assert again.status == 304, "an unchanged asset must still cost no bytes" - # The page itself is a different question and keeps whatever it had. - assert (await client.get("/")).status == 200 + # The page beside them carries the same directive: the shape users run. + assert (await client.get("/")).headers["Cache-Control"] == "no-cache" + finally: + await client.close() + + +async def test_a_rebuilt_page_is_not_served_from_a_stale_browser_cache(tmp_path: Path) -> None: + """The page has the assets' problem, and a worse case of it. + + The sign-in page at ``/auth`` ends by navigating the tab to ``/``, and a + browser answers that navigation from a copy it still guesses fresh -- a + lifetime read off the last-modified date, hours long for a build that was + days old when the tab first loaded it. So a rebuilt page kept opening as + the build before it until a hard reload. Holds with no assets directory at + all: the directive is the page's own, not a side effect of the assets mount. + """ + static = tmp_path / "dist" + static.mkdir() + (static / "index.html").write_text("", encoding="utf-8") + + client = TestClient(TestServer(build_app(WsGateway(), static))) + await client.start_server() + try: + page = await client.get("/") + assert page.status == 200 + assert page.headers["Cache-Control"] == "no-cache" + + etag = page.headers.get("ETag") + assert etag, "revalidation needs something to revalidate against" + again = await client.get("/", headers={"If-None-Match": etag}) + assert again.status == 304, "an unchanged page must still cost no bytes" + assert again.headers["Cache-Control"] == "no-cache", "a 304 must carry the directive too" finally: await client.close() From 3dde9fabc418b17ae5c5826f45d9ccd68f890163 Mon Sep 17 00:00:00 2001 From: 0xKT Date: Mon, 21 Sep 2026 15:55:33 +0800 Subject: [PATCH 2/3] fix(*): say on the page when its build is behind the sources beside it A source checkout serves ui-web/dist, which is git-ignored: a pull brings ui-web/src and the message catalogue but no rebuild, and the page silently stays on the old build. Only the checkout can be in that state; the wheel's copy ships beside the code it was built with. resolve_ui_dist now judges it the way make would, by mtime -- dist/index.html older than anything under ui-web/src (tests, the harness layer and snapshots excluded), the build's own files or i18n/messages.json -- and logs one WARNING naming make build-ui. Both page hosts hand that judgement to build_app, which answers "/" with X-Raven-Page-Behind, asked per response so a rebuild takes the header away without a restart. The page's existing HEAD probe of "/" reads it and raises the rail-foot row with a third notice: stale page build, how to rebuild. It outranks the rebuilt-page notice, whose reload would come back behind as well, yields to a newer release, goes down when a later look no longer carries the header, and its click explains the command instead of reloading. raven web detaches the terminal that gets the warning, so the page is the one place both launch paths can show it. showUpNote writes the row's two texts through one wording() helper, so the DOM-touch ratchet's pin for app/updates.ts drops from 4 to 2. Co-authored-by: Claude (claude-fable-5-1) --- i18n/messages.json | 16 +++ raven/cli/_gateway_page.py | 5 +- raven/cli/serve_commands.py | 55 +++++++- raven/rpc/transports/ws.py | 13 ++ tests/test_cli_gateway_page.py | 34 +++++ tests/test_cli_serve_commands.py | 102 +++++++++++++++ tests/test_rpc_transport.py | 42 +++++++ ui-web/CONTRIBUTING.md | 2 +- ui-web/scripts/gates/state-dom-touch.test.mjs | 4 +- ui-web/src/app/install.ts | 6 +- ui-web/src/app/updates.test.ts | 117 ++++++++++++++++++ ui-web/src/app/updates.ts | 97 +++++++++++---- ui-web/src/styles/page.css | 5 +- 13 files changed, 460 insertions(+), 38 deletions(-) create mode 100644 ui-web/src/app/updates.test.ts diff --git a/i18n/messages.json b/i18n/messages.json index 0a2885c60..440f8fd4c 100644 --- a/i18n/messages.json +++ b/i18n/messages.json @@ -4135,6 +4135,22 @@ "en": "Unpinned", "zh": "已取消固定" }, + "gui.update.behind": { + "en": "Stale page build", + "zh": "页面构建落后于源码" + }, + "gui.update.behind_body": { + "en": "ui-web/src or i18n/messages.json changed after dist/index.html was built, so this page is older than the code it runs on. In the repository root run make build-ui, then reload this page.", + "zh": "ui-web/src 或 i18n/messages.json 在 dist/index.html 构建之后有改动,这个页面比它背后的代码旧。在仓库根目录运行 make build-ui,然后刷新本页。" + }, + "gui.update.behind_how": { + "en": "How to rebuild", + "zh": "如何重建" + }, + "gui.update.behind_title": { + "en": "Rebuild the page", + "zh": "重建页面" + }, "gui.update.note": { "en": "UI updated on disk", "zh": "界面有更新" diff --git a/raven/cli/_gateway_page.py b/raven/cli/_gateway_page.py index 5bea1dc24..ad2560c7c 100644 --- a/raven/cli/_gateway_page.py +++ b/raven/cli/_gateway_page.py @@ -102,6 +102,7 @@ async def mount_page(agent_loop: Any, preferred_port: int) -> PageMount | None: _announce_updates, _write_serve_state, adopt_stored_cookie, + page_behind_sources, port_strict, resolve_ui_dist, ) @@ -135,11 +136,13 @@ async def mount_page(agent_loop: Any, preferred_port: int) -> PageMount | None: stack = await build_rpc_stack(ws_gateway.broadcast, agent_loop=agent_loop) ws_gateway.dispatcher = stack.dispatcher + dist = resolve_ui_dist() app = build_app( ws_gateway, - resolve_ui_dist(), + dist, deliverables=stack.deliverables, agent_loop_factory=lambda: stack.agent_loop, + page_behind=lambda: page_behind_sources(dist), ) runner = web.AppRunner(app) await runner.setup() diff --git a/raven/cli/serve_commands.py b/raven/cli/serve_commands.py index 8fe6717bc..934f02f0d 100644 --- a/raven/cli/serve_commands.py +++ b/raven/cli/serve_commands.py @@ -80,13 +80,60 @@ def port_strict() -> bool: def resolve_ui_dist() -> Optional[Path]: - """Locate the built ui page: wheel-packaged copy first, then source tree.""" + """Locate the built ui page: wheel-packaged copy first, then source tree. + + A source-tree page older than what it is built from is reported here on + every resolve, because every path that serves or opens the page resolves + it once -- `raven serve`, the gateway's page mount and `raven web` alike -- + and the terminal is where the rebuild happens. + """ for candidate in (_PACKAGED_UI_DIST, _UI_DIR / "dist"): if (candidate / "index.html").exists(): + if page_behind_sources(candidate): + from loguru import logger + + logger.warning( + "the built page is older than ui-web/src or i18n/messages.json; run `make build-ui` to rebuild it" + ) return candidate return None +_PAGE_SOURCE_SKIP = frozenset({"test", "__snapshots__", "__golden__"}) +_PAGE_BUILD_FILES = ("build.py", "vite.config.ts", "package.json", "package-lock.json", "icon/raven.svg") + + +def page_behind_sources(dist: Optional[Path]) -> bool: + """Whether the source-tree page was built before its inputs last changed. + + Only the checkout's own ``ui-web/dist`` can fall behind: the wheel's copy + ships beside the code it was built with. Judged by mtime, the way make + would -- a pull that touches ``ui-web/src`` or the message catalogue leaves + those files newer than ``dist/index.html`` until the next build. The + build's own files count too (the assembler, the bundler config, the + dependency lock, the icon it copies): a pull that moves only those changes + the page as surely as a source edit. Tests, the test harness layer and + snapshots are left out: they change without changing the page. + """ + if dist is None or dist != _UI_DIR / "dist": + return False + try: + built = (dist / "index.html").stat().st_mtime + except OSError: + return False + inputs = [_UI_DIR.parent / "i18n" / "messages.json", *(_UI_DIR / name for name in _PAGE_BUILD_FILES)] + inputs.extend( + path + for path in (_UI_DIR / "src").rglob("*") + if path.is_file() and ".test." not in path.name and not _PAGE_SOURCE_SKIP.intersection(path.parts) + ) + newest = 0.0 + for path in inputs: + with suppress(OSError): + newest = max(newest, path.stat().st_mtime) + return newest > built + + def _state_path() -> Path: """Where the running gateway leaves its port and token. @@ -385,14 +432,16 @@ async def _serve_main(port: int, open_browser: bool) -> None: served = _ServedStack(gateway) stack = await served.start() + dist = resolve_ui_dist() app = build_app( gateway, - resolve_ui_dist(), + dist, # Read per request, not once: a first run assembles its stack after the # app is built, and a store captured here would leave that process with # no download surface for the rest of its life. deliverables=lambda: served.current.deliverables, agent_loop_factory=lambda: served.current.agent_loop, + page_behind=lambda: page_behind_sources(dist), ) runner = web.AppRunner(app) await runner.setup() @@ -1067,7 +1116,7 @@ def _web(port: int, *, foreground: bool = False, stop: bool = False, supervise: return if resolve_ui_dist() is None: - typer.echo("No page is built. Run `python ui-web/build.py`, or install raven from a release wheel.") + typer.echo("No page is built. Run `make build-ui`, or install raven from a release wheel.") raise typer.Exit(1) if supervise: diff --git a/raven/rpc/transports/ws.py b/raven/rpc/transports/ws.py index 4458e3fd3..a04eb27c8 100644 --- a/raven/rpc/transports/ws.py +++ b/raven/rpc/transports/ws.py @@ -19,6 +19,7 @@ import json import secrets import time +from collections.abc import Callable from pathlib import Path from typing import Any @@ -507,6 +508,7 @@ def build_app( *, deliverables: Any = None, agent_loop_factory: Any = None, + page_behind: Callable[[], bool] | None = None, ) -> web.Application: from raven.rpc.transports.deliverables import add_files_routes @@ -563,9 +565,20 @@ async def revalidate(_request: web.Request, response: web.StreamResponse) -> Non ``no-cache`` is not "do not store": the copy is kept and offered back with its etag, so an unchanged file costs a 304 and no bytes. Only the guessing is switched off. + + The page also says when it is older than the sources it was built + from, which only a source checkout can be (``page_behind`` is the + caller's judgement of that, asked per response so a rebuild takes + the header away without a restart). The page's own HEAD probe of + ``/`` reads it and shows the reader how to rebuild: the terminal + that resolved the page has already said so, but `raven web` + detaches that terminal, and the page is the one place both launch + paths can show it. """ if _request.path == "/" or _request.path.startswith("/assets/"): response.headers.setdefault("Cache-Control", "no-cache") + if _request.path == "/" and page_behind is not None and page_behind(): + response.headers["X-Raven-Page-Behind"] = "sources" app.on_response_prepare.append(revalidate) else: diff --git a/tests/test_cli_gateway_page.py b/tests/test_cli_gateway_page.py index ee02864eb..65dc7116a 100644 --- a/tests/test_cli_gateway_page.py +++ b/tests/test_cli_gateway_page.py @@ -244,3 +244,37 @@ async def fake_pick(preferred: int, *, strict: bool = False, wait_s: float = 20. with pytest.raises(OSError): await _gateway_page.mount_page(loop, 18931) assert asked == [False], "an ordinary start should still probe forward" + + +async def test_the_mounted_page_says_when_it_is_behind_its_sources(home: Path, tmp_path: Path, monkeypatch) -> None: + """The gateway host hands the resolver's judgement to the app the way + standalone serve does, so the page it mounts carries the header -- pinned + here because the predicate and the header are each tested alone, and a + host that forgot to wire them would keep both green.""" + import os + + import aiohttp + + from raven.cli import serve_commands + from raven.cli._gateway_page import mount_page + + ui = tmp_path / "repo" / "ui-web" + (ui / "dist").mkdir(parents=True) + (ui / "src").mkdir() + (ui / "dist" / "index.html").write_text("", encoding="utf-8") + (ui / "src" / "main.tsx").write_text("x", encoding="utf-8") + os.utime(ui / "dist" / "index.html", (1_000, 1_000)) + os.utime(ui / "src" / "main.tsx", (2_000, 2_000)) + monkeypatch.setattr(serve_commands, "_PACKAGED_UI_DIST", tmp_path / "nowhere") + monkeypatch.setattr(serve_commands, "_UI_DIR", ui) + + mount = await mount_page(_FakeLoop(_FakeCron()), 18931) + assert mount is not None + try: + async with aiohttp.ClientSession() as session: + async with session.head(f"{mount.url}/") as resp: + assert resp.status == 200 + assert resp.headers["X-Raven-Page-Behind"] == "sources" + assert resp.headers["Cache-Control"] == "no-cache" + finally: + await mount.teardown() diff --git a/tests/test_cli_serve_commands.py b/tests/test_cli_serve_commands.py index 1ae55ee1d..568c3c52d 100644 --- a/tests/test_cli_serve_commands.py +++ b/tests/test_cli_serve_commands.py @@ -1520,3 +1520,105 @@ async def test_two_writes_at_once_assemble_once(monkeypatch) -> None: assert both == [True, True] assert len(calls) == 2 # the first start, then one assembly assert served.current is second + + +class TestPageBehindSources: + """A source-tree page is behind when something it is built from is newer. + + The wheel's copy never is: it ships beside the code it was built with, so + nothing newer exists for it to be behind. The comparison is by mtime, the + way make judges a target, and it skips tests and snapshots because those + change without changing the page. + """ + + @staticmethod + def _stamp(path: Path, when: float) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + if not path.exists(): + path.write_text("x", encoding="utf-8") + import os + + os.utime(path, (when, when)) + + def test_a_page_built_after_its_last_source_edit_is_current(self, two_candidates) -> None: + _packaged, source = two_candidates + ui = source.parent + self._stamp(ui / "src" / "main.tsx", 1_000.0) + self._stamp(ui.parent / "i18n" / "messages.json", 1_500.0) + self._stamp(source / "index.html", 2_000.0) + + assert serve_commands.page_behind_sources(source) is False + + def test_a_newer_source_file_puts_the_page_behind(self, two_candidates) -> None: + _packaged, source = two_candidates + ui = source.parent + self._stamp(source / "index.html", 2_000.0) + self._stamp(ui / "src" / "features" / "rail" / "RailPage.tsx", 3_000.0) + + assert serve_commands.page_behind_sources(source) is True + + def test_a_newer_catalogue_puts_the_page_behind(self, two_candidates) -> None: + _packaged, source = two_candidates + ui = source.parent + self._stamp(source / "index.html", 2_000.0) + self._stamp(ui / "src" / "main.tsx", 1_000.0) + self._stamp(ui.parent / "i18n" / "messages.json", 3_000.0) + + assert serve_commands.page_behind_sources(source) is True + + def test_tests_harnesses_and_snapshots_do_not_count(self, two_candidates) -> None: + _packaged, source = two_candidates + ui = source.parent + self._stamp(source / "index.html", 2_000.0) + self._stamp(ui / "src" / "app" / "updates.test.ts", 3_000.0) + self._stamp(ui / "src" / "test" / "settingsHarness.ts", 3_000.0) + self._stamp(ui / "src" / "features" / "rail" / "__snapshots__" / "RailPage.test.tsx.snap", 3_000.0) + self._stamp(ui / "scripts" / "__golden__" / "boot-stub.txt", 3_000.0) + + assert serve_commands.page_behind_sources(source) is False + + def test_a_dependency_bump_or_build_script_edit_puts_the_page_behind(self, two_candidates) -> None: + _packaged, source = two_candidates + ui = source.parent + self._stamp(source / "index.html", 2_000.0) + self._stamp(ui / "src" / "main.tsx", 1_000.0) + self._stamp(ui / "package-lock.json", 3_000.0) + + assert serve_commands.page_behind_sources(source) is True + + self._stamp(ui / "package-lock.json", 1_000.0) + self._stamp(ui / "build.py", 3_000.0) + + assert serve_commands.page_behind_sources(source) is True + + def test_the_packaged_copy_is_never_behind(self, two_candidates) -> None: + packaged, source = two_candidates + ui = source.parent + self._stamp(packaged / "index.html", 1_000.0) + self._stamp(ui / "src" / "main.tsx", 3_000.0) + + assert serve_commands.page_behind_sources(packaged) is False + + def test_no_page_is_not_behind(self, two_candidates) -> None: + _packaged, source = two_candidates + + assert serve_commands.page_behind_sources(None) is False + assert serve_commands.page_behind_sources(source) is False + + def test_the_resolver_warns_where_the_page_is_behind(self, two_candidates, monkeypatch) -> None: + """Every path that serves or opens the page resolves it here once, and + the terminal that resolver prints to is where the rebuild happens.""" + _packaged, source = two_candidates + ui = source.parent + self._stamp(source / "index.html", 2_000.0) + self._stamp(ui / "src" / "main.tsx", 3_000.0) + said: list[str] = [] + from loguru import logger + + token = logger.add(lambda m: said.append(m.record["message"]), level="WARNING") + try: + assert serve_commands.resolve_ui_dist() == source + finally: + logger.remove(token) + + assert [m for m in said if "make build-ui" in m], said diff --git a/tests/test_rpc_transport.py b/tests/test_rpc_transport.py index 2c6bf80e3..cd313d98b 100644 --- a/tests/test_rpc_transport.py +++ b/tests/test_rpc_transport.py @@ -706,3 +706,45 @@ def test_the_frame_ceiling_can_carry_the_largest_upload_the_method_accepts() -> assert envelope + body <= frame_ceiling_for_upload(), ( "a maximal upload must fit the frame the transport accepts, or the socket closes before fs.upload can refuse it" ) + + +@pytest.mark.parametrize("method", ["GET", "HEAD"]) +async def test_a_page_behind_its_sources_says_so_on_the_document_alone(tmp_path: Path, method: str) -> None: + """The page reads this through its HEAD probe of ``/`` -- the terminal has + already been told, but `raven web` detaches it, so the page is where both + launch paths can show it. The header is the caller's judgement, asked per + response so a rebuild takes it away without a restart, and it never lands + on an asset.""" + static = tmp_path / "dist" + (static / "assets").mkdir(parents=True) + (static / "index.html").write_text("", encoding="utf-8") + (static / "assets" / "raven.svg").write_text("", encoding="utf-8") + behind = True + + client = TestClient(TestServer(build_app(WsGateway(), static, page_behind=lambda: behind))) + await client.start_server() + try: + page = await client.request(method, "/") + assert page.status == 200 + assert page.headers["X-Raven-Page-Behind"] == "sources" + assert "X-Raven-Page-Behind" not in (await client.get("/assets/raven.svg")).headers + + behind = False + assert "X-Raven-Page-Behind" not in (await client.request(method, "/")).headers + finally: + await client.close() + + +async def test_a_page_with_no_judgement_of_its_sources_says_nothing(tmp_path: Path) -> None: + """The wheel's copy has no sources beside it to be behind, and a caller + that passes no judgement gets the header nowhere.""" + static = tmp_path / "dist" + static.mkdir() + (static / "index.html").write_text("", encoding="utf-8") + + client = TestClient(TestServer(build_app(WsGateway(), static))) + await client.start_server() + try: + assert "X-Raven-Page-Behind" not in (await client.get("/")).headers + finally: + await client.close() diff --git a/ui-web/CONTRIBUTING.md b/ui-web/CONTRIBUTING.md index 0bf18d50c..ffd4ac479 100644 --- a/ui-web/CONTRIBUTING.md +++ b/ui-web/CONTRIBUTING.md @@ -569,7 +569,7 @@ shrink-only: the way off a list is the fix. | `.newrun` and `.tipdn` are written and never styled | `src/chrome/Rail.tsx` puts `.newrun` on the new-session button and no rule defines it; taking it out edits `src/test/__golden__/region-app.txt`. `.tipdn` is the flag `state/tooltip.ts` reads with `classList.contains` to hang the hover pill below its control, named only in the `page.css` comment over those rules. `src/components/SetupSheet.tsx` writes two more from inside an expression (`.badtx`, `.warntx`), where the check cannot tell a class from a comparison operand and so does not read them | `check-class-namespace`'s `UNSTYLED` | | `src/lib/prose.ts` writes 13 classes that no class namespace holds | It builds the shell's markdown markup as HTML strings (`.cblk`, `.artf`, `.pth` and ten more), and it is a `lib/` module rather than a domain, the page frame or a shared component -- so the gate reads no prefix rule over it. A `lib` namespace would be a fourth prefix invented for one file; the classes belong with the markdown rules in `page.css` until the prose helpers move behind a component | by review (`check-class-namespace` does not read `src/lib/`) | | 26 imperative reaches for an element in the page frame | Not the rule's case: a portal at the body, two popovers filling a served list, the island mount boxes, and `chrome/behaviour/`'s grip drag and overlay scrollbars, which are behaviour rather than rendering and own no component tree. Counted so a third such module cannot appear unnoticed | `state-dom-touch`'s `FRAME` | -| 24 writes of an element's text, class or markup outside `features/` | Rule (a) is a flag on a store's own region; these set `textContent`, `innerHTML` or the class of a node something else rendered. `state/session/registry.ts` and `app/updates.ts` hold four each, and eight more files the rest. A row goes when the markup says the text instead | `state-dom-touch`'s `WRITES` | +| 22 writes of an element's text, class or markup outside `features/` | Rule (a) is a flag on a store's own region; these set `textContent`, `innerHTML` or the class of a node something else rendered. `state/session/registry.ts` holds four, `app/updates.ts` two, and eight more files the rest. A row goes when the markup says the text instead | `state-dom-touch`'s `WRITES` | | `state/session/registry.ts` reaches four ids and its header names none | It reaches `#stage`, `#flash`, `#title` and `#ta` through the page's own `$`, while its header is about which conversation the page is on. One sentence in that header takes the row off, and the gate then fails until it is deleted | `state-dom-touch`'s `SILENT` | | `.xaedit` keeps its old prefix | Five `page.css` rules scope it; it renames with them | `check-class-namespace`'s `LEGACY_LOCAL.extAgents` | | 205 optional contract fields the fixtures never send | Most are one state this canvas is deliberately in; the header names the few a page really draws and this library has never exercised | `fixture-shape`'s `UNSENT` | diff --git a/ui-web/scripts/gates/state-dom-touch.test.mjs b/ui-web/scripts/gates/state-dom-touch.test.mjs index 5bff89b1c..6b0e23004 100644 --- a/ui-web/scripts/gates/state-dom-touch.test.mjs +++ b/ui-web/scripts/gates/state-dom-touch.test.mjs @@ -37,7 +37,7 @@ * class and the markup of an element that was rendered elsewhere. A store may * write the flag on its own region (`data-open`), and that is a dataset write * this does not count; setting `textContent` or `innerHTML` on a node React - * owns is the case rule (a) does not reach, and the twenty-four the tree still + * owns is the case rule (a) does not reach, and the twenty-two the tree still * holds are counted per file so they can only fall. A file absent from that * table may not write at all. features/ is out of this gate's reach, here as * above -- CONTRIBUTING section 4.2 says why. @@ -154,7 +154,7 @@ const WRITES = { 'state/session/registry.ts': 4, 'state/session/runtime.ts': 1, 'state/ws.ts': 3, - 'app/updates.ts': 4, + 'app/updates.ts': 2, 'chrome/behaviour/scrollbars.ts': 1, } diff --git a/ui-web/src/app/install.ts b/ui-web/src/app/install.ts index 4ef3ca4f6..bb73259ee 100644 --- a/ui-web/src/app/install.ts +++ b/ui-web/src/app/install.ts @@ -382,8 +382,10 @@ export function installDevHooks(): void { hooks.__clarify = (p: unknown) => clarifyRequest(p || { request_id: 'dev', question: '预览', choices: ['A', 'B'] }) // The update row's version state only appears when a release is actually - // newer, which never happens on a dev checkout (window.__upnote('ver', '0.1.11')). - hooks.__upnote = (kind: 'ver' | 'ui', latest?: string) => showUpNote(kind || 'ver', latest) + // newer, which never happens on a dev checkout (window.__upnote('ver', '0.1.11')), + // and its behind-sources state only on a served checkout whose dist is stale + // (window.__upnote('behind')). + hooks.__upnote = (kind: 'ver' | 'ui' | 'behind', latest?: string) => showUpNote(kind || 'ver', latest) /* The approval sheet only appears when an engine asks for one, which is too long a loop to design a sheet in (window.__approve('rm -rf build/')). */ diff --git a/ui-web/src/app/updates.test.ts b/ui-web/src/app/updates.test.ts new file mode 100644 index 000000000..11dd94002 --- /dev/null +++ b/ui-web/src/app/updates.test.ts @@ -0,0 +1,117 @@ +// @vitest-environment happy-dom +/* The third thing the rail-foot row can say: the page is behind the sources it + * was built from. Only a source checkout is ever in that state; the terminal is + * told when the page is resolved, but `raven web` detaches that terminal, so + * the row is where both launch paths show it -- through the header the page's + * own HEAD probe of '/' reads. The row has to rise on that header, hold it + * over a rebuild that is itself still behind, fall when a later look no longer + * carries it, and explain how to rebuild rather than reload when clicked. + */ + +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { loadPart } from '../../scripts/module-harness.mjs' +import { t } from '../i18n/t' + +type Updates = typeof import('./updates') + +function row(): HTMLButtonElement { + document.body.innerHTML = + '' + return document.getElementById('upnote') as HTMLButtonElement +} + +/* What the served page answers a HEAD probe with. */ +function served(headers: Record): Response { + return { ok: true, headers: new Headers(headers) } as unknown as Response +} + +/* The probe awaits the fetch and then the handlers; nothing here waits on a + timer, so draining the microtask queue settles it. */ +async function settle(): Promise { + for (let i = 0; i < 8; i++) await Promise.resolve() +} + +async function watching(fakes: Record> = {}): Promise { + vi.stubEnv('PROD', true) + vi.useFakeTimers() + const mod = (await loadPart(() => import('./updates'), { fakes })) as Updates + mod.watchForUpdates() + await settle() + return mod +} + +describe('the behind-sources notice', () => { + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + vi.unstubAllEnvs() + }) + + it('rises on the header and names the rebuild, then falls when a later look no longer carries it', async () => { + let behind = true + vi.stubGlobal('fetch', () => + Promise.resolve(served(behind ? { etag: '"a"', 'x-raven-page-behind': 'sources' } : { etag: '"a"' }))) + const note = row() + + const mod = await watching() + expect(note.hidden).toBe(false) + expect(note.querySelector('.t')!.textContent).toBe(t('gui.update.behind')) + expect(note.querySelector('.rl')!.textContent).toBe(t('gui.update.behind_how')) + + behind = false + mod.onVisible() + await settle() + expect(note.hidden).toBe(true) + }) + + it('keeps the row while the page stays behind, even after a rebuild landed under it', async () => { + let tag = '"a"' + vi.stubGlobal('fetch', () => Promise.resolve(served({ etag: tag, 'x-raven-page-behind': 'sources' }))) + const note = row() + + const mod = await watching() + tag = '"b"' + mod.onVisible() + await settle() + expect(note.hidden).toBe(false) + expect(note.querySelector('.rl')!.textContent).toBe(t('gui.update.behind_how')) + }) + + it('yields the row to a rebuilt page once it is no longer behind, and that reload is what clears it', async () => { + let tag = '"a"' + let behind = true + vi.stubGlobal('fetch', () => + Promise.resolve(served(behind ? { etag: tag, 'x-raven-page-behind': 'sources' } : { etag: tag }))) + const note = row() + + const mod = await watching() + expect(note.querySelector('.rl')!.textContent).toBe(t('gui.update.behind_how')) + + tag = '"b"' + behind = false + mod.onVisible() + await settle() + expect(note.hidden).toBe(false) + expect(note.querySelector('.rl')!.textContent).toBe(t('gui.update.reload')) + }) + + it('explains how to rebuild when clicked, instead of reloading a page that would come back the same', async () => { + vi.stubGlobal('fetch', () => Promise.resolve(served({ etag: '"a"', 'x-raven-page-behind': 'sources' }))) + const asked: string[] = [] + const note = row() + + await watching({ 'src/state/confirm': { ask: (title: string) => { asked.push(title) } } }) + note.click() + + expect(asked).toEqual([t('gui.update.behind_title')]) + }) + + it('stays down for a page that is not behind', async () => { + vi.stubGlobal('fetch', () => Promise.resolve(served({ etag: '"a"' }))) + const note = row() + + await watching() + expect(note.hidden).toBe(true) + }) +}) diff --git a/ui-web/src/app/updates.ts b/ui-web/src/app/updates.ts index 1f2d525ef..f59a9f2b0 100644 --- a/ui-web/src/app/updates.ts +++ b/ui-web/src/app/updates.ts @@ -1,9 +1,10 @@ /* Something newer than what this window is running. * - * Two different things can be: the built page on disk, and the released - * version of Raven itself. They share the rail-foot row because to the reader - * they are one sentence -- something newer exists -- and they differ only in - * what the click does. + * Three different things can be: the built page on disk, the released version + * of Raven itself, and -- on a source checkout -- the sources the page was + * built from. They share the rail-foot row because to the reader they are one + * sentence -- something newer exists -- and they differ only in what the click + * does: reload, upgrade, or be told how to rebuild. * * `raven serve` streams dist straight from disk and stamps static responses * with an mtime+size ETag, so a rebuilt dist is detectable with a HEAD probe -- @@ -24,7 +25,15 @@ import { open as upShade } from '../state/upgradeShade' import type { UpgradeShade } from '../state/upgradeShade' -type UpKind = 'ver' | 'ui' +type UpKind = 'ver' | 'ui' | 'behind' + +/* Which notice wins the one row. A pending release replaces the install, page + included, so it outranks both (a checkout that also sits behind its sources + still has the terminal's warning). A page behind its sources outranks a + rebuilt one: the reload the latter offers would come back behind as well, so + the row asks for the rebuild first, and the probe that sees it land takes + this notice down and lets the reload's take the row. */ +const RANK: Record = { ui: 0, behind: 1, ver: 2 } /* What build this window is running. Filled in from `system.version` once the socket is up, and unknown until then: the running install is the only thing @@ -42,7 +51,7 @@ export function appVersionSet(v: string): void { let upKind: UpKind | null = null let upLatest: string | null = null -/** Which of the two notices is up, for the reconnect that has to explain itself. */ +/** Which notice is up, for the reconnect that has to explain itself. */ export const upgradeKind = (): UpKind | null => upKind /* The version the last notice named, retained here since `showUpNote` wrote @@ -53,22 +62,40 @@ export const upgradeLatest = (): string | null => upLatest export function showUpNote(kind: UpKind, latest?: string | null): void { const note = document.getElementById('upnote') if (!note) return - /* a pending version upgrade outranks a rebuilt page: upgrading reloads anyway */ - if (upKind === 'ver' && kind === 'ui') return + if (upKind && RANK[kind] < RANK[upKind]) return upKind = kind if (latest) upLatest = latest - const line = note.querySelector('.t') as HTMLElement - const rl = note.querySelector('.rl') as HTMLElement - if (kind === 'ver') { - line.textContent = upLatest ? t('gui.upg.note', { v: `v${upLatest}` }) : t('gui.upg.note_bare') - rl.textContent = t('gui.upg.go') - } else { - line.textContent = t('gui.update.note') - rl.textContent = t('gui.update.reload') - } + const [said, action] = wording(kind) + ;(note.querySelector('.t') as HTMLElement).textContent = said + ;(note.querySelector('.rl') as HTMLElement).textContent = action note.hidden = false } +/* The row's line and its action word, per notice. */ +function wording(kind: UpKind): [string, string] { + if (kind === 'ver') return [upLatest ? t('gui.upg.note', { v: `v${upLatest}` }) : t('gui.upg.note_bare'), t('gui.upg.go')] + if (kind === 'behind') return [t('gui.update.behind'), t('gui.update.behind_how')] + return [t('gui.update.note'), t('gui.update.reload')] +} + +/* The row, as the watch below found it. Only the behind-sources notice is ever + taken back down -- its reason is a fact about the disk that the next build + removes, and the probe that saw it go says so -- and that probe only runs + once the watch has the row, so this is the one reach it needs. */ +let noteRow: HTMLElement | null = null + +function hideBehindNote(): void { + if (upKind !== 'behind') return + if (noteRow) noteRow.hidden = true + upKind = null +} + +/* The click for a page behind its sources. The page cannot rebuild itself, so + what it can do is say where and how. */ +function explainBehind(): void { + confirmAsk(t('gui.update.behind_title'), t('gui.update.behind_body'), t('gui.close'), () => {}) +} + /* An upgrade outlives the page that started it: serve exits, a detached helper installs, and what comes back is a fresh load. The marker is how any load tells "an upgrade is running" from "no upgrade has been asked for" -- without @@ -184,7 +211,12 @@ export function watchUpgrade(shade: UpgradeShade, since?: number): void { same question this watcher asks on a timer. */ let distBase: string | null = null -const distProbe = async (): Promise => { +/* One look at the served page: the validator of the build on disk, and + whether the server judged that build older than the sources beside it (the + X-Raven-Page-Behind header, which only a source checkout ever sends). */ +interface DistSeen { tag: string | null; behind: boolean } + +const distProbe = async (): Promise => { /* Only the built page has a dist to watch. Under `vite dev` the dev server owns '/' and answers with a fresh validator after every edit it hot-reloads, so this probe would read the developer's own typing as a new build. */ @@ -192,7 +224,10 @@ const distProbe = async (): Promise => { try { const r = await fetch('/', { method: 'HEAD', cache: 'no-store' }) if (!r.ok) return null - return r.headers.get('etag') || r.headers.get('last-modified') || null + return { + tag: r.headers.get('etag') || r.headers.get('last-modified') || null, + behind: r.headers.has('x-raven-page-behind'), + } } catch { return null } } @@ -201,9 +236,9 @@ const distProbe = async (): Promise => { against all answer false -- reloading on a maybe would throw a live transcript away for nothing. */ export async function distMoved(): Promise { - const tag = await distProbe() - if (tag === null || distBase === null) return false - return tag !== distBase + const seen = await distProbe() + if (seen === null || seen.tag === null || distBase === null) return false + return seen.tag !== distBase } /* Deliberately NOT skipped while a notice is already showing. It used to be, @@ -213,10 +248,13 @@ export async function distMoved(): Promise { watching for it. showUpNote already arbitrates which notice wins, so the ranking does not need a second gate here. */ const probe = async (): Promise => { - const tag = await distProbe() - if (tag === null) return - if (distBase === null) { distBase = tag; return } - if (tag !== distBase) showUpNote('ui') + const seen = await distProbe() + if (seen === null) return + if (seen.behind) showUpNote('behind') + else hideBehindNote() + if (seen.tag === null) return + if (distBase === null) { distBase = seen.tag; return } + if (seen.tag !== distBase) showUpNote('ui') } /* Whether the watch below ever started. The tab-visible probe is registered @@ -234,7 +272,12 @@ export function onVisible(): void { export function watchForUpdates(): void { const note = document.getElementById('upnote') if (!note) return - note.onclick = () => { if (upKind === 'ver') askUpgrade(); else window.location.reload() } + noteRow = note + note.onclick = () => { + if (upKind === 'ver') askUpgrade() + else if (upKind === 'behind') explainBehind() + else window.location.reload() + } /* The click stays wired either way -- the version notice this row also carries comes from the gateway, which the dev server proxies. Only the dist watch is built-page-only; see distProbe. */ diff --git a/ui-web/src/styles/page.css b/ui-web/src/styles/page.css index 94c92ff2c..4f12d188e 100644 --- a/ui-web/src/styles/page.css +++ b/ui-web/src/styles/page.css @@ -1633,8 +1633,9 @@ button, [role="button"] { -webkit-user-select: none; user-select: none; } .dtl .act2 button { font: 11px var(--mono); color: var(--sky); padding: 0; } .dtl .act2 button:hover { text-decoration: underline; } -/* hot-update notice: dist on disk is newer than the loaded page — a quiet - amber row in the rail foot; reloading is always the reader's click */ +/* the update row: something newer exists -- a rebuilt dist, a released + version, or sources the served page is behind; a quiet amber row in the rail + foot whose action is always the reader's click (app/updates.ts) */ .upnote { display: flex; align-items: center; gap: 8px; width: 100%; padding: 7px 10px; margin-bottom: 6px; border-radius: 8px; From ac8e7374115697ea521d8b9a9c3385a03c2e4ff0 Mon Sep 17 00:00:00 2001 From: 0xKT Date: Mon, 21 Sep 2026 16:40:36 +0800 Subject: [PATCH 3/3] chore(ui-tui): regenerate the message table for the update row's new keys i18n/messages.json is the one catalogue both front ends read; the TUI keeps a generated copy that `lint:i18n` holds in step with it, and the four gui.update.behind* keys the page gained had not been carried over. Co-authored-by: Claude (claude-fable-5-1) --- ui-tui/src/i18n/messages.generated.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/ui-tui/src/i18n/messages.generated.ts b/ui-tui/src/i18n/messages.generated.ts index 6a2631ae9..1db61b921 100644 --- a/ui-tui/src/i18n/messages.generated.ts +++ b/ui-tui/src/i18n/messages.generated.ts @@ -1227,6 +1227,11 @@ export const UI_TEXT: Record> = { 'gui.toolgrp.run': 'Commands & processes', 'gui.undo': 'Undo', 'gui.unpinned_ok': 'Unpinned', + 'gui.update.behind': 'Stale page build', + 'gui.update.behind_body': + 'ui-web/src or i18n/messages.json changed after dist/index.html was built, so this page is older than the code it runs on. In the repository root run make build-ui, then reload this page.', + 'gui.update.behind_how': 'How to rebuild', + 'gui.update.behind_title': 'Rebuild the page', 'gui.update.note': 'UI updated on disk', 'gui.update.reload': 'Reload', 'gui.upg.body': '{from} to {to}. The Raven service restarts and this page reloads; your sessions are kept.', @@ -2986,6 +2991,11 @@ export const UI_TEXT: Record> = { 'gui.toolgrp.run': '命令与进程', 'gui.undo': '撤销', 'gui.unpinned_ok': '已取消固定', + 'gui.update.behind': '页面构建落后于源码', + 'gui.update.behind_body': + 'ui-web/src 或 i18n/messages.json 在 dist/index.html 构建之后有改动,这个页面比它背后的代码旧。在仓库根目录运行 make build-ui,然后刷新本页。', + 'gui.update.behind_how': '如何重建', + 'gui.update.behind_title': '重建页面', 'gui.update.note': '界面有更新', 'gui.update.reload': '刷新', 'gui.upg.body': '{from} 升到 {to}。Raven 服务会重启、页面会自动刷新,会话不会丢。',