* 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:
9 files changed
+304
-33
No files matched your search
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;}
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user