From 2736f443b19f28855f816e321ebd0996cd291849 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Fri, 5 Jun 2026 18:39:42 -0700 Subject: [PATCH] =?UTF-8?q?Release=20v0.51.286=20=E2=80=94=20Release=20JB?= =?UTF-8?q?=20(stage-r21=20=E2=80=94=20sidebar=20tab=20reordering=20#3067)?= =?UTF-8?q?=20(#3694)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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]> --- CHANGELOG.md | 5 + api/config.py | 26 +++-- static/boot.js | 3 + static/i18n.js | 4 +- static/index.html | 4 +- static/panels.js | 142 ++++++++++++++++++++++++--- static/style.css | 5 +- tests/test_sidebar_tab_order.py | 93 ++++++++++++++++++ tests/test_sidebar_tab_visibility.py | 55 ++++++++++- 9 files changed, 304 insertions(+), 33 deletions(-) create mode 100644 tests/test_sidebar_tab_order.py 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"