diff --git a/CHANGELOG.md b/CHANGELOG.md index 4821f3c3..0ee42d02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/static/panels.js b/static/panels.js index c6b619f2..182dfccb 100644 --- a/static/panels.js +++ b/static/panels.js @@ -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||{}) diff --git a/static/ui.js b/static/ui.js index 8daea7ac..e8c344d5 100644 --- a/static/ui.js +++ b/static/ui.js @@ -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){ diff --git a/tests/test_dashboard_link_ui.py b/tests/test_dashboard_link_ui.py index 3bbf8cb2..9c9dcfbc 100644 --- a/tests/test_dashboard_link_ui.py +++ b/tests/test_dashboard_link_ui.py @@ -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 diff --git a/tests/test_sidebar_tab_visibility.py b/tests/test_sidebar_tab_visibility.py index d334d199..724c84d6 100644 --- a/tests/test_sidebar_tab_visibility.py +++ b/tests/test_sidebar_tab_visibility.py @@ -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."""