diff --git a/static/css/cookbook-research-memory-settings.css b/static/css/cookbook-research-memory-settings.css
index 67f425dae..9f6df2679 100644
--- a/static/css/cookbook-research-memory-settings.css
+++ b/static/css/cookbook-research-memory-settings.css
@@ -1171,6 +1171,38 @@ details.hwfit-serve-advanced label:has(.hwfit-sf[data-field="vllm_kv_cache_dtype
overflow-y: auto;
overflow-x: hidden;
}
+/* Build provenance at the bottom of the sidebar. The nav above it takes the
+ free space, so this needs no absolute positioning to sit at the bottom.
+ `[hidden]` needs restating: an author `display` rule outranks the UA
+ stylesheet's [hidden] rule, so the attribute alone would not hide it. */
+.settings-sidebar-build {
+ display: flex;
+ min-width: 0;
+ flex: 0 0 auto;
+ flex-direction: column;
+ gap: 1px;
+ margin-top: 8px;
+ padding: 4px 12px 2px;
+ border-top: 1px solid var(--border);
+ font-size: 9px;
+ color: color-mix(in srgb, var(--fg) 45%, transparent);
+}
+.settings-sidebar-build[hidden] {
+ display: none;
+}
+/* Same treatment as the "Admin" group label this sits under. */
+.settings-sidebar-build-version {
+ font-weight: 600;
+ letter-spacing: 1px;
+ text-transform: uppercase;
+}
+.settings-sidebar-build-commit {
+ overflow: hidden;
+ font-variant-numeric: tabular-nums;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ opacity: 0.75;
+}
.settings-nav-search-wrap {
position: relative;
margin: 0 0 8px;
@@ -1326,6 +1358,9 @@ body.settings-sidebar-resize-active {
.settings-sidebar.settings-sidebar-collapsed .settings-sidebar-content {
display: none;
}
+.settings-sidebar.settings-sidebar-collapsed .settings-sidebar-build {
+ display: none;
+}
.settings-sidebar.settings-sidebar-collapsed .settings-sidebar-resize-handle {
display: none;
}
@@ -1356,7 +1391,8 @@ body.settings-sidebar-resize-active {
.settings-nav-search-wrap,
.settings-sidebar-resize-handle,
- .settings-sidebar-toggle {
+ .settings-sidebar-toggle,
+ .settings-sidebar-build {
display: none;
}
@@ -1384,7 +1420,8 @@ body.settings-sidebar-resize-active {
.settings-nav-search-wrap,
.settings-sidebar-resize-handle,
- .settings-sidebar-toggle {
+ .settings-sidebar-toggle,
+ .settings-sidebar-build {
display: none;
}
}
diff --git a/static/index.html b/static/index.html
index dcae7008b..9e537d027 100644
--- a/static/index.html
+++ b/static/index.html
@@ -1532,6 +1532,15 @@
System
+
+
+
diff --git a/static/js/settings/sidebar.js b/static/js/settings/sidebar.js
index 7e1a34aeb..f4787a40f 100644
--- a/static/js/settings/sidebar.js
+++ b/static/js/settings/sidebar.js
@@ -121,10 +121,88 @@ export function setSettingsSidebarWidth(modalEl, width, options = {}) {
return next;
}
+/* ── Build provenance ──
+ The bottom-left of the panel answers "which build am I actually looking
+ at": the version this build registers and the commit its process loaded.
+ Both come from /api/version, which resolves the commit once at import via
+ `git rev-parse HEAD` and reports the string "unknown" when it cannot — a
+ read-only Docker tree with no .git, for instance. */
+const BUILD_COMMIT_SHORT_LEN = 8;
+const BUILD_UNKNOWN = 'unknown';
+const BUILD_COMMIT_SHA = /^[0-9a-f]{7,64}$/i;
+
+function buildField(value) {
+ const text = String(value ?? '').trim();
+ return text && text.toLowerCase() !== BUILD_UNKNOWN ? text : '';
+}
+
+/** Turn an /api/version payload into the two sidebar lines, or null when it
+ * carries nothing worth a footer. */
+export function formatSettingsBuildInfo(payload) {
+ const version = buildField(payload?.version);
+ const build = buildField(payload?.build);
+ const commit = buildField(payload?.source_commit);
+
+ const parts = [];
+ if (version) parts.push(`v${version}`);
+ // The preview harness versions itself separately from the public semver, so
+ // show both — but never the same number twice.
+ if (build && build !== version) parts.push(`build ${build}`);
+
+ // An ODYSSEUS_SOURCE_COMMIT override need not be a sha; leave those intact
+ // and let the CSS ellipsis deal with the width.
+ const commitLabel = BUILD_COMMIT_SHA.test(commit)
+ ? commit.slice(0, BUILD_COMMIT_SHORT_LEN).toLowerCase()
+ : commit;
+
+ if (!parts.length && !commitLabel) return null;
+
+ return {
+ versionLabel: parts.join(' \u00b7 '),
+ commitLabel,
+ commitTitle: commit,
+ };
+}
+
+function paintSettingsBuildInfo(modalEl, info) {
+ const host = modalEl?.querySelector('#settings-sidebar-build');
+ if (!host) return;
+
+ const versionEl = host.querySelector('#settings-sidebar-build-version');
+ const commitEl = host.querySelector('#settings-sidebar-build-commit');
+
+ if (!info) {
+ host.hidden = true;
+ return;
+ }
+
+ if (versionEl) versionEl.textContent = info.versionLabel;
+ if (commitEl) {
+ commitEl.textContent = info.commitLabel;
+ // The short hash is what fits; keep the full one reachable on hover.
+ if (info.commitTitle) commitEl.title = info.commitTitle;
+ }
+
+ host.hidden = false;
+}
+
+async function loadSettingsBuildInfo(modalEl) {
+ try {
+ const res = await fetch('/api/version', { credentials: 'same-origin' });
+ if (!res.ok) return;
+ paintSettingsBuildInfo(modalEl, formatSettingsBuildInfo(await res.json()));
+ } catch {
+ // Provenance is informational: a failed probe leaves the footer hidden
+ // rather than putting an error in the middle of the settings nav.
+ }
+}
+
export function bindSettingsSidebar(modalEl) {
if (!modalEl || _bound.has(modalEl)) return;
_bound.add(modalEl);
+ loadSettingsBuildInfo(modalEl);
+
const sidebar = modalEl.querySelector('.settings-sidebar');
const handle = modalEl.querySelector('#settings-sidebar-resize-handle');
const toggle = modalEl.querySelector('#settings-sidebar-toggle');
diff --git a/tests/test_settings_sidebar_build_provenance.py b/tests/test_settings_sidebar_build_provenance.py
new file mode 100644
index 000000000..c360545c6
--- /dev/null
+++ b/tests/test_settings_sidebar_build_provenance.py
@@ -0,0 +1,232 @@
+"""The settings sidebar footer that names the running build.
+
+The admin panel had no way to answer "which build is this?" from the UI: the
+version and the source commit were only reachable by hand-querying
+``/api/version``. A footer pinned under the sidebar nav now shows the version
+the build registers plus the commit its process loaded.
+
+Three things can break independently, so each is covered on its own:
+
+* ``formatSettingsBuildInfo`` decides what the two lines say, including what
+ to do with the ``"unknown"`` the backend reports when it cannot resolve a
+ commit. That is real logic, so it is exercised in node against the shipped
+ module rather than asserted on as text.
+* the footer's *position* is the requirement — below the scrolling nav, inside
+ the sidebar. Only the markup's structure records that.
+* the layouts that have no bottom-left to write in (the collapsed rail, the
+ narrow tab rail) must hide it, and the ``[hidden]`` attribute only works
+ here because the CSS restates it against the element's own ``display`` rule.
+"""
+
+import json
+import re
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+
+ROOT = Path(__file__).resolve().parents[1]
+INDEX_HTML = (ROOT / "static" / "index.html").read_text(encoding="utf-8")
+SIDEBAR_CSS = (
+ ROOT / "static" / "css" / "cookbook-research-memory-settings.css"
+).read_text(encoding="utf-8")
+
+requires_node = pytest.mark.skipif(
+ not shutil.which("node"), reason="node binary not on PATH"
+)
+
+
+def _format_build_info(payload):
+ """Run the shipped formatter over one payload and return its result."""
+ source = (
+ "import { formatSettingsBuildInfo } from "
+ "'./static/js/settings/sidebar.js';\n"
+ f"console.log(JSON.stringify(formatSettingsBuildInfo({json.dumps(payload)})));\n"
+ )
+ result = subprocess.run(
+ ["node", "--input-type=module", "-e", source],
+ cwd=ROOT,
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+ return json.loads(result.stdout)
+
+
+# ---------------------------------------------------------------------------
+# What the footer says
+# ---------------------------------------------------------------------------
+
+
+@requires_node
+def test_shows_registered_version_and_short_commit():
+ info = _format_build_info(
+ {
+ "version": "1.0.3",
+ "build": "0.20.19",
+ "source_commit": "8a28c5f9c0ffee1234567890abcdef1234567890",
+ }
+ )
+
+ assert info["versionLabel"] == "v1.0.3 · build 0.20.19"
+ assert info["commitLabel"] == "8a28c5f9"
+ # The short hash is what fits the sidebar; the full one stays on hover.
+ assert info["commitTitle"] == "8a28c5f9c0ffee1234567890abcdef1234567890"
+
+
+@requires_node
+def test_build_equal_to_version_is_not_printed_twice():
+ info = _format_build_info(
+ {"version": "1.0.3", "build": "1.0.3", "source_commit": "abcdef1234"}
+ )
+
+ assert info["versionLabel"] == "v1.0.3"
+
+
+@requires_node
+def test_unresolvable_commit_leaves_only_the_version():
+ """`/api/version` reports the string "unknown" when `git rev-parse` fails
+ — a read-only Docker tree with no `.git`. Printing it would be worse than
+ printing nothing."""
+ info = _format_build_info(
+ {"version": "1.0.3", "build": "unknown", "source_commit": "unknown"}
+ )
+
+ assert info["versionLabel"] == "v1.0.3"
+ assert info["commitLabel"] == ""
+ assert info["commitTitle"] == ""
+
+
+@requires_node
+def test_commit_only_build_still_gets_a_footer():
+ info = _format_build_info({"source_commit": "0123456789abcdef"})
+
+ assert info["versionLabel"] == ""
+ assert info["commitLabel"] == "01234567"
+
+
+@requires_node
+def test_non_sha_commit_override_is_left_intact():
+ """`ODYSSEUS_SOURCE_COMMIT` can be any string; truncating a tag to eight
+ characters would corrupt it, so only real hashes get shortened."""
+ info = _format_build_info(
+ {"version": "1.0.3", "source_commit": "release-2026-09-30"}
+ )
+
+ assert info["commitLabel"] == "release-2026-09-30"
+
+
+@requires_node
+@pytest.mark.parametrize(
+ "payload",
+ [
+ {},
+ {"version": "", "build": "", "source_commit": ""},
+ {"version": "unknown", "build": "unknown", "source_commit": "unknown"},
+ ],
+)
+def test_nothing_to_report_means_no_footer(payload):
+ assert _format_build_info(payload) is None
+
+
+# ---------------------------------------------------------------------------
+# Where the footer sits
+# ---------------------------------------------------------------------------
+
+
+def _settings_sidebar_markup():
+ start = INDEX_HTML.index('