feat(settings): sync a Hermes Dashboard chip with Appearance dropdown (#4936)

Adds a "Hermes Dashboard" chip to the Settings > Appearance "Sidebar tabs"
grid, bidirectionally synced with the existing dashboard-mode dropdown
(never/always/auto). Chip off -> dropdown "never"; chip on -> restore last
non-never mode; dropdown change -> chip re-render.

The chip is a proxy for the dashboard config via saveDashboardSettings ->
POST /api/dashboard/config; it NEVER mutates hidden_tabs/tab_order (separate
toggle fn from _toggleTabVisibilityChip). loadDashboardSettings guards a stale
config GET from clobbering a newer save (_dashboardSettingsLoadSeq /
_dashboardSettingsWriteSeq), and a failed chip save rolls the dropdown/chip
back. Chip label via textContent (XSS-safe).

Co-authored-by: rodboev <rodboev@users.noreply.github.com>
This commit is contained in:
nesquena-hermes 2026-06-27 20:28:35 +00:00
parent aa6eb35de7
commit 5bb03a6322
5 changed files with 823 additions and 3 deletions

View file

@ -5,6 +5,8 @@
### Fixed
- **The Hermes Dashboard now has a show/hide chip in Settings → Appearance.** The Appearance → "Sidebar tabs" grid (where you toggle which tabs appear in the sidebar/rail) gains a "Hermes Dashboard" chip alongside the others, bidirectionally synced with the existing dashboard-mode dropdown (never/always/auto): toggling the chip off sets the dropdown to "never," toggling it on restores the last non-never mode, and changing the dropdown updates the chip. The chip is a proxy for the dashboard config — it never mutates the sidebar `hidden_tabs`/`tab_order` state — and the sync is guarded against a stale config GET clobbering a newer save, with a clean rollback if the save fails. Thanks @rodboev. (#4936, fixes #2724)
- **Settings now exposes a "Max output tokens" field.** The streaming path already honored a `max_tokens` cap from the active profile's `config.yaml`, but there was no way to manage it from the UI. Settings → Preferences now has a "Max output tokens" field that writes a root-level `max_tokens` override through the profile config (not `settings.json`, which the streaming worker never reads for this), preserving unrelated YAML keys. Blank clears the override so the existing `agent.max_tokens` fallback resumes; the field surfaces the effective fallback so you can see what a new turn would currently use. Thanks @rodboev. (#5010, fixes #2929)
- **Cross-container Docker setups report gateway health correctly again.** When WebUI and the Hermes agent run in separate containers, the agent-health check resolves the gateway's runtime status from a shared `gateway_state.json`. After the keyword-form `read_runtime_status(pid_path=…)` call fails against an older agent signature, the positional fallback was handed the sibling `gateway.pid` path instead of the runtime-status file — so the reader parsed PID metadata as runtime metadata and `gateway_state`/`updated_at` never resolved, breaking the freshness check. The fallback now passes the correct `gateway_state.json` sibling path (`pid_path.with_name(_RUNTIME_STATUS_FILE)`), with `gateway_state.json` as the default when the agent doesn't expose the filename. The keyword happy-path and normal PID-based health are unchanged. Thanks @rodboev. (#5065, fixes #5030)

View file

@ -6616,6 +6616,34 @@ function _orderedSidebarPanels(order){
return out;
}
function _dashboardPanelMode(){
var modeEl=$('settingsDashboardMode');
var mode=modeEl&&modeEl.value;
return mode==='never'||mode==='always'||mode==='auto'?mode:'auto';
}
function _isDashboardChipOn(){
return _dashboardPanelMode()!=='never';
}
function _renderDashboardVisibilityChip(container){
if(!container)return null;
var chip=document.createElement('button');
chip.type='button';
chip.className='tab-visibility-chip';
chip.setAttribute('data-tab-panel','__hermes_dashboard__');
chip.setAttribute('role','switch');
var isOn=_isDashboardChipOn();
chip.setAttribute('aria-checked',isOn?'true':'false');
if(!isOn) chip.classList.add('chip-off');
chip.textContent=typeof t==='function'?t('tab_dashboard'):'Dashboard';
chip.onclick=function(){
if(Date.now()<_tabVisibilityDragSuppressUntil)return;
_toggleDashboardVisibilityChip();
};
return chip;
}
function _applyTabOrder(order){
var ordered=_orderedSidebarPanels(order);
['.rail','.sidebar-nav'].forEach(function(selector){
@ -6688,6 +6716,8 @@ function _renderTabVisibilityChips(){
_wireTabChipDrag(chip,panel);
container.appendChild(chip);
});
var dashboardChip=_renderDashboardVisibilityChip(container);
if(dashboardChip) container.appendChild(dashboardChip);
}
function _wireTabChipDrag(chip,panel){
@ -6742,6 +6772,21 @@ function _toggleTabVisibilityChip(panel){
_scheduleAppearanceAutosave();
}
function _toggleDashboardVisibilityChip(){
var modeEl=$('settingsDashboardMode');
if(!modeEl||typeof saveDashboardSettings!=='function') return;
var currentMode=_dashboardPanelMode();
var nextMode=currentMode==='never'
? (typeof _getDashboardChipRestoreMode==='function' ? _getDashboardChipRestoreMode() : 'auto')
: 'never';
var previousMode=currentMode;
modeEl.value=nextMode;
Promise.resolve(saveDashboardSettings({raiseOnError:true})).catch(function(){
modeEl.value=previousMode;
if(typeof _renderTabVisibilityChips==='function') _renderTabVisibilityChips();
});
}
function _ensureComposerControlVisibilityState(settings){
const fromSettings=(typeof _composerControlVisibilityFromSettings==='function')
? _composerControlVisibilityFromSettings(settings||{})

View file

@ -1174,11 +1174,28 @@ async function jumpToTurnQuestion(questionRawIdx, assistantRawIdx){
const DASHBOARD_STATUS_TTL_MS=60000;
let _dashboardStatusCache=null;
let _dashboardStatusFetchedAt=0;
let _dashboardLastNonNeverMode='auto'; // Server-scoped dashboard config keeps this restore target session-global on purpose.
let _dashboardSettingsLoadSeq=0;
let _dashboardSettingsWriteSeq=0;
function _dashboardIsBrowserLoopback(){
const host=(window.location.hostname||'').replace(/^\[|\]$/g,'').toLowerCase();
return host==='127.0.0.1'||host==='localhost'||host==='::1';
}
function _normalizeDashboardEnabledMode(mode){
return mode==='auto'||mode==='always'||mode==='never'?mode:'auto';
}
function _setDashboardModeForChip(mode){
mode=_normalizeDashboardEnabledMode(mode);
if(mode==='auto'||mode==='always') _dashboardLastNonNeverMode=mode;
}
function _getDashboardChipRestoreMode(){
return _dashboardLastNonNeverMode||'auto';
}
function _dashboardBrowserUrl(status){
if(!status||!status.running) return '';
if(status.browser_url||status.url){
@ -1234,26 +1251,39 @@ async function loadDashboardSettings(){
const modeEl=$('settingsDashboardMode');
const urlEl=$('settingsDashboardUrl');
if(!modeEl&&!urlEl) return;
const loadSeq=++_dashboardSettingsLoadSeq;
const writeSeq=_dashboardSettingsWriteSeq;
try{
const cfg=await api('/api/dashboard/config');
if(modeEl) modeEl.value=cfg.enabled||'auto';
if(loadSeq!==_dashboardSettingsLoadSeq||writeSeq!==_dashboardSettingsWriteSeq) return;
const mode=_normalizeDashboardEnabledMode(cfg&&cfg.enabled);
if(modeEl) modeEl.value=mode;
_setDashboardModeForChip(mode);
if(urlEl) urlEl.value=cfg.url||'';
if(typeof _renderTabVisibilityChips==='function') _renderTabVisibilityChips();
}catch(_){/* leave defaults visible */}
}
async function saveDashboardSettings(){
async function saveDashboardSettings(opts){
opts=opts||{};
const modeEl=$('settingsDashboardMode');
const urlEl=$('settingsDashboardUrl');
const statusEl=$('settingsDashboardStatus');
const payload={enabled:(modeEl&&modeEl.value)||'auto',url:(urlEl&&urlEl.value||'').trim()};
_dashboardSettingsWriteSeq+=1;
try{
const saved=await api('/api/dashboard/config',{method:'POST',body:JSON.stringify(payload)});
if(modeEl) modeEl.value=saved.enabled||'auto';
const mode=_normalizeDashboardEnabledMode(saved&&saved.enabled);
if(modeEl) modeEl.value=mode;
_setDashboardModeForChip(mode);
if(urlEl) urlEl.value=saved.url||'';
if(statusEl) statusEl.textContent='Dashboard link settings saved.';
await refreshDashboardStatus(true);
if(typeof _renderTabVisibilityChips==='function') _renderTabVisibilityChips();
}catch(err){
if(statusEl) statusEl.textContent='Dashboard link settings failed to save.';
else if(typeof showToast==='function') showToast('Dashboard link settings failed to save.');
try{await loadDashboardSettings();}catch(_){}
if(opts.raiseOnError) throw err;
}
}
function openHermesDashboard(event){

View file

@ -1,10 +1,333 @@
import json
import pathlib
import shutil
import subprocess
import tempfile
import textwrap
import re
import pytest
REPO = pathlib.Path(__file__).parent.parent
INDEX_HTML = (REPO / "static" / "index.html").read_text(encoding="utf-8")
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
STYLE_CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8")
UI_PATH = REPO / "static" / "ui.js"
PANELS_PATH = REPO / "static" / "panels.js"
NODE = shutil.which("node")
requires_node = pytest.mark.skipif(NODE is None, reason="node not on PATH")
_DASHBOARD_LINK_DRIVER = textwrap.dedent(
"""\
const fs = require('fs');
function extractFn(src, name) {
const markers = [`async function ${name}(`, `function ${name}(`];
let start = -1;
for (const marker of markers) {
start = src.indexOf(marker);
if (start >= 0) break;
}
if (start < 0) throw new Error(`${name}() not found`);
let i = src.indexOf('{', start);
let depth = 0;
let inString = null;
let escaped = false;
let inLineComment = false;
let inBlockComment = false;
for (; i < src.length; i++) {
const ch = src[i];
const nxt = src[i + 1] || '';
if (inLineComment) {
if (ch === '\\n') inLineComment = false;
continue;
}
if (inBlockComment) {
if (ch === '*' && nxt === '/') inBlockComment = false;
continue;
}
if (inString) {
if (escaped) {
escaped = false;
} else if (ch === '\\\\') {
escaped = true;
} else if (ch === inString) {
inString = null;
}
continue;
}
if (ch === '/' && nxt === '/') { inLineComment = true; continue; }
if (ch === '/' && nxt === '*') { inBlockComment = true; continue; }
if (ch === '\\'' || ch === '\"' || ch === '`') { inString = ch; continue; }
if (ch === '{') depth += 1;
if (ch === '}') {
depth -= 1;
if (depth === 0) return src.slice(start, i + 1);
}
}
throw new Error(`could not extract ${name}`);
}
function makeEl() {
return {
children: [],
style: {},
classList: {
_set: new Set(),
add(c){this._set.add(c);},
remove(c){this._set.delete(c);},
toggle(c, on){const want = on === undefined ? !this._set.has(c) : Boolean(on); if (want) this._set.add(c); else this._set.delete(c);},
contains(c){return this._set.has(c);},
},
dataset: {},
_attrs: {},
textContent: '',
datasetUrl: '',
setAttribute(name, value) {
this._attrs[name] = String(value);
if (name === 'data-dashboard-url') this.datasetUrl = String(value);
},
getAttribute(name) { return Object.prototype.hasOwnProperty.call(this._attrs, name) ? this._attrs[name] : null; },
hasAttribute(name) { return Object.prototype.hasOwnProperty.call(this._attrs, name); },
appendChild(child) { this.children.push(child); return child; },
};
}
function makeButton(id) {
const btn = makeEl();
btn.id = id;
btn._classes = btn.classList._set;
if (id === 'dashboardRailBtn' || id === 'dashboardMobileBtn') {
btn.setAttribute('data-dashboard-link', '');
btn.setAttribute('aria-label', 'Dashboard');
}
return btn;
}
const action = process.argv[2] || 'load';
const mode = process.argv[3] || 'auto';
const url = process.argv[4] || '';
const uiSrc = fs.readFileSync(process.argv[5], 'utf8');
const panelsSrc = fs.readFileSync(process.argv[6], 'utf8');
const modeEl = makeButton('settingsDashboardMode');
const urlEl = makeButton('settingsDashboardUrl');
const statusEl = makeButton('settingsDashboardStatus');
modeEl.value = mode;
urlEl.value = url;
const railBtn = makeButton('dashboardRailBtn');
const mobileBtn = makeButton('dashboardMobileBtn');
const buttons = [railBtn, mobileBtn];
const result = { calls: [], renderCalls: 0, statusCalls: 0, buttonStates: [] };
let delayedConfigResolve = null;
const delayedConfigValue = { enabled: 'never', url: 'http://stale.local:1234' };
let delayedConfigUsed = false;
global._dashboardLastNonNeverMode = 'auto';
global._dashboardStatusCache = null;
global._dashboardStatusFetchedAt = 0;
global._dashboardSettingsLoadSeq = 0;
global._dashboardSettingsWriteSeq = 0;
global.window = { location: { hostname: '127.0.0.1' } };
global.document = {
createElement: () => makeEl(),
querySelectorAll: (sel) => {
if (sel === '[data-dashboard-link]') return buttons;
return [];
},
querySelector: () => null,
addEventListener: () => {},
};
global.$ = (id) => {
if (id === 'settingsDashboardMode') return modeEl;
if (id === 'settingsDashboardUrl') return urlEl;
if (id === 'settingsDashboardStatus') return statusEl;
return null;
};
global.t = (key) => {
if (key === 'tab_dashboard') return 'Dashboard';
if (key === 'dashboard_loopback_warning') return 'Loopback';
return key;
};
global._dashboardIsBrowserLoopback = () => false;
global.api = (url, opts = {}) => {
result.calls.push({ url: String(url), method: (opts.method || 'GET').toUpperCase(), body: opts.body || '', timeoutToast: !!(opts.timeoutToast) });
if (String(url) === '/api/dashboard/config') {
if (
(opts.method || 'GET').toUpperCase() === 'GET' &&
(action === 'stale-load' || action === 'failed-save-stale-load') &&
!delayedConfigUsed
) {
delayedConfigUsed = true;
return new Promise((resolve) => {
delayedConfigResolve = () => resolve(delayedConfigValue);
});
}
if ((opts.method || 'GET').toUpperCase() === 'GET' && action === 'failed-save-stale-load') {
return Promise.resolve(delayedConfigValue);
}
const payload = opts.body ? JSON.parse(opts.body) : {};
if ((opts.method || 'GET').toUpperCase() === 'POST' && action === 'failed-save-stale-load') {
return Promise.reject(new Error('save failed'));
}
const enabled = payload.enabled || modeEl.value || 'auto';
const configuredUrl = payload.url || urlEl.value || '';
return Promise.resolve({ enabled, url: configuredUrl });
}
if (String(url) === '/api/dashboard/status') {
result.statusCalls += 1;
return Promise.resolve({
running: modeEl.value !== 'never',
browser_url: modeEl.value === 'never' ? '' : 'http://127.0.0.1:1234',
});
}
return Promise.resolve({ running: false });
};
global._renderTabVisibilityChips = () => {
result.renderCalls += 1;
result.lastRenderMode = modeEl.value;
};
for (const name of ['_normalizeDashboardEnabledMode','_setDashboardModeForChip','_getDashboardChipRestoreMode']) {
eval(extractFn(uiSrc, name));
}
for (const name of ['_dashboardBrowserUrl', '_applyDashboardStatus', 'refreshDashboardStatus', 'loadDashboardSettings', 'saveDashboardSettings']) {
let src = extractFn(uiSrc, name);
if(name === 'saveDashboardSettings'){
src = src.replace(
"if(typeof _renderTabVisibilityChips==='function') _renderTabVisibilityChips();",
"if(typeof globalThis._renderTabVisibilityChips==='function') globalThis._renderTabVisibilityChips();"
);
}
eval(src);
}
for (const name of ['_dashboardPanelMode', '_toggleDashboardVisibilityChip']) {
eval(extractFn(panelsSrc, name));
}
function recordButtons() {
result.buttonStates = buttons.map((btn) => ({
id: btn.id,
classes: Array.from(btn.classList._set || []),
display: btn.style.display || '',
dashboardUrl: btn._attrs['data-dashboard-url'] || '',
tooltip: btn._attrs['data-tooltip'] || '',
}));
}
(async () => {
if (action === 'load') {
await loadDashboardSettings();
recordButtons();
console.log(JSON.stringify({
mode: modeEl.value,
url: urlEl.value,
renderCalls: result.renderCalls,
lastRenderMode: result.lastRenderMode || '',
calls: result.calls,
buttonStates: result.buttonStates,
}));
return;
}
if (action === 'failed-save-stale-load') {
const loadPromise = loadDashboardSettings();
modeEl.value = 'always';
urlEl.value = 'http://fresh.local:4321';
await saveDashboardSettings();
if (!delayedConfigResolve) throw new Error('delayed config read was not started');
delayedConfigResolve();
await loadPromise;
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
recordButtons();
console.log(JSON.stringify({
mode: modeEl.value,
url: urlEl.value,
renderCalls: result.renderCalls,
lastRenderMode: result.lastRenderMode || '',
calls: result.calls,
statusCalls: result.statusCalls,
buttonStates: result.buttonStates,
}));
return;
}
if (action === 'stale-load') {
const loadPromise = loadDashboardSettings();
modeEl.value = 'always';
urlEl.value = 'http://fresh.local:4321';
await saveDashboardSettings();
if (!delayedConfigResolve) throw new Error('delayed config read was not started');
delayedConfigResolve();
await loadPromise;
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
recordButtons();
console.log(JSON.stringify({
mode: modeEl.value,
url: urlEl.value,
renderCalls: result.renderCalls,
lastRenderMode: result.lastRenderMode || '',
calls: result.calls,
statusCalls: result.statusCalls,
buttonStates: result.buttonStates,
}));
return;
}
if (action === 'chip-toggle') {
_toggleDashboardVisibilityChip();
} else {
await saveDashboardSettings();
}
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
recordButtons();
console.log(JSON.stringify({
mode: modeEl.value,
url: urlEl.value,
renderCalls: result.renderCalls,
lastRenderMode: result.lastRenderMode || '',
calls: result.calls,
statusCalls: result.statusCalls,
buttonStates: result.buttonStates,
}));
})().catch((err) => { console.error(err); process.exit(1); });
"""
)
def _run_dashboard_link_driver(action: str, mode: str = 'auto', url: str = '') -> dict:
with tempfile.NamedTemporaryFile("w", suffix=".js", encoding="utf-8", delete=False) as f:
f.write(_DASHBOARD_LINK_DRIVER)
driver = f.name
try:
result = subprocess.run(
[
NODE,
driver,
action,
mode,
url,
str(UI_PATH),
str(PANELS_PATH),
],
cwd=REPO,
text=True,
capture_output=True,
timeout=2.0,
check=False,
)
if result.returncode != 0:
raise RuntimeError(f"node harness failed: {result.stderr or result.stdout}")
return json.loads(result.stdout.strip())
finally:
try:
pathlib.Path(driver).unlink()
except OSError:
pass
def test_dashboard_nav_buttons_are_hidden_by_default_and_subpath_safe():
@ -95,3 +418,77 @@ def test_desktop_dashboard_link_button_stays_in_desktop_nav():
assert mobile_nav_match is not None
mobile_nav = mobile_nav_match.group(0)
assert 'id="dashboardMobileBtn"' in mobile_nav
@requires_node
def test_dashboard_dropdown_save_resyncs_chip_state():
out = _run_dashboard_link_driver("save", mode="never", url="http://example.local:1234")
assert out["mode"] == "never"
assert out["url"] == "http://example.local:1234"
assert out["lastRenderMode"] == "never"
assert out["renderCalls"] == 1
assert any(call["url"] == "/api/dashboard/config" and call["method"] == "POST" for call in out["calls"])
assert any(call["url"] == "/api/dashboard/status" for call in out["calls"])
assert out["statusCalls"] >= 1
assert all(
"dashboard-link-visible" not in state["classes"]
and state["display"] == "none"
for state in out["buttonStates"]
)
@requires_node
def test_dashboard_chip_save_keeps_buttons_refreshed():
out = _run_dashboard_link_driver("chip-toggle", mode="never", url="http://example.local:1234")
assert out["mode"] == "auto"
assert out["url"] == "http://example.local:1234"
assert out["renderCalls"] == 1
assert out["lastRenderMode"] == "auto"
assert any(call["url"] == "/api/dashboard/config" and call["method"] == "POST" for call in out["calls"])
assert any(call["url"] == "/api/dashboard/status" for call in out["calls"])
assert out["statusCalls"] >= 1
assert all(
"dashboard-link-visible" in state["classes"]
and state["display"] != "none"
for state in out["buttonStates"]
)
@requires_node
def test_stale_dashboard_load_does_not_overwrite_newer_save():
out = _run_dashboard_link_driver("stale-load", mode="never", url="http://stale.local:1234")
assert out["mode"] == "always"
assert out["url"] == "http://fresh.local:4321"
assert out["renderCalls"] == 1
assert out["lastRenderMode"] == "always"
assert [
(call["url"], call["method"])
for call in out["calls"]
] == [
("/api/dashboard/config", "GET"),
("/api/dashboard/config", "POST"),
("/api/dashboard/status", "GET"),
]
assert out["statusCalls"] == 1
assert all(
"dashboard-link-visible" in state["classes"]
and state["display"] != "none"
for state in out["buttonStates"]
)
@requires_node
def test_failed_dashboard_save_reloads_backend_after_stale_load_is_dropped():
out = _run_dashboard_link_driver("failed-save-stale-load", mode="never", url="http://stale.local:1234")
assert out["mode"] == "never"
assert out["url"] == "http://stale.local:1234"
assert out["renderCalls"] == 1
assert out["lastRenderMode"] == "never"
assert [
(call["url"], call["method"])
for call in out["calls"]
] == [
("/api/dashboard/config", "GET"),
("/api/dashboard/config", "POST"),
("/api/dashboard/config", "GET"),
]
assert out["statusCalls"] == 0

View file

@ -4,16 +4,302 @@ Covers backend validation round-trip, frontend static contracts,
i18n coverage, and the key integration points that have broken before.
"""
import json
import shutil
import subprocess
import tempfile
import textwrap
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
CONFIG_PY = (ROOT / "api" / "config.py").read_text(encoding="utf-8")
PANELS_JS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8")
UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
PANELS_PATH = ROOT / "static" / "panels.js"
UI_PATH = ROOT / "static" / "ui.js"
BOOT_JS = (ROOT / "static" / "boot.js").read_text(encoding="utf-8")
INDEX_HTML = (ROOT / "static" / "index.html").read_text(encoding="utf-8")
STYLE_CSS = (ROOT / "static" / "style.css").read_text(encoding="utf-8")
I18N_JS = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
NODE = shutil.which("node")
requires_node = pytest.mark.skipif(NODE is None, reason="node not on PATH")
_PANELS_DASHBOARD_DRIVER = textwrap.dedent(
"""\
const fs = require('fs');
const path = require('path');
function extractFn(src, name) {
const markers = [`async function ${name}(`, `function ${name}(`];
let start = -1;
for (const marker of markers) {
start = src.indexOf(marker);
if (start >= 0) break;
}
if (start < 0) throw new Error(`${name}() not found`);
let i = src.indexOf('{', start);
let depth = 0;
let inString = null;
let escaped = false;
let inLineComment = false;
let inBlockComment = false;
for (; i < src.length; i++) {
const ch = src[i];
const nxt = src[i + 1] || '';
if (inLineComment) {
if (ch === '\\n') inLineComment = false;
continue;
}
if (inBlockComment) {
if (ch === '*' && nxt === '/') inBlockComment = false;
continue;
}
if (inString) {
if (escaped) {
escaped = false;
} else if (ch === '\\\\') {
escaped = true;
} else if (ch === inString) {
inString = null;
}
continue;
}
if (ch === '/' && nxt === '/') { inLineComment = true; continue; }
if (ch === '/' && nxt === '*') { inBlockComment = true; continue; }
if (ch === '\\'' || ch === '\"' || ch === '`') { inString = ch; continue; }
if (ch === '{') depth += 1;
if (ch === '}') {
depth -= 1;
if (depth === 0) return src.slice(start, i + 1);
}
}
throw new Error(`could not extract ${name}`);
}
function makeEl() {
return {
children: [],
style: {},
classList: {
_set: new Set(),
add(c) { this._set.add(c); },
remove(c) { this._set.delete(c); },
toggle(c, on) {
const want = on === undefined ? !this._set.has(c) : Boolean(on);
if (want) this._set.add(c); else this._set.delete(c);
},
contains(c) { return this._set.has(c); },
},
dataset: {},
_attrs: {},
textContent: '',
setAttribute(name, value) {
this._attrs[name] = String(value);
if (name === 'data-tab-panel') this.dataset.tabPanel = String(value);
},
getAttribute(name) {
return Object.prototype.hasOwnProperty.call(this._attrs, name)
? this._attrs[name]
: null;
},
hasAttribute(name) {
return Object.prototype.hasOwnProperty.call(this._attrs, name);
},
appendChild(child) {
this.children.push(child);
return child;
},
querySelector: () => null,
querySelectorAll: () => [],
};
}
const action = process.argv[2] || 'render';
const mode = process.argv[3] || 'auto';
const priorMode = process.argv[4] || '';
const forceNoRestore = process.argv[5] === '1';
const failSave = process.argv[6] === '1';
const panelsSrc = fs.readFileSync(process.argv[7], 'utf8');
const uiSrc = fs.readFileSync(process.argv[8], 'utf8');
const container = makeEl();
const modeEl = makeEl();
modeEl.id = 'settingsDashboardMode';
modeEl.value = mode;
const urlEl = makeEl();
urlEl.id = 'settingsDashboardUrl';
urlEl.value = '';
const registry = Object.create(null);
registry.tabVisibilityChips = container;
registry.settingsDashboardMode = modeEl;
registry.settingsDashboardUrl = urlEl;
let apiCalls = [];
let renderCalls = 0;
let hiddenCalls = 0;
let tabOrderCalls = 0;
global._dashboardLastNonNeverMode = 'auto';
global.window = {};
global.localStorage = {
_store: Object.create(null),
getItem(k) {
return Object.prototype.hasOwnProperty.call(this._store, k) ? this._store[k] : null;
},
setItem(k, v) {
this._store[k] = String(v);
},
};
global.document = {
createElement: () => makeEl(),
querySelector: () => null,
querySelectorAll: () => [],
addEventListener: () => {},
};
global.$ = (id) => registry[id] || null;
global._orderedSidebarPanels = () => [];
global._getHiddenTabs = () => [];
global._wireTabChipDrag = () => {};
global._tabVisibilityDragSuppressUntil = 0;
global._ALWAYS_VISIBLE_TABS = new Set(['chat', 'settings']);
global.t = (key) => key === 'tab_dashboard' ? 'Hermes Dashboard' : String(key);
let failNextSave = failSave;
global.api = (url, opts = {}) => {
apiCalls.push({ url: String(url), method: (opts.method || 'GET').toUpperCase(), body: opts.body || '' });
if (String(url) === '/api/dashboard/config' && failNextSave) {
failNextSave = false;
return Promise.reject(new Error('save failed'));
}
const payload = opts.body ? JSON.parse(opts.body) : {};
return Promise.resolve({ enabled: payload.enabled || 'auto', url: payload.url || '' });
};
global.saveDashboardSettings = async function (opts = {}) {
const payload = { enabled: modeEl.value || 'auto', url: (urlEl.value || '').trim() };
try {
const saved = await api('/api/dashboard/config', { method: 'POST', body: JSON.stringify(payload) });
const normalized = _normalizeDashboardEnabledMode(saved && saved.enabled);
modeEl.value = normalized;
_setDashboardModeForChip(normalized);
return saved;
} catch (err) {
if (opts.raiseOnError) throw err;
}
};
global._setHiddenTabs = () => { hiddenCalls += 1; };
global._setTabOrder = () => { tabOrderCalls += 1; };
global._renderTabVisibilityChips = () => { renderCalls += 1; };
for (const name of ['_normalizeDashboardEnabledMode', '_setDashboardModeForChip', '_getDashboardChipRestoreMode']) {
eval(extractFn(uiSrc, name));
}
for (const name of [
'_dashboardPanelMode',
'_isDashboardChipOn',
'_renderDashboardVisibilityChip',
'_renderTabVisibilityChips',
'_toggleDashboardVisibilityChip'
]) {
eval(extractFn(panelsSrc, name));
}
const realRenderTabVisibilityChips = _renderTabVisibilityChips;
_renderTabVisibilityChips = function () {
renderCalls += 1;
return realRenderTabVisibilityChips();
};
(async () => {
if (forceNoRestore) global._dashboardLastNonNeverMode = null;
if (priorMode) _setDashboardModeForChip(priorMode);
if (action === 'render') {
_renderTabVisibilityChips();
const chip = container.children.find((node) => node.getAttribute('data-tab-panel') === '__hermes_dashboard__');
console.log(JSON.stringify({
chipCount: container.children.length,
hasChip: !!chip,
chipText: chip && chip.textContent,
chipAriaChecked: chip && chip.getAttribute('aria-checked'),
chipIsOff: chip && chip.classList.contains('chip-off'),
}));
return;
}
_toggleDashboardVisibilityChip();
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
const firstMode = modeEl.value;
if (action === 'toggle-fail') {
console.log(JSON.stringify({
firstMode,
dashboardLastNonNeverMode: global._dashboardLastNonNeverMode,
apiCalls,
hiddenCalls,
tabOrderCalls,
renderCalls,
}));
return;
}
_toggleDashboardVisibilityChip();
await new Promise((resolve) => setTimeout(resolve, 0));
const secondMode = modeEl.value;
console.log(JSON.stringify({
firstMode,
secondMode,
dashboardLastNonNeverMode: global._dashboardLastNonNeverMode,
apiCalls,
hiddenCalls,
tabOrderCalls,
renderCalls,
}));
})().catch((err) => { console.error(err); process.exit(1); });
"""
)
def _run_panels_driver(
action: str,
mode: str = 'auto',
prior_mode: str = '',
force_no_restore: bool = False,
fail_save: bool = False,
) -> dict:
"""Run the dashboard-visibility chip helpers with a DOM shim."""
with tempfile.NamedTemporaryFile("w", suffix=".js", encoding="utf-8", delete=False) as f:
f.write(_PANELS_DASHBOARD_DRIVER)
driver = f.name
try:
result = subprocess.run(
[
NODE,
driver,
action,
mode,
prior_mode,
"1" if force_no_restore else "0",
"1" if fail_save else "0",
str(PANELS_PATH),
str(UI_PATH),
],
cwd=ROOT,
text=True,
capture_output=True,
timeout=2.0,
check=False,
)
if result.returncode != 0:
raise RuntimeError(f"node harness failed: {result.stderr or result.stdout}")
return json.loads(result.stdout.strip())
finally:
try:
Path(driver).unlink()
except OSError:
pass
def test_backend_round_trip_and_validation(monkeypatch, tmp_path):
"""hidden_tabs defaults to [], saves/reloads, rejects non-list, filters empty strings."""
@ -180,6 +466,66 @@ def test_chip_a11y_uses_switch_role_with_aria_checked():
"chip needs drag-over style for mouse reorder target feedback"
@requires_node
def test_dashboard_chip_renders_in_tab_visibility_grid():
"""Behavioral verification for render state and placement in the chip grid."""
for mode in ("auto", "always", "never"):
out = _run_panels_driver("render", mode=mode)
assert out["hasChip"] is True, out
if mode == "never":
assert out["chipIsOff"] is True, out
assert out["chipAriaChecked"] == "false", out
else:
assert out["chipIsOff"] is False, out
assert out["chipAriaChecked"] == "true", out
assert out["chipText"] == "Hermes Dashboard", out
assert out["chipCount"] >= 1, out
@requires_node
def test_dashboard_chip_off_sets_never_and_chip_on_restores_prior_mode():
"""Chip toggling must save 'never' and restore non-never mode on the next toggle."""
out = _run_panels_driver("toggle", mode="always", prior_mode="always")
assert out["firstMode"] == "never", out
assert out["secondMode"] == "always", out
assert len([call for call in out["apiCalls"] if call["url"] == "/api/dashboard/config"]) == 2, out
assert out["hiddenCalls"] == 0, out
assert out["tabOrderCalls"] == 0, out
assert out["dashboardLastNonNeverMode"] == "always", out
@requires_node
def test_dashboard_chip_toggle_does_not_mutate_hidden_tabs_or_tab_order():
"""Dashboard chip toggles stay on the dashboard config path, never the sidebar-tab payload."""
out = _run_panels_driver("toggle", mode="auto", prior_mode="auto")
assert out["firstMode"] == "never", out
assert len([call for call in out["apiCalls"] if call["url"] == "/api/dashboard/config"]) == 2, out
assert out["hiddenCalls"] == 0, out
assert out["tabOrderCalls"] == 0, out
@requires_node
def test_dashboard_chip_on_defaults_to_auto_without_prior_mode():
"""Missing restore state should fall back to 'auto' before returning chip-on."""
out = _run_panels_driver("toggle", mode="never", force_no_restore=True)
assert out["firstMode"] == "auto", out
assert out["secondMode"] == "never", out
assert out["hiddenCalls"] == 0, out
assert out["tabOrderCalls"] == 0, out
@requires_node
def test_dashboard_chip_failed_save_restores_previous_mode():
"""A failed chip save must roll the dropdown and chip state back to the prior mode."""
out = _run_panels_driver("toggle-fail", mode="auto", prior_mode="auto", fail_save=True)
assert out["firstMode"] == "auto", out
assert len([call for call in out["apiCalls"] if call["url"] == "/api/dashboard/config"]) == 1, out
assert out["hiddenCalls"] == 0, out
assert out["tabOrderCalls"] == 0, out
assert out["renderCalls"] == 1, out
assert out["dashboardLastNonNeverMode"] == "auto", out
def test_tab_order_excludes_always_visible_tabs(monkeypatch, tmp_path):
"""Server-side tab_order validation mirrors hidden_tabs: chat and settings
remain fixed, so a tampered payload must not persist them in custom order."""