diff --git a/static/app.js b/static/app.js index 514d59d8b..dae6b31a6 100644 --- a/static/app.js +++ b/static/app.js @@ -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); diff --git a/static/js/ui_visibility.js b/static/js/ui_visibility.js new file mode 100644 index 000000000..f64c23fa9 --- /dev/null +++ b/static/js/ui_visibility.js @@ -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} state + * @returns {Record} 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; +}; \ No newline at end of file diff --git a/tests/test_ui_visibility_js.py b/tests/test_ui_visibility_js.py new file mode 100644 index 000000000..55e678a86 --- /dev/null +++ b/tests/test_ui_visibility_js.py @@ -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 \ No newline at end of file