Improve WebUI bundling, manifest and stop command

Introduce a WebUI extension manifest and rewrite the asset bundler/server to support caller-supplied entry sets, extension-injected entries, and negotiated gzip. Key changes: add get_webui_extension_manifest() to helpers/extension, refactor Stop logic into stop_context() and reuse it from the connector `/stop` command, and add a new `/rename` slash command for chat naming. ui_bundler now accepts entry_urls, includes enabled extension entry files, raises the embedded text file size limit to 512 KiB, computes per-entry-set cache keys, and returns a bundle version based on the bundle signature. ui_server applies Starlette GZip middleware, adds routes (/, /index.html, /ui/index, /safe), serves splash/safe documents, injects the serialized webui_extension_manifest into the rendered index, and streamlines the /ui/asset-bundle endpoint with ETag and gzip handling. Also add multiple WebUI assets and fonts, new/updated plugin command YAML and Python command handlers, and corresponding tests covering bundling, commands, chat naming, and WebUI behaviors. Documentation (.dox.md) updated to reflect the new runtime contracts and guidance.

frdel committed Jul 29, 2026 at 19:49 UTC bebe6826cf8a654d1423a9aa0f1c118116a3bc0e
66 files changed +2079 -418
api/load_webui_extensions.py.dox.md
+1
@@ -20,6 +20,7 @@
20 - Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change.
21 - `LoadWebuiExtensions` is an `ApiHandler`.
22 - `LoadWebuiExtensions` defines `process(...)`.
23 +- The rendered main index normally supplies the complete enabled extension manifest, so this endpoint is a compatibility fallback for callers that do not have `runtimeInfo.webuiExtensions`; its single-extension-point request and response shape remains stable.
24 - Imported dependency areas include: `helpers`, `helpers.api`.
25
26 ## Key Concepts
api/stop.py
+18 -14
@@ -2,6 +2,23 @@ from agent import AgentContext
2 from helpers.api import ApiHandler, Request, Response
3
4
5 +def stop_context(context: AgentContext) -> dict:
6 + was_running = context.is_running()
7 +
8 + context.kill_process()
9 + context.paused = False
10 + context.log.set_progress("", active=False)
11 +
12 + message = "Agent process stopped."
13 + context.log.log(type="info", content=message, finished=True)
14 +
15 + return {
16 + "message": message,
17 + "context": context.id,
18 + "stopped": was_running,
19 + }
20 +
21 +
22 class Stop(ApiHandler):
23 async def process(self, input: dict, request: Request) -> dict | Response:
24 ctxid = input.get("context", "")
@@ -19,17 +36,4 @@ class Stop(ApiHandler):
36 status=404,
37 mimetype="application/json",
38 )
22 - was_running = context.is_running()
23 -
24 - context.kill_process()
25 - context.paused = False
26 - context.log.set_progress("", active=False)
27 -
28 - msg = "Agent process stopped."
29 - context.log.log(type="info", content=msg, finished=True)
30 -
31 - return {
32 - "message": msg,
33 - "context": context.id,
34 - "stopped": was_running,
35 - }
39 + return stop_context(context)
api/stop.py.dox.md
+2 -1
@@ -6,7 +6,7 @@
6
7 ## Ownership
8
9 -- `stop.py` resolves the requested in-memory context and cancels its current process.
9 +- `stop.py` resolves the requested in-memory context and exposes the shared `stop_context()` operation used by the endpoint and slash command.
10
11 ## Runtime Contracts
12
@@ -15,6 +15,7 @@
15 - Stopping cancels the context task through `AgentContext.kill_process()`, clears pause state, preserves chat history and queued messages, and does not start another run.
16 - The endpoint clears active progress and logs a terminal `Agent process stopped.` info step so the WebUI closes the interrupted process group.
17 - The response contains `message`, `context`, and a `stopped` boolean indicating whether the context was running when requested.
18 +- Other authenticated entry points should call `stop_context()` so cancellation, progress cleanup, and terminal logging remain identical to the Stop button.
19
20 ## Work Guidance
21
helpers/extension.py
+44
@@ -18,6 +18,11 @@ USER_EXTENSIONS_FOLDER = "usr/extensions"
18
19 _EXTENSIONS_CACHE_AREA = "extension_folder_classes(extensions)"
20 _CLASSES_CACHE_AREA = "extension_classes(extensions)"
21 +_WEBUI_MANIFEST_CACHE_AREA = "webui_extension_manifest(extensions)(plugins)"
22 +_WEBUI_MANIFEST_SUFFIXES = {
23 + "html": (".html", ".htm", ".xhtml"),
24 + "js": (".js", ".mjs"),
25 +}
26 # cache.toggle_area(_EXTENSIONS_CACHE_AREA, False)
27 # cache.toggle_area(_CLASSES_CACHE_AREA, False)
28
@@ -279,6 +284,45 @@ def get_webui_extensions(
284 return entries
285
286
287 +def get_webui_extension_manifest(
288 + agent: "Agent | None",
289 +) -> dict[str, dict[str, list[str]]]:
290 + """Return every WebUI extension URL grouped by asset type and extension point."""
291 + from helpers import subagents
292 +
293 + cache_key = cache.determine_cache_key(agent)
294 + cached = cache.get(_WEBUI_MANIFEST_CACHE_AREA, cache_key)
295 + if cached is not None:
296 + return cached
297 +
298 + manifest: dict[str, dict[str, list[str]]] = {
299 + asset_type: {} for asset_type in _WEBUI_MANIFEST_SUFFIXES
300 + }
301 + roots = subagents.get_paths(agent, "extensions/webui")
302 + for root in roots:
303 + relative_files = sorted(files.list_files_in_dir_recursively(root))
304 + for asset_type, suffixes in _WEBUI_MANIFEST_SUFFIXES.items():
305 + for suffix in suffixes:
306 + for relative_file in relative_files:
307 + if not relative_file.lower().endswith(suffix):
308 + continue
309 + extension_point = os.path.dirname(relative_file).replace(
310 + os.sep, "/"
311 + )
312 + if not extension_point or extension_point == ".":
313 + continue
314 + absolute_path = files.get_abs_path(root, relative_file)
315 + relative_path = files.deabsolute_path(absolute_path).replace(
316 + os.sep, "/"
317 + )
318 + manifest[asset_type].setdefault(extension_point, []).append(
319 + "/" + relative_path.lstrip("/")
320 + )
321 +
322 + cache.add(_WEBUI_MANIFEST_CACHE_AREA, cache_key, manifest)
323 + return manifest
324 +
325 +
326 def _get_extension_classes(
327 extension_point: str, agent: "Agent|None" = None, **kwargs
328 ) -> list[Type[Extension]]:
helpers/extension.py.dox.md
+3 -1
@@ -20,11 +20,12 @@
20 - `async call_extensions_async(extension_point: str, agent: 'Agent|None'=..., **kwargs)`
21 - `call_extensions_sync(extension_point: str, agent: 'Agent|None'=..., **kwargs)`
22 - `get_webui_extensions(agent: 'Agent | None', extension_point: str, filters: list[str] | None=...)`
23 +- `get_webui_extension_manifest(agent: 'Agent | None') -> dict[str, dict[str, list[str]]]`
24 - `_get_extension_classes(extension_point: str, agent: 'Agent|None'=..., **kwargs) -> list[Type[Extension]]`
25 - `_get_file_from_module(module_name: str) -> str`
26 - `_get_extensions(folder: str)`
27 - `register_extensions_watchdogs()`
27 -- Notable constants/configuration names: `DEFAULT_EXTENSIONS_FOLDER`, `USER_EXTENSIONS_FOLDER`, `_EXTENSIONS_CACHE_AREA`, `_CLASSES_CACHE_AREA`, `_UNSET`, `_EXTENSIONS_LOG_COUNTS`.
28 +- Notable constants/configuration names: `DEFAULT_EXTENSIONS_FOLDER`, `USER_EXTENSIONS_FOLDER`, `_EXTENSIONS_CACHE_AREA`, `_CLASSES_CACHE_AREA`, `_WEBUI_MANIFEST_CACHE_AREA`, `_WEBUI_MANIFEST_SUFFIXES`, `_UNSET`, `_EXTENSIONS_LOG_COUNTS`.
29
30 ## Runtime Contracts
31
@@ -37,6 +38,7 @@
38 ## Key Concepts
39
40 - Important called helpers/classes observed in the source: `_Unset`, `inspect.iscoroutinefunction`, `wraps`, `_log_extension_call`, `_get_extension_classes`, `subagents.get_paths`, `cache.determine_cache_key`, `cache.add`, `files.get_abs_path`, `modules.load_classes_from_folder`, `watchdog.add_watchdog`, `os.path.join`, `_get_agent`, `_prepare_inputs`, `_process_result`, `call_extensions_sync`, `cls.execute`, `files.deabsolute_path`, `_get_file_from_module`, `module_name.split`.
41 +- `get_webui_extension_manifest()` scans enabled WebUI extension roots once, preserves root/filter ordering, groups URLs into `html` and `js` maps by extension point, and caches under extension/plugin invalidation scopes for injection into the rendered index.
42 - Keep request/response, tool, or helper semantics documented here at the same time as source changes.
43
44 ## Work Guidance
helpers/ui_bundler.py
+67 -67
@@ -15,12 +15,12 @@ if TYPE_CHECKING:
15
16
17 _CACHE_AREA = "ui_asset_bundle(extensions)(plugins)"
18 -_CACHE_KEY = "webui"
18 +_CACHE_KEY_PREFIX = "webui"
19 _LOCAL_ORIGIN = "https://agent-zero.local"
20 -_BUNDLE_POLICY_VERSION = "text-assets-v1-40k"
20 +_BUNDLE_POLICY_VERSION = "text-startup-v4-512k"
21 _BUNDLE_SUFFIXES = {".css", ".htm", ".html", ".js", ".mjs", ".xhtml"}
22 -_HTML_SUFFIXES = {".htm", ".html", ".xhtml"}
23 -_MAX_BUNDLE_FILE_BYTES = 40 * 1024
22 +_WEBUI_EXTENSION_ENTRY_SUFFIXES = {".htm", ".html", ".js", ".mjs", ".xhtml"}
23 +_MAX_BUNDLE_FILE_BYTES = 512 * 1024
24
25 _CSS_REFERENCE_RES = (
26 re.compile(r"url\(\s*(?:[\"'])?([^\"')\s]+)", re.IGNORECASE),
@@ -90,58 +90,71 @@ class _AssetRoot:
90 )
91
92
93 -def get_ui_asset_bundle(agent: "Agent | None" = None) -> dict:
94 - """Build a versioned URL-to-content bundle for the site-wide browser cache."""
95 - roots, extension_roots, plugin_webui_roots = _get_asset_roots(agent)
96 - signature = _asset_signature(roots)
97 - cached = cache.get(_CACHE_AREA, _CACHE_KEY)
98 - if cached is not None and cached.get("signature") == signature:
93 +def get_ui_asset_bundle(
94 + entry_urls: Iterable[str],
95 + agent: "Agent | None" = None,
96 +) -> dict:
97 + """Build a versioned recursive text-asset bundle from the supplied entries."""
98 + entries = list(dict.fromkeys(entry_urls))
99 + cache_key = _cache_key(entries)
100 + cached = cache.get(_CACHE_AREA, cache_key)
101 + if cached is not None:
102 return cached["bundle"]
103
104 + roots, extension_roots = _get_asset_roots(agent)
105 +
106 + # WebUI extension paths are injected into the rendered application document
107 + # at runtime, so they cannot be discovered from the supplied source alone.
108 + # Include those actual extension entry files, then let the same recursive
109 + # scan discover their component, stylesheet, and module dependencies.
110 + # Unrelated files stay lazy and use the service worker's ordinary
111 + # fetch-and-cache fallback.
112 + for root in extension_roots:
113 + for path in _iter_root_files(
114 + root.path,
115 + suffixes=_WEBUI_EXTENSION_ENTRY_SUFFIXES,
116 + ):
117 + url = _url_for_path(path, roots)
118 + if url:
119 + entries.append(url)
120 +
121 + signature = _bundle_signature(roots, entries)
122 + result = _build_asset_bundle(entries, roots, signature)
123 + cache.add(
124 + _CACHE_AREA,
125 + cache_key,
126 + {"signature": signature, "bundle": result},
127 + )
128 + return result
129 +
130 +
131 +def _cache_key(entry_urls: Iterable[str]) -> str:
132 + digest = hashlib.sha256()
133 + for entry_url in sorted(set(entry_urls)):
134 + digest.update(entry_url.encode("utf-8"))
135 + digest.update(b"\0")
136 + return f"{_CACHE_KEY_PREFIX}:{digest.hexdigest()[:20]}"
137 +
138 +
139 +def _build_asset_bundle(
140 + entry_urls: Iterable[str],
141 + roots: list[_AssetRoot],
142 + signature: str,
143 +) -> dict:
144 pending: list[str] = []
145 queued: set[str] = set()
146 entries: dict[str, list[str]] = {}
104 - raw_entries: dict[str, bytes] = {}
147
148 def enqueue(url: str) -> None:
149 normalized = _normalize_url(url)
150 suffix = Path(unquote(urlsplit(normalized).path)).suffix.lower() if normalized else ""
109 - if (
110 - normalized
111 - and suffix in _BUNDLE_SUFFIXES
112 - and normalized not in queued
113 - and urlsplit(normalized).path != "/sw.js"
114 - ):
115 - queued.add(normalized)
116 - pending.append(normalized)
117 -
118 - index_path = Path(files.get_abs_path("webui/index.html"))
119 - if index_path.is_file():
120 - index_text = index_path.read_text(encoding="utf-8")
121 - for reference in _extract_references(index_text, "/", ".html"):
122 - enqueue(reference)
151 + if normalized and suffix in _BUNDLE_SUFFIXES:
152 + if normalized not in queued:
153 + queued.add(normalized)
154 + pending.append(normalized)
155
124 - component_root = Path(files.get_abs_path("webui/components")).resolve()
125 - for path in _iter_root_files(component_root, suffixes=_HTML_SUFFIXES):
126 - url = _url_for_path(path, roots)
127 - if url:
128 - enqueue(url)
129 -
130 - for root in extension_roots:
131 - for path in _iter_root_files(root.path, suffixes=_BUNDLE_SUFFIXES):
132 - url = _url_for_path(path, roots)
133 - if url:
134 - enqueue(url)
135 -
136 - for root in plugin_webui_roots:
137 - for path in _iter_root_files(
138 - root.path,
139 - suffixes=_HTML_SUFFIXES,
140 - recursive=False,
141 - ):
142 - url = _url_for_path(path, roots)
143 - if url:
144 - enqueue(url)
156 + for entry_url in entry_urls:
157 + enqueue(entry_url)
158
159 while pending:
160 url = pending.pop()
@@ -158,29 +171,13 @@ def get_ui_asset_bundle(agent: "Agent | None" = None) -> dict:
171 continue
172 if len(content) <= _MAX_BUNDLE_FILE_BYTES:
173 entries[url] = [_content_type(path), "text", text]
161 - raw_entries[url] = content
174 for reference in _extract_references(text, url, path.suffix.lower()):
175 enqueue(reference)
176
165 - digest = hashlib.sha256()
166 - digest.update(signature.encode("ascii"))
167 - digest.update(b"\0")
168 - for url in sorted(raw_entries):
169 - digest.update(url.encode("utf-8"))
170 - digest.update(b"\0")
171 - digest.update(raw_entries[url])
172 - digest.update(b"\0")
173 -
174 - result = {
175 - "version": digest.hexdigest()[:20],
177 + return {
178 + "version": signature[:20],
179 "files": {url: entries[url] for url in sorted(entries)},
180 }
178 - cache.add(
179 - _CACHE_AREA,
180 - _CACHE_KEY,
181 - {"signature": signature, "bundle": result},
182 - )
183 - return result
181
182
183 def serialize_ui_asset_bundle(bundle: dict) -> str:
@@ -194,7 +191,7 @@ def serialize_ui_asset_bundle(bundle: dict) -> str:
191
192 def _get_asset_roots(
193 agent: "Agent | None",
197 -) -> tuple[list[_AssetRoot], list[_AssetRoot], list[_AssetRoot]]:
194 +) -> tuple[list[_AssetRoot], list[_AssetRoot]]:
195 from helpers import plugins, subagents
196
197 webui_root = _AssetRoot(Path(files.get_abs_path("webui")), "/")
@@ -219,7 +216,7 @@ def _get_asset_roots(
216 plugin_webui_roots = list(dict.fromkeys(plugin_webui_roots))
217 roots = list(dict.fromkeys([*extension_roots, *plugin_webui_roots, webui_root]))
218 roots.sort(key=lambda root: len(root.url_prefix), reverse=True)
222 - return roots, extension_roots, plugin_webui_roots
219 + return roots, extension_roots
220
221
222 def _iter_root_files(
@@ -241,10 +238,13 @@ def _iter_root_files(
238 yield resolved
239
240
244 -def _asset_signature(roots: list[_AssetRoot]) -> str:
241 +def _bundle_signature(roots: list[_AssetRoot], entry_urls: Iterable[str]) -> str:
242 digest = hashlib.sha256()
243 digest.update(_BUNDLE_POLICY_VERSION.encode("ascii"))
244 digest.update(b"\0")
245 + for entry_url in sorted(set(entry_urls)):
246 + digest.update(entry_url.encode("utf-8"))
247 + digest.update(b"\0")
248 for root in roots:
249 digest.update(root.url_prefix.encode("utf-8"))
250 digest.update(b"\0")
helpers/ui_bundler.py.dox.md
+10 -8
@@ -2,29 +2,31 @@
2
3 ## Purpose
4
5 -- Build the versioned static-asset payload used to prefill the WebUI service-worker cache.
5 +- Build the versioned recursive text-asset bundle used to prepare the WebUI service-worker cache.
6 - Keep component and extension loaders independent from transport-level caching.
7
8 ## Ownership
9
10 -- `ui_bundler.py` owns same-origin text-asset discovery, recursive reference scanning, safe URL-to-file resolution, bundle hashing, and JSON serialization.
10 +- `ui_bundler.py` owns generic same-origin text-asset discovery from caller-supplied entries, recursive reference scanning, safe URL-to-file resolution, asset-set versioning, and JSON serialization.
11 - `ui_bundler.py.dox.md` owns the durable contracts for that behavior.
12 - Top-level functions:
13 - - `get_ui_asset_bundle(agent: Agent | None = None) -> dict`
13 + - `get_ui_asset_bundle(entry_urls: Iterable[str], agent: Agent | None = None) -> dict`
14 - `serialize_ui_asset_bundle(bundle: dict) -> str`
15
16 ## Runtime Contracts
17
18 -- The bundle seeds core component HTML, eligible HTML/CSS/JavaScript under enabled extension roots, top-level enabled-plugin HTML entry pages, and recursively referenced HTML/CSS/JavaScript.
18 +- The caller supplies one or more entry URLs. The scanner traverses their union once and adds enabled WebUI extension HTML/JavaScript entries because those paths are injected into the rendered document at runtime. It follows generic HTML links/scripts/components, CSS imports/URLs, JavaScript imports/exports/workers, and relative or root-local quoted asset URLs.
19 +- The bundler contains no library-specific path or module-name mappings. References that cannot be derived statically, including runtime-computed URLs, use the service worker's ordinary backend fetch-and-cache fallback.
20 - Asset URLs resolve only inside the WebUI static root or an enabled WebUI extension/plugin root; external URLs, traversal, and symlink escapes are excluded.
20 -- Only valid UTF-8 HTML, CSS, and JavaScript files no larger than 40 KiB are embedded. Oversized eligible text is still scanned for referenced dependencies, while images, audio, video, fonts, manifests, and other file types remain under normal browser HTTP caching.
21 +- Valid UTF-8 HTML, CSS, and JavaScript files no larger than 512 KiB each are embedded. Oversized eligible text is still scanned for dependencies but its body is omitted so the browser can request it normally. Images, audio, video, fonts, manifests, and other file types remain under normal browser HTTP caching.
22 - Every entry carries the response content type required by browsers and native ES modules.
22 -- Bundle versions combine the cache-policy version, bundled content, and an HTML/CSS/JavaScript path/mtime/size inventory. Policy changes and changes to both preloaded and runtime-cached eligible files therefore advance the service-worker cache version, and activation removes caches created under the old policy. The helper cache area also participates in normal extension/plugin invalidation.
23 -- Serialized JSON is returned by the authenticated `/ui/asset-bundle` endpoint rather than inserted into `index.html`.
23 +- Bundle versions cover the caller entries, cache policy, and HTML/CSS/JavaScript path/mtime/size inventory. Policy or eligible-file changes therefore advance the service-worker cache version; the helper cache also participates in normal extension/plugin invalidation.
24 +- Caller entry sets have distinct helper-cache keys. A built bundle is returned without rescanning the filesystem until extension/plugin invalidation or process restart; deployed core WebUI changes take effect with the new server process.
25 +- Serialized JSON is returned by the authenticated `/ui/asset-bundle` endpoint rather than inserted into the application document. HTTP gzip compresses the shared transfer.
26
27 ## Work Guidance
28
27 -- Add reference extractors here rather than teaching component or extension loaders about preloading.
29 +- Add format-level reference extractors here; never add mappings for a particular library, component, theme, mode, or file.
30 - Keep API, WebSocket, navigation, authentication, and other dynamic responses outside the asset bundle.
31 - Preserve URL identity so native module imports, component fetches, and stylesheet resolution use normal browser semantics.
32
helpers/ui_server.py
+92 -17
@@ -22,6 +22,7 @@ from flask import (
22 )
23 from socketio import ASGIApp
24 from starlette.applications import Starlette
25 +from starlette.middleware.gzip import GZipMiddleware
26 from starlette.routing import Mount
27 from uvicorn.middleware.wsgi import WSGIMiddleware
28 from werkzeug.wrappers.request import Request as WerkzeugRequest
@@ -29,11 +30,14 @@ import socketio # type: ignore[import-untyped]
30
31 from helpers import dotenv, fasta2a_server, files, git, login, mcp_server, runtime
32 from helpers.api import get_safe_next_url, register_api_route, requires_auth
32 -from helpers.extension import extensible
33 +from helpers.extension import extensible, get_webui_extension_manifest
34 from helpers.files import get_abs_path
35 from helpers.print_style import PrintStyle
36 from helpers.server_startup import StartupMonitor
36 -from helpers.ui_bundler import get_ui_asset_bundle, serialize_ui_asset_bundle
37 +from helpers.ui_bundler import (
38 + get_ui_asset_bundle,
39 + serialize_ui_asset_bundle,
40 +)
41 from helpers import settings as settings_helper
42 from helpers.ws import register_ws_namespace, validate_ws_origin
43 from helpers.ws_manager import WsManager, set_shared_ws_manager
@@ -42,6 +46,9 @@ from helpers.ws_manager import WsManager, set_shared_ws_manager
46 UPLOAD_LIMIT_BYTES = 5 * 1024 * 1024 * 1024
47 SOCKETIO_PING_INTERVAL_SECONDS = 45
48 SOCKETIO_PING_TIMEOUT_SECONDS = 120
49 +GZIP_MINIMUM_RESPONSE_BYTES = 1024
50 +GZIP_COMPRESSION_LEVEL = 6
51 +UI_INDEX_ASSET_URL = "/index.html"
52
53
54 def _positive_int_env(name: str, default: int) -> int:
@@ -153,9 +160,27 @@ class UiServerRuntime:
160 self.webapp.add_url_rule(
161 "/",
162 "serve_index",
163 + handlers.serve_splash,
164 + methods=["GET"],
165 + )
166 + self.webapp.add_url_rule(
167 + "/index.html",
168 + "serve_app_index",
169 handlers.serve_index,
170 methods=["GET"],
171 )
172 + self.webapp.add_url_rule(
173 + "/ui/index",
174 + "serve_bootstrap_index",
175 + handlers.serve_index,
176 + methods=["GET"],
177 + )
178 + self.webapp.add_url_rule(
179 + "/safe",
180 + "serve_safe",
181 + handlers.serve_safe,
182 + methods=["GET"],
183 + )
184 self.webapp.add_url_rule(
185 "/ui/asset-bundle",
186 "serve_ui_asset_bundle",
@@ -213,9 +238,14 @@ class UiServerRuntime:
238 ],
239 lifespan=startup_monitor.lifespan(),
240 )
241 + compressed_http_app = GZipMiddleware(
242 + starlette_app,
243 + minimum_size=GZIP_MINIMUM_RESPONSE_BYTES,
244 + compresslevel=GZIP_COMPRESSION_LEVEL,
245 + )
246
247 with startup_monitor.stage("socketio.asgi.create"):
218 - return ASGIApp(self.socketio_server, other_asgi_app=starlette_app)
248 + return ASGIApp(self.socketio_server, other_asgi_app=compressed_http_app)
249
250 def access_log_enabled(self) -> bool:
251 return self.settings_snapshot.get("uvicorn_access_logs_enabled", False)
@@ -253,6 +283,24 @@ class UiRouteHandlers:
283 session.pop("authentication", None)
284 return redirect(url_for("login_handler"))
285
286 + @requires_auth
287 + async def serve_splash(self):
288 + return Response(
289 + files.read_file("webui/splash.html"),
290 + content_type="text/html; charset=utf-8",
291 + headers={"Cache-Control": "no-store"},
292 + )
293 +
294 + @requires_auth
295 + async def serve_safe(self):
296 + if request.args.get("__direct") == "1":
297 + return await self.serve_index()
298 + return Response(
299 + files.read_file("webui/safe.html"),
300 + content_type="text/html; charset=utf-8",
301 + headers={"Cache-Control": "no-store"},
302 + )
303 +
304 @requires_auth
305 @extensible
306 async def serve_index(self):
@@ -278,6 +326,18 @@ class UiRouteHandlers:
326 )
327 except Exception:
328 user_ui_control_visibility = json.dumps(settings_helper.UI_CONTROL_VISIBILITY_DEFAULTS)
329 + try:
330 + webui_extension_manifest = json.dumps(
331 + get_webui_extension_manifest(agent=None),
332 + separators=(",", ":"),
333 + )
334 + webui_extension_manifest = (
335 + webui_extension_manifest.replace("&", "\\u0026")
336 + .replace("<", "\\u003c")
337 + .replace(">", "\\u003e")
338 + )
339 + except Exception:
340 + webui_extension_manifest = "null"
341
342 index = files.read_file("webui/index.html")
343 return files.replace_placeholders_text(
@@ -290,25 +350,14 @@ class UiRouteHandlers:
350 user_timezone_setting=user_timezone_setting,
351 user_time_format_setting=user_time_format_setting,
352 user_ui_control_visibility=user_ui_control_visibility,
353 + webui_extension_manifest=webui_extension_manifest,
354 )
355
356 @requires_auth
357 async def serve_ui_asset_bundle(self):
358 try:
298 - bundle = get_ui_asset_bundle(agent=None)
299 - payload = serialize_ui_asset_bundle(bundle).encode("utf-8")
300 - use_gzip = request.accept_encodings["gzip"] > 0
301 - response = Response(
302 - gzip.compress(payload) if use_gzip else payload,
303 - content_type="application/json; charset=utf-8",
304 - )
305 - if use_gzip:
306 - response.headers["Content-Encoding"] = "gzip"
307 - response.headers["Vary"] = "Accept-Encoding"
308 - response.set_etag(bundle["version"], weak=True)
309 - response.cache_control.private = True
310 - response.cache_control.no_cache = True
311 - return response.make_conditional(request)
359 + bundle = get_ui_asset_bundle([UI_INDEX_ASSET_URL], agent=None)
360 + return self._serve_ui_asset_payload(bundle)
361 except Exception as error:
362 PrintStyle.warning(f"Unable to build WebUI asset bundle: {error}")
363 return Response(
@@ -318,6 +367,32 @@ class UiRouteHandlers:
367 headers={"Cache-Control": "no-store"},
368 )
369
370 + def _serve_ui_asset_payload(self, asset_payload: dict):
371 + version = str(asset_payload.get("version") or "")
372 + if not version:
373 + raise ValueError("WebUI asset payload has no version")
374 + if request.if_none_match.contains_weak(version):
375 + response = Response(status=304)
376 + response.headers["Vary"] = "Accept-Encoding"
377 + response.set_etag(version, weak=True)
378 + response.cache_control.private = True
379 + response.cache_control.no_cache = True
380 + return response
381 +
382 + payload = serialize_ui_asset_bundle(asset_payload).encode("utf-8")
383 + use_gzip = request.accept_encodings["gzip"] > 0
384 + response = Response(
385 + gzip.compress(payload) if use_gzip else payload,
386 + content_type="application/json; charset=utf-8",
387 + )
388 + if use_gzip:
389 + response.headers["Content-Encoding"] = "gzip"
390 + response.headers["Vary"] = "Accept-Encoding"
391 + response.set_etag(version, weak=True)
392 + response.cache_control.private = True
393 + response.cache_control.no_cache = True
394 + return response
395 +
396 @requires_auth
397 async def serve_builtin_plugin_asset(self, plugin_name, asset_path):
398 return await self._serve_plugin_asset(plugin_name, asset_path)
helpers/ui_server.py.dox.md
+7 -4
@@ -21,6 +21,8 @@
21 - `UiRouteHandlers` (no explicit base class)
22 - `async login_handler(self)`
23 - `async logout_handler(self)`
24 + - `async serve_splash(self)`
25 + - `async serve_safe(self)`
26 - `async serve_index(self)`
27 - `async serve_ui_asset_bundle(self)`
28 - `async serve_builtin_plugin_asset(self, plugin_name, asset_path)`
@@ -29,7 +31,7 @@
31 - Top-level functions:
32 - `_positive_int_env(name: str, default: int) -> int`
33 - `configure_process_environment() -> None`
32 -- Notable constants/configuration names: `UPLOAD_LIMIT_BYTES`, `SOCKETIO_PING_INTERVAL_SECONDS`, `SOCKETIO_PING_TIMEOUT_SECONDS`, `A0_SOCKETIO_PING_INTERVAL_SECONDS`, `A0_SOCKETIO_PING_TIMEOUT_SECONDS`.
34 +- Notable constants/configuration names: `UPLOAD_LIMIT_BYTES`, `SOCKETIO_PING_INTERVAL_SECONDS`, `SOCKETIO_PING_TIMEOUT_SECONDS`, `GZIP_MINIMUM_RESPONSE_BYTES`, `GZIP_COMPRESSION_LEVEL`, `UI_INDEX_ASSET_URL`, `A0_SOCKETIO_PING_INTERVAL_SECONDS`, `A0_SOCKETIO_PING_TIMEOUT_SECONDS`.
35
36 ## Runtime Contracts
37
@@ -37,13 +39,14 @@
39 - Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
40 - Socket.IO heartbeat defaults are intentionally longer than Engine.IO's short defaults so CLI sessions survive long prompt/context work; environment overrides must remain positive integers and fall back to source defaults when invalid.
41 - Observed side-effect areas: filesystem reads, network calls, subprocess/runtime control, WebSocket state, plugin state, settings/state persistence, secret handling.
40 -- Imported dependency areas include: `asyncio`, `dataclasses`, `datetime`, `flask`, `helpers`, `helpers.api`, `helpers.extension`, `helpers.files`, `helpers.print_style`, `helpers.server_startup`, `helpers.ws`, `helpers.ws_manager`, `logging`, `os`, `secrets`, `socketio`.
42 +- Imported dependency areas include: `asyncio`, `dataclasses`, `datetime`, `flask`, `helpers`, `helpers.api`, `helpers.extension`, `helpers.files`, `helpers.print_style`, `helpers.server_startup`, `helpers.ws`, `helpers.ws_manager`, `logging`, `os`, `secrets`, `socketio`, `starlette.middleware.gzip`.
43
44 ## Key Concepts
45
46 - Important called helpers/classes observed in the source: `logging.getLogger.setLevel`, `Localization.get.apply_process_timezone`, `_positive_int_env`, `field`, `Flask`, `threading.RLock`, `socketio.AsyncServer`, `WsManager`, `set_shared_ws_manager`, `cls`, `server_runtime.refresh_runtime_settings`, `settings_helper.get_settings`, `settings_helper.set_runtime_settings_snapshot`, `self.ws_manager.set_server_restart_broadcast`, `UiRouteHandlers`, `self.webapp.add_url_rule`, `register_api_route`, `register_ws_namespace`, `files.read_file`, `render_template_string`, `session.pop`.
45 -- `serve_index()` bootstraps the normalized UI control visibility map alongside timezone and time-format preferences so controls render correctly before Settings is opened.
46 -- `serve_index()` returns the lightweight UI shell without waiting for bundle construction. The authenticated `serve_ui_asset_bundle()` endpoint builds the versioned payload asynchronously from the browser's perspective, supports gzip transfer and ETag revalidation, and keeps component, extension, and Alpine lifecycles unchanged.
47 +- `serve_index()` bootstraps the normalized UI control visibility map, timezone and time-format preferences, and the complete enabled WebUI extension manifest so startup extension discovery requires no per-surface API requests.
48 +- The authenticated `/` route uses `serve_splash()` to return the no-store, self-contained bootstrap document. The authenticated extensionless `/ui/index` route renders the existing index and runtime/user placeholders for the splash to install into the current document without navigation; `/index.html` remains a direct fallback for the same rendering path. The authenticated `/safe` route first returns a no-store, self-contained document that unregisters all origin service workers, then renders the existing index through `serve_index()` when its internal `__direct=1` marker is present; it never initializes the asset bundle or a worker. The authenticated `serve_ui_asset_bundle()` endpoint passes the application entry URL to the generic recursive bundler and supports gzip transfer and payload-specific ETag revalidation while component, extension, and Alpine lifecycles remain unchanged.
49 +- The Starlette HTTP branch applies negotiated gzip to responses of at least 1 KiB at compression level 6 while preserving already encoded responses; Socket.IO remains outside that middleware branch.
50 - Keep request/response, tool, or helper semantics documented here at the same time as source changes.
51
52 ## Work Guidance
plugins/_chat_naming/AGENTS.md
+2
@@ -11,6 +11,7 @@
11 - `api/chat_name.py` owns modal reads, generation, and manual saves.
12 - `webui/` and `extensions/webui/sidebar-row-actions-menu/` own the standard rename modal and row-menu action.
13 - `prompts/` owns the Utility Model naming instructions.
14 +- `commands/` owns the plugin-contributed `/rename <new name|auto>` slash command.
15
16 ## Local Contracts
17
@@ -21,6 +22,7 @@
22 - Generated names are concise and normalized before persistence.
23 - Renaming a parallel child updates both its context name and sidebar label.
24 - Manual task renames update both scheduler metadata and the task context name.
25 +- `/rename auto` uses the same generation and persistence helpers as the rename modal; any other non-empty argument is saved as the custom chat name.
26
27 ## Work Guidance
28
plugins/_chat_naming/README.md
+2
@@ -3,3 +3,5 @@
3 Chat Naming adds a standard sidebar action for manually renaming chats and tasks. Its modal can ask the chat's configured Utility Model to suggest a concise name from recent user messages.
4
5 Automatic naming is configured per project and agent profile. It can name an unnamed chat once from its first user message, or refresh the name after every user message using recent user-only context.
6 +
7 +Use `/rename New Chat Name` to rename the current chat directly, or `/rename auto` to generate and save a name with its configured Utility Model.
plugins/_chat_naming/commands/rename.command.yaml new
+5
@@ -0,0 +1,5 @@
1 +name: rename
2 +description: Rename this chat manually or with the Utility Model.
3 +argument_hint: "<new name|auto>"
4 +type: script
5 +script_path: rename_command.py
plugins/_chat_naming/commands/rename_command.py new
+29
@@ -0,0 +1,29 @@
1 +from __future__ import annotations
2 +
3 +from typing import Any
4 +
5 +from plugins._chat_naming.helpers import naming
6 +
7 +
8 +async def run(payload: dict[str, Any]) -> dict[str, Any]:
9 + invocation = payload.get("invocation") or {}
10 + raw_name = str(invocation.get("raw_arguments") or "").strip()
11 + agent = (payload.get("context") or {}).get("agent")
12 +
13 + if not agent:
14 + return _toast("Open or create a chat context first.", level="error")
15 + if not raw_name:
16 + return _toast("Usage: /rename <new name|auto>", level="error")
17 +
18 + if raw_name.casefold() == "auto":
19 + raw_name = await naming.generate_name(agent)
20 +
21 + name = naming.save_context_name(agent, raw_name)
22 + return _toast(f'Chat renamed to "{name}".')
23 +
24 +
25 +def _toast(message: str, *, level: str = "success") -> dict[str, Any]:
26 + return {
27 + "text": "",
28 + "effects": [{"type": "toast", "message": message, "level": level}],
29 + }
plugins/_chat_naming/tests/test_chat_naming.py
+58
@@ -3,6 +3,7 @@ from types import SimpleNamespace
3 import pytest
4
5 from agent import AgentContextType
6 +from plugins._chat_naming.commands import rename_command
7 from plugins._chat_naming.extensions.python.monologue_start import _60_rename_chat as rename_chat
8 from plugins._chat_naming.helpers import naming
9
@@ -243,6 +244,63 @@ async def test_manual_api_generates_and_saves_with_target_chat_agent(monkeypatch
244 assert saved == [(agent, "Manual Chat")]
245
246
247 +async def test_rename_command_saves_a_custom_name(monkeypatch):
248 + agent = _Agent([_Message({"user_message": "Plan a launch"}, sequence=1)])
249 + saved = []
250 + monkeypatch.setattr(
251 + rename_command.naming,
252 + "save_context_name",
253 + lambda target_agent, name: saved.append((target_agent, name)) or name,
254 + )
255 +
256 + result = await rename_command.run(
257 + {
258 + "invocation": {"raw_arguments": "New Chat Name"},
259 + "context": {"agent": agent},
260 + }
261 + )
262 +
263 + assert saved == [(agent, "New Chat Name")]
264 + assert result == {
265 + "text": "",
266 + "effects": [
267 + {
268 + "type": "toast",
269 + "message": 'Chat renamed to "New Chat Name".',
270 + "level": "success",
271 + }
272 + ],
273 + }
274 +
275 +
276 +async def test_rename_auto_generates_and_saves_a_name(monkeypatch):
277 + agent = _Agent([_Message({"user_message": "Plan a launch"}, sequence=1)])
278 + generated = []
279 + saved = []
280 +
281 + async def generate(target_agent):
282 + generated.append(target_agent)
283 + return "Launch Plan"
284 +
285 + monkeypatch.setattr(rename_command.naming, "generate_name", generate)
286 + monkeypatch.setattr(
287 + rename_command.naming,
288 + "save_context_name",
289 + lambda target_agent, name: saved.append((target_agent, name)) or name,
290 + )
291 +
292 + result = await rename_command.run(
293 + {
294 + "invocation": {"raw_arguments": "auto"},
295 + "context": {"agent": agent},
296 + }
297 + )
298 +
299 + assert generated == [agent]
300 + assert saved == [(agent, "Launch Plan")]
301 + assert result["effects"][0]["message"] == 'Chat renamed to "Launch Plan".'
302 +
303 +
304 async def test_manual_task_rename_updates_scheduler_and_context(monkeypatch):
305 from plugins._chat_naming.api import chat_name
306
plugins/_commands/AGENTS.md
+2 -1
@@ -11,7 +11,7 @@
11 - `helpers/commands.py` owns command name sanitization, argument parsing, scope resolution, file persistence, plugin command discovery, and command invocation resolution.
12 - `api/commands.py` owns the Commands API actions used by the WebUI.
13 - `webui/` owns the manager/editor modal stores, HTML surfaces, and thumbnail asset.
14 -- `commands/` owns bundled read-only slash command definitions shipped by `_commands`.
14 +- `commands/` owns bundled read-only slash command definitions shipped by `_commands`, including `/stop` agent-run control.
15 - `extensions/` owns the chat composer slash picker and incoming-message command resolution.
16 - `extensions/python/startup_migration/` owns one-time migration from the legacy community `commands` plugin namespace.
17 - `skills/commands-create-slash-command/` owns the agent-facing authoring workflow for reusable slash commands.
@@ -32,6 +32,7 @@
32 - Script commands may emit `send_message` with `text` to submit the rendered composer text immediately after command resolution.
33 - Commands accept prefix syntax (`/goal objective`) and exact postfix syntax (`objective /goal`); ordinary mid-sentence mentions are not invocations.
34 - WebUI sends resolve through the picker effect path, while backend-originated messages resolve before reaching the agent.
35 +- `/stop` uses the same shared cancellation operation as the composer Stop button, including progress cleanup and terminal logging.
36 - Built-in `/computer-use on|off` emits a bounded `computer_use` effect. WebUI
37 only directs the user to Host access in A0 Launcher or the same command in A0
38 CLI; it never changes a Launcher gateway lease from Agent Zero page content.
plugins/_commands/README.md
+1
@@ -18,6 +18,7 @@ Commands are managed from the plugin modal and can be inserted directly from the
18 - Prefix and postfix command resolution for WebUI and remote/AI-sent messages
19 - Scope-aware command resolution across project and global scopes
20 - Built-in A0 CLI connector command pack for common session, queue, model, project, browser, and connector status commands
21 +- `/stop` control that uses the same hard-stop operation as the WebUI composer button
22 - Slash picker in the chat composer with keyboard navigation and create-on-empty flow
23
24 ## Command File Model
plugins/_commands/commands/connector_commands.py
+11
@@ -3,6 +3,7 @@ from __future__ import annotations
3 from typing import Any
4
5 from agent import AgentContext
6 +from api.stop import stop_context
7 from helpers import message_queue as mq
8 from helpers import plugins, projects
9 from helpers.integration_commands import try_handle_command
@@ -44,6 +45,8 @@ def run(payload: dict[str, Any]) -> dict[str, Any]:
45 return _effects(_toast("Resume requested."), {"type": "pause_agent", "paused": False})
46 if command == "nudge":
47 return _effects(_toast("Nudge sent."), {"type": "nudge_agent"})
48 + if command == "stop":
49 + return _handle_stop(context)
50 if command == "send":
51 return _handle_queue(context, ["send"])
52 if command == "queue":
@@ -212,6 +215,14 @@ def _handle_queue(context: AgentContext | None, tokens: list[str]) -> dict[str,
215 return _effects(_toast("Usage: /queue [send|clear|remove <number|id>]", level="error"))
216
217
218 +def _handle_stop(context: AgentContext | None) -> dict[str, Any]:
219 + error = _require_context(context)
220 + if error:
221 + return _effects(_toast(error, level="error"))
222 + result = stop_context(context)
223 + return _effects(_toast(str(result["message"])))
224 +
225 +
226 def _queue_summary(queue: list[dict[str, Any]]) -> str:
227 if not queue:
228 return "No queued messages."
plugins/_commands/commands/stop.command.yaml new
+4
@@ -0,0 +1,4 @@
1 +name: stop
2 +description: Stop the active agent run.
3 +type: script
4 +script_path: connector_commands.py
plugins/_commands/tests/test_commands_plugin.py
+43
@@ -267,6 +267,49 @@ def test_models_command_always_opens_modal():
267 }
268
269
270 +def test_stop_command_uses_the_composer_stop_operation(monkeypatch):
271 + class Log:
272 + def __init__(self):
273 + self.progress = []
274 + self.entries = []
275 +
276 + def set_progress(self, value, *, active):
277 + self.progress.append((value, active))
278 +
279 + def log(self, **kwargs):
280 + self.entries.append(kwargs)
281 +
282 + context = SimpleNamespace(
283 + id="stop-command-context",
284 + paused=True,
285 + log=Log(),
286 + is_running=lambda: True,
287 + kill_process=lambda: setattr(context, "killed", True),
288 + killed=False,
289 + )
290 + monkeypatch.setattr(connector_commands, "_context", lambda _context_id: context)
291 +
292 + result = connector_commands.run(
293 + {
294 + "invocation": {"command_name": "stop", "raw_arguments": ""},
295 + "context": {"context_id": context.id},
296 + }
297 + )
298 +
299 + assert context.killed is True
300 + assert context.paused is False
301 + assert context.log.progress == [("", False)]
302 + assert context.log.entries == [
303 + {"type": "info", "content": "Agent process stopped.", "finished": True}
304 + ]
305 + assert result == {
306 + "text": "",
307 + "effects": [
308 + {"type": "toast", "message": "Agent process stopped.", "level": "success"}
309 + ],
310 + }
311 +
312 +
313 @pytest.mark.parametrize(("argument", "enabled"), [("on", True), ("off", False)])
314 def test_computer_use_command_guides_launcher_or_cli(
315 argument: str,
plugins/_commands/tests/test_plugin_command_discovery.py
+1 -1
@@ -155,7 +155,7 @@ def test_builtin_commands_use_canonical_names_only():
155 discovered = commands_helper._discover_builtin_commands()
156 names = {command["name"] for command in discovered}
157
158 - assert {"attach", "computer-use", "models", "plugins", "project"} <= names
158 + assert {"attach", "computer-use", "models", "plugins", "project", "stop"} <= names
159 assert {
160 "computer",
161 "cu",
tests/test_ui_bundler.py
+66 -13
@@ -19,6 +19,7 @@ def configure_roots(
19 "add",
20 lambda area, key, value: stored.__setitem__((area, key), value),
21 )
22 + monkeypatch.setattr(cache, "clear", lambda *_args, **_kwargs: stored.clear())
23 monkeypatch.setattr(
24 files,
25 "get_abs_path",
@@ -65,7 +66,7 @@ def test_bundle_discovers_assets_without_changing_frontend_loaders(
66 webui / "theme.css": b"body { color: black; }",
67 webui / "index.js": b'import "./js/module.js";',
68 webui / "large.js": (
68 - b'import "./js/large-child.js";' + b" " * (40 * 1024)
69 + b'import "./js/large-child.js";' + b" " * (512 * 1024)
70 ),
71 webui / "js" / "large-child.js": b"export const child = true;",
72 webui / "js" / "module.js": b'export { value } from "./value.js";',
@@ -102,24 +103,19 @@ def test_bundle_discovers_assets_without_changing_frontend_loaders(
103 path.write_bytes(content)
104
105 configure_roots(tmp_path, monkeypatch, extension_root, plugin_root)
105 - bundle = ui_bundler.get_ui_asset_bundle(agent=None)
106 + bundle = ui_bundler.get_ui_asset_bundle(["/index.html"], agent=None)
107
108 assert bundle["version"]
109 assert set(bundle["files"]) == {
109 - "/components/nested/child.html",
110 - "/components/root.html",
111 - "/extensions/webui/sidebar/entry.css",
110 "/extensions/webui/sidebar/entry.html",
111 "/index.css",
112 + "/index.html",
113 "/index.js",
114 "/js/large-child.js",
115 "/js/module.js",
116 "/js/value.js",
118 - "/plugins/example/webui/feature.css",
117 "/plugins/example/webui/feature.js",
120 - "/plugins/example/webui/main.html",
118 "/plugins/example/webui/nested/helper.js",
122 - "/plugins/example/webui/nested/modal.html",
119 "/theme.css",
120 }
121 assert bundle["files"]["/index.js"][:2] == [
@@ -127,6 +123,9 @@ def test_bundle_discovers_assets_without_changing_frontend_loaders(
123 "text",
124 ]
125 assert "/large.js" not in bundle["files"]
126 + assert "/js/large-child.js" in bundle["files"]
127 + assert "/components/root.html" not in bundle["files"]
128 + assert "/plugins/example/webui/main.html" not in bundle["files"]
129 assert "/public/font.bin" not in bundle["files"]
130 assert "/public/image.png" not in bundle["files"]
131 assert "/public/audio.mp3" not in bundle["files"]
@@ -134,21 +133,23 @@ def test_bundle_discovers_assets_without_changing_frontend_loaders(
133 assert "/plugins/example/webui/icon.svg" not in bundle["files"]
134 assert all(entry[1] == "text" for entry in bundle["files"].values())
135 assert all(
137 - len(entry[2].encode("utf-8")) <= 40 * 1024
136 + len(entry[2].encode("utf-8")) <= 512 * 1024
137 for entry in bundle["files"].values()
138 )
139
140 previous_version = bundle["version"]
141 assets[webui / "js" / "value.js"] = b"export const value = 22;"
142 (webui / "js" / "value.js").write_bytes(assets[webui / "js" / "value.js"])
144 - rebuilt = ui_bundler.get_ui_asset_bundle(agent=None)
143 + cache.clear("ui_asset_bundle")
144 + rebuilt = ui_bundler.get_ui_asset_bundle(["/index.html"], agent=None)
145 assert rebuilt["version"] != previous_version
146 assert rebuilt["files"]["/js/value.js"][2] == "export const value = 22;"
147
148 selected_version = rebuilt["version"]
149 runtime_only = plugin_root / "nested" / "runtime-only.css"
150 runtime_only.write_bytes(b"runtime-two-is-newer")
151 - runtime_rebuilt = ui_bundler.get_ui_asset_bundle(agent=None)
151 + cache.clear("ui_asset_bundle")
152 + runtime_rebuilt = ui_bundler.get_ui_asset_bundle(["/index.html"], agent=None)
153 assert runtime_rebuilt["version"] != selected_version
154 assert "/plugins/example/webui/nested/runtime-only.css" not in runtime_rebuilt["files"]
155
@@ -162,16 +163,68 @@ def test_bundle_excludes_symlink_escapes_and_serializes_json(
163 plugin_root = tmp_path / "plugins" / "example" / "webui"
164 for root in (component_root, extension_root, plugin_root):
165 root.mkdir(parents=True)
165 - (webui / "index.html").write_text("<main></main>", encoding="utf-8")
166 + (webui / "index.html").write_text(
167 + '<x-component path="safe.html"></x-component>'
168 + '<x-component path="escape.html"></x-component>',
169 + encoding="utf-8",
170 + )
171 (component_root / "safe.html").write_text("</script>&", encoding="utf-8")
172 outside = tmp_path / "outside.html"
173 outside.write_text("outside", encoding="utf-8")
174 (component_root / "escape.html").symlink_to(outside)
175
176 configure_roots(tmp_path, monkeypatch, extension_root, plugin_root)
172 - bundle = ui_bundler.get_ui_asset_bundle(agent=None)
177 + bundle = ui_bundler.get_ui_asset_bundle(["/index.html"], agent=None)
178 payload = ui_bundler.serialize_ui_asset_bundle(bundle)
179
180 assert "/components/safe.html" in payload
181 assert "/components/escape.html" not in payload
182 assert "</script>&" in payload
183 +
184 +
185 +def test_bundle_recursively_scans_a_caller_supplied_entry(
186 + tmp_path: Path, monkeypatch
187 +) -> None:
188 + webui = tmp_path / "webui"
189 + extension_root = tmp_path / "extensions" / "webui"
190 + plugin_root = tmp_path / "plugins" / "example" / "webui"
191 + assets = {
192 + webui / "index.html": b"<main></main>",
193 + webui / "components" / "ad-hoc.html": (
194 + b'<script type="module" src="./ad-hoc.js"></script>'
195 + b'<link rel="stylesheet" href="/css/ad-hoc.css">'
196 + ),
197 + webui / "components" / "ad-hoc.js": b'import "../js/shared.js";',
198 + webui / "js" / "shared.js": b"export const shared = true;",
199 + webui / "css" / "ad-hoc.css": b'@import "./shared.css";',
200 + webui / "css" / "shared.css": b"body { color: black; }",
201 + }
202 + for path, content in assets.items():
203 + path.parent.mkdir(parents=True, exist_ok=True)
204 + path.write_bytes(content)
205 + extension_root.mkdir(parents=True)
206 + plugin_root.mkdir(parents=True)
207 +
208 + configure_roots(tmp_path, monkeypatch, extension_root, plugin_root)
209 + bundle = ui_bundler.get_ui_asset_bundle(
210 + [
211 + "/components/ad-hoc.html",
212 + "/css/ad-hoc.css",
213 + "/components/ad-hoc.html",
214 + "https://example.com/remote.js",
215 + ],
216 + agent=None,
217 + )
218 +
219 + assert bundle["version"]
220 + assert set(bundle["files"]) == {
221 + "/components/ad-hoc.html",
222 + "/components/ad-hoc.js",
223 + "/css/ad-hoc.css",
224 + "/css/shared.css",
225 + "/js/shared.js",
226 + }
227 +
228 + index_bundle = ui_bundler.get_ui_asset_bundle(["/index.html"], agent=None)
229 + assert set(index_bundle["files"]) == {"/index.html"}
230 + assert index_bundle["version"] != bundle["version"]
tests/test_webui_chat_deletion.py new
+168
@@ -0,0 +1,168 @@
1 +import base64
2 +from pathlib import Path
3 +import shutil
4 +import subprocess
5 +
6 +import pytest
7 +
8 +
9 +PROJECT_ROOT = Path(__file__).resolve().parents[1]
10 +CHATS_STORE_JS = (
11 + PROJECT_ROOT / "webui" / "components" / "sidebar" / "chats" / "chats-store.js"
12 +)
13 +
14 +
15 +def test_chat_deletion_is_optimistic_and_rejects_stale_snapshots() -> None:
16 + if not shutil.which("node"):
17 + pytest.skip("Node.js is required to execute the chat-deletion regression.")
18 +
19 + source = CHATS_STORE_JS.read_text(encoding="utf-8")
20 + model_start = source.index("const model =")
21 + store_start = source.index('const store = createStore("chats", model);')
22 + model_source = source[model_start:store_start] + "\nexport { model };\n"
23 + stubs = """
24 +const callJsonApi = (...args) => globalThis.__callJsonApi(...args);
25 +const sendJsonData = (...args) => globalThis.__sendJsonData(...args);
26 +const getContext = () => globalThis.__context;
27 +const setContext = (id) => { globalThis.__context = id; };
28 +const toastFetchError = (...args) => globalThis.__toastFetchError(...args);
29 +const toast = () => {};
30 +const justToast = (...args) => globalThis.__justToast(...args);
31 +const getConnectionStatus = () => true;
32 +const notificationStore = {};
33 +const sidebarStore = { sortRows: (_kind, rows) => [...rows] };
34 +const tasksStore = { tasks: [] };
35 +const syncStore = { mode: "HEALTHY" };
36 +const chatInputStore = {};
37 +"""
38 + module_url = "data:text/javascript;base64," + base64.b64encode(
39 + (stubs + model_source).encode("utf-8")
40 + ).decode("ascii")
41 + script = f"""
42 +globalThis.sessionStorage = {{
43 + values: new Map(),
44 + getItem(key) {{ return this.values.get(key) ?? null; }},
45 + setItem(key, value) {{ this.values.set(key, String(value)); }},
46 + removeItem(key) {{ this.values.delete(key); }},
47 +}};
48 +globalThis.__callJsonApi = async () => ({{}});
49 +globalThis.__toastFetchError = () => {{}};
50 +globalThis.__justToast = () => {{}};
51 +
52 +const {{ model }} = await import({module_url!r});
53 +
54 +function assert(condition, message) {{
55 + if (!condition) throw new Error(message);
56 +}}
57 +
58 +function reset(contexts, selected) {{
59 + model.contexts = contexts.map((context) => ({{ ...context }}));
60 + model.selected = selected;
61 + model.selectedContext = model.contexts.find((context) => context.id === selected);
62 + model.deletedContextIds = {{}};
63 + globalThis.__context = selected;
64 +}}
65 +
66 +const chats = [
67 + {{ id: "a", created_at: 30 }},
68 + {{ id: "b", created_at: 20 }},
69 + {{ id: "c", created_at: 10 }},
70 +];
71 +
72 +let resolveDelete;
73 +globalThis.__sendJsonData = () => new Promise((resolve) => {{ resolveDelete = resolve; }});
74 +reset(chats, "a");
75 +const deletion = model.killChat("a");
76 +
77 +assert(
78 + model.contexts.map((context) => context.id).join(",") === "b,c",
79 + "the deleted row must disappear before the request completes",
80 +);
81 +assert(model.selected === "b", "fallback selection must happen in the same turn");
82 +assert(model.deletedContextIds.a === true, "the in-flight delete needs a tombstone");
83 +await model.selectChat("a");
84 +assert(
85 + model.selected === "b" && globalThis.__context === "b",
86 + "a queued click must not navigate back to a context being deleted",
87 +);
88 +
89 +model.applyContexts(chats);
90 +assert(
91 + model.contexts.map((context) => context.id).join(",") === "b,c",
92 + "an in-flight stale snapshot must not restore the deleted row",
93 +);
94 +
95 +await new Promise((resolve) => setTimeout(resolve, 0));
96 +assert(typeof resolveDelete === "function", "the delete request should be in flight");
97 +resolveDelete({{ message: "Context removed." }});
98 +await deletion;
99 +assert(model.deletedContextIds.a === true, "the tombstone must survive the HTTP acknowledgement");
100 +
101 +model.applyContexts(chats);
102 +assert(
103 + model.contexts.map((context) => context.id).join(",") === "b,c",
104 + "a stale post-acknowledgement snapshot must remain filtered",
105 +);
106 +model.applyContexts(chats.slice(1));
107 +assert(
108 + model.deletedContextIds.a === true,
109 + "an absent snapshot must not retire the tombstone while older polls can still arrive",
110 +);
111 +model.applyContexts(chats);
112 +assert(
113 + model.contexts.map((context) => context.id).join(",") === "b,c",
114 + "an older present snapshot arriving after an absent one must remain filtered",
115 +);
116 +
117 +const rapidDeleteResolvers = {{}};
118 +globalThis.__sendJsonData = (_url, payload) => new Promise((resolve) => {{
119 + rapidDeleteResolvers[payload.context] = resolve;
120 +}});
121 +reset(chats, "a");
122 +const deleteA = model.killChat("a");
123 +await new Promise((resolve) => setTimeout(resolve, 0));
124 +const deleteB = model.killChat("b");
125 +assert(
126 + model.contexts.map((context) => context.id).join(",") === "c",
127 + "rapid deletes must remove every pending row immediately",
128 +);
129 +assert(model.selected === "c", "rapid selected-chat deletes must advance without a stale row");
130 +model.applyContexts(chats);
131 +assert(
132 + model.contexts.map((context) => context.id).join(",") === "c",
133 + "one stale snapshot must not reinsert any concurrently deleted row",
134 +);
135 +await new Promise((resolve) => setTimeout(resolve, 0));
136 +rapidDeleteResolvers.b({{ message: "Context removed." }});
137 +rapidDeleteResolvers.a({{ message: "Context removed." }});
138 +await Promise.all([deleteA, deleteB]);
139 +model.applyContexts(chats.slice(2));
140 +assert(
141 + model.deletedContextIds.a === true && model.deletedContextIds.b === true,
142 + "rapid-delete tombstones must survive an absent snapshot",
143 +);
144 +model.applyContexts(chats);
145 +assert(
146 + model.contexts.map((context) => context.id).join(",") === "c",
147 + "late snapshots must not reinsert any rapidly deleted row",
148 +);
149 +
150 +globalThis.__sendJsonData = async () => {{ throw new Error("delete failed"); }};
151 +reset(chats, "a");
152 +const originalConsoleError = console.error;
153 +console.error = () => {{}};
154 +await model.killChat("a");
155 +console.error = originalConsoleError;
156 +assert(
157 + model.contexts.map((context) => context.id).join(",") === "a,b,c",
158 + "a failed delete must restore the optimistically removed row",
159 +);
160 +assert(!model.deletedContextIds.a, "a failed delete must clear its tombstone");
161 +assert(model.selected === "b", "rollback must not override the fallback or a later user selection");
162 +"""
163 +
164 + subprocess.run(
165 + ["node", "--input-type=module", "-e", script],
166 + check=True,
167 + text=True,
168 + )
tests/test_webui_extension_surfaces.py
+16
@@ -25,6 +25,7 @@ class _TestAgentContext:
25 sys.modules.setdefault("agent", SimpleNamespace(AgentContext=_TestAgentContext))
26
27 from api.load_webui_extensions import LoadWebuiExtensions
28 +from helpers.extension import get_webui_extension_manifest
29
30
31 SURFACE_SCENARIOS: list[tuple[str, str]] = [
@@ -168,3 +169,18 @@ async def test_webui_surface_extension_point_end_to_end(
169 ]
170
171 assert any(path.endswith(expected_suffix) for path in extension_paths)
172 +
173 +
174 +def test_webui_extension_manifest_groups_plugin_assets_by_type_and_surface() -> None:
175 + surface = "manifest-probe"
176 + with _temporary_probe_plugin(surface) as (plugin_id, probe_file_name):
177 + manifest = get_webui_extension_manifest(agent=None)
178 + expected_suffix = (
179 + f"/{plugin_id}/extensions/webui/{surface}/{probe_file_name}"
180 + )
181 +
182 + assert any(
183 + path.endswith(expected_suffix)
184 + for path in manifest["html"].get(surface, [])
185 + )
186 + assert surface not in manifest["js"]
tests/test_webui_message_ordering_static.py
+66 -3
@@ -37,17 +37,80 @@ def test_message_ordering_uses_a_bounded_tail_first_renderer_cache():
37 def test_virtual_paging_uses_passive_loaders_and_cancels_stale_scrolling():
38 messages_js = read("webui", "js", "messages.js")
39 scroller_js = read("webui", "js", "scroller.js")
40 - messages_css = read("webui", "css", "messages.css")
40 + loading_css = read("webui", "css", "loading-indicators.css")
41
42 assert "createMessageWindowIndicator" in messages_js
43 assert 'indicator.setAttribute("role", "status")' in messages_js
44 - assert "message-window-loader-bubble" in messages_css
45 - assert "@keyframes message-window-loader-dot" in messages_css
44 + assert "createThreeBubbleLoader({ active: isLoading })" in messages_js
45 + assert "@keyframes three-bubble-loader-jump" in loading_css
46 assert "Load ${Math.min" not in messages_js
47 assert "export function cancelPendingScroll" in scroller_js
48 assert "cancelPendingScroll(history)" in messages_js
49
50
51 +def test_context_switch_uses_three_bubble_chat_loading_splash():
52 + index_html = read("webui", "index.html")
53 + index_js = read("webui", "index.js")
54 + messages_js = read("webui", "js", "messages.js")
55 + messages_css = read("webui", "css", "messages.css")
56 + loading_js = read("webui", "js", "loading-indicators.js")
57 + loading_css = read("webui", "css", "loading-indicators.css")
58 + welcome_html = read("webui", "components", "welcome", "welcome-screen.html")
59 +
60 + splash = re.search(
61 + r'<div id="chat-loading-splash"(?P<body>.*?)</div>',
62 + index_html,
63 + flags=re.DOTALL,
64 + )
65 + assert splash
66 + assert "for (let index = 0; index < 3; index += 1)" in loading_js
67 + assert "createThreeBubbleLoader({ active: true })" in index_js
68 + assert "createThreeBubbleLoader({ active: isLoading })" in messages_js
69 + assert "beginChatLoading(id)" in index_js
70 + assert "id !== loadingContext" in index_js
71 + assert "finishChatLoading(snapshot.context)" in index_js
72 + assert "CHAT_LOADING_TEST_MIN_DURATION_MS" not in index_js
73 + assert "const CHAT_LOADING_SPLASH_DELAY_MS = 300" in index_js
74 + assert "chatLoadingSplashVisible = true" in index_js
75 + assert "@keyframes chat-loading-splash-fade-in" in messages_css
76 + index_css = read("webui", "index.css")
77 + assert "'chat-active': $store.welcomeStore" in index_html
78 + assert "#right-panel.chat-active" in index_css
79 + assert "background-color 0.2s ease-out" in index_css
80 + assert "background: var(--color-background)" not in welcome_html
81 + assert "--three-bubble-loader-delay: 0.16s" in loading_css
82 + assert "--three-bubble-loader-delay: 0.32s" in loading_css
83 + assert "animation-delay: var(--three-bubble-loader-delay)" in loading_css
84 +
85 +
86 +def test_utility_prefixed_process_groups_are_not_hidden_from_partial_dom_state():
87 + messages_js = read("webui", "js", "messages.js")
88 + preferences_js = read(
89 + "webui",
90 + "components",
91 + "sidebar",
92 + "bottom",
93 + "preferences",
94 + "preferences-store.js",
95 + )
96 + process_group_css = read(
97 + "webui",
98 + "components",
99 + "messages",
100 + "process-group",
101 + "process-group.css",
102 + )
103 +
104 + assert 'else if (log.type === "util")' in messages_js
105 + assert 'group.classList.remove("utility-only")' in messages_js
106 + assert "isUtilityOnlyProcessGroup" not in messages_js
107 + assert "group.hidden" not in messages_js
108 + assert '"show-utility-messages"' in preferences_js
109 + assert ".show-utility-messages .process-group.utility-only" in process_group_css
110 + assert ".process-step.message-util {" in process_group_css
111 + assert ".show-utility-messages .process-step.message-util" in process_group_css
112 +
113 +
114 def test_message_actions_put_copy_before_speak():
115 sources = [
116 read("webui", "js", "messages.js"),
tests/test_webui_message_window.py
+5 -2
@@ -253,9 +253,12 @@ def test_process_groups_are_atomic_and_page_steps_in_fifties():
253 assert 'button.className = "process-group-show-more"' in messages
254 assert "current + PROCESS_GROUP_STEP_PAGE_SIZE" in messages
255 assert "group.dataset.fullStartTimestamp" in messages
256 - assert "isUtilityOnlyProcessGroup(group)" in messages
256 + assert 'else if (log.type === "util")' in messages
257 + assert 'group?.classList.contains("utility-only")' in messages
258 assert "allowCompletedGroup: false" in messages
258 - assert ".process-group.utility-only[hidden]" in group_css
259 + assert ".process-group.utility-only {" in group_css
260 + assert ".show-utility-messages .process-group.utility-only" in group_css
261 + assert ".process-group.utility-only[hidden]" not in group_css
262 assert ".process-group-show-more" in group_css
263
264
tests/test_webui_offline_assets.py new
+150
@@ -0,0 +1,150 @@
1 +import re
2 +from html.parser import HTMLParser
3 +from pathlib import Path
4 +
5 +
6 +PROJECT_ROOT = Path(__file__).resolve().parents[1]
7 +WEBUI_ROOT = PROJECT_ROOT / "webui"
8 +REMOTE_URL = re.compile(r"^(?:https?:)?//", re.IGNORECASE)
9 +REMOTE_CSS_ASSET = re.compile(
10 + r"(?:@import\s+(?:url\()?|url\()\s*['\"]?(?:https?:)?//",
11 + re.IGNORECASE,
12 +)
13 +
14 +
15 +class PassiveAssetParser(HTMLParser):
16 + passive_link_relations = {
17 + "icon",
18 + "manifest",
19 + "modulepreload",
20 + "prefetch",
21 + "preload",
22 + "stylesheet",
23 + }
24 +
25 + def __init__(self) -> None:
26 + super().__init__()
27 + self.remote_assets: list[str] = []
28 +
29 + def handle_starttag(
30 + self, tag: str, attrs: list[tuple[str, str | None]]
31 + ) -> None:
32 + values = dict(attrs)
33 + candidates: list[str] = []
34 +
35 + if tag in {"audio", "embed", "iframe", "img", "script", "source", "video"}:
36 + candidates.extend(filter(None, (values.get("src"), values.get("poster"))))
37 + elif tag == "link":
38 + relations = set((values.get("rel") or "").lower().split())
39 + if relations & self.passive_link_relations:
40 + candidates.extend(filter(None, (values.get("href"),)))
41 +
42 + self.remote_assets.extend(value for value in candidates if REMOTE_URL.match(value))
43 +
44 +
45 +def read(path: Path) -> str:
46 + return path.read_text(encoding="utf-8")
47 +
48 +
49 +def test_core_stylesheets_do_not_load_remote_assets() -> None:
50 + offenders = []
51 + for path in WEBUI_ROOT.rglob("*.css"):
52 + if "vendor" in path.parts:
53 + continue
54 + if REMOTE_CSS_ASSET.search(read(path)):
55 + offenders.append(str(path.relative_to(PROJECT_ROOT)))
56 +
57 + assert offenders == []
58 +
59 +
60 +def test_webui_markup_does_not_passively_load_remote_assets() -> None:
61 + offenders: dict[str, list[str]] = {}
62 + for path in WEBUI_ROOT.rglob("*.html"):
63 + parser = PassiveAssetParser()
64 + parser.feed(read(path))
65 + if parser.remote_assets:
66 + offenders[str(path.relative_to(PROJECT_ROOT))] = parser.remote_assets
67 +
68 + assert offenders == {}
69 +
70 +
71 +def test_main_and_login_pages_load_the_shared_local_font_stylesheet() -> None:
72 + index_html = read(WEBUI_ROOT / "index.html")
73 + login_html = read(WEBUI_ROOT / "login.html")
74 +
75 + assert 'href="/vendor/fonts/fonts.css"' in index_html
76 + assert 'href="/vendor/fonts/fonts.css"' in login_html
77 + assert "fonts.googleapis.com" not in read(WEBUI_ROOT / "index.css")
78 + assert "fonts.googleapis.com" not in read(WEBUI_ROOT / "login.css")
79 +
80 +
81 +def test_login_uses_full_svg_logo() -> None:
82 + login_html = read(WEBUI_ROOT / "login.html")
83 +
84 + assert 'src="/public/dark.svg"' in login_html
85 + assert (WEBUI_ROOT / "public" / "dark.svg").is_file()
86 + assert 'src="/public/splash.jpg"' not in login_html
87 +
88 +
89 +def test_vendored_variable_font_bundle_is_complete() -> None:
90 + fonts_root = WEBUI_ROOT / "vendor" / "fonts"
91 + font_css = read(fonts_root / "fonts.css")
92 + expected_fonts = {
93 + "rubik-variable.ttf": (
94 + 'font-family: "Rubik"',
95 + "font-style: normal",
96 + "font-weight: 300 900",
97 + ),
98 + "rubik-italic-variable.ttf": (
99 + 'font-family: "Rubik"',
100 + "font-style: italic",
101 + "font-weight: 300 900",
102 + ),
103 + "roboto-mono-variable.ttf": (
104 + 'font-family: "Roboto Mono"',
105 + "font-style: normal",
106 + "font-weight: 100 700",
107 + ),
108 + "roboto-mono-italic-variable.ttf": (
109 + 'font-family: "Roboto Mono"',
110 + "font-style: italic",
111 + "font-weight: 100 700",
112 + ),
113 + }
114 +
115 + for filename, declarations in expected_fonts.items():
116 + font_path = fonts_root / filename
117 + font_face = next(
118 + block
119 + for block in re.findall(r"@font-face\s*\{([^}]+)\}", font_css)
120 + if f'url("./{filename}")' in block
121 + )
122 + assert font_path.read_bytes()[:4] == b"\x00\x01\x00\x00"
123 + assert all(declaration in font_face for declaration in declarations)
124 +
125 + assert REMOTE_CSS_ASSET.search(font_css) is None
126 + assert (fonts_root / "rubik-OFL.txt").is_file()
127 + assert (fonts_root / "roboto-mono-OFL.txt").is_file()
128 +
129 +
130 +def test_material_icon_font_is_preloaded_and_layout_stable() -> None:
131 + icon_root = WEBUI_ROOT / "vendor" / "google"
132 + icon_css = read(icon_root / "google-icons.css")
133 + index_html = read(WEBUI_ROOT / "index.html")
134 + splash_html = read(WEBUI_ROOT / "splash.html")
135 +
136 + assert (icon_root / "google-icons.woff2").read_bytes()[:4] == b"wOF2"
137 + assert "url(./google-icons.woff2) format('woff2')" in icon_css
138 + assert "font-display: block" in icon_css
139 + assert "width: 1em !important" in icon_css
140 + assert "min-width: 1em !important" in icon_css
141 + assert "max-width: 1em !important" in icon_css
142 + assert "height: 1em !important" in icon_css
143 + assert "overflow: hidden !important" in icon_css
144 + assert "html:not(.material-icons-ready)" in icon_css
145 + preload = (
146 + '<link rel="preload" href="/vendor/google/google-icons.woff2" '
147 + 'as="font" type="font/woff2" crossorigin>'
148 + )
149 + assert preload in index_html
150 + assert preload not in splash_html
tests/test_webui_service_worker.py
+30 -11
@@ -8,38 +8,57 @@ def read(*parts: str) -> str:
8 return PROJECT_ROOT.joinpath(*parts).read_text(encoding="utf-8")
9
10
11 -def test_root_service_worker_caches_only_small_text_assets() -> None:
11 +def test_root_service_worker_prepares_one_bundle_and_caches_runtime_misses() -> None:
12 worker = read("webui", "sw.js")
13
14 assert 'event.data?.type !== "preload-ui-bundle"' in worker
15 + assert "const replyPort = event.ports?.[0];" in worker
16 + assert 'replyPort?.postMessage({ ok: true, version: activeAssetVersion })' in worker
17 + assert worker.index("replyPort?.postMessage({ ok: true") < worker.index(
18 + "event.waitUntil(cachePopulation.promise"
19 + )
20 assert "await cache.put(request, response);" in worker
16 - assert "activeBundleEntries.get(event.request.url)" in worker
21 + assert "activeBundleEntries.get(request.url)" in worker
22 assert "responseFromEntry(bundledEntry)" in worker
18 - assert "const cached = await cache.match(event.request);" in worker
19 - assert "const MAX_CACHEABLE_FILE_BYTES = 40 * 1024;" in worker
23 + assert "const cached = await cache.match(request);" in worker
24 + assert "const MAX_RUNTIME_CACHEABLE_TRANSFER_BYTES = 256 * 1024;" in worker
25 assert "CACHEABLE_FILE_PATTERN" in worker
26 assert "cacheRuntimeResponse(" in worker
27 assert 'entry[1] === "text"' in worker
28 assert "decodeBase64" not in worker
24 - assert "body.byteLength > MAX_CACHEABLE_FILE_BYTES" in worker
29 + assert "body.byteLength > MAX_RUNTIME_CACHEABLE_TRANSFER_BYTES" in worker
30 assert "cacheMarkerRequest(targetCacheName)" in worker
31 + assert "cacheBundleRequest(targetCacheName)" in worker
32 + assert "new Response(JSON.stringify(bundle)" in worker
33 + assert "const bundle = await response.json();" in worker
34 + assert "await restorePersistedBundle();" in worker
35 + assert "return fetchBackendAsset(request, cache);" in worker
36 assert "cleanupCaches(activeCacheName)" in worker
37 + assert "catch(() => fetch(event.request))" in worker
38 + assert "asset-graph" not in worker
39 + assert "queueGraphRequest" not in worker
40 + assert "preloadNetworkFiles" not in worker
41 + assert "self.clients.get" not in worker
42 + assert "CompressionStream" not in worker
43 assert 'url.pathname.startsWith("/api/")' in worker
44 assert 'request.mode === "navigate"' in worker
45
46
31 -def test_index_registers_root_cache_without_frontend_loader_hooks() -> None:
47 +def test_splash_registers_root_cache_without_frontend_loader_hooks() -> None:
48 index = read("webui", "index.html")
49 + splash = read("webui", "splash.html")
50 components = read("webui", "js", "components.js")
51 extensions = read("webui", "js", "extensions.js")
52 init_fw = read("webui", "js", "initFw.js")
53
37 - assert 'fetch("/ui/asset-bundle"' in index
54 + assert 'fetch("/ui/asset-bundle"' in splash
55 + assert 'fetch("/ui/asset-bundle"' not in index
56 assert 'id="ui-asset-bundle"' not in index
39 - assert 'navigator.serviceWorker.register(' in index
40 - assert '{ scope: "/", updateViaCache: "none" }' in index
41 - assert "preload-ui-bundle" in index
42 - assert 'new Event("webui-bundle-loaded")' in index
57 + assert 'navigator.serviceWorker.register(expectedUrl' in splash
58 + assert 'updateViaCache: "none"' in splash
59 + assert "preload-ui-bundle" in splash
60 + assert "new MessageChannel()" in splash
61 + assert "await sendBundle(worker, bundle, true)" in splash
62 assert "webuiComponentCache" not in components
63 assert "preload-ui-bundle" not in components
64 assert "preload-ui-bundle" not in extensions
tests/test_webui_startup_assets.py
+89 -18
@@ -40,43 +40,99 @@ def test_classic_startup_scripts_are_deferred() -> None:
40 assert blocking_scripts == []
41
42
43 -def test_ui_asset_bundle_fetch_precedes_frontend_assets() -> None:
43 +def test_splash_prepares_worker_before_replacing_document_in_place() -> None:
44 + splash_html = (PROJECT_ROOT / "webui" / "splash.html").read_text(
45 + encoding="utf-8"
46 + )
47 index_html = (PROJECT_ROOT / "webui" / "index.html").read_text(encoding="utf-8")
48 ui_server = (PROJECT_ROOT / "helpers" / "ui_server.py").read_text(encoding="utf-8")
49
47 - bundle_index = index_html.index('fetch("/ui/asset-bundle"')
48 - assert bundle_index < index_html.index('<link rel="preload" as="style" href="index.css"')
49 - assert bundle_index < index_html.index('<script type="module" src="index.js"></script>')
50 + assert 'fetch("/ui/asset-bundle"' in splash_html
51 + assert 'const APP_DOCUMENT_PATH = "/ui/index"' in splash_html
52 + assert "const appDocumentPromise = fetch(APP_DOCUMENT_PATH" in splash_html
53 + assert 'navigator.serviceWorker.register(expectedUrl' in splash_html
54 + assert "await sendBundle(worker, bundle, true)" in splash_html
55 + assert 'const overlay = document.getElementById("startup-transition")' in splash_html
56 + assert 'overlay.dataset.theme = "light"' in splash_html
57 + assert "(bodyTag) => bodyTag + overlay.outerHTML" in splash_html
58 + assert "document.open()" in splash_html
59 + assert "document.write(appWithTransition)" in splash_html
60 + assert "document.close()" in splash_html
61 + assert "location.replace" not in splash_html
62 + assert 'const APP_PATH = "/index.html"' not in splash_html
63 + assert 'fetch("/ui/asset-bundle"' not in index_html
64 assert 'id="ui-asset-bundle"' not in index_html
65 + assert '"/index.html"' in ui_server
66 + assert '"/ui/index"' in ui_server
67 + assert '"/safe"' in ui_server
68 + assert "handlers.serve_splash" in ui_server
69 + assert "handlers.serve_safe" in ui_server
70 + assert "handlers.serve_index" in ui_server
71 assert '"/ui/asset-bundle"' in ui_server
72 assert "handlers.serve_ui_asset_bundle" in ui_server
73 + assert '"/ui/asset-graph"' not in ui_server
74 + assert "handlers.serve_ui_asset_graph" not in ui_server
75 + assert "GZipMiddleware(" in ui_server
76 + assert "minimum_size=GZIP_MINIMUM_RESPONSE_BYTES" in ui_server
77 + assert "compresslevel=GZIP_COMPRESSION_LEVEL" in ui_server
78 assert 'response.headers["Content-Encoding"] = "gzip"' in ui_server
54 - assert 'response.set_etag(bundle["version"], weak=True)' in ui_server
79 + assert 'request.if_none_match.contains_weak(version)' in ui_server
80 + assert 'response.set_etag(version, weak=True)' in ui_server
81
82
57 -def test_initial_styles_load_without_blocking_splash_paint() -> None:
83 +def test_safe_mode_disables_workers_before_rendering_index_directly() -> None:
84 + safe_html = (PROJECT_ROOT / "webui" / "safe.html").read_text(encoding="utf-8")
85 index_html = (PROJECT_ROOT / "webui" / "index.html").read_text(encoding="utf-8")
86 + ui_server = (PROJECT_ROOT / "helpers" / "ui_server.py").read_text(encoding="utf-8")
87
60 - assert '<link rel="stylesheet"' not in index_html
61 - assert index_html.count('rel="preload" as="style"') == 18
62 - assert index_html.count("onload=\"this.onload=null;this.rel='stylesheet'\"") == 18
63 - assert 'new Promise((resolve) => addEventListener("load", resolve' in index_html
64 - assert 'document.addEventListener("webui-bundle-loaded"' in index_html
88 + assert "navigator.serviceWorker.getRegistrations()" in safe_html
89 + assert "registration.unregister()" in safe_html
90 + assert 'const DIRECT_PARAMETER = "__direct"' in safe_html
91 + assert "location.replace(target.href)" in safe_html
92 + assert 'fetch("/ui/asset-bundle"' not in safe_html
93 + assert "navigator.serviceWorker.register" not in safe_html
94 + assert ' src="' not in safe_html
95 + assert ' href="/' not in safe_html
96 + assert 'request.args.get("__direct") == "1"' in ui_server
97 + assert "return await self.serve_index()" in ui_server
98 + assert 'files.read_file("webui/safe.html")' in ui_server
99 + assert 'safeUrl.searchParams.delete("__direct")' in index_html
100 + assert "navigator.serviceWorker.getRegistrations()" in index_html
101 + assert "registration.unregister()" in index_html
102 + assert "navigator.serviceWorker.register" not in index_html
103 +
104 +
105 +def test_only_the_icon_guard_stylesheet_blocks_application_paint() -> None:
106 + index_html = (PROJECT_ROOT / "webui" / "index.html").read_text(encoding="utf-8")
107
108 + assert index_html.count('<link rel="stylesheet"') == 1
109 + assert '<link rel="stylesheet" href="vendor/google/google-icons.css">' in index_html
110 + assert index_html.count('rel="preload" as="style"') == 19
111 + assert index_html.count("onload=\"this.onload=null;this.rel='stylesheet'\"") == 19
112 + assert 'id="startup-splash"' not in index_html
113 + assert 'document.addEventListener("webui-bundle-loaded"' not in index_html
114
67 -def test_startup_splash_is_inline_and_waits_for_extension_readiness() -> None:
115 +
116 +def test_startup_splash_is_handed_to_the_index_and_fades_when_ready() -> None:
117 + splash_html = (PROJECT_ROOT / "webui" / "splash.html").read_text(
118 + encoding="utf-8"
119 + )
120 index_html = (PROJECT_ROOT / "webui" / "index.html").read_text(encoding="utf-8")
121 extensions_js = (PROJECT_ROOT / "webui" / "js" / "extensions.js").read_text(
122 encoding="utf-8"
123 )
124
73 - assert index_html.index("#startup-splash") < index_html.index('fetch("/ui/asset-bundle"')
74 - assert index_html.index('id="startup-splash"') < index_html.index('<div class="container">')
75 - assert 'data-splash-theme="dark"' in index_html
76 - assert 'localStorage.getItem("darkMode") === "false"' in index_html
77 - assert 'src="/public/a0-fullDark.svg"' in index_html
78 - assert "width: clamp(12rem, 34vw, 23rem)" in index_html
125 + assert 'id="startup-splash"' not in index_html
126 + assert 'data-splash-theme="dark"' not in index_html
127 + assert '<main id="startup-transition"' not in index_html
128 + assert 'id="startup-transition-critical"' in index_html
129 assert 'document.addEventListener("webui-extensions-loaded"' in index_html
130 + assert 'overlay.classList.add("startup-transition-leaving")' in index_html
131 + assert 'document.fonts.load(\'24px "Material Symbols Outlined"\')' in index_html
132 + assert 'localStorage.getItem("darkMode") === "false"' in index_html
133 + assert "webuiExtensions: {{webui_extension_manifest}}" in index_html
134 + assert 'manifestExtensionPaths("html", extensionPoint)' in extensions_js
135 + assert 'manifestExtensionPaths("js", extensionPoint)' in extensions_js
136 assert 'export let initialHtmlExtensionsLoaded = false' in extensions_js
137 assert 'const LOADING_SELECTOR = "x-component > .loading:empty, x-extension.loading"' in extensions_js
138 assert 'targetElement.classList.add("loading")' in extensions_js
@@ -85,3 +141,18 @@ def test_startup_splash_is_inline_and_waits_for_extension_readiness() -> None:
141 assert "globalThis.Alpine.nextTick" in extensions_js
142 assert "pendingHtmlImports" not in extensions_js
143 assert "data-extension-loaded" not in extensions_js
144 + assert '<svg xmlns="http://www.w3.org/2000/svg"' in splash_html
145 + assert '<main id="startup-transition"' in splash_html
146 + assert 'localStorage.getItem("darkMode") === "false"' in splash_html
147 + assert ' src="' not in splash_html
148 + assert ' href="/' not in splash_html
149 +
150 +
151 +def test_generic_loading_indicator_has_a_shared_default_delay() -> None:
152 + modals_css = (PROJECT_ROOT / "webui" / "css" / "modals.css").read_text(
153 + encoding="utf-8"
154 + )
155 +
156 + assert "--loading-delay: 500ms" in modals_css
157 + assert "fadeIn 500ms ease-out var(--loading-delay) forwards" in modals_css
158 + assert "fadeIn 0s linear var(--loading-delay) forwards" in modals_css
webui/AGENTS.md
+5 -3
@@ -7,7 +7,7 @@
7
8 ## Ownership
9
10 -- `index.html`, `index.js`, and `index.css` define the main UI shell.
10 +- `splash.html` owns the self-contained cache/bootstrap document served at `/`; `safe.html` owns the self-contained service-worker escape hatch served at `/safe`; `index.html`, `index.js`, and `index.css` define the rendered main UI shell served directly at `/index.html` and `/safe`, and fetched from `/ui/index` for in-place installation at `/`.
11 - `components/` owns self-contained Alpine components and component stores.
12 - `js/` owns shared frontend modules, API clients, WebSocket clients, stores, extension loaders, and utility code.
13 - `css/` owns shared stylesheet modules.
@@ -22,12 +22,14 @@
22 - Use `/js/api.js` helpers so CSRF and auth behavior stays consistent.
23 - Component tags use `<x-component path="...">`; paths are resolved under `webui/components/` when not already prefixed.
24 - Frontend extension breakpoints use `<x-extension id="...">` and are loaded through `/js/extensions.js`.
25 -- `sw.js` owns same-origin HTML/CSS/JavaScript caching for files no larger than 40 KiB. The index fetches its versioned preload payload asynchronously from `/ui/asset-bundle`; the worker serves that payload immediately, persists it once per version, and removes obsolete version caches while component, extension, and Alpine loaders remain transport-agnostic. Media, images, fonts, manifests, and oversized text assets use normal browser HTTP caching.
26 -- The startup splash has inline critical styling in `index.html`, applies the persisted light/dark preference before first paint, and leaves only after the static page load, asynchronous bundle fetch, and initial component/extension tree report readiness. Initial stylesheets must remain non-render-blocking so they cannot delay the first splash paint.
25 +- `sw.js` owns same-origin HTML/CSS/JavaScript caching. The self-contained splash fetches `/ui/index` and the gzip-compressed `/ui/asset-bundle` in parallel, installs the versioned worker, waits until the bundle is usable in memory, and then installs the rendered index into the current `/` document without navigation. The worker persists the whole prepared bundle as one Cache Storage response in the background and removes obsolete version caches. Eligible text bodies up to 512 KiB each may be embedded; unknown or runtime-computed text URLs use one ordinary backend request and are cached as the native response when their transferred body is at most 256 KiB. The worker does not parse imports, call a secondary graph endpoint, or perform compression. Worker, cache, bundle, and timeout failures fall through to the already-started backend document or ordinary backend asset request. Media, images, fonts, and manifests use normal browser HTTP caching.
26 +- `splash.html` keeps its logo, styling, and bootstrap logic self-contained. Before replacing the document, it carries its current overlay markup into the rendered index. `index.html` owns the matching inline critical overlay style and fades that same visual overlay after initial component/extension readiness, with a bounded failure timeout; it must not contain duplicate static overlay markup. Icon-font preloading begins when the index is placed behind the overlay, continues independently behind square transparent placeholders, and must neither compete with the startup bundle nor delay the application reveal. `safe.html` must remain self-contained: its first phase unregisters all service workers for the origin, then replaces itself with the directly rendered `/safe` phase without fetching the bundle or registering a worker. The direct phase repeats unregistration defensively but does not initialize a worker; returning to `/` may install the current worker again. The rendered index removes only the internal safe-mode query marker from `/safe` in browser history. Except for the tiny blocking icon guard stylesheet needed to prevent ligature layout shifts, initial application stylesheets remain non-render-blocking.
27 +- Material Symbols use the local WOFF2 font with a TTF fallback. Their shared vendor CSS constrains every ligature box to a clipped 1:1 em square and keeps it transparent until the font is confirmed, so slow or failed fonts cannot expose icon-name text or shift surrounding layout.
28 - Component HTML loaded by the shared loader may include `<title>`, module scripts, body content, and scoped styles; modal content uses the same loader path.
29 - Do not bypass WebSocket origin/auth/CSRF assumptions from frontend code.
30 - Avoid editing vendored files unless intentionally updating the vendor asset.
31 - Startup scripts in `index.html` must be local and non-parser-blocking: use ES modules, `defer`, or `async` for every script with `src`.
32 +- Baseline main and login pages must load scripts, styles, fonts, and images from same-origin vendored or first-party assets so they remain usable without internet access.
33 - Rubik (`--font-family-main`) is the default WebUI text and control font; use the code/mono font tokens only for code, logs, paths, and fixed-width data.
34 - Hover, focus, and active border treatments should follow existing neutral border/background patterns; avoid hard-coded blue border highlights unless matching an established specialized surface.
35 - Keep transient UI affordances such as Bootstrap tooltips and notification toasts above normal and legacy modal layers while confirmation dialogs remain on top.
webui/components/messages/AGENTS.md
+1 -1
@@ -21,7 +21,7 @@
21 - Preference-driven process detail modes must await the same materialization path as manual expansion and accept an explicit chat-history target for off-screen window staging. `STEP` opens only the current non-utility step at the live tail; historical windows must not invent a current step at their boundary.
22 - Keep oversized standalone replay bodies and key/value tables in a bounded preview state until the user expands them; collapsing must remove the full body again.
23 - Message-window boundaries must not split process groups. Groups with more than 50 steps initially render their newest 50 steps and prepend earlier steps in 50-step increments through the group-local `Show more` control while retaining stable full-group header metrics.
24 -- A root response may attach only to a group containing a non-utility process step. Utility-only groups remain separate and hidden while utility messages are disabled, and completed groups must not absorb later utility records.
24 +- A root response may attach only to a substantive process render unit. Utility-prefixed units remain visible even while utility steps are hidden; standalone utility-only groups remain separate and hidden while utility messages are disabled, and completed groups must not absorb later utility records. Determine this from full-log render metadata, not partially mounted DOM children.
25
26 ## Work Guidance
27
webui/components/messages/process-group/process-group.css
+13 -1
@@ -11,10 +11,14 @@
11 flex-shrink: 0;
12 }
13
14 -.process-group.utility-only[hidden] {
14 +.process-group.utility-only {
15 display: none !important;
16 }
17
18 +.show-utility-messages .process-group.utility-only {
19 + display: flex !important;
20 +}
21 +
22 /* Embedded Process Group inside Response */
23 .process-group.embedded {
24 display: flex;
@@ -410,6 +414,14 @@
414 overflow: hidden;
415 }
416
417 +.process-step.message-util {
418 + display: none;
419 +}
420 +
421 +.show-utility-messages .process-step.message-util {
422 + display: flex;
423 +}
424 +
425 /* Utility/Info/Hint steps have subtle background tint */
426 .process-step[data-type="util"],
427 .process-step[data-type="info"],
webui/components/sidebar/AGENTS.md
+1
@@ -30,6 +30,7 @@
30 - Instance-level interface visibility preferences own independent mobile and desktop states for the chat-top controls and right canvas rail; mobile uses the shared 768px breakpoint.
31 - Process-detail preference changes must use the message renderer's async expansion hooks and honor an explicit chat-history render target so staged pages are ready before an atomic swap.
32 - The utility-message preference controls both individual utility steps and utility-only process-group chrome so hidden utility runs cannot leave empty headers in the transcript.
33 +- Chat deletion removes the sidebar row optimistically in the same render batch as fallback selection. Keep successful local deletion tombstones for the page session so out-of-order poll or push snapshots cannot reinsert rows; restore the row and clear its tombstone if the delete request fails.
34
35 ## Work Guidance
36
webui/components/sidebar/bottom/preferences/preferences-store.js
+3 -8
@@ -1,5 +1,4 @@
1 import { createStore } from "/js/AlpineStore.js";
2 -import * as css from "/js/css.js";
2 import { ttsService } from "/js/tts-service.js";
3 import { applyModeSteps } from "/components/messages/process-group/process-group-dom.js";
4
@@ -204,14 +203,10 @@ const model = {
203
204 _applyShowUtils(value) {
205 localStorage.setItem("showUtils", value);
207 - css.toggleCssProperty(
208 - ".process-step.message-util",
209 - "display",
210 - value ? undefined : "none"
206 + document.documentElement.classList.toggle(
207 + "show-utility-messages",
208 + Boolean(value),
209 );
212 - document.querySelectorAll(".process-group.utility-only").forEach((group) => {
213 - group.hidden = !value;
214 - });
210 },
211
212 _applyChatWidth(value) {
webui/components/sidebar/chats/chats-store.js
+33 -10
@@ -21,6 +21,7 @@ const model = {
21 selectedContext: null,
22 loggedIn: false,
23 expandedParents: {},
24 + deletedContextIds: {},
25
26 // for convenience
27 getSelectedChatId() {
@@ -52,12 +53,14 @@ const model = {
53 }
54 },
55
55 - // Update contexts from polling
56 + // Update contexts from sync snapshots
57 applyContexts(contextsList) {
58 + const incomingContexts = Array.isArray(contextsList) ? contextsList : [];
59 +
60 // Sort by created_at time (newer first)
58 - this.contexts = [...contextsList].sort(
59 - (a, b) => (b.created_at || 0) - (a.created_at || 0)
60 - );
61 + this.contexts = incomingContexts
62 + .filter((context) => !this.deletedContextIds[context?.id])
63 + .sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
64
65 // Keep selectedContext in sync when the currently selected context's
66 // metadata changes (e.g. project activation/deactivation).
@@ -117,6 +120,9 @@ const model = {
120
121 // Select a chat
122 async selectChat(id) {
123 + // The row may still have a queued click while Alpine removes it.
124 + if (!id || this.deletedContextIds[id]) return;
125 +
126 const currentContext = getContext();
127 if (id === currentContext) return; // already selected
128
@@ -145,24 +151,41 @@ const model = {
151 console.error("No chat ID provided for deletion");
152 return;
153 }
154 + if (this.deletedContextIds[id]) return;
155 +
156 + const removedContext = this.contexts.find((context) => context.id === id);
157 + const deletingSelectedContext = this.selected === id || getContext() === id;
158 +
159 + // Remove first, before selecting the fallback chat. Alpine batches both
160 + // state changes into one render so the old row cannot remain above the new
161 + // selection while the HTTP request is in flight.
162 + this.deletedContextIds = { ...this.deletedContextIds, [id]: true };
163 + this.contexts = this.contexts.filter((context) => context.id !== id);
164
165 try {
166 // Switch to another context if deleting current
151 - if (this.selected === id) {
167 + if (deletingSelectedContext) {
168 await this.switchFromContext(id);
169 }
170
171 // Delete the chat on the server
172 await sendJsonData("/chat_remove", { context: id });
173
158 - // Update the UI - remove from contexts
159 - const updatedContexts = this.contexts.filter((ctx) => ctx.id !== id);
160 - // Force UI update by creating a new array
161 - this.contexts = [...updatedContexts];
162 -
174 // Show success notification
175 justToast("Chat deleted successfully", "success", 1000, "chat-removal");
176 } catch (e) {
177 + const deletedContextIds = { ...this.deletedContextIds };
178 + delete deletedContextIds[id];
179 + this.deletedContextIds = deletedContextIds;
180 +
181 + // Roll back the optimistic row removal without disturbing any chat the
182 + // user selected while the request was pending.
183 + if (removedContext && !this.contexts.some((context) => context.id === id)) {
184 + this.contexts = [...this.contexts, removedContext].sort(
185 + (a, b) => (b.created_at || 0) - (a.created_at || 0),
186 + );
187 + }
188 +
189 console.error("Error deleting chat:", e);
190 toastFetchError("Error deleting chat", e);
191 }
webui/components/welcome/AGENTS.md
+1
@@ -18,6 +18,7 @@
18 - Do not show setup prompts for already configured plugins when backend status can prevent it.
19 - Do not replace the welcome composer with blocking model setup UI; missing model setup is deferred to the chat thread on first send.
20 - The welcome screen mounts the shared chat composer to start a new chat; keep it mutually exclusive with the normal chat input DOM.
21 +- Keep the welcome container background transparent so the persistent right-panel background transition remains visible when entering and leaving Welcome.
22 - Reserve the welcome composer's final minimum height before its nested input components hydrate.
23 - Render `system-resources` as the dedicated System Resources panel, not as a generic alert banner.
24 - Utility quick actions from welcome must keep the first screen intact; use modal/floating entry points instead of docking the right canvas beside welcome content.
webui/components/welcome/welcome-screen.html
-1
@@ -177,7 +177,6 @@
177 min-height: 100vh;
178 overflow: hidden;
179 padding: clamp(4.8rem, 8vh, 6.4rem) clamp(1.25rem, 5.4vw, 6.5rem) 3.5rem;
180 - background: var(--color-background);
180 color: var(--color-text);
181 font-family: var(--font-family-main, "Rubik", Arial, Helvetica, sans-serif);
182 }
webui/css/AGENTS.md
+3 -1
@@ -8,7 +8,7 @@
8 ## Ownership
9
10 - Each CSS file owns a named surface or primitive family such as buttons, messages, modals, notifications, scheduler, settings, surfaces, tables, or toast.
11 -- `messages.css` owns shared chat-history paging indicators and lazy message-preview states in addition to message presentation.
11 +- `loading-indicators.css` owns reusable loading visuals; `messages.css` owns chat-history paging placement, the context-switch splash surface, and lazy message-preview states in addition to message presentation.
12 - Component-specific styles should usually stay inside the component HTML unless they are intentionally shared.
13 - `modals.css` owns the shared stacked modal shell, backdrop, scroll area, footer slot, modal button classes, floating/no-backdrop modal behavior, and shared modal section primitives.
14 - `surfaces.css` owns surface modal switchers, action rails, draggable header affordances, focus-button state, and right-canvas surface primitives.
@@ -28,8 +28,10 @@
28 - `.modal-floating` must keep the full-screen shell pointer-transparent while `.modal-inner` remains pointer-active.
29 - Use `.modal-no-backdrop` only for backdrop suppression without click-through floating behavior.
30 - Shared modal layers must stay above the mobile right-canvas rail while confirmation dialogs remain above normal modals.
31 +- Generic `.loading` placeholders and their shimmer pseudo-elements remain invisible for the default 500ms loading delay.
32 - Shared message collapsing targets `.message-collapse-content`; user-message attachments must remain outside that target so expanding text never changes attachment visibility.
33 - The virtualized chat history disables native scroll anchoring and replay fade-in motion; the message-window renderer owns anchor restoration during atomic page swaps.
34 +- The persistent right panel transitions its background symmetrically between Welcome and chat in 200ms; the Welcome container remains transparent so it cannot hide the return transition.
35 - Do not add decorative one-note palette changes that conflict with existing WebUI design.
36
37 ## Work Guidance
webui/css/loading-indicators.css new
+66
@@ -0,0 +1,66 @@
1 +.three-bubble-loader {
2 + display: inline-flex;
3 + align-items: center;
4 + gap: 0.65rem;
5 + color: var(--color-text-muted);
6 + opacity: 0.58;
7 + transition: opacity var(--transition-speed) ease;
8 +}
9 +
10 +.three-bubble-loader.is-active {
11 + opacity: 1;
12 +}
13 +
14 +.three-bubble-loader-dot {
15 + --three-bubble-loader-delay: 0s;
16 + width: 0.75rem;
17 + height: 0.75rem;
18 + border-radius: 50%;
19 + background: currentColor;
20 + opacity: 0.35;
21 +}
22 +
23 +.three-bubble-loader.is-active > .three-bubble-loader-dot {
24 + animation: three-bubble-loader-jump 1.2s ease-in-out infinite;
25 + animation-delay: var(--three-bubble-loader-delay);
26 +}
27 +
28 +.three-bubble-loader-dot:nth-child(2) {
29 + --three-bubble-loader-delay: 0.16s;
30 +}
31 +
32 +.three-bubble-loader-dot:nth-child(3) {
33 + --three-bubble-loader-delay: 0.32s;
34 +}
35 +
36 +.loading-indicator-label {
37 + position: absolute;
38 + width: 1px;
39 + height: 1px;
40 + padding: 0;
41 + margin: -1px;
42 + overflow: hidden;
43 + clip: rect(0, 0, 0, 0);
44 + white-space: nowrap;
45 + border: 0;
46 +}
47 +
48 +@keyframes three-bubble-loader-jump {
49 + 0%,
50 + 55%,
51 + 100% {
52 + opacity: 0.35;
53 + transform: translateY(0);
54 + }
55 + 20% {
56 + opacity: 1;
57 + transform: translateY(-0.4rem);
58 + }
59 +}
60 +
61 +@media (prefers-reduced-motion: reduce) {
62 + .three-bubble-loader.is-active > .three-bubble-loader-dot {
63 + animation: none;
64 + opacity: 0.7;
65 + }
66 +}
webui/css/messages.css
+17 -54
@@ -1,6 +1,6 @@
1 /* Chat History */
2 #chat-history {
3 - background-color: var(--color-chat-background);
3 + background-color: transparent;
4 position: relative;
5 display: -webkit-flex;
6 display: flex;
@@ -35,72 +35,36 @@
35 pointer-events: none;
36 }
37
38 -.message-window-loader-bubble {
39 - display: inline-flex;
38 +.chat-loading-splash {
39 + position: absolute;
40 + inset: 0;
41 + z-index: 110;
42 align-items: center;
41 - gap: 0.28rem;
42 - padding: 0.55rem 0.75rem;
43 - border: 1px solid color-mix(in srgb, var(--color-border) 75%, transparent);
44 - border-radius: 999px;
45 - background: color-mix(in srgb, var(--color-panel) 92%, transparent);
46 - box-shadow: 0 0.2rem 0.7rem rgba(0, 0, 0, 0.14);
47 - opacity: 0.58;
48 - transition: opacity var(--transition-speed) ease;
49 -}
50 -
51 -.message-window-loader.is-loading .message-window-loader-bubble {
52 - opacity: 1;
53 -}
54 -
55 -.message-window-loader-bubble > span {
56 - width: 0.34rem;
57 - height: 0.34rem;
58 - border-radius: 50%;
59 - background: var(--color-text-muted);
60 - opacity: 0.55;
61 -}
62 -
63 -.message-window-loader.is-loading .message-window-loader-bubble > span {
64 - animation: message-window-loader-dot 1.05s ease-in-out infinite;
65 -}
66 -
67 -.message-window-loader-bubble > span:nth-child(2) {
68 - animation-delay: 0.14s;
43 + margin: 0;
44 + min-height: 0;
45 + background: var(--color-chat-background);
46 }
47
71 -.message-window-loader-bubble > span:nth-child(3) {
72 - animation-delay: 0.28s;
48 +.chat-loading-splash[hidden] {
49 + display: none;
50 }
51
75 -.message-window-loader-label {
76 - position: absolute;
77 - width: 1px;
78 - height: 1px;
79 - padding: 0;
80 - margin: -1px;
81 - overflow: hidden;
82 - clip: rect(0, 0, 0, 0);
83 - white-space: nowrap;
84 - border: 0;
52 +.chat-loading-splash:not([hidden]) {
53 + animation: chat-loading-splash-fade-in 180ms ease-out both;
54 }
55
87 -@keyframes message-window-loader-dot {
88 - 0%,
89 - 60%,
90 - 100% {
91 - opacity: 0.35;
92 - transform: translateY(0);
56 +@keyframes chat-loading-splash-fade-in {
57 + from {
58 + opacity: 0;
59 }
94 - 30% {
60 + to {
61 opacity: 1;
96 - transform: translateY(-0.2rem);
62 }
63 }
64
65 @media (prefers-reduced-motion: reduce) {
101 - .message-window-loader-bubble > span {
66 + .chat-loading-splash:not([hidden]) {
67 animation: none;
103 - opacity: 0.7;
68 }
69 }
70
@@ -347,7 +311,6 @@
311
312 .message-util {
313 background-color: transparent;
350 - display: none;
314 }
315
316 .message-warning {
webui/css/modals.css
+6 -3
@@ -521,6 +521,7 @@ input[type="range"]::-moz-range-thumb {
521 }
522
523 .loading {
524 + --loading-delay: 500ms;
525 width: calc(100% - 4rem);
526 max-width: 1200px;
527 min-height: 50px;
@@ -530,7 +531,7 @@ input[type="range"]::-moz-range-thumb {
531 overflow: hidden;
532 margin: 2rem auto;
533 opacity: 0;
533 - animation: fadeIn 500ms ease-out 500ms forwards;
534 + animation: fadeIn 500ms ease-out var(--loading-delay) forwards;
535 }
536
537 @keyframes fadeIn {
@@ -555,8 +556,10 @@ input[type="range"]::-moz-range-thumb {
556 var(--color-border),
557 var(--color-background)
558 );
558 - animation: shimmer 2s infinite;
559 - animation-delay: 250ms;
559 + opacity: 0;
560 + animation:
561 + shimmer 2s linear var(--loading-delay) infinite,
562 + fadeIn 0s linear var(--loading-delay) forwards;
563 background-size: 200% 100%;
564 }
565
webui/index.css
+9 -4
@@ -1,5 +1,3 @@
1 -@import url("https://fonts.googleapis.com/css2?family=Roboto+Mono:ital,wght@0,100..700;1,100..700&family=Rubik:ital,wght@0,300..900;1,300..900&display=swap");
2 -
1 /* Add box-sizing globally for better cross-browser consistency */
2 *,
3 *::before,
@@ -345,8 +343,15 @@ img {
343 min-width: 0;
344 flex-direction: column;
345 flex: 1 1 0;
348 - -webkit-transition: margin-left var(--transition-speed) ease-in-out;
349 - transition: margin-left var(--transition-speed) ease-in-out;
346 + background-color: var(--color-background);
347 + -webkit-transition: margin-left var(--transition-speed) ease-in-out,
348 + background-color 0.2s ease-out;
349 + transition: margin-left var(--transition-speed) ease-in-out,
350 + background-color 0.2s ease-out;
351 +}
352 +
353 +#right-panel.chat-active {
354 + background-color: var(--color-chat-background);
355 }
356
357 /* Chat area wrapper – contains chat-history + floating nav buttons */
webui/index.html
+97 -88
@@ -1,102 +1,106 @@
1 <!DOCTYPE html>
2 -<html lang="en" data-splash-theme="dark">
2 +<html lang="en">
3
4 <head>
5 <meta charset="UTF-8">
6 <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
7 <title>Agent Zero</title>
8
9 - <script>
10 - try {
11 - if (localStorage.getItem("darkMode") === "false") document.documentElement.dataset.splashTheme = "light"
12 - } catch (_) {}
13 - Promise.all([
14 - new Promise((resolve) => addEventListener("load", resolve, { once: true })),
15 - new Promise((resolve) => document.addEventListener("webui-bundle-loaded", resolve, { once: true })),
16 - new Promise((resolve) => document.addEventListener("webui-extensions-loaded", resolve, { once: true })),
17 - ]).then(() => {
18 - const splash = document.getElementById("startup-splash")
19 - if (!splash) return
20 - splash.addEventListener("transitionend", () => splash.remove(), { once: true })
21 - splash.classList.add("startup-splash-loaded")
22 - document.documentElement.removeAttribute("data-splash-theme")
23 - })
24 - </script>
25 - <style>
26 - html { background: #131313; }
27 - html[data-splash-theme="light"] { background: #fafafa; }
28 - #startup-splash {
29 - position: fixed; inset: 0; z-index: 2147483647;
30 - display: grid; place-items: center;
9 + <style id="startup-transition-critical">
10 + #startup-transition {
11 + position: fixed;
12 + inset: 0;
13 + z-index: 2147483647;
14 + display: grid;
15 + place-items: center;
16 + margin: 0;
17 background: #131313;
32 - transition: opacity .32s ease, visibility 0s linear .32s;
18 + color: #fff;
19 + opacity: 1;
20 + transition: opacity 240ms ease;
21 + }
22 + #startup-transition[data-theme="light"],
23 + body.light-mode #startup-transition { background: #fafafa; color: #000; }
24 + #startup-transition > svg {
25 + width: clamp(12rem, 34vw, 23rem);
26 + max-width: 72vw;
27 + height: auto;
28 + opacity: 1;
29 + animation: none;
30 + }
31 + #startup-transition > p {
32 + position: absolute;
33 + width: 1px;
34 + height: 1px;
35 + overflow: hidden;
36 + clip-path: inset(50%);
37 }
34 - html[data-splash-theme="light"] #startup-splash { background: #fafafa; }
35 - #startup-splash img {
36 - width: clamp(12rem, 34vw, 23rem); max-width: 72vw; height: auto;
37 - opacity: 0; filter: brightness(0) invert(1);
38 - animation: startup-logo-in .55s ease-out .05s forwards;
38 + #startup-transition.startup-transition-leaving {
39 + opacity: 0;
40 + pointer-events: none;
41 }
40 - html[data-splash-theme="light"] #startup-splash img { filter: brightness(0); }
41 - #startup-splash.startup-splash-loaded {
42 - opacity: 0; visibility: hidden; pointer-events: none;
42 + @media (prefers-reduced-motion: reduce) {
43 + #startup-transition { transition: none; }
44 }
44 - @keyframes startup-logo-in { to { opacity: 1; } }
45 </style>
46 - <link rel="preload" href="/public/a0-fullDark.svg" as="image" type="image/svg+xml">
46
47 <script>
49 - (async () => {
50 - let bundle
51 - try {
52 - const response = await fetch("/ui/asset-bundle", { credentials: "same-origin" })
53 - if (!response.ok) throw new Error(`Bundle request failed: ${response.status}`)
54 - bundle = await response.json()
55 - } catch (error) {
56 - console.warn("WebUI asset bundle unavailable.", error)
57 - return
58 - } finally {
59 - document.dispatchEvent(new Event("webui-bundle-loaded"))
48 + if (location.pathname === "/safe") {
49 + const safeUrl = new URL(location.href)
50 + safeUrl.searchParams.delete("__direct")
51 + history.replaceState(null, "", `${safeUrl.pathname}${safeUrl.search}${safeUrl.hash}`)
52 + if ("serviceWorker" in navigator) {
53 + navigator.serviceWorker.getRegistrations()
54 + .then((registrations) => Promise.allSettled(
55 + registrations.map((registration) => registration.unregister()),
56 + ))
57 + .catch((error) => console.warn("Unable to disable service workers.", error))
58 }
59 + }
60 + </script>
61 +
62 + <link rel="icon" type="image/svg+xml" href="public/favicon.svg">
63 + <link rel="preload" href="/vendor/google/google-icons.woff2" as="font" type="font/woff2" crossorigin>
64 + <link rel="stylesheet" href="vendor/google/google-icons.css">
65 + <script>
66 + (() => {
67 + const root = document.documentElement
68 + let applicationReady = false
69 + let revealStarted = false
70
62 - if (!("serviceWorker" in navigator) || !bundle?.version || !bundle.files) return
63 - try {
64 - const sentWorkers = new WeakSet()
65 - const sendBundle = (worker) => {
66 - if (!worker || sentWorkers.has(worker)) return
67 - sentWorkers.add(worker)
68 - worker.postMessage({ type: "preload-ui-bundle", bundle })
69 - }
70 -
71 - sendBundle(navigator.serviceWorker.controller)
72 - const onControllerChange = () => {
73 - sendBundle(navigator.serviceWorker.controller)
74 - navigator.serviceWorker.removeEventListener("controllerchange", onControllerChange)
75 - }
76 - navigator.serviceWorker.addEventListener("controllerchange", onControllerChange)
77 -
78 - const registrations = await navigator.serviceWorker.getRegistrations()
79 - await Promise.all(
80 - registrations
81 - .filter((registration) => new URL(registration.scope).pathname === "/js/")
82 - .map((registration) => registration.unregister()),
83 - )
84 - const registration = await navigator.serviceWorker.register(
85 - `/sw.js?version=${encodeURIComponent(bundle.version)}`,
86 - { scope: "/", updateViaCache: "none" },
87 - )
88 - sendBundle(registration.installing)
89 - sendBundle(registration.waiting)
90 - sendBundle(registration.active)
91 - sendBundle((await navigator.serviceWorker.ready).active)
92 - } catch (error) {
93 - console.warn("WebUI asset cache unavailable.", error)
71 + const revealApplication = (force = false) => {
72 + if (revealStarted || (!force && !applicationReady)) return
73 + const overlay = document.getElementById("startup-transition")
74 + if (!overlay) return
75 + revealStarted = true
76 + requestAnimationFrame(() => requestAnimationFrame(() => {
77 + const removeOverlay = () => overlay.remove()
78 + overlay.classList.add("startup-transition-leaving")
79 + overlay.addEventListener("transitionend", removeOverlay, { once: true })
80 + setTimeout(removeOverlay, 600)
81 + }))
82 }
83 +
84 + document.addEventListener("webui-extensions-loaded", () => {
85 + applicationReady = true
86 + revealApplication()
87 + }, { once: true })
88 +
89 + const fontLoad = document.fonts?.load
90 + ? document.fonts.load('24px "Material Symbols Outlined"')
91 + : Promise.resolve([])
92 + fontLoad.then((faces) => {
93 + if (faces.length > 0) root.classList.add("material-icons-ready")
94 + }).catch((error) => {
95 + console.warn("Material Symbols font unavailable.", error)
96 + })
97 +
98 + setTimeout(() => revealApplication(true), 8000)
99 })()
100 </script>
97 -
98 - <link rel="icon" type="image/svg+xml" href="public/favicon.svg">
101 + <link rel="preload" as="style" href="/vendor/fonts/fonts.css" onload="this.onload=null;this.rel='stylesheet'">
102 <link rel="preload" as="style" href="index.css" onload="this.onload=null;this.rel='stylesheet'">
103 + <link rel="preload" as="style" href="css/loading-indicators.css" onload="this.onload=null;this.rel='stylesheet'">
104 <link rel="preload" as="style" href="css/messages.css" onload="this.onload=null;this.rel='stylesheet'">
105 <link rel="preload" as="style" href="components/messages/action-buttons/simple-action-buttons.css" onload="this.onload=null;this.rel='stylesheet'">
106 <link rel="preload" as="style" href="components/messages/process-group/process-group.css" onload="this.onload=null;this.rel='stylesheet'">
@@ -127,7 +131,6 @@
131 <script defer src="vendor/bootstrap/bootstrap.bundle.min.js"></script>
132
133 <!-- Then load Alpine.js -->
130 - <!-- <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.3/dist/cdn.min.js"></script> -->
134 <script defer src="vendor/ace-min/ace.js"></script>
135 <script defer src="vendor/qrcode.min.js"></script>
136 <script type="module" src="js/initFw.js"></script>
@@ -140,9 +143,6 @@
143 <script defer src="vendor/katex/katex.min.js" crossorigin="anonymous"></script>
144 <script defer src="vendor/katex/katex.auto-render.min.js" crossorigin="anonymous"></script>
145
143 - <!-- Google Icons -->
144 - <link rel="preload" as="style" href="vendor/google/google-icons.css" onload="this.onload=null;this.rel='stylesheet'">
145 -
146 <!-- Link the PWA manifest file -->
147 <link rel="manifest" href="js/manifest.json">
148
@@ -159,6 +159,7 @@
159 timezone: "{{user_timezone_setting}}",
160 timeFormat: "{{user_time_format_setting}}",
161 uiControlVisibility: {{user_ui_control_visibility}},
162 + webuiExtensions: {{webui_extension_manifest}},
163 };
164 </script>
165 <!-- Plugin head injections (scripts, stylesheets) -->
@@ -167,11 +168,10 @@
168
169 <body class="dark-mode device-pointer" x-data>
170 <script>
170 - if (document.documentElement.dataset.splashTheme === "light") document.body.classList.replace("dark-mode", "light-mode")
171 + try {
172 + if (localStorage.getItem("darkMode") === "false") document.body.classList.replace("dark-mode", "light-mode")
173 + } catch (_) {}
174 </script>
172 - <div id="startup-splash" aria-hidden="true">
173 - <img src="/public/a0-fullDark.svg" alt="">
174 - </div>
175 <div class="container">
176 <!-- Sidebar Overlay -->
177 <div id="sidebar-overlay" x-data>
@@ -183,7 +183,8 @@
183 <x-component path="sidebar/left-sidebar.html"></x-component>
184
185 <!-- Right Panel (Message History and Input Section) -->
186 - <div id="right-panel" class="panel" >
186 + <div id="right-panel" class="panel"
187 + :class="{ 'chat-active': $store.welcomeStore && !$store.welcomeStore.isVisible }">
188
189 <!-- top section with time, status etc. -->
190 <x-component path="chat/top-section/chat-top.html"></x-component>
@@ -200,6 +201,14 @@
201 <div id="chat-area-wrapper" x-data x-show="!$store.welcomeStore || !$store.welcomeStore.isVisible">
202 <div id="chat-history"></div>
203
204 + <div id="chat-loading-splash"
205 + class="message-window-loader is-loading chat-loading-splash"
206 + role="status"
207 + aria-live="polite"
208 + hidden>
209 + <span class="loading-indicator-label">Loading chat</span>
210 + </div>
211 +
212 <!-- Chat Navigation Buttons (floating over chat area) -->
213 <div id="chat-nav-buttons" aria-label="Chat navigation" x-cloak>
214 <button class="btn-icon-action" title="Scroll to top" @click="$store.chatNavigation.scrollToTop()">
webui/index.js
+52
@@ -17,6 +17,7 @@ import { store as syncStore } from "/components/sync/sync-store.js"
17 import { store as welcomeStore } from "/components/welcome/welcome-store.js";
18 import { store as modelGateStore } from "/components/chat/model-gate-store.js";
19 import { getUserHour12, getUserTimezone } from "/js/time-utils.js";
20 +import { createThreeBubbleLoader } from "/js/loading-indicators.js";
21
22 globalThis.fetchApi = api.fetchApi; // TODO - backward compatibility for non-modular scripts, remove once refactored to alpine
23
@@ -34,8 +35,52 @@ let leftPanel,
35
36 let autoScroll = true;
37 let context = null;
38 +let loadingContext = null;
39 +let chatLoadingSplashVisible = false;
40 +let chatLoadingSplashTimer = null;
41 globalThis.resetCounter = 0; // Used by stores and getChatBasedId
42 let skipOneSpeech = false;
43 +const CHAT_LOADING_SPLASH_DELAY_MS = 300;
44 +
45 +function syncChatLoadingSplash() {
46 + const splash = document.getElementById("chat-loading-splash");
47 + if (!splash) return;
48 + if (!splash.querySelector(":scope > .three-bubble-loader")) {
49 + splash.prepend(createThreeBubbleLoader({ active: true }));
50 + }
51 + splash.hidden = !chatLoadingSplashVisible;
52 +}
53 +
54 +function beginChatLoading(id) {
55 + if (chatLoadingSplashTimer) {
56 + clearTimeout(chatLoadingSplashTimer);
57 + chatLoadingSplashTimer = null;
58 + }
59 + loadingContext = id || null;
60 + chatLoadingSplashVisible = false;
61 + syncChatLoadingSplash();
62 +
63 + if (loadingContext !== null) {
64 + const expectedContext = loadingContext;
65 + chatLoadingSplashTimer = setTimeout(() => {
66 + chatLoadingSplashTimer = null;
67 + if (loadingContext !== expectedContext) return;
68 + chatLoadingSplashVisible = true;
69 + syncChatLoadingSplash();
70 + }, CHAT_LOADING_SPLASH_DELAY_MS);
71 + }
72 +}
73 +
74 +function finishChatLoading(id) {
75 + if (loadingContext === null || id !== loadingContext) return;
76 + if (chatLoadingSplashTimer) {
77 + clearTimeout(chatLoadingSplashTimer);
78 + chatLoadingSplashTimer = null;
79 + }
80 + loadingContext = null;
81 + chatLoadingSplashVisible = false;
82 + syncChatLoadingSplash();
83 +}
84
85 // Sidebar toggle logic is now handled by sidebar-store.js
86
@@ -428,6 +473,10 @@ export async function applySnapshot(snapshot, options = {}) {
473 // update message queue
474 messageQueueStore.updateFromPoll();
475
476 + // A context switch is visually complete only after its matching snapshot
477 + // has rendered and the surrounding chat state has been synchronized.
478 + finishChatLoading(snapshot.context);
479 +
480 return { updated };
481 }
482
@@ -560,6 +609,8 @@ globalThis.newContext = newContext;
609 export const setContext = function (id) {
610 if (id == context) return;
611 context = id;
612 + if (id) beginChatLoading(id);
613 + else beginChatLoading(null);
614 // Always reset the log tracking variables when switching contexts
615 // This ensures we get fresh data from the backend
616 lastLogGuid = "";
@@ -789,6 +840,7 @@ document.addEventListener("DOMContentLoaded", function () {
840 progressBar = document.getElementById("progress-bar");
841 autoScrollSwitch = document.getElementById("auto-scroll-switch");
842 timeDate = document.getElementById("time-date-container");
843 + syncChatLoadingSplash();
844
845
846 // Start polling for updates
webui/js/AGENTS.md
+4 -1
@@ -17,6 +17,7 @@
17 - `initFw.js` owns Alpine bootstrap and custom lifecycle directives such as `x-create`, `x-destroy`, and periodic `x-every-*` hooks.
18 - `messages.js` owns native message/process-step rendering, safe Markdown and HTML conversion, and KaTeX delimiter handling.
19 - `message-window.js` owns the bounded, tail-first raw-log window used by message rendering.
20 +- `loading-indicators.js` owns reusable DOM factories for shared loading visuals.
21 - `scroller.js` owns bottom-following snapshots and cancellation of pending scroll effects.
22 - Other modules own focused UI utilities such as modals, messages, safe markdown, shortcuts, TTS/STT, surfaces, and initialization.
23
@@ -38,6 +39,7 @@
39 - Click-outside close requires both `mousedown` and `mouseup` on the outer `.modal` container.
40 - `scrollModal(id)` scrolls inside the top modal's `.modal-scroll`.
41 - Keep extension loader cache keys and extension point names stable for plugins.
42 +- The rendered index supplies a complete `runtimeInfo.webuiExtensions` manifest; `extensions.js` must resolve HTML and JavaScript extension paths from it without per-extension-point startup API calls, while retaining `/api/load_webui_extensions` as a compatibility fallback when the manifest is unavailable.
43 - HTML extension loading turns discovered HTML files into `<x-component>` tags; JavaScript extensions must export a default function.
44 - `extensions.js` exposes `initialHtmlExtensionsLoaded` and emits `webui-extensions-loaded` once after Alpine and the initial recursive component/extension loading placeholders have cleared.
45 - Transport-level preloading must remain outside `components.js`, `extensions.js`, and `initFw.js`; cache hits flow through their ordinary asynchronous requests.
@@ -50,9 +52,10 @@
52 - Full message snapshots that start at backend log `no` 0 must replace the current message DOM before rendering; incremental snapshots should keep patching existing messages.
53 - Long histories stay cached as raw log data but render a contiguous tail-first DOM window. The initial base view contains one 60-entry page; after paging, the base window contains two aligned pages, retaining the adjacent page and discarding only the far page in either direction. Visible boundaries expand to whole logical process groups so a page never reconstructs a partial group; the unit classifier must include plugin-backed process steps such as `code_exe`, and oversized groups use their own 50-step incremental window. Paging must preserve a visible anchor and occur at the scroll boundary after user intent, using passive loading indicators rather than count-bearing controls. Live entries and late content growth follow the tail until the reader deliberately moves away; historical window rebuilds must cancel pending auto-scroll effects, render in an off-screen staging history, and atomically swap fully laid-out content into the live scroller before restoring its anchor.
54 - Message-window cache identity must keep different log types distinct even when they share a backend ID; root-agent GEN and response records intentionally use the same ID and must both survive replay, while same-ID/same-type updates still replace their earlier cached version.
53 -- Utility records join a process render unit only when a substantive process step follows before the next standalone boundary. Utility-only runs must not wrap root responses in empty groups or reopen completed groups; their group chrome stays hidden unless utility messages are enabled.
55 +- Utility records join a process render unit only when a substantive process step follows before the next standalone boundary. Group visibility must use that full-log classification rather than infer utility-only state from partially mounted DOM children. Standalone utility-only runs must not wrap root responses or reopen completed groups, and their group chrome stays hidden unless utility messages are enabled.
56 - `set_messages_after_loop` receives offscreen live updates as results with `result.virtualized === true` and `result.element === null`; extensions that only need `args` may still react, while DOM-mutating extensions must guard the element.
57 - Context switches, log GUID resets, and full log snapshots must reset both message DOM and message-window cache state.
58 +- Context switches clear stale history immediately but defer the chat loading splash for 300 ms so fast loads do not flash; slower loads fade it in and dismiss it only after the matching context snapshot finishes rendering. Stale snapshots and timers must not affect a newer switch's splash.
59 - Long primary-agent responses start collapsed only during chat replay; long user-message text starts collapsed during both live sends and replay. Both use the shared collapsible-message behavior, but user attachments remain outside its clipped content target.
60 - Info log entries with `kvps.finished` complete the active process group and clear its running treatment.
61
webui/js/extensions.js
+33 -12
@@ -43,6 +43,15 @@ export function clearCache() {
43 cache.clear(HTML_CACHE_AREA);
44 }
45
46 +function manifestExtensionPaths(assetType, extensionPoint) {
47 + const manifest = globalThis.runtimeInfo?.webuiExtensions;
48 + if (!manifest || typeof manifest !== "object") return null;
49 + const extensionsByPoint = manifest[assetType];
50 + if (!extensionsByPoint || typeof extensionsByPoint !== "object") return null;
51 + const extensions = extensionsByPoint[extensionPoint];
52 + return Array.isArray(extensions) ? extensions : [];
53 +}
54 +
55 /**
56 * Call all JS extensions for a given extension point.
57 *
@@ -72,14 +81,20 @@ export async function loadJsExtensions(extensionPoint) {
81 const cached = cache.get(JS_CACHE_AREA, extensionPoint, null);
82 if (cached != null) return cached;
83
75 - /** @type {LoadWebuiExtensionsResponse} */
76 - const response = await api.callJsonApi(`/api/load_webui_extensions`, {
77 - extension_point: extensionPoint,
78 - filters: ["*.js", "*.mjs"],
79 - });
84 + const manifestExtensions = manifestExtensionPaths("js", extensionPoint);
85 + /** @type {WebuiExtension[]} */
86 + let extensionPaths = manifestExtensions;
87 + if (extensionPaths == null) {
88 + /** @type {LoadWebuiExtensionsResponse} */
89 + const response = await api.callJsonApi(`/api/load_webui_extensions`, {
90 + extension_point: extensionPoint,
91 + filters: ["*.js", "*.mjs"],
92 + });
93 + extensionPaths = response.extensions;
94 + }
95 /** @type {JsExtensionImport[]} */
96 const imports = await Promise.all(
82 - response.extensions.map(async (path) => ({
97 + extensionPaths.map(async (path) => ({
98 path,
99 module: await import(normalizePath(path))
100 }))
@@ -180,13 +195,19 @@ export async function importHtmlExtensions(extensionPoint, targetElement) {
195 return;
196 }
197
183 - /** @type {LoadWebuiExtensionsResponse} */
184 - const response = await api.callJsonApi(`/api/load_webui_extensions`, {
185 - extension_point: extensionPoint,
186 - filters: ["*.html", "*.htm", "*.xhtml"],
187 - });
198 + const manifestExtensions = manifestExtensionPaths("html", extensionPoint);
199 + /** @type {WebuiExtension[]} */
200 + let extensionPaths = manifestExtensions;
201 + if (extensionPaths == null) {
202 + /** @type {LoadWebuiExtensionsResponse} */
203 + const response = await api.callJsonApi(`/api/load_webui_extensions`, {
204 + extension_point: extensionPoint,
205 + filters: ["*.html", "*.htm", "*.xhtml"],
206 + });
207 + extensionPaths = response.extensions;
208 + }
209 let combinedHTML = "";
189 - for (const extension of response.extensions) {
210 + for (const extension of extensionPaths) {
211 const path = normalizePath(extension);
212 combinedHTML += `<x-component path="${path}"></x-component>`;
213 }
webui/js/loading-indicators.js new
+20
@@ -0,0 +1,20 @@
1 +/**
2 + * Build the shared three-bubble loading indicator.
3 + *
4 + * The caller owns status text and ARIA live-region behavior so this visual can
5 + * be reused inside an existing status container without duplicate announcements.
6 + */
7 +export function createThreeBubbleLoader({ active = true } = {}) {
8 + const loader = document.createElement("span");
9 + loader.className = "three-bubble-loader";
10 + loader.classList.toggle("is-active", active);
11 + loader.setAttribute("aria-hidden", "true");
12 +
13 + for (let index = 0; index < 3; index += 1) {
14 + const bubble = document.createElement("span");
15 + bubble.className = "three-bubble-loader-dot";
16 + loader.appendChild(bubble);
17 + }
18 +
19 + return loader;
20 +}
webui/js/messages.js
+22 -23
@@ -25,6 +25,7 @@ import {
25 import { callJsExtensions } from "/js/extensions.js";
26 import { addBlankTargetsToLinks } from "/js/html-links.js";
27 import { sanitizeHtml } from "/js/safe-markdown.js";
28 +import { createThreeBubbleLoader } from "/js/loading-indicators.js";
29
30 // Delay before collapsing previous steps when a new step is added
31 const STEP_COLLAPSE_DELAY = {
@@ -1014,6 +1015,9 @@ function setMessageWindowIndicatorLoading(history, direction, loading) {
1015 if (!indicator) return;
1016
1017 indicator.classList.toggle("is-loading", loading);
1018 + indicator
1019 + .querySelector(":scope > .three-bubble-loader")
1020 + ?.classList.toggle("is-active", loading);
1021 if (loading) {
1022 const label = direction === "older" ? "earlier" : "newer";
1023 indicator.setAttribute("role", "status");
@@ -1042,12 +1046,11 @@ function createMessageWindowIndicator(direction) {
1046 } else {
1047 indicator.setAttribute("aria-hidden", "true");
1048 }
1045 - indicator.innerHTML = `
1046 - <span class="message-window-loader-bubble" aria-hidden="true">
1047 - <span></span><span></span><span></span>
1048 - </span>
1049 - <span class="message-window-loader-label">Loading ${label} messages</span>
1050 - `;
1049 + indicator.appendChild(createThreeBubbleLoader({ active: isLoading }));
1050 + const statusLabel = document.createElement("span");
1051 + statusLabel.className = "loading-indicator-label";
1052 + statusLabel.textContent = `Loading ${label} messages`;
1053 + indicator.appendChild(statusLabel);
1054 return indicator;
1055 }
1056
@@ -1223,19 +1226,6 @@ function getLastProcessGroup(allowCompleted = true) {
1226 return group;
1227 }
1228
1226 -function isUtilityOnlyProcessGroup(group) {
1227 - const steps = group?.querySelectorAll?.(".process-step") || [];
1228 - return steps.length > 0 &&
1229 - !group.querySelector(".process-step:not(.message-util)");
1230 -}
1231 -
1232 -function updateUtilityOnlyProcessGroup(group) {
1233 - if (!group) return;
1234 - const utilityOnly = isUtilityOnlyProcessGroup(group);
1235 - group.classList.toggle("utility-only", utilityOnly);
1236 - group.hidden = utilityOnly && !preferencesStore.showUtils;
1237 -}
1238 -
1229 function getOrCreateProcessGroup(id, allowCompleted = true, renderInfo = null) {
1230 const groupIdentity = renderInfo?.id || id;
1231 // first try direct match by ID
@@ -1297,13 +1287,24 @@ export function drawProcessStep({
1287 const stepId = `process-step-${id}`;
1288 let step = getChatHistoryElementById(stepId);
1289
1290 + const renderInfo = log[PROCESS_GROUP_RENDER_INFO];
1291 const group =
1292 getStepProcessGroup(step) ||
1293 getOrCreateProcessGroup(
1294 id,
1295 allowCompletedGroup,
1305 - log[PROCESS_GROUP_RENDER_INFO],
1296 + renderInfo,
1297 );
1298 + if (renderInfo) {
1299 + // A later process step can promote a previously standalone live utility
1300 + // into a substantive unit when the full cache is reclassified.
1301 + group.classList.remove("utility-only");
1302 + } else if (log.type === "util") {
1303 + // Standalone utilities are not part of a substantive render unit. Mark
1304 + // them directly from the full-log classifier instead of inferring group
1305 + // visibility from whichever child steps happen to be mounted so far.
1306 + group.classList.add("utility-only");
1307 + }
1308 const stepsContainer = group.querySelector(".process-steps");
1309
1310 const isNewStep = !step;
@@ -1472,7 +1473,6 @@ export function drawProcessStep({
1473
1474 // update the process grop header by this step
1475 updateProcessGroupHeader(group);
1475 - updateUtilityOnlyProcessGroup(group);
1476
1477 // remove shine from previous steps and add to this one if new and not completed
1478 if (isNewStep && !isGroupComplete) {
@@ -1956,10 +1956,9 @@ export function drawMessageResponse({
1956 // get last process group or create new container (if first message)
1957
1958 let group = getLastProcessGroup();
1959 - if (isUtilityOnlyProcessGroup(group)) {
1959 + if (group?.classList.contains("utility-only")) {
1960 group.setAttribute("data-group-complete", "true");
1961 updateProcessGroupHeader(group);
1962 - updateUtilityOnlyProcessGroup(group);
1962 group = null;
1963 }
1964 let container = getChatHistoryElementById(`message-${id}`); // first check for already existing message
webui/login.css
+1 -4
@@ -1,5 +1,3 @@
1 -@import url("https://fonts.googleapis.com/css2?family=Rubik:ital,wght@0,300..900;1,300..900&display=swap");
2 -
1 body {
2 background-color: #131313;
3 color: #d4d4d4;
@@ -34,9 +32,8 @@ input {
32 }
33
34 .logo {
37 - width: min(55vw, 12.5rem);
35 + width: min(57.75vw, 14.85rem);
36 height: auto;
39 - border-radius: 0.3125rem; /* Match the main page logo style */
37 margin-bottom: 2rem;
38 }
39
webui/login.html
+2 -1
@@ -4,6 +4,7 @@
4 <meta charset="UTF-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 <title>Login - Agent Zero</title>
7 + <link rel="stylesheet" href="/vendor/fonts/fonts.css">
8 <link rel="stylesheet" href="/login.css">
9 <link rel="icon" type="image/svg+xml" href="/public/favicon.svg">
10 </head>
@@ -13,7 +14,7 @@
14 {% if next %}
15 <input type="hidden" name="next" value="{{ next }}">
16 {% endif %}
16 - <img src="/public/splash.jpg" alt="Agent Zero Logo" class="logo">
17 + <img src="/public/dark.svg" alt="Agent Zero" class="logo">
18 <div class="input-group">
19 <label for="username">Username</label>
20 <input type="text" id="username" name="username" required>
webui/public/dark.svg new
+20
@@ -0,0 +1,20 @@
1 +<?xml version="1.0" encoding="UTF-8"?>
2 +<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1440 1440">
3 + <path d="m922.78,720.83c-68.52-117.75-137.34-236.02-207.55-356.67-69.47,120.35-137.92,238.96-206.28,357.4h-106.11c104.3-180.26,312.68-537.74,312.68-537.74h0s209.3,356.65,313.57,537h-106.32Z" fill="#7a7a7a" stroke-width="0"/>
4 + <path d="m849.11,721.33h-269.24c17.93-31.17,35.27-61.34,52.48-91.26h165.45c16.77,29.83,33.46,59.52,51.3,91.26Z" fill="#7a7a7a" stroke-width="0"/>
5 + <path d="m855.05,1206.57c10.69,16.35,20.76,31.74,32.02,48.96h-34.84c-10.02-13.63-20.9-28.43-31.61-42.99h-39.57v42.82h-27.99v-147.26c16.45,0,32.16-.12,47.86.04,12.86.13,25.92-.69,38.54,1.21,20.63,3.1,36.67,14.08,41.02,35.55,4.51,22.21.88,42.92-19.45,57.3-1.72,1.22-3.4,2.49-5.98,4.38Zm-73.95-22.11c18.62,0,36.54,1.02,54.27-.36,11.76-.92,16.96-9.82,17.29-21.79.34-12.53-4.94-22.22-16.97-23.4-17.96-1.76-36.23-.46-54.58-.46v46.01Z" fill="#7a7a7a" stroke-width="0"/>
6 + <path d="m403.72,1108.89v21.02c-28.68,32.22-57.1,64.16-87.72,98.55h87.05v27.1h-124.68v-28.42c25.73-28.55,52.05-57.74,81.02-89.88h-80.67v-28.36h124.98Z" fill="#7a7a7a" stroke-width="0"/>
7 + <path d="m900.13,840.53c35.32,35,68.53,67.91,103.71,102.78v-84.31h25.96v148.54c-35.16-34.47-68.49-67.15-104.09-102.04v88.97h-25.59v-153.95Z" fill="#7a7a7a" stroke-width="0"/>
8 + <path d="m1006.95,1219.73c7.56-3.75,18.36-8.63,24.87-11.86,22.7,30.27,57.55,23.3,72.48,9.86,14.93-13.44,19.41-32.85,13.81-52.29-7.32-18.81-23.54-33.49-47.3-32.66-23.76.83-44.67,17.75-46.66,46.2h-27.56c-.32-27.45,19.87-59.9,51.56-69.43,39.86-11.99,79.37,7.25,94.25,45.88,13.79,35.8-3.14,75.91-39.55,93.66-33.24,16.21-76.78,3.82-95.9-29.36Z" fill="#7a7a7a" stroke-width="0"/>
9 + <path d="m562.28,879c-6.06,4.6-13.45,9.69-18.69,13.67-30.67-25.38-65.52-16.91-77.96,14.94-7.47,20.91,3.44,48.31,26.4,56.76,19.97,7.34,42.08-.34,47.83-17.24-4.74-1.43-9.47-2.86-15.47-4.68-.51-7.03-1.02-14.06-1.62-22.4h53.03c3.97,30.68-20.48,64.7-51.72,72.4-34.54,8.51-70.85-10.52-83.43-43.72-12.4-32.74,2.64-71.9,33.89-88.27,30.63-16.05,69.43-8.42,87.73,18.56Z" fill="#7a7a7a" stroke-width="0"/>
10 + <path d="m270.94,848.74c26.4,50.55,50.96,97.57,76.62,146.71h-29.28c-15.28-27.69-30.93-56.05-47.5-86.07-15.85,29.74-30.73,57.67-45.91,86.16h-29.87c25.13-48.57,49.73-96.13,75.94-146.8Z" fill="#7a7a7a" stroke-width="0"/>
11 + <path d="m1170.37,881.96h-49.28v-25.32h123.91v25.33h-47.34v112.78h-27.29v-112.8Z" fill="#7a7a7a" stroke-width="0"/>
12 + <g>
13 + <path d="m553.53,1135.42v36.02l34.56.5s-.01,16.45-.01,27.57c-14.07.49-29.08.24-42.92-3.03-11.71-2.77-20.12-12.39-20.64-25.22-.82-20.25-.22-41.56-.22-63.12h118.59v27.28h-89.36Z" fill="#7a7a7a" stroke-width="0"/>
14 + <path d="m524.56,1255.14v-37.59c7.73-.35,14.91-.68,22.44-1.03,1.9,3.83,1.68,10.85,5.45,11.01,3.77.16,58.97,0,89.86,0v27.61h-117.75Z" fill="#7a7a7a" stroke-width="0"/>
15 + </g>
16 + <g>
17 + <path d="m712.11,883.49v33.32l31.97.46s-.01,15.22-.01,25.51c-13.07.22-26.9.22-39.7-2.8-10.83-2.56-18.61-11.46-19.09-23.33-.76-18.74-.2-38.45-.2-58.39h109.71v25.23h-82.67Z" fill="#7a7a7a" stroke-width="0"/>
18 + <path d="m685.31,994.24v-34.77c7.15-.33,13.79-.63,20.76-.95,1.75,3.54,1.55,10.04,5.04,10.18,3.49.15,54.55,0,83.12,0v25.54h-108.93Z" fill="#7a7a7a" stroke-width="0"/>
19 + </g>
20 +</svg>
webui/safe.html new
+67
@@ -0,0 +1,67 @@
1 +<!DOCTYPE html>
2 +<html lang="en" data-safe-theme="dark">
3 +
4 +<head>
5 + <meta charset="UTF-8">
6 + <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
7 + <title>Agent Zero — Safe mode</title>
8 + <link rel="icon" href="data:,">
9 + <script>
10 + try {
11 + if (localStorage.getItem("darkMode") === "false") document.documentElement.dataset.safeTheme = "light"
12 + } catch (_) {}
13 + </script>
14 + <style>
15 + html, body { width: 100%; height: 100%; margin: 0; }
16 + html { background: #131313; color: #fff; }
17 + html[data-safe-theme="light"] { background: #fafafa; color: #111; }
18 + body { display: grid; place-items: center; font-family: system-ui, sans-serif; }
19 + main { display: grid; justify-items: center; gap: .75rem; padding: 2rem; text-align: center; }
20 + strong { font-size: 1.15rem; }
21 + p { margin: 0; color: #aaa; }
22 + html[data-safe-theme="light"] p { color: #666; }
23 + </style>
24 +</head>
25 +
26 +<body>
27 + <main role="status" aria-live="polite">
28 + <strong>Starting Agent Zero in safe mode</strong>
29 + <p>Disabling the offline worker before loading the application…</p>
30 + </main>
31 +
32 + <script>
33 + (() => {
34 + const DIRECT_PARAMETER = "__direct"
35 + const FALLBACK_TIMEOUT_MS = 3000
36 + let navigationStarted = false
37 +
38 + const navigateToApp = () => {
39 + if (navigationStarted) return
40 + navigationStarted = true
41 + const target = new URL(location.href)
42 + target.pathname = "/safe"
43 + target.searchParams.set(DIRECT_PARAMETER, "1")
44 + location.replace(target.href)
45 + }
46 +
47 + const fallbackTimer = setTimeout(navigateToApp, FALLBACK_TIMEOUT_MS)
48 +
49 + const disableServiceWorkers = async () => {
50 + if (!("serviceWorker" in navigator)) return
51 + const registrations = await navigator.serviceWorker.getRegistrations()
52 + await Promise.allSettled(
53 + registrations.map((registration) => registration.unregister()),
54 + )
55 + }
56 +
57 + disableServiceWorkers()
58 + .catch((error) => console.warn("Unable to disable service workers.", error))
59 + .finally(() => {
60 + clearTimeout(fallbackTimer)
61 + navigateToApp()
62 + })
63 + })()
64 + </script>
65 +</body>
66 +
67 +</html>
webui/splash.html new
+232
@@ -0,0 +1,232 @@
1 +<!DOCTYPE html>
2 +<html lang="en" data-splash-theme="dark">
3 +
4 +<head>
5 + <meta charset="UTF-8">
6 + <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
7 + <title>Agent Zero</title>
8 + <link rel="icon" href="data:,">
9 + <script>
10 + try {
11 + if (localStorage.getItem("darkMode") === "false") document.documentElement.dataset.splashTheme = "light"
12 + } catch (_) {}
13 + </script>
14 + <style>
15 + html, body { width: 100%; height: 100%; margin: 0; }
16 + html { background: #131313; color: #fff; }
17 + html[data-splash-theme="light"] { background: #fafafa; color: #000; }
18 + body { display: grid; place-items: center; font-family: system-ui, sans-serif; }
19 + #startup-transition { display: grid; justify-items: center; gap: 1rem; }
20 + #startup-transition > svg {
21 + width: clamp(12rem, 34vw, 23rem); max-width: 72vw; height: auto;
22 + opacity: 0; animation: startup-logo-in .55s ease-out .05s forwards;
23 + }
24 + #startup-transition > p { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
25 + @keyframes startup-logo-in { to { opacity: 1; } }
26 + </style>
27 +</head>
28 +
29 +<body>
30 + <main id="startup-transition" role="status" aria-live="polite">
31 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 163 26" aria-hidden="true">
32 + <path fill="currentColor" d="m138.7 15.3c0.8 1.2 1.6 2.4 2.5 3.7h-2.7c-0.8-1-1.6-2.2-2.4-3.3h-3.1v3.3h-2.1v-11.3q1.8 0 3.6 0c1 0 2-0.1 3 0.1 1.6 0.2 2.8 1.1 3.2 2.7 0.3 1.7 0 3.3-1.5 4.4-0.2 0.1-0.3 0.2-0.5 0.4zm-5.7-1.7c1.5 0 2.8 0 4.2-0.1 0.9 0 1.3-0.7 1.3-1.6 0-1-0.4-1.7-1.3-1.8-1.4-0.2-2.8-0.1-4.2-0.1zm-29-5.8v1.6c-2.2 2.5-4.4 5-6.7 7.6h6.7v2.1h-9.6v-2.2c2-2.2 4-4.4 6.2-6.9h-6.2v-2.2zm-48.2-0.7c2.8 2.7 5.3 5.2 8 7.9v-6.5h2v11.4c-2.7-2.6-5.2-5.1-8-7.8v6.8h-2zm94.6 9.3c0.6-0.3 1.4-0.7 1.9-0.9 1.7 2.3 4.4 1.8 5.6 0.7 1.1-1 1.5-2.5 1-4-0.5-1.4-1.8-2.6-3.6-2.5-1.8 0.1-3.4 1.4-3.6 3.5h-2.1c0-2.1 1.5-4.6 3.9-5.3 3.1-0.9 6.1 0.6 7.3 3.5 1 2.8-0.2 5.9-3 7.2-2.6 1.3-6 0.3-7.4-2.2zm-120.5-6.3c-0.5 0.3-1.1 0.7-1.5 1-2.3-1.9-5-1.3-6 1.2-0.5 1.6 0.3 3.7 2.1 4.3 1.5 0.6 3.2 0 3.6-1.3-0.3-0.1-0.7-0.2-1.1-0.3-0.1-0.6-0.1-1.1-0.2-1.8h4.1c0.3 2.4-1.6 5-4 5.6-2.6 0.7-5.4-0.8-6.4-3.4-0.9-2.5 0.2-5.5 2.6-6.7 2.4-1.3 5.4-0.7 6.8 1.4zm-22.4-2.3c2 3.8 3.9 7.5 5.9 11.2h-2.3q-1.8-3.2-3.6-6.6c-1.3 2.3-2.4 4.4-3.6 6.6h-2.3c2-3.7 3.9-7.4 5.9-11.2zm69.1 2.5h-3.8v-1.9h9.5v1.9h-3.6v8.7h-2.1zm38.9-0.4v2.7l2.7 0.1c0 0 0 1.2 0 2.1-1.1 0-2.3 0-3.3-0.2-0.9-0.2-1.6-1-1.6-2-0.1-1.5 0-3.2 0-4.8h9.1v2.1zm-2.2 9.2v-2.9c0.5 0 1.1-0.1 1.7-0.1 0.1 0.3 0.1 0.9 0.4 0.9 0.3 0 4.5 0 6.9 0v2.1zm-71.9-8.7v2.6h2.5c0 0 0 1.2 0 2-1 0-2.1 0-3.1-0.2-0.8-0.2-1.4-0.9-1.5-1.8q0-2.2 0-4.5h8.5v1.9zm-2.1 8.5v-2.6c0.6-0.1 1.1-0.1 1.6-0.1 0.2 0.3 0.2 0.8 0.4 0.8 0.3 0 4.2 0 6.4 0v1.9z"/>
33 + </svg>
34 + <p id="startup-status">Starting Agent Zero</p>
35 + </main>
36 +
37 + <script>
38 + (() => {
39 + const APP_DOCUMENT_PATH = "/ui/index"
40 + const ACTIVATION_TIMEOUT_MS = 8000
41 + const CACHE_TIMEOUT_MS = 10000
42 + const FALLBACK_TIMEOUT_MS = 15000
43 + let replacementStarted = false
44 +
45 + const appDocumentPromise = fetch(APP_DOCUMENT_PATH, {
46 + cache: "no-store",
47 + credentials: "same-origin",
48 + headers: { Accept: "text/html" },
49 + }).then(async (response) => {
50 + if (!response.ok) throw new Error(`App document request failed: ${response.status}`)
51 + return response.text()
52 + })
53 +
54 + const replaceWithApp = async () => {
55 + if (replacementStarted) return
56 + const appHtml = await appDocumentPromise
57 + if (replacementStarted) return
58 + replacementStarted = true
59 + const overlay = document.getElementById("startup-transition")
60 + if (overlay && document.documentElement.dataset.splashTheme === "light") {
61 + overlay.dataset.theme = "light"
62 + }
63 + const appWithTransition = overlay
64 + ? appHtml.replace(
65 + /<body\b[^>]*>/i,
66 + (bodyTag) => bodyTag + overlay.outerHTML,
67 + )
68 + : appHtml
69 + document.open()
70 + document.write(appWithTransition)
71 + document.close()
72 + }
73 +
74 + const reportAppLoadFailure = (error) => {
75 + console.error("WebUI document unavailable.", error)
76 + const status = document.getElementById("startup-status")
77 + if (status) {
78 + status.textContent = "Unable to load Agent Zero. Refresh to retry."
79 + status.style.cssText = "position:static;width:auto;height:auto;clip-path:none"
80 + }
81 + }
82 +
83 + const fallbackTimer = setTimeout(() => {
84 + replaceWithApp().catch(reportAppLoadFailure)
85 + }, FALLBACK_TIMEOUT_MS)
86 +
87 + const withTimeout = (promise, timeout, label) => new Promise((resolve, reject) => {
88 + const timer = setTimeout(() => reject(new Error(`${label} timed out`)), timeout)
89 + promise.then(
90 + (value) => { clearTimeout(timer); resolve(value) },
91 + (error) => { clearTimeout(timer); reject(error) },
92 + )
93 + })
94 +
95 + const waitForActivation = (worker) => {
96 + if (worker.state === "activated") return Promise.resolve(worker)
97 + return new Promise((resolve, reject) => {
98 + const onStateChange = () => {
99 + if (worker.state === "activated") {
100 + worker.removeEventListener("statechange", onStateChange)
101 + resolve(worker)
102 + } else if (worker.state === "redundant") {
103 + worker.removeEventListener("statechange", onStateChange)
104 + reject(new Error("Service worker became redundant"))
105 + }
106 + }
107 + worker.addEventListener("statechange", onStateChange)
108 + })
109 + }
110 +
111 + const waitForExpectedWorker = async (registration, expectedUrl) => {
112 + const findWorker = () => [
113 + registration.installing,
114 + registration.waiting,
115 + registration.active,
116 + ].find((worker) => worker?.scriptURL === expectedUrl)
117 +
118 + let worker = findWorker()
119 + if (!worker) {
120 + worker = await withTimeout(new Promise((resolve) => {
121 + const onUpdateFound = () => {
122 + const candidate = findWorker()
123 + if (!candidate) return
124 + registration.removeEventListener("updatefound", onUpdateFound)
125 + resolve(candidate)
126 + }
127 + registration.addEventListener("updatefound", onUpdateFound)
128 + }), ACTIVATION_TIMEOUT_MS, "Service worker update")
129 + }
130 + return withTimeout(
131 + waitForActivation(worker),
132 + ACTIVATION_TIMEOUT_MS,
133 + "Service worker activation",
134 + )
135 + }
136 +
137 + const waitForController = (expectedUrl) => {
138 + if (navigator.serviceWorker.controller?.scriptURL === expectedUrl) {
139 + return Promise.resolve(navigator.serviceWorker.controller)
140 + }
141 + return withTimeout(new Promise((resolve) => {
142 + const onControllerChange = () => {
143 + const controller = navigator.serviceWorker.controller
144 + if (controller?.scriptURL !== expectedUrl) return
145 + navigator.serviceWorker.removeEventListener(
146 + "controllerchange",
147 + onControllerChange,
148 + )
149 + resolve(controller)
150 + }
151 + navigator.serviceWorker.addEventListener(
152 + "controllerchange",
153 + onControllerChange,
154 + )
155 + }), ACTIVATION_TIMEOUT_MS, "Service worker control")
156 + }
157 +
158 + const sendBundle = (worker, bundle, waitForReady) => {
159 + if (!waitForReady) {
160 + return Promise.resolve().then(() => {
161 + worker.postMessage({ type: "preload-ui-bundle", bundle })
162 + })
163 + }
164 +
165 + return withTimeout(new Promise((resolve, reject) => {
166 + const channel = new MessageChannel()
167 + channel.port1.onmessage = (event) => {
168 + channel.port1.close()
169 + if (event.data?.ok && event.data.version === bundle.version) {
170 + resolve()
171 + } else {
172 + reject(new Error(event.data?.error || "Asset cache rejected bundle"))
173 + }
174 + }
175 + try {
176 + worker.postMessage(
177 + { type: "preload-ui-bundle", bundle },
178 + [channel.port2],
179 + )
180 + } catch (error) {
181 + channel.port1.close()
182 + reject(error)
183 + }
184 + }), CACHE_TIMEOUT_MS, "Asset cache preparation")
185 + }
186 +
187 + const initialize = async () => {
188 + if (!navigator.serviceWorker) return
189 + const response = await fetch("/ui/asset-bundle", {
190 + cache: "no-cache",
191 + credentials: "same-origin",
192 + })
193 + if (!response.ok) throw new Error(`Bundle request failed: ${response.status}`)
194 + const bundle = await response.json()
195 + if (!bundle?.version || !bundle.files) throw new Error("Invalid asset bundle")
196 +
197 + const registrations = await navigator.serviceWorker.getRegistrations()
198 + await Promise.all(
199 + registrations
200 + .filter((registration) => new URL(registration.scope).pathname === "/js/")
201 + .map((registration) => registration.unregister()),
202 + )
203 +
204 + const expectedUrl = new URL(
205 + `/sw.js?version=${encodeURIComponent(bundle.version)}`,
206 + location.origin,
207 + ).href
208 + const previousWorker = navigator.serviceWorker.controller
209 + if (previousWorker && previousWorker.scriptURL !== expectedUrl) {
210 + sendBundle(previousWorker, bundle, false).catch(() => undefined)
211 + }
212 +
213 + const registration = await navigator.serviceWorker.register(expectedUrl, {
214 + scope: "/",
215 + updateViaCache: "none",
216 + })
217 + const worker = await waitForExpectedWorker(registration, expectedUrl)
218 + await sendBundle(worker, bundle, true)
219 + await waitForController(expectedUrl)
220 + }
221 +
222 + initialize()
223 + .catch((error) => console.warn("WebUI bootstrap cache unavailable.", error))
224 + .finally(() => {
225 + clearTimeout(fallbackTimer)
226 + replaceWithApp().catch(reportAppLoadFailure)
227 + })
228 + })()
229 + </script>
230 +</body>
231 +
232 +</html>
webui/sw.js
+87 -36
@@ -1,11 +1,13 @@
1 const CACHE_PREFIX = "agent-zero-ui-assets-";
2 const SCRIPT_VERSION = new URL(self.location.href).searchParams.get("version") || "runtime";
3 -const MAX_CACHEABLE_FILE_BYTES = 40 * 1024;
3 +const MAX_RUNTIME_CACHEABLE_TRANSFER_BYTES = 256 * 1024;
4 const CACHEABLE_FILE_PATTERN = /\.(?:css|html?|xhtml|m?js)$/i;
5
6 let activeCacheName = cacheName(SCRIPT_VERSION);
7 +let activeAssetVersion = SCRIPT_VERSION;
8 let activeBundleEntries = new Map();
9 let cachePopulation = { name: "", promise: Promise.resolve() };
10 +let persistedBundleRestore = null;
11
12 self.addEventListener("install", () => {
13 self.skipWaiting();
@@ -23,10 +25,16 @@ self.addEventListener("activate", (event) => {
25 self.addEventListener("message", (event) => {
26 if (event.data?.type !== "preload-ui-bundle") return;
27 const bundle = event.data.bundle;
26 - if (!bundle?.version || !bundle.files || typeof bundle.files !== "object") return;
28 + const replyPort = event.ports?.[0];
29 + if (!bundle?.version || !bundle.files || typeof bundle.files !== "object") {
30 + replyPort?.postMessage({ ok: false, error: "invalid-bundle" });
31 + return;
32 + }
33
28 - activeCacheName = cacheName(bundle.version);
34 + activeAssetVersion = bundle.version;
35 + activeCacheName = cacheName(activeAssetVersion);
36 activeBundleEntries = bundleEntries(bundle.files);
37 + persistedBundleRestore = null;
38
39 if (cachePopulation.name !== activeCacheName) {
40 const targetCacheName = activeCacheName;
@@ -39,38 +47,51 @@ self.addEventListener("message", (event) => {
47 cachePopulation = { name: targetCacheName, promise };
48 }
49
42 - event.waitUntil(cachePopulation.promise);
50 + // The in-memory map is immediately usable by fetch events. Persist it in the
51 + // background while the message lifetime keeps this worker alive, so the app
52 + // does not wait for hundreds of Cache Storage writes before it can render.
53 + replyPort?.postMessage({ ok: true, version: activeAssetVersion });
54 + event.waitUntil(cachePopulation.promise.catch(() => undefined));
55 });
56
57 self.addEventListener("fetch", (event) => {
58 if (!isCacheableRequest(event.request)) return;
59 + event.respondWith(
60 + respondToCacheableRequest(event.request).catch(() => fetch(event.request)),
61 + );
62 +});
63
48 - const bundledEntry = activeBundleEntries.get(event.request.url);
49 - if (bundledEntry) {
50 - event.respondWith(Promise.resolve(responseFromEntry(bundledEntry)));
51 - return;
64 +async function respondToCacheableRequest(request) {
65 + let bundledEntry = activeBundleEntries.get(request.url);
66 + if (!bundledEntry && activeBundleEntries.size === 0) {
67 + await restorePersistedBundle();
68 + bundledEntry = activeBundleEntries.get(request.url);
69 }
70 + if (bundledEntry) return responseFromEntry(bundledEntry);
71
54 - let cacheWrite = Promise.resolve();
55 - const response = (async () => {
56 - const cache = await caches.open(activeCacheName);
57 - const cached = await cache.match(event.request);
72 + let cache;
73 + try {
74 + cache = await caches.open(activeCacheName);
75 + const cached = await cache.match(request);
76 if (cached) return cached;
77 + } catch (_error) {
78 + return fetch(request);
79 + }
80
60 - const networkResponse = await fetch(event.request);
61 - if (isCacheableResponse(networkResponse)) {
62 - cacheWrite = cacheRuntimeResponse(
63 - cache,
64 - event.request,
65 - networkResponse.clone(),
66 - );
67 - }
68 - return networkResponse;
69 - })();
81 + return fetchBackendAsset(request, cache);
82 +}
83
71 - event.respondWith(response);
72 - event.waitUntil(response.then(() => cacheWrite).catch(() => undefined));
73 -});
84 +async function fetchBackendAsset(request, cache) {
85 + const networkResponse = await fetch(request);
86 + if (isCacheableResponse(networkResponse)) {
87 + try {
88 + await cacheRuntimeResponse(cache, request, networkResponse.clone());
89 + } catch (_error) {
90 + // A cache failure must never hide a successful backend response.
91 + }
92 + }
93 + return networkResponse;
94 +}
95
96 function cacheName(version) {
97 const safeVersion = String(version).replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 64);
@@ -81,21 +102,42 @@ async function preloadBundle(bundle, targetCacheName) {
102 await cleanupCaches(targetCacheName);
103 const cache = await caches.open(targetCacheName);
104 const marker = cacheMarkerRequest(targetCacheName);
84 - if (await cache.match(marker)) return;
105 + const payloadRequest = cacheBundleRequest(targetCacheName);
106 + if ((await cache.match(marker)) && (await cache.match(payloadRequest))) return;
107
86 - await Promise.all(
87 - Object.entries(bundle.files).map(async ([url, entry]) => {
88 - const response = responseFromEntry(entry);
89 - if (!response) return;
90 - const request = new Request(new URL(url, self.location.origin), {
91 - credentials: "same-origin",
92 - });
93 - await cache.put(request, response);
108 + await cache.put(
109 + payloadRequest,
110 + new Response(JSON.stringify(bundle), {
111 + headers: { "Content-Type": "application/json; charset=utf-8" },
112 }),
113 );
114 await cache.put(marker, new Response(bundle.version));
115 }
116
117 +async function restorePersistedBundle() {
118 + if (activeBundleEntries.size > 0) return;
119 + if (!persistedBundleRestore) {
120 + const expectedVersion = activeAssetVersion;
121 + const expectedCacheName = activeCacheName;
122 + persistedBundleRestore = (async () => {
123 + const cache = await caches.open(expectedCacheName);
124 + const response = await cache.match(cacheBundleRequest(expectedCacheName));
125 + if (!response) return;
126 + const bundle = await response.json();
127 + if (
128 + bundle?.version !== expectedVersion ||
129 + expectedVersion !== activeAssetVersion ||
130 + !bundle.files ||
131 + typeof bundle.files !== "object"
132 + ) {
133 + return;
134 + }
135 + activeBundleEntries = bundleEntries(bundle.files);
136 + })().catch(() => undefined);
137 + }
138 + await persistedBundleRestore;
139 +}
140 +
141 async function cleanupCaches(targetCacheName) {
142 const names = await caches.keys();
143 await Promise.all(
@@ -146,6 +188,15 @@ function cacheMarkerRequest(targetCacheName) {
188 );
189 }
190
191 +function cacheBundleRequest(targetCacheName) {
192 + return new Request(
193 + new URL(
194 + `/.agent-zero-cache/${encodeURIComponent(targetCacheName)}/bundle`,
195 + self.location.origin,
196 + ),
197 + );
198 +}
199 +
200 function isCacheableRequest(request) {
201 if (request.method !== "GET" || request.headers.has("range")) return false;
202 const url = new URL(request.url);
@@ -173,10 +224,10 @@ async function cacheRuntimeResponse(cache, request, response) {
224 const contentLength = response.headers.get("content-length");
225 if (contentLength !== null) {
226 const size = Number(contentLength);
176 - if (!Number.isFinite(size) || size > MAX_CACHEABLE_FILE_BYTES) return;
227 + if (!Number.isFinite(size) || size > MAX_RUNTIME_CACHEABLE_TRANSFER_BYTES) return;
228 } else {
229 const body = await response.clone().arrayBuffer();
179 - if (body.byteLength > MAX_CACHEABLE_FILE_BYTES) return;
230 + if (body.byteLength > MAX_RUNTIME_CACHEABLE_TRANSFER_BYTES) return;
231 }
232 await cache.put(request, response);
233 }
webui/vendor/AGENTS.md
+1
@@ -38,6 +38,7 @@ Direct child DOX files:
38 | [bootstrap/AGENTS.md](bootstrap/AGENTS.md) | Vendored Bootstrap JavaScript bundle for collapse and tooltip behavior. |
39 | [dompurify/AGENTS.md](dompurify/AGENTS.md) | Vendored DOMPurify sanitizer module. |
40 | [flatpickr/AGENTS.md](flatpickr/AGENTS.md) | Vendored Flatpickr date/time picker assets. |
41 +| [fonts/AGENTS.md](fonts/AGENTS.md) | Vendored Rubik and Roboto Mono WebUI font bundle. |
42 | [google/AGENTS.md](google/AGENTS.md) | Vendored Google Material Symbols font and stylesheet. |
43 | [katex/AGENTS.md](katex/AGENTS.md) | Vendored KaTeX rendering assets. |
44 | [marked/AGENTS.md](marked/AGENTS.md) | Vendored Marked markdown parser module. |
webui/vendor/fonts/AGENTS.md new
+33
@@ -0,0 +1,33 @@
1 +# WebUI Font Vendor DOX
2 +
3 +## Purpose
4 +
5 +- Own the locally served Rubik and Roboto Mono WebUI font bundle.
6 +- Keep core typography available without internet access.
7 +
8 +## Ownership
9 +
10 +- `fonts.css` owns the local `@font-face` declarations and supported variable weight ranges.
11 +- `*.ttf` files are clean upstream font artifacts.
12 +- `*-OFL.txt` files own the corresponding upstream licenses.
13 +- `README.md` records upstream provenance and artifact mappings.
14 +
15 +## Local Contracts
16 +
17 +- Font sources in `fonts.css` must remain same-origin relative URLs.
18 +- Preserve normal and italic Rubik weights 300–900 and Roboto Mono weights 100–700.
19 +- Keep each font artifact paired with its upstream license and pinned provenance.
20 +
21 +## Work Guidance
22 +
23 +- Replace fonts from a pinned official upstream revision rather than modifying binaries.
24 +- Update `fonts.css`, licenses, and provenance together when changing the bundle.
25 +
26 +## Verification
27 +
28 +- Run `pytest tests/test_webui_offline_assets.py`.
29 +- Smoke-test the main and login pages without external network access.
30 +
31 +## Child DOX Index
32 +
33 +No child DOX files.
webui/vendor/fonts/README.md new
+15
@@ -0,0 +1,15 @@
1 +# Vendored WebUI fonts
2 +
3 +Rubik and Roboto Mono are vendored from the official
4 +[`google/fonts`](https://github.com/google/fonts) repository at commit
5 +`7ff85c87f93ea6cca5f41c69f2e4edcb90240f26`.
6 +
7 +| Local artifact | Upstream artifact | License |
8 +| --- | --- | --- |
9 +| `rubik-variable.ttf` | `ofl/rubik/Rubik[wght].ttf` | `rubik-OFL.txt` |
10 +| `rubik-italic-variable.ttf` | `ofl/rubik/Rubik-Italic[wght].ttf` | `rubik-OFL.txt` |
11 +| `roboto-mono-variable.ttf` | `ofl/robotomono/RobotoMono[wght].ttf` | `roboto-mono-OFL.txt` |
12 +| `roboto-mono-italic-variable.ttf` | `ofl/robotomono/RobotoMono-Italic[wght].ttf` | `roboto-mono-OFL.txt` |
13 +
14 +`fonts.css` exposes the upstream variable weight ranges through local
15 +`@font-face` declarations. The WebUI must not fall back to a remote font host.
webui/vendor/fonts/fonts.css new
+31
@@ -0,0 +1,31 @@
1 +@font-face {
2 + font-family: "Rubik";
3 + font-style: normal;
4 + font-weight: 300 900;
5 + font-display: swap;
6 + src: url("./rubik-variable.ttf") format("truetype");
7 +}
8 +
9 +@font-face {
10 + font-family: "Rubik";
11 + font-style: italic;
12 + font-weight: 300 900;
13 + font-display: swap;
14 + src: url("./rubik-italic-variable.ttf") format("truetype");
15 +}
16 +
17 +@font-face {
18 + font-family: "Roboto Mono";
19 + font-style: normal;
20 + font-weight: 100 700;
21 + font-display: swap;
22 + src: url("./roboto-mono-variable.ttf") format("truetype");
23 +}
24 +
25 +@font-face {
26 + font-family: "Roboto Mono";
27 + font-style: italic;
28 + font-weight: 100 700;
29 + font-display: swap;
30 + src: url("./roboto-mono-italic-variable.ttf") format("truetype");
31 +}
webui/vendor/fonts/roboto-mono-OFL.txt new
+93
@@ -0,0 +1,93 @@
1 +Copyright 2015 The Roboto Mono Project Authors (https://github.com/googlefonts/robotomono)
2 +
3 +This Font Software is licensed under the SIL Open Font License, Version 1.1.
4 +This license is copied below, and is also available with a FAQ at:
5 +https://openfontlicense.org
6 +
7 +
8 +-----------------------------------------------------------
9 +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
10 +-----------------------------------------------------------
11 +
12 +PREAMBLE
13 +The goals of the Open Font License (OFL) are to stimulate worldwide
14 +development of collaborative font projects, to support the font creation
15 +efforts of academic and linguistic communities, and to provide a free and
16 +open framework in which fonts may be shared and improved in partnership
17 +with others.
18 +
19 +The OFL allows the licensed fonts to be used, studied, modified and
20 +redistributed freely as long as they are not sold by themselves. The
21 +fonts, including any derivative works, can be bundled, embedded,
22 +redistributed and/or sold with any software provided that any reserved
23 +names are not used by derivative works. The fonts and derivatives,
24 +however, cannot be released under any other type of license. The
25 +requirement for fonts to remain under this license does not apply
26 +to any document created using the fonts or their derivatives.
27 +
28 +DEFINITIONS
29 +"Font Software" refers to the set of files released by the Copyright
30 +Holder(s) under this license and clearly marked as such. This may
31 +include source files, build scripts and documentation.
32 +
33 +"Reserved Font Name" refers to any names specified as such after the
34 +copyright statement(s).
35 +
36 +"Original Version" refers to the collection of Font Software components as
37 +distributed by the Copyright Holder(s).
38 +
39 +"Modified Version" refers to any derivative made by adding to, deleting,
40 +or substituting -- in part or in whole -- any of the components of the
41 +Original Version, by changing formats or by porting the Font Software to a
42 +new environment.
43 +
44 +"Author" refers to any designer, engineer, programmer, technical
45 +writer or other person who contributed to the Font Software.
46 +
47 +PERMISSION & CONDITIONS
48 +Permission is hereby granted, free of charge, to any person obtaining
49 +a copy of the Font Software, to use, study, copy, merge, embed, modify,
50 +redistribute, and sell modified and unmodified copies of the Font
51 +Software, subject to the following conditions:
52 +
53 +1) Neither the Font Software nor any of its individual components,
54 +in Original or Modified Versions, may be sold by itself.
55 +
56 +2) Original or Modified Versions of the Font Software may be bundled,
57 +redistributed and/or sold with any software, provided that each copy
58 +contains the above copyright notice and this license. These can be
59 +included either as stand-alone text files, human-readable headers or
60 +in the appropriate machine-readable metadata fields within text or
61 +binary files as long as those fields can be easily viewed by the user.
62 +
63 +3) No Modified Version of the Font Software may use the Reserved Font
64 +Name(s) unless explicit written permission is granted by the corresponding
65 +Copyright Holder. This restriction only applies to the primary font name as
66 +presented to the users.
67 +
68 +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
69 +Software shall not be used to promote, endorse or advertise any
70 +Modified Version, except to acknowledge the contribution(s) of the
71 +Copyright Holder(s) and the Author(s) or with their explicit written
72 +permission.
73 +
74 +5) The Font Software, modified or unmodified, in part or in whole,
75 +must be distributed entirely under this license, and must not be
76 +distributed under any other license. The requirement for fonts to
77 +remain under this license does not apply to any document created
78 +using the Font Software.
79 +
80 +TERMINATION
81 +This license becomes null and void if any of the above conditions are
82 +not met.
83 +
84 +DISCLAIMER
85 +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
86 +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
87 +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
88 +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
89 +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
90 +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
91 +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
92 +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
93 +OTHER DEALINGS IN THE FONT SOFTWARE.
webui/vendor/fonts/roboto-mono-italic-variable.ttf
Binary files /dev/null and b/webui/vendor/fonts/roboto-mono-italic-variable.ttf differ
webui/vendor/fonts/roboto-mono-variable.ttf
Binary files /dev/null and b/webui/vendor/fonts/roboto-mono-variable.ttf differ
webui/vendor/fonts/rubik-OFL.txt new
+93
@@ -0,0 +1,93 @@
1 +Copyright 2015 The Rubik Project Authors (https://github.com/googlefonts/rubik)
2 +
3 +This Font Software is licensed under the SIL Open Font License, Version 1.1.
4 +This license is copied below, and is also available with a FAQ at:
5 +https://scripts.sil.org/OFL
6 +
7 +
8 +-----------------------------------------------------------
9 +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
10 +-----------------------------------------------------------
11 +
12 +PREAMBLE
13 +The goals of the Open Font License (OFL) are to stimulate worldwide
14 +development of collaborative font projects, to support the font creation
15 +efforts of academic and linguistic communities, and to provide a free and
16 +open framework in which fonts may be shared and improved in partnership
17 +with others.
18 +
19 +The OFL allows the licensed fonts to be used, studied, modified and
20 +redistributed freely as long as they are not sold by themselves. The
21 +fonts, including any derivative works, can be bundled, embedded,
22 +redistributed and/or sold with any software provided that any reserved
23 +names are not used by derivative works. The fonts and derivatives,
24 +however, cannot be released under any other type of license. The
25 +requirement for fonts to remain under this license does not apply
26 +to any document created using the fonts or their derivatives.
27 +
28 +DEFINITIONS
29 +"Font Software" refers to the set of files released by the Copyright
30 +Holder(s) under this license and clearly marked as such. This may
31 +include source files, build scripts and documentation.
32 +
33 +"Reserved Font Name" refers to any names specified as such after the
34 +copyright statement(s).
35 +
36 +"Original Version" refers to the collection of Font Software components as
37 +distributed by the Copyright Holder(s).
38 +
39 +"Modified Version" refers to any derivative made by adding to, deleting,
40 +or substituting -- in part or in whole -- any of the components of the
41 +Original Version, by changing formats or by porting the Font Software to a
42 +new environment.
43 +
44 +"Author" refers to any designer, engineer, programmer, technical
45 +writer or other person who contributed to the Font Software.
46 +
47 +PERMISSION & CONDITIONS
48 +Permission is hereby granted, free of charge, to any person obtaining
49 +a copy of the Font Software, to use, study, copy, merge, embed, modify,
50 +redistribute, and sell modified and unmodified copies of the Font
51 +Software, subject to the following conditions:
52 +
53 +1) Neither the Font Software nor any of its individual components,
54 +in Original or Modified Versions, may be sold by itself.
55 +
56 +2) Original or Modified Versions of the Font Software may be bundled,
57 +redistributed and/or sold with any software, provided that each copy
58 +contains the above copyright notice and this license. These can be
59 +included either as stand-alone text files, human-readable headers or
60 +in the appropriate machine-readable metadata fields within text or
61 +binary files as long as those fields can be easily viewed by the user.
62 +
63 +3) No Modified Version of the Font Software may use the Reserved Font
64 +Name(s) unless explicit written permission is granted by the corresponding
65 +Copyright Holder. This restriction only applies to the primary font name as
66 +presented to the users.
67 +
68 +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
69 +Software shall not be used to promote, endorse or advertise any
70 +Modified Version, except to acknowledge the contribution(s) of the
71 +Copyright Holder(s) and the Author(s) or with their explicit written
72 +permission.
73 +
74 +5) The Font Software, modified or unmodified, in part or in whole,
75 +must be distributed entirely under this license, and must not be
76 +distributed under any other license. The requirement for fonts to
77 +remain under this license does not apply to any document created
78 +using the Font Software.
79 +
80 +TERMINATION
81 +This license becomes null and void if any of the above conditions are
82 +not met.
83 +
84 +DISCLAIMER
85 +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
86 +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
87 +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
88 +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
89 +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
90 +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
91 +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
92 +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
93 +OTHER DEALINGS IN THE FONT SOFTWARE.
webui/vendor/fonts/rubik-italic-variable.ttf
Binary files /dev/null and b/webui/vendor/fonts/rubik-italic-variable.ttf differ
webui/vendor/fonts/rubik-variable.ttf
Binary files /dev/null and b/webui/vendor/fonts/rubik-variable.ttf differ
webui/vendor/google/AGENTS.md
+4 -3
@@ -7,16 +7,17 @@
7 ## Ownership
8
9 - `google-icons.css` owns the font-face and icon class definitions.
10 -- `google-icons.ttf` owns the font binary used by the WebUI.
10 +- `google-icons.woff2` is the primary compressed font binary; `google-icons.ttf` is its compatibility fallback.
11
12 ## Local Contracts
13
14 -- Keep font filenames and CSS references synchronized.
14 +- Keep the WOFF2 and TTF fonts equivalent and keep their CSS references synchronized.
15 +- Icon ligature elements must remain exactly one em wide and high, clip fallback text, and stay transparent until the Font Loading API confirms the Material Symbols face. A failed font may leave a blank square but must never expose a layout-breaking ligature name.
16 - Do not add unrelated remote font assets or tracking references.
17
18 ## Work Guidance
19
19 -- Replace font and stylesheet together when updating icon coverage.
20 +- Replace both font formats and the stylesheet together when updating icon coverage.
21
22 ## Verification
23
webui/vendor/google/google-icons.css
+22 -3
@@ -2,10 +2,14 @@
2 font-family: 'Material Symbols Outlined';
3 font-style: normal;
4 font-weight: 400;
5 - src: url(./google-icons.ttf) format('truetype');
5 + font-display: block;
6 + src:
7 + url(./google-icons.woff2) format('woff2'),
8 + url(./google-icons.ttf) format('truetype');
9 }
10
8 -.material-symbols-outlined {
11 +.material-symbols-outlined,
12 +.material-icons-outlined {
13 font-family: 'Material Symbols Outlined';
14 font-weight: normal;
15 font-style: normal;
@@ -13,8 +17,23 @@
17 line-height: 1;
18 letter-spacing: normal;
19 text-transform: none;
16 - display: inline-block;
20 + display: inline-flex;
21 + align-items: center;
22 + justify-content: center;
23 + box-sizing: border-box;
24 + width: 1em !important;
25 + min-width: 1em !important;
26 + max-width: 1em !important;
27 + height: 1em !important;
28 + overflow: hidden !important;
29 + flex: 0 0 1em !important;
30 + vertical-align: middle;
31 white-space: nowrap;
32 word-wrap: normal;
33 direction: ltr;
34 }
35 +
36 +html:not(.material-icons-ready) .material-symbols-outlined,
37 +html:not(.material-icons-ready) .material-icons-outlined {
38 + color: transparent !important;
39 +}
webui/vendor/google/google-icons.woff2
Binary files /dev/null and b/webui/vendor/google/google-icons.woff2 differ