diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7ff4f966..5ceacdfc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/api/config.py b/api/config.py
index ae41d29b..b1acc0c8 100644
--- a/api/config.py
+++ b/api/config.py
@@ -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)
diff --git a/static/boot.js b/static/boot.js
index 9faba114..01ec91d4 100644
--- a/static/boot.js
+++ b/static/boot.js
@@ -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());
}
diff --git a/static/i18n.js b/static/i18n.js
index ef21ed78..9834848e 100644
--- a/static/i18n.js
+++ b/static/i18n.js
@@ -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',
diff --git a/static/index.html b/static/index.html
index 91abde0c..1a44b24d 100644
--- a/static/index.html
+++ b/static/index.html
@@ -184,7 +184,7 @@
before any paint. Mirrors the theme/skin/font-size inline scripts in
.
The boot.js _restoreTabVisibility IIFE is a secondary fallback that also
handles the active-tab switch. -->
-
+
@@ -998,7 +998,7 @@
-
Choose which tabs appear in the sidebar and rail. Chat and Settings are always visible.
+
Choose which tabs appear in the sidebar and rail. Drag chips to reorder them. Chat and Settings are always visible.
diff --git a/static/panels.js b/static/panels.js
index d0ae30b8..d5422e26 100644
--- a/static/panels.js
+++ b/static/panels.js
@@ -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();
diff --git a/static/style.css b/static/style.css
index ef10ee00..5d9ad0ac 100644
--- a/static/style.css
+++ b/static/style.css
@@ -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;}
diff --git a/tests/test_sidebar_tab_order.py b/tests/test_sidebar_tab_order.py
new file mode 100644
index 00000000..42355045
--- /dev/null
+++ b/tests/test_sidebar_tab_order.py
@@ -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("")
+ 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("", body_script_start)]
+ assert "hermes-webui-hidden-tabs" in body_script
+ assert "hermes-webui-tab-order" in body_script
+ assert "insertBefore" in body_script
diff --git a/tests/test_sidebar_tab_visibility.py b/tests/test_sidebar_tab_visibility.py
index 2d80187b..d334d199 100644
--- a/tests/test_sidebar_tab_visibility.py
+++ b/tests/test_sidebar_tab_visibility.py
@@ -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"
\ No newline at end of file
+ "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"