diff --git a/CHANGELOG.md b/CHANGELOG.md
index d282ead..ed63bd6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,8 @@
# Changelog — ChannelGate
+- Keep sidebar update messages inside the rail, wrapping long details and showing a short commit
+ revision with the full hash on hover.
+
- Add System health to the admin UI with real CPU/RAM/load and storage metrics, historical
charts and peaks, storage warnings and capacity estimates, and refreshed hardware inventory.
diff --git a/FEATURES.md b/FEATURES.md
index 97cd244..1639da2 100644
--- a/FEATURES.md
+++ b/FEATURES.md
@@ -2491,6 +2491,9 @@ are retired, bullet by bullet; everything else stands.
`refused`, or `failed`. Slack/MCP thread markers are transaction-bound and survive intermediate
boots; only the matching terminal result is posted, with a deterministic Slack message id, then
the marker is removed. → TEST-PLAN: Transactional self-update.
+- **Sidebar update text stays inside the rail**: completion, rollback, errors, progress and
+ sign-in links wrap as normal text, including long unbroken paths. Result revisions show seven
+ characters with the full hash on hover. → TEST-PLAN: Sidebar update layout (engine-independent).
- **npm advisory policy**: candidate production dependencies are audited after `npm ci`.
Critical/high findings block and roll back the update; moderate findings are reported and
reviewed in the same development cycle. Weekly Dependabot discovery, exception policy, and the
diff --git a/TEST-PLAN.md b/TEST-PLAN.md
index 3a4482c..29a0687 100644
--- a/TEST-PLAN.md
+++ b/TEST-PLAN.md
@@ -4948,6 +4948,22 @@ Manual checks for the daemon-level behavior:
connected Airtable account. The cases above are ready to transfer using the operator's personal
connection; this is not a claim of live engine acceptance or stable-release readiness.
+### Sidebar update layout (engine-independent)
+
+- [x] Chromium acceptance: `CG_BROWSER_MODULE=/absolute/path/to/playwright/index.mjs node --test test/update-ui.test.js`.
+ Uses the shipped sidebar markup, stylesheet and update renderer in a disposable browser;
+ no daemon state changes or engine calls. At 1440, 800 and 390 px widths, render updated,
+ unchanged, rolled-back, refused, failed, image-warning, running preflight and sign-in states.
+ Fixtures use a 40-character revision and a `/tmp/` error path containing twelve repeated
+ `long-path-segment` strings. Pass: every rendered text/element rectangle stays within the
+ sidebar, dots remain 7 px wide, and the sign-in link remains visible. The revision unit case
+ requires a seven-character label and the complete hash in its tooltip.
+- Manual reproduction: open the Admin UI after a completed update, resize to desktop and mobile,
+ and hover the revision. Pass: no update text crosses into page content; the full revision
+ remains available on hover. Claude/Codex share this browser-only presentation; neither
+ engine participates in layout. Automated fixture passed; a real updater run is unnecessary
+ for this presentation-only change.
+
### Transactional self-update
- [x] Unit: exclusive reservation, live-owner refusal, dead/abandoned-owner recovery, ownership
diff --git a/public/app.js b/public/app.js
index 8cb17b9..2648343 100644
--- a/public/app.js
+++ b/public/app.js
@@ -3741,7 +3741,9 @@ function updateResultHtml(transaction) {
const cause = transaction.candidateError ? ` Candidate error: ${transaction.candidateError}` : "";
return `${escapeHtml(transaction.reason + cause)}`;
}
- const revision = transaction.runningRevision ? ` ${escapeHtml(transaction.runningRevision)}` : "";
+ const revision = transaction.runningRevision
+ ? ` ${escapeHtml(transaction.runningRevision.slice(0, 7))}`
+ : "";
if (transaction.result === "updated" && transaction.imageWarning) {
return `container image needs attention — ${escapeHtml(transaction.imageWarning)}`;
}
diff --git a/public/styles.css b/public/styles.css
index 7e780a5..80864b1 100644
--- a/public/styles.css
+++ b/public/styles.css
@@ -837,7 +837,8 @@ button.clear-tok.armed { background: rgba(229, 96, 77, .14); border-color: rgba(
/* Gateway version chip + one-click update (rail footer) */
#update { margin-top: 4px; display: flex; flex-direction: column; gap: 4px; }
-#update .statuschip { padding: 3px 10px; }
+#update .statuschip { display: block; overflow-wrap: anywhere; padding: 3px 10px; }
+#update .statuschip .dot { display: inline-block; margin-right: 8px; vertical-align: middle; }
.update-btn { font-size: 11.5px; padding: 6px 10px; margin: 2px 10px 0; }
/* Per-model Codex rates table (Settings → Behavior) */
diff --git a/test/update-ui.test.js b/test/update-ui.test.js
index 5bebd78..60ef115 100644
--- a/test/update-ui.test.js
+++ b/test/update-ui.test.js
@@ -80,3 +80,63 @@ test("session loss after restart asks for login without leaking or inventing upd
assert.match(el.innerHTML, /completion is not yet verified/);
assert.equal(timers.length, 0);
});
+
+test("completed update shows a short revision with the full hash available on hover", () => {
+ const { context } = fixture();
+ const revision = "79ee4e6de60c3ea7be98699deea56922a6f36170";
+ for (const transaction of [{ result: "updated" }, { result: "updated", changed: false }, { result: "rolled_back" }]) {
+ const html = context.updateResultHtml({ ...transaction, runningRevision: revision });
+ assert.ok(html.includes(`79ee4e6`));
+ }
+});
+
+// Use the real sidebar markup, CSS and update renderer: DOM-only tests cannot detect overflow.
+test("sidebar update results, progress and login links stay within the rail", { skip: !process.env.CG_BROWSER_MODULE }, async (t) => {
+ const { chromium } = await import(process.env.CG_BROWSER_MODULE);
+ const browser = await chromium.launch({ headless: true, args: ["--no-sandbox"] });
+ t.after(() => browser.close());
+ const page = await browser.newPage();
+ const index = readFileSync(new URL("../public/index.html", import.meta.url), "utf8");
+ const sidebar = index.match(/