Merge pull request #1713 from nesquena/stage-302

v0.51.5 — 4-PR full-sweep batch
This commit is contained in:
nesquena-hermes
2026-05-05 11:00:37 -07:00
committed by GitHub
22 changed files with 743 additions and 29 deletions
+41
View File
@@ -1,5 +1,46 @@
# Hermes Web UI -- Changelog
## [v0.51.5] — 2026-05-05 — 4-PR full-sweep batch
### Added
- **PR #1688** by @Michaelyklam — VPS resource health Insights panel (closes #693). New `api/system_health.py` provides a dependency-free Linux/stdlib metrics collector for aggregate CPU (via /proc/stat delta sample), memory (/proc/meminfo), and root disk (shutil.disk_usage). Authenticated `GET /api/system/health` returns sanitized aggregate fields only — no process argv, env, paths, or secrets. The card lives in the Insights tab (NOT always-visible top chrome) per maintainer placement feedback. Polling is gated by `visibilityState` so hidden tabs don't poll, and on macOS/Windows the panel hides itself instead of showing a noisy error. 7 regression tests pin endpoint registration, payload sanitization, Insights placement, and absence from top chrome.
### Fixed
- **PR #1709** by @Michaelyklam — Preserve scroll on stream completion (closes #1690). `_run_background_title_refresh()` and terminal stream handlers were clearing `S.activeStreamId` before the final `renderMessages()` call, while `renderMessages()` chose between `scrollIfPinned()` and `scrollToBottom()` based on stream liveness alone. Result: long stream + user scrolls up to read earlier content + stream finishes → cursor jumped to bottom. Fix adds `_scrollAfterMessageRender(preserveScroll)` helper. When `preserveScroll=true`, calls `scrollIfPinned()` (respects pin state); when false (load/switch path), legacy `scrollToBottom()`. 4 callsites in messages.js terminal-stream paths (`done`, `error`, `cancel`, fallback) pass `{preserveScroll: true}`.
- **PR #1711** by @nesquena-hermes — Hide 'Double-click to rename' tooltip on folders (closes #1710). Workspace file-tree row tooltip said "Double-click to rename" on every entry — including folders. But folder dblclick navigates via `loadDir()`, not rename; rename for folders lives in the right-click context menu. The tooltip was misleading. 4-line fix in `_renderTreeItems()`: gate `nameEl.title = t('double_click_rename')` on `item.type !== 'dir'`. Reported by @Deor in the WebUI Discord testers thread May 5 2026.
- **PR #1712** by @24601 — Guard `localStorage.setItem('hermes-webui-model')` against `QuotaExceededError`. On setups with localStorage near quota, the bare `setItem` call threw an unhandled `DOMException` that broke model selection and prevented the chat UI from loading. Wraps both callsites (boot.js modelSelect.onchange handler, onboarding.js _saveOnboardingDefaults) in `try{...}catch{}` so the error is silently absorbed and the UI falls back to server-side model state on next load. The stored value (a model ID string) is tiny — quota failure is from overall localStorage pressure, not this key.
### Tests
4504 → **4527 passing** (+23 regression tests across the 4 PRs, mostly from #1688's 7-test suite). 0 regressions. Full suite ~130s.
### Pre-release verification
- Stage-302: 4 PRs merged with zero conflicts (each rebased clean against current master). Zero stage-applied edits to any file — every change ships exactly as the contributor wrote it.
- All JS files syntax-clean (`node -c static/{boot,messages,onboarding,panels,ui}.js`).
- All Python files syntax-clean (py_compile on every changed file).
- Live browser walkthrough on port 8789:
- `/api/system/health` returns sanitized JSON with CPU/memory/disk percentages (no /proc paths, no argv leakage)
- System health card renders in Insights with Live badge + 3 progress bars (visual rated 9.5/10 via vision check)
- System health card NOT in top chrome (per nesquena placement feedback)
- Sidebar scroll holds at 400px (carry-over fix from v0.51.2 preserved)
- `_scrollAfterMessageRender` 4-branch behavioral test all correct (preserveScroll respects pin state in all paths)
- Recent-release feature inventory verified: PR #1644 model picker chip, PR #1685 Codex spark group, PR #1684 update banner network detection, PR #1671 quota card endpoint, PR #1676 heartbeat banner default-hidden, PR #1664 LLM Wiki endpoint, PR #1662 Logs nav button (via aria-label), PR #1706 paste-multiple fix
- Opus advisor: SHIP, 6/6 verification clean, 0 MUST-FIX, 0 SHOULD-FIX. Two non-blocking observations:
- `/api/system/health` could use `Cache-Control: no-store` (optional, defensive)
- `}catch{}` in #1712 swallows all errors silently (acceptable for 2-LOC defensive guard)
### Notes on this sweep
- **#1686** (Docker enhance by @binhpt310) was held back. Opus advisor flagged a blocker: the PR's `docker-compose.yml` change (`build context: ..`) and `COPY hermes-agent-desktop/...` Dockerfile additions assume a sibling `hermes-agent-desktop/` directory at clone time, which would break standalone clones. Left open for follow-up.
- **#1712** was force-pushed mid-sweep to a simpler form (drops `console.warn`). v2 adopted; fits in the original `test_provider_mismatch.py` 1100-char window so no test widening needed.
- **#1688** was on the held list (ux + hold labels) but per maintainer call ("Looks much better, thanks! Going to move towards review and merge"), labels removed and PR included in batch. CI was already green on all 3 Python versions.
Closes #693, #1690, #1710.
## [v0.51.5] — 2026-05-05 — single-PR hotfix (#1707)
### Fixed
+1 -1
View File
@@ -2,7 +2,7 @@
> Web companion to the Hermes Agent CLI. Same workflows, browser-native.
>
> Last updated: v0.51.5 (May 5, 2026) — 4517 tests collected — single-PR hotfix #1707 (workspace filename single-click regression)
> Last updated: v0.51.5 (May 5, 2026) — 4527 tests collected
> Test source: `pytest tests/ --collect-only -q`
> Per-version detail: see [CHANGELOG.md](./CHANGELOG.md)
+1 -1
View File
@@ -1836,7 +1836,7 @@ Bridged CLI sessions:
---
*Last updated: v0.51.5, May 5, 2026*
*Total automated tests collected: 4503*
*Total automated tests collected: 4527*
*Regression gate: tests/test_regressions.py*
*Run: pytest tests/ -v --timeout=60*
*Source: <repo>/*
+5
View File
@@ -480,6 +480,7 @@ from api.helpers import (
_redact_text,
)
from api.agent_health import build_agent_health_payload
from api.system_health import build_system_health_payload
def _clear_stale_stream_state(session) -> bool:
@@ -2491,6 +2492,10 @@ def handle_get(handler, parsed) -> bool:
if parsed.path == "/api/health/agent":
return j(handler, build_agent_health_payload())
if parsed.path == "/api/system/health":
j(handler, build_system_health_payload())
return True
if parsed.path == "/api/models":
return j(handler, get_available_models())
+167
View File
@@ -0,0 +1,167 @@
"""Safe aggregate host resource metrics for the WebUI VPS panel (#693).
The browser only needs coarse CPU/RAM/disk usage. Keep this module intentionally
small and dependency-free: no process lists, command strings, user identities,
environment variables, or filesystem topology leave the server.
"""
from __future__ import annotations
import shutil
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
_PROC_STAT = Path("/proc/stat")
_PROC_MEMINFO = Path("/proc/meminfo")
_CPU_SAMPLE_SECONDS = 0.05
def _checked_at() -> str:
return datetime.now(timezone.utc).isoformat()
def _clamp_percent(value: Any) -> float:
try:
numeric = float(value)
except (TypeError, ValueError):
return 0.0
if numeric < 0:
numeric = 0.0
if numeric > 100:
numeric = 100.0
return round(numeric, 1)
def _read_proc_stat_cpu() -> tuple[int, int]:
"""Return (idle_ticks, total_ticks) from Linux /proc/stat."""
with _PROC_STAT.open("r", encoding="utf-8") as handle:
first = handle.readline().strip().split()
if not first or first[0] != "cpu":
raise RuntimeError("proc_stat_unavailable")
values = [int(part) for part in first[1:]]
if len(values) < 4:
raise RuntimeError("proc_stat_unavailable")
idle = values[3] + (values[4] if len(values) > 4 else 0)
total = sum(values)
if total <= 0:
raise RuntimeError("proc_stat_unavailable")
return idle, total
def _cpu_delta_percent(start: tuple[int, int], end: tuple[int, int]) -> float:
idle_delta = end[0] - start[0]
total_delta = end[1] - start[1]
if total_delta <= 0:
return 0.0
busy_delta = max(0, total_delta - max(0, idle_delta))
return _clamp_percent((busy_delta / total_delta) * 100.0)
def _cpu_percent() -> float:
"""Sample aggregate CPU usage without psutil.
A short local sample avoids storing cross-request state and returns a stable
percentage on the first poll. Unsupported platforms raise a safe error code.
"""
start = _read_proc_stat_cpu()
time.sleep(_CPU_SAMPLE_SECONDS)
end = _read_proc_stat_cpu()
return _cpu_delta_percent(start, end)
def _read_meminfo_kib() -> dict[str, int]:
data: dict[str, int] = {}
with _PROC_MEMINFO.open("r", encoding="utf-8") as handle:
for line in handle:
key, _, rest = line.partition(":")
if not key or not rest:
continue
parts = rest.strip().split()
if not parts:
continue
try:
data[key] = int(parts[0])
except ValueError:
continue
return data
def _memory_usage() -> dict[str, int | float]:
meminfo = _read_meminfo_kib()
total = int(meminfo.get("MemTotal") or 0) * 1024
if total <= 0:
raise RuntimeError("meminfo_unavailable")
available_kib = meminfo.get("MemAvailable")
if available_kib is None:
available_kib = (
meminfo.get("MemFree", 0)
+ meminfo.get("Buffers", 0)
+ meminfo.get("Cached", 0)
+ meminfo.get("SReclaimable", 0)
- meminfo.get("Shmem", 0)
)
available = max(0, int(available_kib) * 1024)
used = max(0, min(total, total - available))
return {
"used_bytes": used,
"total_bytes": total,
"percent": _clamp_percent((used / total) * 100.0),
}
def _disk_usage() -> dict[str, int | float]:
usage = shutil.disk_usage("/")
total = int(usage.total)
if total <= 0:
raise RuntimeError("disk_unavailable")
used = int(usage.used)
return {
"used_bytes": used,
"total_bytes": total,
"percent": _clamp_percent((used / total) * 100.0),
}
def _safe_error(metric: str, exc: Exception) -> dict[str, str]:
# Keep this intentionally coarse. Exception messages can contain local paths
# on unusual platforms; the browser only needs a safe unavailable reason.
return {"metric": metric, "code": type(exc).__name__}
def build_system_health_payload() -> dict[str, Any]:
metrics: dict[str, Any] = {"cpu": None, "memory": None, "disk": None}
errors: list[dict[str, str]] = []
collectors = {
"cpu": _cpu_percent,
"memory": _memory_usage,
"disk": _disk_usage,
}
for name, collect in collectors.items():
try:
value = collect()
if name == "cpu":
metrics[name] = {"percent": _clamp_percent(value)}
else:
metrics[name] = {
"used_bytes": max(0, int(value["used_bytes"])),
"total_bytes": max(0, int(value["total_bytes"])),
"percent": _clamp_percent(value["percent"]),
}
except Exception as exc:
errors.append(_safe_error(name, exc))
available = any(metrics[name] is not None for name in metrics)
status = "ok" if available and not errors else "partial" if available else "unavailable"
return {
"status": status,
"available": available,
"checked_at": _checked_at(),
"cpu": metrics["cpu"],
"memory": metrics["memory"],
"disk": metrics["disk"],
"errors": errors,
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

+1 -1
View File
@@ -815,7 +815,7 @@ $('modelSelect').onchange=async()=>{
: {model:selectedModel,model_provider:null};
if(typeof closeModelDropdown==='function') closeModelDropdown();
if(typeof _writePersistedModelState==='function') _writePersistedModelState(modelState.model,modelState.model_provider);
else localStorage.setItem('hermes-webui-model', modelState.model);
else try{localStorage.setItem('hermes-webui-model',modelState.model)}catch{}
await api('/api/session/update',{method:'POST',body:JSON.stringify({
session_id:S.session.session_id,
workspace:S.session.workspace,
+8 -8
View File
@@ -932,7 +932,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
// No-reply guard (#373): if agent returned nothing, show inline error
if(!S.messages.some(m=>m.role==='assistant'&&String(m.content||'').trim())&&!assistantText){removeThinking();S.messages.push({role:'assistant',content:'**No response received.** Check your API key and model selection.'});}
if(isSessionViewed) _markSessionViewed(completedSid, completedSession.message_count ?? S.messages.length);
syncTopbar();renderMessages();loadDir('.');
syncTopbar();renderMessages({preserveScroll:true});loadDir('.');
// TTS auto-read: speak the last assistant response if enabled (#499)
if(typeof autoReadLastAssistant==='function') setTimeout(()=>autoReadLastAssistant(), 300);
}
@@ -1038,7 +1038,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
S.messages.push({role:'assistant',content:'**Error:** An error occurred. Check server logs.'});
}
_markSessionViewed(activeSid, S.messages.length);
renderMessages();
renderMessages({preserveScroll:true});
}else if(typeof trackBackgroundError==='function'){
const _errTitle=(typeof _allSessions!=='undefined'&&_allSessions.find(s=>s.session_id===activeSid)||{}).title||null;
try{const d=JSON.parse(e.data);trackBackgroundError(activeSid,_errTitle,d.message||'Error');}
@@ -1113,13 +1113,13 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
S.messages=(data.session.messages||[]).filter(m=>m&&m.role);
clearLiveToolCards();if(!assistantText)removeThinking();
_markSessionViewed(activeSid, data.session.message_count ?? S.messages.length);
renderMessages();
renderMessages({preserveScroll:true});
}
}catch(_){
// Fallback to local cancel message if API fails
if(S.session&&S.session.session_id===activeSid){
clearLiveToolCards();if(!assistantText)removeThinking();
S.messages.push({role:'assistant',content:'*Task cancelled.*'});renderMessages();
S.messages.push({role:'assistant',content:'*Task cancelled.*'});renderMessages({preserveScroll:true});
_markSessionViewed(activeSid, S.messages.length);
}
}
@@ -1169,7 +1169,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
S.toolCalls=[];
}
if(isSessionViewed) _markSessionViewed(completedSid, session.message_count ?? S.messages.length);
syncTopbar();renderMessages();
syncTopbar();renderMessages({preserveScroll:true});
}
_queueDrainSid=activeSid;renderSessionList();setBusy(false);setComposerStatus('');
return true;
@@ -1192,7 +1192,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
if(S.session&&S.session.session_id===activeSid){
S.activeStreamId=null;
clearLiveToolCards();if(!assistantText)removeThinking();
S.messages.push({role:'assistant',content:'**Error:** Connection lost'});renderMessages();
S.messages.push({role:'assistant',content:'**Error:** Connection lost'});renderMessages({preserveScroll:true});
_markSessionViewed(activeSid, S.messages.length);
}else{
if(typeof trackBackgroundError==='function'){
@@ -1223,7 +1223,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
removeThinking();
_queueDrainSid=activeSid;setBusy(false);
setComposerStatus('');
renderMessages();
renderMessages({preserveScroll:true});
renderSessionList();
}
return;
@@ -1980,7 +1980,7 @@ function startBackgroundPolling(parentSid, taskId, prompt){
delete _bgPollTimers[taskId];
const msg={role:'assistant',content:`**${t('bg_label')}** ${prompt.slice(0,80)}\n\n${res.answer||t('bg_no_answer')}`,'_background':true,_ts:Date.now()/1000};
S.messages.push(msg);
renderMessages();
renderMessages({preserveScroll:true});
showToast(t('bg_complete'));
return;
}
+1 -1
View File
@@ -465,7 +465,7 @@ async function _saveOnboardingDefaults(){
if(ONBOARDING.status){
ONBOARDING.status.settings={...(ONBOARDING.status.settings||{}),password_enabled:!!saved.auth_enabled};
}
localStorage.setItem('hermes-webui-model',model);
try{localStorage.setItem('hermes-webui-model',model)}catch{}
if($('modelSelect')) _applyModelToDropdown(model,$('modelSelect'));
}
+32
View File
@@ -215,6 +215,7 @@ async function switchPanel(name, opts = {}) {
if (nextPanel === 'insights') await loadInsights();
if (nextPanel === 'logs') await loadLogs();
_syncLogsAutoRefresh();
if (typeof _syncSystemHealthMonitorVisibility === 'function') _syncSystemHealthMonitorVisibility();
if (nextPanel === 'settings') {
switchSettingsSection(_currentSettingsSection);
loadSettingsPanel();
@@ -2118,6 +2119,8 @@ async function loadInsights(animate) {
api('/api/wiki/status').catch(err => ({status:'error', error: err.message || String(err)})),
]);
_renderInsights(data, box, wikiStatus);
if (typeof _syncSystemHealthMonitorVisibility === 'function') _syncSystemHealthMonitorVisibility();
if (typeof pollSystemHealth === 'function') void pollSystemHealth();
} catch(e) {
box.innerHTML = `<div style="color:var(--accent);font-size:12px">${esc(t('error_prefix') + e.message)}</div>`;
} finally {
@@ -2134,6 +2137,34 @@ function _formatLlmWikiTimestamp(value) {
catch (_) { return String(value); }
}
function _renderSystemHealthPanel() {
return `
<section class="insights-card system-health-panel loading" id="systemHealthPanel" aria-label="Host resource health" aria-live="polite">
<div class="system-health-head">
<div>
<div class="insights-card-title">System health</div>
<div class="system-health-sub">Current VPS resource usage</div>
</div>
<span class="system-health-status" id="systemHealthStatus"><span class="system-health-dot" aria-hidden="true"></span>Loading</span>
</div>
<div class="system-health-metrics">
<div class="system-health-metric" data-system-health-metric="cpu">
<div class="system-health-label"><span>CPU</span><span class="system-health-value" data-system-health-value></span></div>
<div class="system-health-bar" role="progressbar" aria-label="CPU usage" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"><div class="system-health-bar-fill"></div></div>
</div>
<div class="system-health-metric" data-system-health-metric="memory">
<div class="system-health-label"><span>RAM</span><span class="system-health-value" data-system-health-value></span></div>
<div class="system-health-bar" role="progressbar" aria-label="RAM usage" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"><div class="system-health-bar-fill"></div></div>
</div>
<div class="system-health-metric" data-system-health-metric="disk">
<div class="system-health-label"><span>Disk</span><span class="system-health-value" data-system-health-value></span></div>
<div class="system-health-bar" role="progressbar" aria-label="Disk usage" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"><div class="system-health-bar-fill"></div></div>
</div>
</div>
<div class="system-health-foot">Live snapshot only; historical resource charts can build on this surface later.</div>
</section>`;
}
function _renderLlmWikiStatus(d) {
const status = d || {status:'error'};
const isReady = status.available && status.status === 'ready';
@@ -2279,6 +2310,7 @@ function _renderInsights(d, box, wikiStatus) {
</div>`;
box.innerHTML = `
${_renderSystemHealthPanel()}
${_renderLlmWikiStatus(wikiStatus)}
<div class="insights-grid">
${overviewCards.map(c => `<div class="insights-stat"><div class="insights-stat-icon">${c.icon}</div><div class="insights-stat-info"><div class="insights-stat-value">${c.value}</div><div class="insights-stat-label">${esc(c.label)}</div></div></div>`).join('')}
+19
View File
@@ -293,6 +293,20 @@
.layout{display:flex;width:100%;flex:1 1 auto;min-height:0;}
.app-titlebar{display:flex;align-items:center;justify-content:center;height:38px;flex-shrink:0;background:var(--sidebar);border-bottom:1px solid var(--border);padding:0 12px;padding-top:var(--app-titlebar-safe-top);padding-left:max(12px,env(safe-area-inset-left,0));padding-right:max(12px,env(safe-area-inset-right,0));box-sizing:content-box;font-size:12px;color:var(--muted);user-select:none;-webkit-app-region:drag;position:relative;z-index:20;}
.app-titlebar-inner{display:flex;align-items:center;gap:8px;min-width:0;max-width:100%;justify-content:center;}
.system-health-panel.insights-card{display:flex;flex-direction:column;gap:12px;color:var(--muted);}
.system-health-panel.unavailable{display:none;}
.system-health-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;}
.system-health-sub{font-size:11px;color:var(--muted);margin-top:-4px;}
.system-health-dot{width:7px;height:7px;border-radius:999px;background:var(--accent);box-shadow:0 0 0 3px var(--accent-bg);opacity:.88;}
.system-health-panel.loading .system-health-dot{background:var(--muted);box-shadow:none;opacity:.55;}
.system-health-status{display:inline-flex;align-items:center;gap:7px;border-radius:999px;padding:3px 8px;font-size:11px;font-weight:700;border:1px solid var(--border);color:var(--muted);background:var(--surface);white-space:nowrap;}
.system-health-metrics{display:grid;grid-template-columns:repeat(3,minmax(120px,1fr));gap:10px;min-width:0;}
.system-health-metric{min-width:0;display:flex;flex-direction:column;gap:5px;padding:10px 11px;border:1px solid var(--border);border-radius:8px;background:var(--surface);}
.system-health-label{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:11px;line-height:1;color:var(--muted);}
.system-health-value{font-variant-numeric:tabular-nums;color:var(--text);font-weight:650;}
.system-health-bar{height:5px;overflow:hidden;border-radius:999px;background:color-mix(in srgb,var(--border) 70%,transparent);border:1px solid color-mix(in srgb,var(--border) 75%,transparent);}
.system-health-bar-fill{height:100%;width:0%;border-radius:inherit;background:linear-gradient(90deg,var(--accent),var(--accent-hover));transition:width .25s ease;}
.system-health-foot{font-size:11px;color:var(--muted);line-height:1.45;opacity:.82;}
.app-titlebar-icon{display:inline-flex;align-items:center;color:var(--accent);}
.app-titlebar-title{font-size:12px;font-weight:600;color:var(--text);letter-spacing:-.01em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:60vw;}
.app-titlebar-sub{font-size:10px;color:var(--muted);background:var(--hover-bg);padding:2px 7px;border-radius:4px;font-family:'SF Mono',ui-monospace,monospace;white-space:nowrap;flex-shrink:0;}
@@ -1280,6 +1294,11 @@
.app-titlebar{justify-content:space-between;}
.app-titlebar-hamburger,.app-titlebar-spacer{display:flex;}
.app-titlebar-inner{flex:1 1 auto;}
.system-health-panel.insights-card{gap:10px;padding:12px;}
.system-health-head{align-items:flex-start;}
.system-health-metrics{grid-template-columns:1fr;gap:8px;}
.system-health-label{font-size:10px;gap:4px;}
.system-health-bar{height:4px;}
/* Overlay backdrop */
.mobile-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);
z-index:199;-webkit-tap-highlight-color:transparent;}
+118 -8
View File
@@ -3091,6 +3091,100 @@ function dismissReconnect() {
clearInflight();
}
// ── Live host resource health panel (#693) ──
const SYSTEM_HEALTH_INTERVAL_MS=5000;
let _systemHealthTimer=null;
function _systemHealthPercent(metric){
const percent=Number(metric&&metric.percent);
if(!Number.isFinite(percent)) return null;
return Math.max(0,Math.min(100,Math.round(percent*10)/10));
}
function _formatSystemHealthPercent(percent){
if(percent == null) return '—';
return `${percent.toFixed(percent%1?1:0)}%`;
}
function _formatSystemHealthBytes(metric){
if(!metric||!metric.used_bytes||!metric.total_bytes) return '';
const units=['B','KB','MB','GB','TB'];
const fmt=(bytes)=>{
let value=Number(bytes)||0, idx=0;
while(value>=1024&&idx<units.length-1){value/=1024;idx++;}
return `${value.toFixed(value>=10||idx===0?0:1)} ${units[idx]}`;
};
return `${fmt(metric.used_bytes)} / ${fmt(metric.total_bytes)}`;
}
function _updateSystemHealthMetric(name,metric){
const row=document.querySelector(`[data-system-health-metric="${name}"]`);
if(!row) return;
const rawPercent=_systemHealthPercent(metric);
const percent=rawPercent == null ? 0 : rawPercent;
const label=row.querySelector('[data-system-health-value]');
const bar=row.querySelector('.system-health-bar');
const fill=row.querySelector('.system-health-bar-fill');
const text=_formatSystemHealthPercent(rawPercent);
if(label){
label.textContent=text;
const bytes=(name==='memory'||name==='disk')?_formatSystemHealthBytes(metric):'';
label.title=bytes||text;
}
if(bar) bar.setAttribute('aria-valuenow',String(percent));
if(fill) fill.style.width=`${percent}%`;
}
function setSystemHealthUnavailable(message){
const panel=$('systemHealthPanel');
const status=$('systemHealthStatus');
if(!panel) return;
panel.classList.remove('loading');
panel.classList.add('unavailable');
if(status) status.textContent=message||'Unavailable';
['cpu','memory','disk'].forEach(name=>_updateSystemHealthMetric(name,null));
}
function renderSystemHealth(payload){
const panel=$('systemHealthPanel');
const status=$('systemHealthStatus');
if(!panel) return;
if(!payload||payload.available===false){
setSystemHealthUnavailable('Unavailable');
return;
}
panel.classList.remove('loading','unavailable');
if(status) status.textContent=payload.status==='partial'?'Partial':'Live';
_updateSystemHealthMetric('cpu',payload.cpu);
_updateSystemHealthMetric('memory',payload.memory);
_updateSystemHealthMetric('disk',payload.disk);
}
async function pollSystemHealth(){
if(document.visibilityState !== 'visible') return;
if(!_systemHealthPanelIsVisible()) return;
try{
const payload=await api('/api/system/health');
renderSystemHealth(payload);
}catch(_){
setSystemHealthUnavailable('Unavailable');
}
}
function _systemHealthPanelIsVisible(){
return document.visibilityState === 'visible' &&
!!document.querySelector('main.main.showing-insights') &&
!!$('systemHealthPanel');
}
function startSystemHealthMonitor(){
if(!_systemHealthPanelIsVisible()) return;
if(_systemHealthTimer) return;
void pollSystemHealth();
_systemHealthTimer=setInterval(pollSystemHealth,SYSTEM_HEALTH_INTERVAL_MS);
}
function stopSystemHealthMonitor(){
if(_systemHealthTimer){clearInterval(_systemHealthTimer);_systemHealthTimer=null;}
}
function _syncSystemHealthMonitorVisibility(){
if(_systemHealthPanelIsVisible()) startSystemHealthMonitor();
else stopSystemHealthMonitor();
}
document.addEventListener('visibilitychange',_syncSystemHealthMonitorVisibility);
if(document.readyState==='loading') document.addEventListener('DOMContentLoaded',startSystemHealthMonitor);
else startSystemHealthMonitor();
// ── Hermes agent/gateway heartbeat alert (#716) ──
const AGENT_HEALTH_INTERVAL_MS=30000;
const AGENT_HEALTH_DISMISSED_KEY='agent-health-dismissed';
@@ -4014,7 +4108,23 @@ function clearMessageRenderCache(){
_sessionHtmlCacheSid=null;
}
function renderMessages(){
function _scrollAfterMessageRender(preserveScroll){
// Terminal stream renders can happen after S.activeStreamId is cleared.
// In that case, preserveScroll asks the normal pin-state helper to decide:
// pinned users stay at bottom; users who manually scrolled up stay put.
if(preserveScroll){
scrollIfPinned();
return;
}
if(S.activeStreamId){
scrollIfPinned();
return;
}
scrollToBottom();
}
function renderMessages(options){
const preserveScroll=!!(options&&options.preserveScroll);
const inner=$('msgInner');
const sid=S.session?S.session.session_id:null;
const msgCount=S.messages.length;
@@ -4039,7 +4149,7 @@ function renderMessages(){
inner.innerHTML=cached.html;
_sessionHtmlCacheSid=sid;
_wireMessageWindowLoadEarlierButton();
if(S.activeStreamId){scrollIfPinned();}else{scrollToBottom();}
_scrollAfterMessageRender(preserveScroll);
requestAnimationFrame(()=>{highlightCode();addCopyButtons();loadDiffInline();loadCsvInline();loadExcalidrawInline();loadPdfInline();loadHtmlInline();renderMermaidBlocks();renderKatexBlocks();});
requestAnimationFrame(()=>{highlightCode();addCopyButtons();initTreeViews();loadPdfInline();loadHtmlInline();renderMermaidBlocks();renderKatexBlocks();});
if(typeof _initMediaPlaybackObserver==='function') _initMediaPlaybackObserver();
@@ -4574,11 +4684,7 @@ function renderMessages(){
// Only force-scroll when not actively streaming — mid-stream re-renders
// (tool completion, session switch) must not override the user's scroll position.
// scrollIfPinned() respects _scrollPinned, so it's a no-op if user scrolled up.
if(S.activeStreamId){
scrollIfPinned();
} else {
scrollToBottom();
}
_scrollAfterMessageRender(preserveScroll);
// Apply syntax highlighting after DOM is built
requestAnimationFrame(()=>{highlightCode();addCopyButtons();loadDiffInline();loadCsvInline();loadExcalidrawInline();loadPdfInline();loadHtmlInline();renderMermaidBlocks();renderKatexBlocks();});
requestAnimationFrame(()=>{highlightCode();addCopyButtons();initTreeViews();loadPdfInline();loadHtmlInline();renderMermaidBlocks();renderKatexBlocks();});
@@ -5701,7 +5807,11 @@ function _renderTreeItems(container, entries, depth){
// Name
const nameEl=document.createElement('span');
nameEl.className='file-name';nameEl.textContent=item.name;nameEl.title=t('double_click_rename');
nameEl.className='file-name';nameEl.textContent=item.name;
// Tooltip only on FILES — dblclick renames them. On directories, dblclick
// navigates into the folder; rename lives in the right-click context menu
// (the "Double-click to rename" hint here would be misleading). #1710.
if(item.type!=='dir')nameEl.title=t('double_click_rename');
// Single-click opens (file) or expand-toggles (dir) but is debounced 300ms so a
// double-click can cancel it and trigger rename instead. Without the debounce, the
// click bubbles to el.onclick before dblclick can fire — that's #1698. Without the
+76
View File
@@ -0,0 +1,76 @@
"""Tests for #1710 — file-tree tooltip says "Double-click to rename" on folders too,
but folders don't rename on double-click; they navigate via loadDir(). The tooltip
is therefore misleading on directory rows.
Fix: gate the tooltip on `item.type !== 'dir'` so it appears only on files.
Folder rename is still reachable via the right-click context menu.
"""
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
UI_JS_PATH = REPO_ROOT / "static" / "ui.js"
def _read_ui_js() -> str:
with open(UI_JS_PATH, encoding="utf-8") as f:
return f.read()
def _name_block() -> str:
"""Source slice covering the file-tree row's name span construction."""
src = _read_ui_js()
start = src.find("// Name\n const nameEl=document.createElement('span');")
assert start >= 0, "name span construction marker not found in static/ui.js"
end = src.find("el.appendChild(nameEl);", start)
assert end >= 0, "el.appendChild(nameEl) not found after name span"
return src[start:end]
class TestFolderTooltipGated:
"""The 'Double-click to rename' tooltip must only attach to files, not dirs."""
def test_tooltip_assignment_is_guarded_by_item_type(self):
block = _name_block()
# We expect the tooltip line to be wrapped in an `if(item.type!=='dir')` guard.
# The pre-fix shape was `nameEl.title=t('double_click_rename');` unconditionally.
# Find every line that assigns nameEl.title and confirm at least one is gated.
gated = "if(item.type!=='dir')nameEl.title=t('double_click_rename')"
unguarded = " nameEl.className='file-name';nameEl.textContent=item.name;nameEl.title=t('double_click_rename');"
assert gated in block, (
"tooltip assignment must be guarded by `if(item.type!=='dir')` so directories "
"do not show the misleading 'Double-click to rename' hint (#1701)"
)
assert unguarded not in block, (
"the pre-fix unguarded tooltip assignment is still present — folders will "
"still show the misleading hint"
)
def test_dir_dblclick_still_navigates_not_renames(self):
"""Sanity: directory dblclick path is unchanged — must still call loadDir()."""
block = _name_block()
assert "if(item.type==='dir'){loadDir(item.path);return;}" in block, (
"directory dblclick must still navigate (call loadDir); the rename-only "
"tooltip gating depends on this contract being unchanged"
)
def test_files_still_get_tooltip(self):
"""Sanity: the tooltip text is still defined for files via the i18n key."""
block = _name_block()
assert "t('double_click_rename')" in block, (
"tooltip i18n key must still be referenced — the gate hides it for dirs, "
"not for files"
)
def test_i18n_key_still_defined_in_all_locales(self):
"""The i18n key must remain defined in every locale block in static/i18n.js."""
i18n = (REPO_ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
# i18n.js has 9 locale blocks with the same key. Lock that the key still exists
# at least 5 times (en, plus a quorum of locales) — exact count is i18n maintenance.
count = i18n.count("double_click_rename:")
assert count >= 5, (
f"i18n key 'double_click_rename' should be defined in multiple locales; "
f"found {count} occurrences — did this PR accidentally drop translations?"
)
+75
View File
@@ -0,0 +1,75 @@
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
MESSAGES_JS = (REPO / "static" / "messages.js").read_text(encoding="utf-8")
SESSIONS_JS = (REPO / "static" / "sessions.js").read_text(encoding="utf-8")
def _function_body(src: str, name: str) -> str:
start = src.index(f"function {name}")
brace = src.index("{", start)
depth = 0
for i in range(brace, len(src)):
if src[i] == "{":
depth += 1
elif src[i] == "}":
depth -= 1
if depth == 0:
return src[start : i + 1]
raise AssertionError(f"function {name} body not found")
def _event_listener_body(src: str, event_name: str) -> str:
needle = f"source.addEventListener('{event_name}'"
start = src.index(needle)
brace = src.index("{", start)
depth = 0
for i in range(brace, len(src)):
if src[i] == "{":
depth += 1
elif src[i] == "}":
depth -= 1
if depth == 0:
return src[start : i + 1]
raise AssertionError(f"event listener {event_name!r} body not found")
def test_terminal_done_render_preserves_manual_scroll_after_active_stream_is_cleared():
done_block = _event_listener_body(MESSAGES_JS, "done")
clear_idx = done_block.index("S.activeStreamId=null")
render_idx = done_block.index("renderMessages({preserveScroll:true})")
assert clear_idx < render_idx, (
"the done handler should clear stream liveness before the final render, "
"but must pass preserveScroll so renderMessages does not infer bottom-pin "
"from S.activeStreamId alone"
)
def test_render_messages_preserve_scroll_option_uses_user_pin_state_not_stream_liveness():
render_body = _function_body(UI_JS, "renderMessages")
scroll_helper = _function_body(UI_JS, "_scrollAfterMessageRender")
assert "function renderMessages(options)" in render_body
assert "const preserveScroll=!!(options&&options.preserveScroll);" in render_body
assert "_scrollAfterMessageRender(preserveScroll);" in render_body
assert "if(preserveScroll){\n scrollIfPinned();\n return;\n }" in scroll_helper
assert "if(S.activeStreamId){\n scrollIfPinned();\n return;\n }" in scroll_helper
def test_cached_render_path_uses_same_scroll_policy_as_fresh_render():
render_body = _function_body(UI_JS, "renderMessages")
cached_branch = render_body[render_body.index("if(sid&&sid!==_sessionHtmlCacheSid") : render_body.index("const compressionState=")]
assert "_scrollAfterMessageRender(preserveScroll);" in cached_branch
assert "if(S.activeStreamId){scrollIfPinned();}else{scrollToBottom();}" not in cached_branch
def test_session_switch_and_idle_session_load_keep_default_bottom_pin_behavior():
load_session = _function_body(SESSIONS_JS, "loadSession")
idle_branch = load_session[load_session.index("}else{\n S.busy=false;") : load_session.index("// Sync context usage indicator")]
assert "syncTopbar();renderMessages();" in idle_branch
assert "preserveScroll:true" not in idle_branch
+9 -4
View File
@@ -25,18 +25,23 @@ class TestScrollPinningFix:
instead when S.activeStreamId is set.
"""
# Find renderMessages function
rm_start = UI_JS.find("function renderMessages()")
rm_start = UI_JS.find("function renderMessages(")
assert rm_start != -1, "renderMessages() not found in ui.js"
rm_end = UI_JS.find("\nfunction ", rm_start + 1)
rm_body = UI_JS[rm_start:rm_end]
helper_start = UI_JS.find("function _scrollAfterMessageRender")
assert helper_start != -1, "renderMessages scroll helper not found in ui.js"
helper_end = UI_JS.find("\nfunction ", helper_start + 1)
helper_body = UI_JS[helper_start:helper_end]
# Must check activeStreamId before deciding which scroll fn to call
assert "activeStreamId" in rm_body, (
assert "activeStreamId" in helper_body, (
"renderMessages() must check S.activeStreamId before scrolling — "
"unconditional scrollToBottom() overrides user scroll position (#677)"
)
# scrollIfPinned must be called inside renderMessages (stream path)
assert "scrollIfPinned()" in rm_body, (
# scrollIfPinned must be called through the renderMessages scroll policy (stream path)
assert "_scrollAfterMessageRender(preserveScroll);" in rm_body
assert "scrollIfPinned()" in helper_body, (
"renderMessages() must call scrollIfPinned() during streaming (#677)"
)
+183
View File
@@ -0,0 +1,183 @@
"""Regression coverage for #693 live VPS host resource health panel."""
from __future__ import annotations
import json
import pathlib
from types import SimpleNamespace
from urllib.parse import urlparse
REPO_ROOT = pathlib.Path(__file__).parent.parent
UI_JS = (REPO_ROOT / "static" / "ui.js").read_text(encoding="utf-8")
PANELS_JS = (REPO_ROOT / "static" / "panels.js").read_text(encoding="utf-8")
INDEX_HTML = (REPO_ROOT / "static" / "index.html").read_text(encoding="utf-8")
STYLE_CSS = (REPO_ROOT / "static" / "style.css").read_text(encoding="utf-8")
ROUTES_PY = (REPO_ROOT / "api" / "routes.py").read_text(encoding="utf-8")
AUTH_PY = (REPO_ROOT / "api" / "auth.py").read_text(encoding="utf-8")
class _FakeHandler:
def __init__(self):
self.status = None
self.sent_headers = []
self.body = bytearray()
self.wfile = self
self.headers = {}
def send_response(self, status):
self.status = status
def send_header(self, name, value):
self.sent_headers.append((name, value))
def end_headers(self):
pass
def write(self, data):
self.body.extend(data)
def json_body(self):
return json.loads(bytes(self.body).decode("utf-8"))
def test_system_health_payload_normalizes_safe_aggregate_metrics(monkeypatch):
from api import system_health
monkeypatch.setattr(system_health, "_cpu_percent", lambda: 17.345)
monkeypatch.setattr(
system_health,
"_memory_usage",
lambda: {"used_bytes": 4_000, "total_bytes": 10_000, "percent": 40.0},
)
monkeypatch.setattr(
system_health,
"_disk_usage",
lambda: {"used_bytes": 55_500, "total_bytes": 100_000, "percent": 55.5},
)
payload = system_health.build_system_health_payload()
assert payload["status"] == "ok"
assert payload["available"] is True
assert payload["cpu"] == {"percent": 17.3}
assert payload["memory"] == {"used_bytes": 4000, "total_bytes": 10000, "percent": 40.0}
assert payload["disk"] == {"used_bytes": 55500, "total_bytes": 100000, "percent": 55.5}
assert payload["checked_at"]
rendered = repr(payload)
for private_fragment in ("/home/", "/Users/", "mount", "path", "argv", "command", "env", "token"):
assert private_fragment not in rendered
def test_system_health_payload_partial_and_unavailable_are_graceful(monkeypatch):
from api import system_health
def boom():
raise RuntimeError("private /home/user/path should not leak")
monkeypatch.setattr(system_health, "_cpu_percent", boom)
monkeypatch.setattr(system_health, "_memory_usage", boom)
monkeypatch.setattr(
system_health,
"_disk_usage",
lambda: {"used_bytes": 1, "total_bytes": 4, "percent": 25.0},
)
partial = system_health.build_system_health_payload()
assert partial["status"] == "partial"
assert partial["available"] is True
assert partial["disk"]["percent"] == 25.0
assert partial["cpu"] is None
assert partial["memory"] is None
assert {e["metric"] for e in partial["errors"]} == {"cpu", "memory"}
assert "/home/user" not in repr(partial)
monkeypatch.setattr(system_health, "_disk_usage", boom)
unavailable = system_health.build_system_health_payload()
assert unavailable["status"] == "unavailable"
assert unavailable["available"] is False
assert unavailable["cpu"] is None
assert unavailable["memory"] is None
assert unavailable["disk"] is None
assert "/home/user" not in repr(unavailable)
def test_system_health_route_registered_and_auth_gated(monkeypatch):
assert 'parsed.path == "/api/system/health"' in ROUTES_PY
assert "build_system_health_payload()" in ROUTES_PY
assert '"/api/system/health"' not in AUTH_PY, "system metrics must not be public"
monkeypatch.setenv("HERMES_WEBUI_PASSWORD", "test-password")
from api.auth import check_auth
handler = _FakeHandler()
assert check_auth(handler, SimpleNamespace(path="/api/system/health", query="")) is False
assert handler.status in (302, 401)
def test_system_health_route_returns_only_sanitized_payload(monkeypatch):
from api import routes
monkeypatch.setattr(
routes,
"build_system_health_payload",
lambda: {
"status": "ok",
"available": True,
"checked_at": "2026-05-05T00:00:00+00:00",
"cpu": {"percent": 12.0},
"memory": {"used_bytes": 1, "total_bytes": 2, "percent": 50.0},
"disk": {"used_bytes": 3, "total_bytes": 4, "percent": 75.0},
"errors": [],
},
)
handler = _FakeHandler()
assert routes.handle_get(handler, urlparse("http://example.test/api/system/health")) is True
payload = handler.json_body()
assert payload["cpu"]["percent"] == 12.0
assert set(payload) == {"status", "available", "checked_at", "cpu", "memory", "disk", "errors"}
def test_system_health_panel_markup_and_styles_live_under_insights_not_top_chrome():
top_shell = INDEX_HTML[: INDEX_HTML.index('<div class="layout">')]
assert 'id="systemHealthPanel"' not in top_shell
assert 'aria-label="Host resource health"' not in top_shell
assert 'function _renderSystemHealthPanel()' in PANELS_JS
assert 'id="systemHealthPanel"' in PANELS_JS
assert 'aria-label="Host resource health"' in PANELS_JS
assert 'System health' in PANELS_JS
assert 'Current VPS resource usage' in PANELS_JS
assert PANELS_JS.index('_renderSystemHealthPanel()') < PANELS_JS.index('_renderLlmWikiStatus(wikiStatus)')
assert 'data-system-health-metric="cpu"' in PANELS_JS
assert 'data-system-health-metric="memory"' in PANELS_JS
assert 'data-system-health-metric="disk"' in PANELS_JS
assert ".system-health-panel.insights-card" in STYLE_CSS
assert ".system-health-bar-fill" in STYLE_CSS
assert ".system-health-panel.unavailable" in STYLE_CSS
assert "@media(max-width:640px)" in STYLE_CSS and ".system-health-panel.insights-card" in STYLE_CSS
def test_system_health_frontend_polls_visible_and_renders_progress_labels():
assert "const SYSTEM_HEALTH_INTERVAL_MS=5000" in UI_JS
assert "api('/api/system/health')" in UI_JS
assert "document.visibilityState !== 'visible'" in UI_JS
assert "document.querySelector('main.main.showing-insights')" in UI_JS
assert "document.addEventListener('visibilitychange',_syncSystemHealthMonitorVisibility)" in UI_JS
assert "typeof _syncSystemHealthMonitorVisibility === 'function'" in PANELS_JS
assert "function renderSystemHealth(payload)" in UI_JS
assert "setSystemHealthUnavailable" in UI_JS
assert "data-system-health-metric" in PANELS_JS
assert "CPU" in PANELS_JS and "RAM" in PANELS_JS and "Disk" in PANELS_JS
assert "aria-valuenow" in UI_JS
assert "style.width=`${percent}%`" in UI_JS
def test_system_health_backend_uses_no_shell_or_private_process_sources():
src = (REPO_ROOT / "api" / "system_health.py").read_text(encoding="utf-8")
assert "import subprocess" not in src
assert "import psutil" not in src
assert "os.environ" not in src
assert "ps aux" not in src
assert "/proc/self/environ" not in src
for private_field in ("argv", "cmdline", "username", "mountpoint"):
assert private_field not in src
+1 -1
View File
@@ -24,7 +24,7 @@ def test_load_earlier_expands_local_window_before_server_pagination_and_preserve
def test_windowed_render_keeps_streaming_and_tool_activity_anchored_to_rendered_messages():
assert "if(S.activeStreamId){\n scrollIfPinned();" in UI_JS
assert "_scrollAfterMessageRender(preserveScroll);" in UI_JS
assert "const assistantIdxs=[...assistantSegments.keys()].sort((a,b)=>a-b);" in UI_JS
assert "if(aIdx<assistantIdxs[0]) continue;" in UI_JS
assert "const renderedAssistantIdxs=[...assistantSegments.keys()].sort((a,b)=>a-b);" in UI_JS
@@ -302,7 +302,7 @@ def test_hidden_active_done_still_updates_current_pane_but_not_read_state():
viewed_const_idx = done_block.find("const isSessionViewed=_isSessionActivelyViewed(activeSid);")
active_guard_idx = done_block.find("if(isActiveSession){", viewed_const_idx)
session_update_idx = done_block.find("S.session=d.session", active_guard_idx)
render_idx = done_block.find("renderMessages()", active_guard_idx)
render_idx = done_block.find("renderMessages(", active_guard_idx)
load_dir_idx = done_block.find("loadDir('.')", active_guard_idx)
mark_viewed_idx = done_block.find("if(isSessionViewed) _markSessionViewed(completedSid", active_guard_idx)
+4 -3
View File
@@ -436,11 +436,12 @@ def test_done_handler_sets_busy_false_before_renderMessages(cleanup_test_session
stream_end_idx = src.find("source.addEventListener('stream_end'", done_idx)
assert stream_end_idx >= 0, "stream_end listener after done handler not found"
done_block = src[done_idx:stream_end_idx]
# S.busy=false must appear before renderMessages() within the done handler
# S.busy=false must appear before the terminal render call within the done handler.
busy_pos = done_block.find("S.busy=false;")
render_pos = done_block.find("renderMessages()")
render_pos = done_block.find("renderMessages(")
assert busy_pos >= 0, "done handler must set S.busy=false before renderMessages()"
assert busy_pos < render_pos, f"S.busy=false (pos {busy_pos}) must come before renderMessages() (pos {render_pos})"
assert render_pos >= 0, "done handler must call renderMessages after settling state"
assert busy_pos < render_pos, f"S.busy=false (pos {busy_pos}) must come before renderMessages (pos {render_pos})"
# ── R14: send() uses stale modelSelect.value instead of session model ────────