Release v0.51.286 — Release JB (stage-r21 — sidebar tab reordering #3067) (#3694)

* feat: allow sidebar tab reordering via drag (#3067)

Drag-reorder for sidebar tab chips in Settings, persisted via a sanitized
tab_order setting (collapses duplicates, rejects chat/settings, strips
non-strings).

Co-authored-by: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com>

* docs(changelog): v0.51.286 — Release JB (stage-r21)

---------

Co-authored-by: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com>
Co-authored-by: nesquena-hermes <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-06-05 18:39:42 -07:00
1 parent e9bb354908
commit 2736f443b1
9 files changed
+304 -33

No files matched your search

+5
View File
@@ -3,6 +3,11 @@
## [Unreleased]
## [v0.51.286] — 2026-06-06 — Release JB (stage-r21 — sidebar tab reordering)
### Added
- **Drag-reorder for sidebar tabs.** In Settings → Appearance, the "Sidebar tabs" chips (Tasks, Kanban, Skills, Memory, Spaces, Profiles, Todos, Insights, Logs) can be dragged to reorder how they appear in the left rail and sidebar nav, persisted via a sanitized `tab_order` setting (collapses duplicates, rejects `chat`/`settings`, strips non-strings). Chat and Settings stay fixed. Reorder is pointer/desktop-based (consistent with the existing Kanban drag-and-drop); the chips remain tappable for show/hide on touch. (#3067, @ai-ag2026)
## [v0.51.285] — 2026-06-06 — Release JA (stage-r19 — update-reload server-identity race fix)
### Fixed
+16 -10
View File
@@ -5095,6 +5095,7 @@ _SETTINGS_DEFAULTS = {
"inflight_state_max_string_chars": 60000, # max string length kept inside a recovery snapshot field
"inflight_state_max_json_chars": 1500000, # max serialized recovery snapshot payload before pruning
"hidden_tabs": [], # sidebar tab panel names hidden by user (e.g. ["tasks","kanban"]); chat and settings are always visible
"tab_order": [], # user-defined sidebar/rail tab order for reorderable tabs; chat/settings stay fixed
"language": "en", # UI locale code; must match a key in static/i18n.js LOCALES
"bot_name": os.getenv(
"HERMES_WEBUI_BOT_NAME", "Hermes"
@@ -5325,18 +5326,23 @@ def save_settings(settings: dict) -> dict:
not isinstance(v, str) or not _SETTINGS_LANG_RE.match(v)
):
continue
# Validate hidden_tabs: must be a list of non-empty strings.
# Belt-and-suspenders strip of "chat" and "settings" so a
# malicious POST cannot lock the user out of the always-visible
# nav tabs even though the client also filters them at apply time.
# Stage-394 follow-up to #2636 deep review.
if k == "hidden_tabs":
# Validate list-valued sidebar tab settings. Chat/settings stay fixed
# even if a tampered POST tries to persist them, and duplicates are
# collapsed while preserving the first requested order.
if k in {"hidden_tabs", "tab_order"}:
if not isinstance(v, list):
continue
v = [
s for s in v
if isinstance(s, str) and s.strip() and s not in {"chat", "settings"}
]
seen = set()
cleaned = []
for s in v:
if not isinstance(s, str):
continue
s = s.strip()
if not s or s in {"chat", "settings"} or s in seen:
continue
seen.add(s)
cleaned.append(s)
v = cleaned
# Coerce bool keys
if k in _SETTINGS_BOOL_KEYS:
v = bool(v)
+3
View File
@@ -394,6 +394,9 @@ function expandSidebar(){
// panels.js hasn't loaded yet (typeof guard).
(function _restoreTabVisibility(){
try{
if(typeof _applyTabOrder==='function'&&typeof _getTabOrder==='function'){
_applyTabOrder(_getTabOrder());
}
if(typeof _applyTabVisibility==='function'&&typeof _getHiddenTabs==='function'){
_applyTabVisibility(_getHiddenTabs());
}
+2 -2
View File
@@ -547,7 +547,7 @@ const LOCALES = {
settings_desc_activity_feed_expanded_default: 'Open new Activity disclosures automatically so tool and model progress is visible without an extra click. Per-turn manual collapse/expand choices still win.',
settings_label_tab_visibility: 'Sidebar tabs',
settings_desc_tab_visibility: 'Choose which tabs appear in the sidebar and rail. Chat and Settings are always visible.',
settings_desc_tab_visibility: 'Choose which tabs appear in the sidebar and rail. Drag chips to reorder them. Chat and Settings are always visible.',
open_in_browser: 'Open in browser',
settings_dropdown_conversation: 'Conversation',
settings_dropdown_appearance: 'Appearance',
@@ -7378,7 +7378,7 @@ const LOCALES = {
settings_desc_activity_feed_expanded_default: 'Open new Activity disclosures automatically so tool and model progress is visible without an extra click. Per-turn manual collapse/expand choices still win.',
settings_label_tab_visibility: 'Seitenleiste-Tabs',
settings_desc_tab_visibility: 'Wähle, welche Tabs in der Seitenleiste und im Rail angezeigt werden. Chat und Einstellungen sind immer sichtbar.',
settings_desc_tab_visibility: 'Wähle, welche Tabs in der Seitenleiste und im Rail angezeigt werden. Ziehe die Chips, um die Reihenfolge zu ändern. Chat und Einstellungen sind immer sichtbar.',
workspace_drag_hint: 'Ziehen zum Neuordnen',
workspace_reorder_failed: 'Neuordnen fehlgeschlagen',
+2 -2
View File
@@ -184,7 +184,7 @@
before any paint. Mirrors the theme/skin/font-size inline scripts in <head>.
The boot.js _restoreTabVisibility IIFE is a secondary fallback that also
handles the active-tab switch. -->
<script>(function(){try{var h=localStorage.getItem('hermes-webui-hidden-tabs');if(!h)return;var p=JSON.parse(h);if(!Array.isArray(p))return;var a=new Set(['chat','settings']);p.forEach(function(id){if(a.has(id))return;document.querySelectorAll('[data-panel="'+id+'"]').forEach(function(el){el.classList.add('nav-tab-hidden');});});try{var ac=document.querySelector('.rail .rail-btn.nav-tab.active[data-panel]')||document.querySelector('.sidebar-nav .nav-tab.active[data-panel]');if(ac&&ac.classList.contains('nav-tab-hidden')){ac.classList.remove('active');var c=document.querySelector('.rail .rail-btn.nav-tab[data-panel="chat"]')||document.querySelector('.sidebar-nav .nav-tab[data-panel="chat"]');if(c)c.classList.add('active');}}catch(_){}}catch(e){}})();</script>
<script>(function(){try{var fixed=new Set(['chat','settings']);var clean=function(v){var o=[];if(!Array.isArray(v))return o;v.forEach(function(id){if(typeof id!=='string')return;id=id.trim();if(!id||fixed.has(id)||o.indexOf(id)!==-1)return;o.push(id);});return o;};var reorder=function(order){order=clean(order);if(!order.length)return;['.rail','.sidebar-nav'].forEach(function(sel){var c=document.querySelector(sel);if(!c)return;var available=[];c.querySelectorAll('.nav-tab[data-panel]').forEach(function(el){var id=el.dataset.panel;if(!id||fixed.has(id)||el.classList.contains('dashboard-link')||el.hasAttribute('data-dashboard-link'))return;if(available.indexOf(id)===-1)available.push(id);});var final=[];order.forEach(function(id){if(available.indexOf(id)!==-1&&final.indexOf(id)===-1)final.push(id);});available.forEach(function(id){if(final.indexOf(id)===-1)final.push(id);});var anchor=Array.prototype.find.call(c.children,function(el){if(el.classList&&el.classList.contains('rail-spacer'))return true;if(el.classList&&el.classList.contains('dashboard-link'))return true;if(el.hasAttribute&&el.hasAttribute('data-dashboard-link'))return true;return el.dataset&&el.dataset.panel==='settings';});final.forEach(function(id){var el=c.querySelector('.nav-tab[data-panel="'+id+'"]');if(el)c.insertBefore(el,anchor||null);});});};var o=localStorage.getItem('hermes-webui-tab-order');if(o)reorder(JSON.parse(o));var h=localStorage.getItem('hermes-webui-hidden-tabs');if(!h)return;var p=clean(JSON.parse(h));p.forEach(function(id){document.querySelectorAll('[data-panel="'+id+'"]').forEach(function(el){el.classList.add('nav-tab-hidden');});});try{var ac=document.querySelector('.rail .rail-btn.nav-tab.active[data-panel]')||document.querySelector('.sidebar-nav .nav-tab.active[data-panel]');if(ac&&ac.classList.contains('nav-tab-hidden')){ac.classList.remove('active');var c=document.querySelector('.rail .rail-btn.nav-tab[data-panel="chat"]')||document.querySelector('.sidebar-nav .nav-tab[data-panel="chat"]');if(c)c.classList.add('active');}}catch(_){}}catch(e){}})();</script>
<!-- Chat panel -->
<div class="panel-view active" id="panelChat">
<div class="panel-head">
@@ -998,7 +998,7 @@
<div class="settings-field">
<label id="tabVisibilityLabel" data-i18n="settings_label_tab_visibility">Sidebar tabs</label>
<div id="tabVisibilityChips" class="tab-visibility-chips" role="group" aria-labelledby="tabVisibilityLabel"></div>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_tab_visibility">Choose which tabs appear in the sidebar and rail. Chat and Settings are always visible.</div>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_tab_visibility">Choose which tabs appear in the sidebar and rail. Drag chips to reorder them. Chat and Settings are always visible.</div>
</div>
<div id="settingsAppearanceAutosaveStatus" class="settings-autosave-status" aria-live="polite"></div>
</div>
+129 -13
View File
@@ -5061,7 +5061,11 @@ function _refreshProfileSwitchBackground(gen){
if (gen !== _profileSwitchGeneration) return;
var hidden = (s && Array.isArray(s.hidden_tabs)) ? s.hidden_tabs : [];
hidden = hidden.filter(function(x){ return typeof x === 'string' && x.trim(); });
var order = (s && Array.isArray(s.tab_order)) ? s.tab_order : [];
order = order.filter(function(x){ return typeof x === 'string' && x.trim(); });
if (typeof _setHiddenTabs === 'function') _setHiddenTabs(hidden);
if (typeof _setTabOrder === 'function') _setTabOrder(order);
if (typeof _applyTabOrder === 'function') _applyTabOrder(order);
if (typeof _applyTabVisibility === 'function') _applyTabVisibility(hidden);
}).catch(function(){});
}
@@ -5698,21 +5702,84 @@ let _settingsAppearanceAutosaveRetryPayload = null;
let _settingsPreferencesAutosaveTimer = null;
let _settingsPreferencesAutosaveRetryPayload = null;
// ── Sidebar tab visibility ─────────────────────────────────────────────────
// ── Sidebar tab visibility/order ────────────────────────────────────────────
const _ALWAYS_VISIBLE_TABS = new Set(['chat','settings']);
const _HIDDEN_TABS_LS_KEY = 'hermes-webui-hidden-tabs';
const _TAB_ORDER_LS_KEY = 'hermes-webui-tab-order';
let _tabVisibilityDragSuppressUntil = 0;
function _sanitizeTabPanelList(panels){
if(!Array.isArray(panels)) return [];
var out=[];
panels.forEach(function(panel){
if(typeof panel!=='string') return;
panel=panel.trim();
if(!panel||_ALWAYS_VISIBLE_TABS.has(panel)) return;
if(out.indexOf(panel)===-1) out.push(panel);
});
return out;
}
function _getHiddenTabs(){
try{var h=localStorage.getItem(_HIDDEN_TABS_LS_KEY);if(h){var p=JSON.parse(h);if(Array.isArray(p))return p;}}catch(e){}
try{var h=localStorage.getItem(_HIDDEN_TABS_LS_KEY);if(h)return _sanitizeTabPanelList(JSON.parse(h));}catch(e){}
return[];
}
function _setHiddenTabs(panels){
try{localStorage.setItem(_HIDDEN_TABS_LS_KEY,JSON.stringify(panels));}catch(e){}
try{localStorage.setItem(_HIDDEN_TABS_LS_KEY,JSON.stringify(_sanitizeTabPanelList(panels)));}catch(e){}
}
function _getTabOrder(){
try{var h=localStorage.getItem(_TAB_ORDER_LS_KEY);if(h)return _sanitizeTabPanelList(JSON.parse(h));}catch(e){}
return[];
}
function _setTabOrder(panels){
try{localStorage.setItem(_TAB_ORDER_LS_KEY,JSON.stringify(_sanitizeTabPanelList(panels)));}catch(e){}
}
function _availableSidebarPanels(){
var out=[];
var tabs=document.querySelectorAll('.rail .rail-btn.nav-tab[data-panel], .sidebar-nav .nav-tab[data-panel]');
tabs.forEach(function(tab){
var panel=tab.dataset.panel;
if(!panel||_ALWAYS_VISIBLE_TABS.has(panel)) return;
if(tab.classList.contains('dashboard-link')||tab.hasAttribute('data-dashboard-link')) return;
if(out.indexOf(panel)===-1) out.push(panel);
});
return out;
}
function _orderedSidebarPanels(order){
var available=_availableSidebarPanels();
var requested=_sanitizeTabPanelList(Array.isArray(order)?order:_getTabOrder());
var out=[];
requested.forEach(function(panel){ if(available.indexOf(panel)!==-1&&out.indexOf(panel)===-1) out.push(panel); });
available.forEach(function(panel){ if(out.indexOf(panel)===-1) out.push(panel); });
return out;
}
function _applyTabOrder(order){
var ordered=_orderedSidebarPanels(order);
['.rail','.sidebar-nav'].forEach(function(selector){
var container=document.querySelector(selector);
if(!container) return;
var anchor=Array.prototype.find.call(container.children,function(child){
if(child.classList&&child.classList.contains('rail-spacer')) return true;
if(child.classList&&child.classList.contains('dashboard-link')) return true;
if(child.hasAttribute&&child.hasAttribute('data-dashboard-link')) return true;
return child.dataset&&child.dataset.panel==='settings';
});
ordered.forEach(function(panel){
var node=container.querySelector('.nav-tab[data-panel="'+panel+'"]');
if(node) container.insertBefore(node,anchor||null);
});
});
}
function _applyTabVisibility(hidden){
if(!Array.isArray(hidden)) hidden=[];
hidden=_sanitizeTabPanelList(hidden);
_applyTabOrder(_getTabOrder());
// Hide/unhide all [data-panel] elements (sidebar-nav buttons + rail buttons)
document.querySelectorAll('[data-panel]').forEach(function(el){
var panel=el.dataset.panel;
@@ -5735,14 +5802,12 @@ function _renderTabVisibilityChips(){
var container=$('tabVisibilityChips');
if(!container)return;
var hidden=_getHiddenTabs();
// Scan rail buttons to discover all available panels (skip always-visible + dashboard-link)
var tabs=document.querySelectorAll('.rail .rail-btn.nav-tab[data-panel]');
var panels=_orderedSidebarPanels();
container.innerHTML='';
tabs.forEach(function(tab){
var panel=tab.dataset.panel;
if(!panel||_ALWAYS_VISIBLE_TABS.has(panel))return;
if(tab.classList.contains('dashboard-link'))return;
var label=tab.dataset.tooltip||tab.dataset.label||panel;
panels.forEach(function(panel){
var tab=document.querySelector('.rail .rail-btn.nav-tab[data-panel="'+panel+'"]')
||document.querySelector('.sidebar-nav .nav-tab[data-panel="'+panel+'"]');
var label=(tab&&(tab.dataset.tooltip||tab.dataset.label))||panel;
// Capitalize first letter
label=label.charAt(0).toUpperCase()+label.slice(1);
var chip=document.createElement('button');
@@ -5752,17 +5817,59 @@ function _renderTabVisibilityChips(){
if(isOff)chip.classList.add('chip-off');
chip.textContent=label;
chip.setAttribute('data-tab-panel',panel);
chip.setAttribute('draggable','true');
// Use role="switch" + aria-checked instead of aria-pressed so screen
// readers narrate "Tasks switch on/off" (matches user mental model) rather
// than "Tasks toggle button pressed/not-pressed" (where the polarity is
// confusing because chip-off looks like the "off" state).
chip.setAttribute('role','switch');
chip.setAttribute('aria-checked',isOff?'false':'true');
chip.onclick=function(){_toggleTabVisibilityChip(panel);};
chip.onclick=function(){
if(Date.now()<_tabVisibilityDragSuppressUntil)return;
_toggleTabVisibilityChip(panel);
};
_wireTabChipDrag(chip,panel);
container.appendChild(chip);
});
}
function _wireTabChipDrag(chip,panel){
if(!chip)return;
chip.addEventListener('dragstart',function(e){
chip.classList.add('dragging');
if(e.dataTransfer){
e.dataTransfer.effectAllowed='move';
e.dataTransfer.setData('text/plain',panel);
}
});
chip.addEventListener('dragend',function(){chip.classList.remove('dragging');});
chip.addEventListener('dragover',function(e){e.preventDefault();chip.classList.add('drag-over');if(e.dataTransfer)e.dataTransfer.dropEffect='move';});
chip.addEventListener('dragleave',function(){chip.classList.remove('drag-over');});
chip.addEventListener('drop',function(e){_handleTabVisibilityChipDrop(e,panel);});
}
function _moveTabOrderPanel(sourcePanel,targetPanel){
if(!sourcePanel||!targetPanel||sourcePanel===targetPanel) return false;
var order=_orderedSidebarPanels();
var from=order.indexOf(sourcePanel);
var to=order.indexOf(targetPanel);
if(from===-1||to===-1) return false;
order.splice(from,1);
order.splice(to,0,sourcePanel);
_setTabOrder(order);
_applyTabOrder(order);
_renderTabVisibilityChips();
_scheduleAppearanceAutosave();
return true;
}
function _handleTabVisibilityChipDrop(e,targetPanel){
if(e){e.preventDefault();e.stopPropagation();}
document.querySelectorAll('.tab-visibility-chip.drag-over').forEach(function(el){el.classList.remove('drag-over');});
var sourcePanel=e&&e.dataTransfer?e.dataTransfer.getData('text/plain'):'';
if(_moveTabOrderPanel(sourcePanel,targetPanel)) _tabVisibilityDragSuppressUntil=Date.now()+250;
}
function _toggleTabVisibilityChip(panel){
if(_ALWAYS_VISIBLE_TABS.has(panel))return;
var hidden=_getHiddenTabs();
@@ -5926,6 +6033,7 @@ function _appearancePayloadFromUi(){
session_endless_scroll: !!($('settingsSessionEndlessScroll')||{}).checked,
activity_feed_expanded_default: !!($('settingsActivityFeedExpandedDefault')||{}).checked,
hidden_tabs: _getHiddenTabs(),
tab_order: _getTabOrder(),
};
}
@@ -6214,7 +6322,7 @@ async function loadSettingsPanel(){
_scheduleAppearanceAutosave();
};
}
// Tab visibility chips (dynamically populated from DOM)
// Tab visibility/order chips (dynamically populated from DOM)
var hiddenTabs=[];
if(Array.isArray(settings.hidden_tabs)){
// Server value takes priority — even an empty array means "no tabs hidden"
@@ -6223,6 +6331,14 @@ async function loadSettingsPanel(){
// Server has no hidden_tabs key — fall back to localStorage
hiddenTabs=_getHiddenTabs();
}
var tabOrder=[];
if(Array.isArray(settings.tab_order)){
tabOrder=settings.tab_order.filter(function(s){return typeof s==='string'&&s.trim();});
}else{
tabOrder=_getTabOrder();
}
_setTabOrder(tabOrder);
_applyTabOrder(tabOrder);
_setHiddenTabs(hiddenTabs);
_applyTabVisibility(hiddenTabs);
_renderTabVisibilityChips();
+4 -1
View File
@@ -3374,7 +3374,10 @@ main.main > #mainPlugin{display:none;}
/* Tab visibility chips in Settings > Appearance */
.tab-visibility-chips{display:flex;flex-wrap:wrap;gap:6px;margin-top:4px;}
.tab-visibility-chip{font-size:12px;line-height:1;padding:5px 10px;border-radius:var(--radius-pill,999px);border:1px solid var(--accent,var(--link));background:var(--accent,var(--link));color:#1a1a1a;font-weight:600;cursor:pointer;transition:background .15s,border-color .15s,color .15s,opacity .15s;white-space:nowrap;user-select:none;}
.tab-visibility-chip{font-size:12px;line-height:1;padding:5px 10px;border-radius:var(--radius-pill,999px);border:1px solid var(--accent,var(--link));background:var(--accent,var(--link));color:#1a1a1a;font-weight:600;cursor:pointer;transition:background .15s,border-color .15s,color .15s,opacity .15s,transform .15s;white-space:nowrap;user-select:none;}
.tab-visibility-chip[draggable="true"]{cursor:grab;}
.tab-visibility-chip.dragging{opacity:.55;cursor:grabbing;transform:scale(.96);}
.tab-visibility-chip.drag-over{outline:2px solid var(--link,var(--accent));outline-offset:2px;}
.tab-visibility-chip:hover{filter:brightness(0.92);}
.tab-visibility-chip:focus-visible{outline:2px solid var(--link,var(--accent));outline-offset:2px;}
.tab-visibility-chip.chip-off{background:transparent;color:var(--muted);border-color:var(--border);font-weight:400;}
+93
View File
@@ -0,0 +1,93 @@
"""Regression tests for configurable sidebar tab ordering."""
from pathlib import Path
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")
INDEX_HTML = (ROOT / "static" / "index.html").read_text(encoding="utf-8")
STYLE_CSS = (ROOT / "static" / "style.css").read_text(encoding="utf-8")
def _function_body(source: str, name: str, limit: int = 5000) -> str:
start = source.find(f"function {name}(")
assert start >= 0, f"{name} not found"
end = source.find("\nfunction ", start + 1)
if end < 0:
end = start + limit
return source[start:end]
def test_backend_round_trip_and_validation_for_tab_order(monkeypatch, tmp_path):
"""tab_order is persisted as a sanitized list and never includes fixed tabs."""
import api.config as config
settings_path = tmp_path / "settings.json"
monkeypatch.setattr(config, "SETTINGS_FILE", settings_path)
loaded = config.load_settings()
assert loaded["tab_order"] == [], "default tab_order must be empty list"
saved = config.save_settings({"tab_order": ["logs", "tasks", "kanban"]})
assert saved["tab_order"] == ["logs", "tasks", "kanban"]
assert config.load_settings()["tab_order"] == ["logs", "tasks", "kanban"]
bad = config.save_settings({"tab_order": "logs,tasks"})
assert bad["tab_order"] == ["logs", "tasks", "kanban"], "non-list payload is ignored"
saved = config.save_settings({"tab_order": ["chat", "logs", "", "logs", "settings", " ", "tasks"]})
assert saved["tab_order"] == ["logs", "tasks"], \
"tab_order must strip fixed tabs, blanks, and duplicates while preserving order"
assert "tab_order" in config._SETTINGS_ALLOWED_KEYS
assert "tab_order" not in config._SETTINGS_BOOL_KEYS
def test_frontend_static_contracts_for_tab_order():
"""Frontend must expose order helpers, drag/drop chips, and apply order to both navs."""
assert "_TAB_ORDER_LS_KEY" in PANELS_JS
assert "hermes-webui-tab-order" in PANELS_JS
for fn in (
"_getTabOrder",
"_setTabOrder",
"_orderedSidebarPanels",
"_applyTabOrder",
"_moveTabOrderPanel",
"_handleTabVisibilityChipDrop",
):
assert f"function {fn}(" in PANELS_JS, f"panels.js must define {fn}()"
apply_body = _function_body(PANELS_JS, "_applyTabOrder")
assert ".rail" in apply_body and ".sidebar-nav" in apply_body, \
"tab order must be applied to both rail and mobile/sidebar nav"
assert "insertBefore" in apply_body, "applying tab order should reorder existing DOM nodes"
assert "settings" in apply_body and "rail-spacer" in apply_body, "chat/settings must remain fixed"
render_body = _function_body(PANELS_JS, "_renderTabVisibilityChips")
assert "draggable" in render_body, "chips must be draggable with the mouse"
assert "_wireTabChipDrag" in render_body, "chips need drag/drop wiring"
drag_body = _function_body(PANELS_JS, "_wireTabChipDrag")
assert "dragstart" in drag_body and "drop" in drag_body, \
"chips need dragstart/drop wiring"
assert "_tabVisibilityDragSuppressUntil" in PANELS_JS and "Date.now()+250" in PANELS_JS, \
"drag/drop click suppression must be short-lived, not a sticky boolean"
assert "_orderedSidebarPanels" in render_body, "chip order should follow persisted tab_order"
payload_body = _function_body(PANELS_JS, "_appearancePayloadFromUi")
assert "tab_order" in payload_body and "_getTabOrder" in payload_body, \
"Appearance autosave payload must include tab_order"
assert ".tab-visibility-chip.dragging" in STYLE_CSS
assert ".tab-visibility-chip.drag-over" in STYLE_CSS
def test_inline_boot_script_applies_hidden_tabs_and_order_without_head_flash():
"""The synchronous body script should apply both visibility and order before panels paint."""
head_end = INDEX_HTML.find("</head>")
assert "hermes-webui-tab-order" not in INDEX_HTML[:head_end]
body_script_start = INDEX_HTML.find("Flash-prevention")
assert body_script_start >= 0
body_script = INDEX_HTML[body_script_start: INDEX_HTML.find("</script>", body_script_start)]
assert "hermes-webui-hidden-tabs" in body_script
assert "hermes-webui-tab-order" in body_script
assert "insertBefore" in body_script
+50 -5
View File
@@ -23,11 +23,19 @@ def test_backend_round_trip_and_validation(monkeypatch, tmp_path):
loaded = config.load_settings()
assert loaded["hidden_tabs"] == [], "default must be empty list"
assert loaded["tab_order"] == [], "tab order default must be empty list"
saved = config.save_settings({"hidden_tabs": ["kanban", "insights"]})
assert saved["hidden_tabs"] == ["kanban", "insights"]
assert config.load_settings()["hidden_tabs"] == ["kanban", "insights"]
saved = config.save_settings({"tab_order": ["logs", "tasks", "kanban"]})
assert saved["tab_order"] == ["logs", "tasks", "kanban"]
assert config.load_settings()["tab_order"] == ["logs", "tasks", "kanban"]
bad_order = config.save_settings({"tab_order": "logs,tasks"})
assert bad_order["tab_order"] == ["logs", "tasks", "kanban"]
# Non-list is rejected, default preserved
bad = config.save_settings({"hidden_tabs": "not-a-list"})
assert bad["hidden_tabs"] == ["kanban", "insights"]
@@ -61,8 +69,11 @@ def test_frontend_static_contracts():
assert "'settings'" in PANELS_JS.split("_ALWAYS_VISIBLE_TABS")[1][:80]
assert "_HIDDEN_TABS_LS_KEY" in PANELS_JS
assert "hermes-webui-hidden-tabs" in PANELS_JS
for fn in ("_getHiddenTabs", "_setHiddenTabs", "_applyTabVisibility",
"_renderTabVisibilityChips", "_toggleTabVisibilityChip"):
assert "_TAB_ORDER_LS_KEY" in PANELS_JS
assert "hermes-webui-tab-order" in PANELS_JS
for fn in ("_getHiddenTabs", "_setHiddenTabs", "_getTabOrder", "_setTabOrder",
"_applyTabOrder", "_applyTabVisibility", "_renderTabVisibilityChips",
"_toggleTabVisibilityChip", "_moveTabOrderPanel", "_wireTabChipDrag"):
assert f"function {fn}(" in PANELS_JS, f"panels.js must define {fn}()"
# Toggle must autosave and respect always-visible tabs
@@ -71,11 +82,13 @@ def test_frontend_static_contracts():
assert "_scheduleAppearanceAutosave" in toggle_body
assert "_ALWAYS_VISIBLE_TABS" in toggle_body
# Appearance payload must include hidden_tabs
# Appearance payload must include hidden_tabs and tab_order
payload_block = PANELS_JS[PANELS_JS.find("function _appearancePayloadFromUi"):]
payload_body = payload_block[:payload_block.find("\nfunction ", 1) or 2000]
assert "hidden_tabs" in payload_body
assert "_getHiddenTabs" in payload_body
assert "tab_order" in payload_body
assert "_getTabOrder" in payload_body
# CSS: hidden class and chip styles
assert ".nav-tab-hidden" in STYLE_CSS
@@ -158,6 +171,38 @@ def test_chip_a11y_uses_switch_role_with_aria_checked():
assert 'role="group"' in INDEX_HTML, "chip container needs role=group"
assert 'aria-labelledby="tabVisibilityLabel"' in INDEX_HTML, \
"chip container needs aria-labelledby pointing at the label"
# Focus-visible style exists
# Focus-visible and drag styles exist
assert ".tab-visibility-chip:focus-visible" in STYLE_CSS, \
"chip needs a :focus-visible style for keyboard nav"
"chip needs a :focus-visible style for keyboard nav"
assert ".tab-visibility-chip.dragging" in STYLE_CSS, \
"chip needs dragging style for mouse reorder feedback"
assert ".tab-visibility-chip.drag-over" in STYLE_CSS, \
"chip needs drag-over style for mouse reorder target feedback"
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."""
import api.config as config
settings_path = tmp_path / "settings.json"
monkeypatch.setattr(config, "SETTINGS_FILE", settings_path)
saved = config.save_settings({"tab_order": ["chat", "logs", "settings", "tasks", "logs"]})
assert saved["tab_order"] == ["logs", "tasks"], \
"chat/settings must be stripped and duplicate panel ids collapsed server-side"
def test_profile_switch_reconciles_tab_order():
"""Profile switching must also restore per-profile custom tab ordering."""
bg_start = PANELS_JS.find("function _refreshProfileSwitchBackground")
assert bg_start >= 0, "_refreshProfileSwitchBackground not found"
bg_end = PANELS_JS.find("\nfunction ", bg_start + 1)
if bg_end < 0:
bg_end = bg_start + 4000
bg_body = PANELS_JS[bg_start:bg_end]
assert "tab_order" in bg_body, \
"profile-switch background refresh must read tab_order from server response"
assert "_setTabOrder" in bg_body, \
"profile-switch background refresh must store tab_order for the new profile"
assert "_applyTabOrder" in bg_body, \
"profile-switch background refresh must apply tab ordering"