mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-09-10 18:22:20 +02:00
fix(sidebar): keep minimized icon rail in sync with per-tab visibility (#5987)
* fix(sidebar): keep minimized icon rail in sync with per-tab visibility Per-tab visibility (Customize UI / Appearance checkboxes, stored in localStorage under `odysseus-ui-visibility`) was only applied to the full sidebar elements — `UI_VIS_MAP` never targeted the collapsed `#icon-rail` launchers. So a user who turned a tab off (e.g. Email) in the full view saw every tab reappear when minimizing the sidebar to the icon rail. Pair each tool/section selector with its `#rail-*` counterpart (mapping mirrors `_railToolMap`), so `applyUIVis()` hides the rail launcher too. Admin feature-flag handling is unaffected: the features-fetch reconcile at app.js already re-applies `applyUIVis()`, so rail launchers now track admin disables exactly like their sidebar buttons. Adds a static regression test asserting every customizable tab pairs its rail button. Co-Authored-By: Claude <noreply@anthropic.com> * refactor(sidebar): extract UI visibility into testable module Move UI_VIS_MAP, UI_VIS_DEFAULT_OFF, and a pure resolveVisibility() into static/js/ui_visibility.js so the icon-rail visibility rules are unit testable without a DOM. app.js applies resolveVisibility() to the document, replacing the ad-hoc tools-section override with an inline parent rule (tools-section off hides every tool rail launcher). Add edge-case tests covering per-tool off, the tools-section parent rule, parent+child combos, email-section, and the tool-library <-> #rail-archive mapping. Refs #5985 Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
This commit is contained in:
co-authored by
Claude
Alexandre Teixeira
parent
5a016e492c
commit
bea48c749c
+6
-45
@@ -28,6 +28,7 @@ import memoryModule from './js/memory.js?v=20260722memoryloading1';
|
||||
import voiceRecorderModule from './js/voiceRecorder.js';
|
||||
import censorModule from './js/censor.js';
|
||||
import galleryModule from './js/gallery.js';
|
||||
import { UI_VIS_DEFAULT_OFF, resolveVisibility } from './js/ui_visibility.js';
|
||||
import tasksModule from './js/tasks.js?v=20260723tasksbulkfeedback1';
|
||||
import calendarModule from './js/calendar.js';
|
||||
import notesModule from './js/notes.js';
|
||||
@@ -2725,46 +2726,6 @@ function initializeEventListeners() {
|
||||
// ── UI Visibility (Customize UI modal) ──
|
||||
const UI_VIS_KEY = 'odysseus-ui-visibility';
|
||||
|
||||
// Selector map: key → CSS selector(s) for targets
|
||||
const UI_VIS_MAP = {
|
||||
'sidebar-brand': '.sidebar-brand-title',
|
||||
'sidebar-new-chat': '#sidebar-new-chat-btn',
|
||||
'sidebar-search': '#sidebar-search-btn',
|
||||
'sessions-section': '#sessions-section',
|
||||
'email-section': '#email-section',
|
||||
'tools-section': '#tools-section',
|
||||
// Per-tool visibility — fine-grained control over which entries show
|
||||
// inside the Tools section in the sidebar.
|
||||
'tool-calendar': '#tool-calendar-btn',
|
||||
'tool-compare': '#tool-compare-btn',
|
||||
'tool-cookbook': '#tool-cookbook-btn',
|
||||
'tool-research': '#tool-research-btn',
|
||||
'tool-gallery': '#tool-gallery-btn',
|
||||
'tool-library': '#tool-library-btn',
|
||||
'tool-memory': '#tool-memory-btn',
|
||||
'tool-notes': '#tool-notes-btn',
|
||||
'tool-tasks': '#tool-tasks-btn',
|
||||
'tool-theme': '#tool-theme-btn',
|
||||
'user-bar': '#user-bar-profile',
|
||||
'sidebar-settings-btn':'#user-bar-settings',
|
||||
'chat-meta': '.chat-meta-overlay',
|
||||
'welcome-text': '.welcome-name, .welcome-sub, #welcome-tip',
|
||||
'incognito-btn': '.incognito-btn',
|
||||
'web-toggle-btn': '#web-toggle-btn',
|
||||
'doc-toggle-btn': '#overflow-doc-btn',
|
||||
'rag-toggle-btn': '#overflow-rag-btn',
|
||||
'bash-toggle-btn': '#bash-toggle-btn',
|
||||
'overflow-plus-btn': '.overflow-wrapper',
|
||||
'mode-toggle': '.mode-toggle',
|
||||
'preset-mini-btn': '#overflow-preset-btn',
|
||||
'attach-btn': '#overflow-attach-btn',
|
||||
'research-btn': '#overflow-research-btn',
|
||||
'rail-new-chat': '#rail-new-session',
|
||||
};
|
||||
|
||||
// Keys hidden by default on first run (no localStorage yet)
|
||||
const UI_VIS_DEFAULT_OFF = new Set(['rag-toggle-btn', 'text-emojis', 'chat-fullwidth']);
|
||||
|
||||
// Keys that need admin to toggle off (reserved for future use)
|
||||
const UI_VIS_ADMIN_ONLY = new Set([]);
|
||||
|
||||
@@ -2777,14 +2738,14 @@ function initializeEventListeners() {
|
||||
}
|
||||
|
||||
function applyUIVis(state) {
|
||||
Object.entries(UI_VIS_MAP).forEach(([key, selector]) => {
|
||||
// section-drag-reorder uses a body class instead of inline styles
|
||||
if (key === 'section-drag-reorder') return;
|
||||
const visible = key in state ? state[key] !== false : !UI_VIS_DEFAULT_OFF.has(key);
|
||||
// resolveVisibility computes selector→visible (pure; ui_visibility.js),
|
||||
// including the tools-section parent rule that hides every tool rail
|
||||
// launcher when Tools is off. Apply the result to the DOM here.
|
||||
for (const [selector, visible] of Object.entries(resolveVisibility(state))) {
|
||||
document.querySelectorAll(selector).forEach(el => {
|
||||
el.style.display = visible ? '' : 'none';
|
||||
});
|
||||
});
|
||||
}
|
||||
// Drag reorder: use body class so dynamically created handles are covered
|
||||
const dragEnabled = state['section-drag-reorder'] === true;
|
||||
document.body.classList.toggle('rearrange-mode', dragEnabled);
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// static/js/ui_visibility.js
|
||||
//
|
||||
// Per-item visibility for the sidebar and collapsed icon rail. Drives the
|
||||
// Settings → Appearance ("Customize UI") checkboxes, persisted in localStorage
|
||||
// under `odysseus-ui-visibility` (loaded/saved by app.js).
|
||||
//
|
||||
// Each key maps to the CSS selector(s) it controls. Tool/section selectors pair
|
||||
// the full-sidebar element with its #rail-* launcher so a tab hidden in the
|
||||
// full view also hides when the sidebar is minimized to the icon rail (id
|
||||
// mapping mirrors _railToolMap in app.js; #tool-library-btn ↔ #rail-archive).
|
||||
|
||||
// Selector map: UI customization key → CSS selector(s) for its target(s).
|
||||
export const UI_VIS_MAP = {
|
||||
'sidebar-brand': '.sidebar-brand-title',
|
||||
'sidebar-new-chat': '#sidebar-new-chat-btn',
|
||||
'sidebar-search': '#sidebar-search-btn',
|
||||
'sessions-section': '#sessions-section',
|
||||
'email-section': '#email-section, #rail-email',
|
||||
'tools-section': '#tools-section',
|
||||
// Per-tool entries pair the sidebar button with its rail launcher.
|
||||
'tool-calendar': '#tool-calendar-btn, #rail-calendar',
|
||||
'tool-compare': '#tool-compare-btn, #rail-compare',
|
||||
'tool-cookbook': '#tool-cookbook-btn, #rail-cookbook',
|
||||
'tool-research': '#tool-research-btn, #rail-research',
|
||||
'tool-gallery': '#tool-gallery-btn, #rail-gallery',
|
||||
'tool-library': '#tool-library-btn, #rail-archive',
|
||||
'tool-memory': '#tool-memory-btn, #rail-memory',
|
||||
'tool-notes': '#tool-notes-btn, #rail-notes',
|
||||
'tool-tasks': '#tool-tasks-btn, #rail-tasks',
|
||||
'tool-theme': '#tool-theme-btn, #rail-theme',
|
||||
'user-bar': '#user-bar-profile',
|
||||
'sidebar-settings-btn':'#user-bar-settings',
|
||||
'chat-meta': '.chat-meta-overlay',
|
||||
'welcome-text': '.welcome-name, .welcome-sub, #welcome-tip',
|
||||
'incognito-btn': '.incognito-btn',
|
||||
'web-toggle-btn': '#web-toggle-btn',
|
||||
'doc-toggle-btn': '#overflow-doc-btn',
|
||||
'rag-toggle-btn': '#overflow-rag-btn',
|
||||
'bash-toggle-btn': '#bash-toggle-btn',
|
||||
'overflow-plus-btn': '.overflow-wrapper',
|
||||
'mode-toggle': '.mode-toggle',
|
||||
'preset-mini-btn': '#overflow-preset-btn',
|
||||
'attach-btn': '#overflow-attach-btn',
|
||||
'research-btn': '#overflow-research-btn',
|
||||
'rail-new-chat': '#rail-new-session',
|
||||
};
|
||||
|
||||
// Keys hidden by default on first run (no localStorage yet).
|
||||
export const UI_VIS_DEFAULT_OFF = new Set(['rag-toggle-btn', 'text-emojis', 'chat-fullwidth']);
|
||||
|
||||
/**
|
||||
* Resolve every UI_VIS_MAP selector to visible (true) or hidden (false) for the
|
||||
* given saved state. Pure: no DOM, no localStorage — app.js applies the result.
|
||||
*
|
||||
* A key is visible when its stored value is not `false`, defaulting to on
|
||||
* unless it is in UI_VIS_DEFAULT_OFF. Per-tool entries also require the Tools
|
||||
* section to be on: hiding Tools hides every tool, mirroring the full sidebar
|
||||
* where the #tools-section container already hides them (the rail has no
|
||||
* container, so this rule keeps it in sync).
|
||||
*
|
||||
* @param {Record<string, boolean>} state
|
||||
* @returns {Record<string, boolean>} selector → visible
|
||||
*/
|
||||
export const resolveVisibility = (state = {}) => {
|
||||
const toolsOn = state['tools-section'] !== false;
|
||||
const out = {};
|
||||
for (const [key, selector] of Object.entries(UI_VIS_MAP)) {
|
||||
let visible = key in state ? state[key] !== false : !UI_VIS_DEFAULT_OFF.has(key);
|
||||
if (!toolsOn && key.startsWith('tool-')) visible = false;
|
||||
out[selector] = visible;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
@@ -0,0 +1,139 @@
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
pytestmark = pytest.mark.skipif(not shutil.which("node"), reason="node binary not on PATH")
|
||||
|
||||
|
||||
def _node_eval(source):
|
||||
result = subprocess.run(
|
||||
["node", "--input-type=module", "-e", source],
|
||||
cwd=ROOT,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def _resolve(state):
|
||||
"""Run resolveVisibility(state) in node; return {selector: visible}."""
|
||||
return _node_eval(
|
||||
f"""
|
||||
const {{ resolveVisibility }} = await import('./static/js/ui_visibility.js');
|
||||
console.log(JSON.stringify(resolveVisibility({json.dumps(state)})));
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _map():
|
||||
return _node_eval(
|
||||
"""
|
||||
const { UI_VIS_MAP } = await import('./static/js/ui_visibility.js');
|
||||
console.log(JSON.stringify(UI_VIS_MAP));
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
# Selectors (kept in one place so the tests read as plain assertions).
|
||||
EMAIL = "#email-section, #rail-email"
|
||||
TOOLS = "#tools-section"
|
||||
CAL = "#tool-calendar-btn, #rail-calendar"
|
||||
COMPARE = "#tool-compare-btn, #rail-compare"
|
||||
LIB = "#tool-library-btn, #rail-archive"
|
||||
RESEARCH = "#tool-research-btn, #rail-research"
|
||||
NEWCHAT = "#rail-new-session"
|
||||
RAG = "#overflow-rag-btn"
|
||||
|
||||
# Full-sidebar tabs that have an icon-rail counterpart must pair it into their
|
||||
# UI_VIS_MAP selector; otherwise minimizing the sidebar re-shows a tab the user
|
||||
# turned off in the full view (#tool-library-btn's rail counterpart is #rail-archive).
|
||||
EXPECTED_RAIL_PAIRS = {
|
||||
"email-section": "#rail-email",
|
||||
"tool-calendar": "#rail-calendar",
|
||||
"tool-compare": "#rail-compare",
|
||||
"tool-cookbook": "#rail-cookbook",
|
||||
"tool-research": "#rail-research",
|
||||
"tool-gallery": "#rail-gallery",
|
||||
"tool-library": "#rail-archive",
|
||||
"tool-memory": "#rail-memory",
|
||||
"tool-notes": "#rail-notes",
|
||||
"tool-tasks": "#rail-tasks",
|
||||
"tool-theme": "#rail-theme",
|
||||
}
|
||||
|
||||
|
||||
def test_every_customizable_tab_pairs_its_rail_button():
|
||||
ui_vis_map = _map()
|
||||
missing = {
|
||||
key: rail
|
||||
for key, rail in EXPECTED_RAIL_PAIRS.items()
|
||||
if rail not in ui_vis_map.get(key, "")
|
||||
}
|
||||
assert not missing, (
|
||||
"these tabs are missing their icon-rail counterpart in UI_VIS_MAP "
|
||||
f"(minimizing the sidebar would re-show them): {missing}"
|
||||
)
|
||||
|
||||
|
||||
def test_defaults_everything_visible_except_default_off():
|
||||
m = _resolve({})
|
||||
assert m[EMAIL] is True
|
||||
assert m[TOOLS] is True
|
||||
assert m[CAL] is True
|
||||
assert m[NEWCHAT] is True
|
||||
assert m[RAG] is False # rag-toggle-btn is default-off
|
||||
|
||||
|
||||
def test_email_off_hides_email_and_its_rail_only():
|
||||
m = _resolve({"email-section": False})
|
||||
assert m[EMAIL] is False
|
||||
assert m[CAL] is True
|
||||
assert m[TOOLS] is True
|
||||
|
||||
|
||||
def test_tool_off_hides_its_rail_launcher():
|
||||
m = _resolve({"tool-calendar": False})
|
||||
assert m[CAL] is False
|
||||
assert m[COMPARE] is True
|
||||
|
||||
|
||||
def test_library_off_hides_archive_rail():
|
||||
# tool-library's rail counterpart is #rail-archive (mirrors _railToolMap).
|
||||
m = _resolve({"tool-library": False})
|
||||
assert m[LIB] is False
|
||||
|
||||
|
||||
def test_tools_off_hides_every_tool_rail_but_not_email():
|
||||
m = _resolve({"tools-section": False})
|
||||
assert m[TOOLS] is False
|
||||
for sel in (CAL, COMPARE, LIB, RESEARCH):
|
||||
assert m[sel] is False, sel
|
||||
assert m[EMAIL] is True # email is independent of the Tools section
|
||||
|
||||
|
||||
def test_tools_off_overrides_per_tool_on():
|
||||
# A tool individually "on" must still hide when its parent Tools is off.
|
||||
m = _resolve({"tools-section": False, "tool-calendar": True})
|
||||
assert m[CAL] is False
|
||||
|
||||
|
||||
def test_tools_on_with_tool_off_hides_only_that_tool():
|
||||
m = _resolve({"tools-section": True, "tool-research": False})
|
||||
assert m[RESEARCH] is False
|
||||
assert m[CAL] is True
|
||||
|
||||
|
||||
def test_rail_new_chat_off_hides_new_session():
|
||||
m = _resolve({"rail-new-chat": False})
|
||||
assert m[NEWCHAT] is False
|
||||
|
||||
|
||||
def test_explicit_false_takes_precedence_over_default_on():
|
||||
m = _resolve({"rag-toggle-btn": True})
|
||||
assert m[RAG] is True
|
||||
Reference in New Issue
Block a user