refactor(settings): add registry-backed navigation and finder (#6040)

* refactor(settings): add modular shell primitives

* refactor(settings): wire modular shell

* test(settings): exercise real coordinator ESM boundary

* refactor(settings): add registry-backed settings finder

* fix(settings): harden registry navigation behavior
This commit is contained in:
Alexandre Teixeira
2026-08-16 02:48:19 +01:00
committed by GitHub
parent 0dd70a7556
commit cee319050c
12 changed files with 3267 additions and 154 deletions
+51 -1
View File
@@ -1421,6 +1421,55 @@
</div>
<div class="settings-layout">
<div class="settings-sidebar">
<button
type="button"
class="settings-sidebar-toggle"
id="settings-sidebar-toggle"
aria-label="Collapse settings navigation"
title="Collapse settings navigation"
>
<svg class="settings-sidebar-toggle-collapse" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
<svg class="settings-sidebar-toggle-expand" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</button>
<div
class="settings-sidebar-resize-handle"
id="settings-sidebar-resize-handle"
role="separator"
aria-orientation="vertical"
aria-label="Resize settings navigation"
aria-valuemin="150"
aria-valuemax="340"
aria-valuenow="220"
tabindex="0"
></div>
<div class="settings-sidebar-content">
<div class="settings-nav-search-wrap">
<svg class="settings-nav-search-icon" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
<circle cx="11" cy="11" r="7"></circle>
<path d="M20 20l-4-4"></path>
</svg>
<input
type="search"
id="settings-nav-search"
class="settings-nav-search"
placeholder="Find settings…"
autocomplete="off"
aria-label="Find settings"
aria-controls="settings-nav-search-results"
/>
<div
id="settings-nav-search-results"
class="settings-nav-search-results hidden"
role="listbox"
aria-label="Settings search results"
></div>
</div>
<!-- Section 1: AI plumbing (Add Models → AI Defaults → Search) -->
<button class="settings-nav-item active" data-settings-tab="services">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14"/><path d="M5 12h14"/></svg>
@@ -1481,9 +1530,10 @@
<span>Users</span>
</button>
<button class="settings-nav-item admin-only" data-settings-tab="system">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v-.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09A1.65 1.65 0 0 0 19.4 15z"/></svg>
<span>System</span>
</button>
</div>
</div>
<div class="settings-panels">
+83 -150
View File
@@ -3,8 +3,25 @@
import uiModule from './ui.js';
import searchModule from './search.js';
import { makeWindowDraggable } from './windowDrag.js';
import { clearDockSide } from './modalSnap.js';
import { byId } from './settings/dom.js';
import {
getSettingsRegistryIssues,
isAdminManagedSettingsTab,
} from './settings/registry.js';
import { bindSettingsSearch } from './settings/search.js';
import { bindSettingsSidebar } from './settings/sidebar.js';
import {
activateSettingsPanel,
getActiveSettingsTab,
bindSettingsNavigation,
} from './settings/navigation.js';
import {
bindSettingsDrag,
bindSettingsClose,
bindOpenPromptModalLink,
showSettingsModal,
hideSettingsModal,
} from './settings/lifecycle.js';
import { sortModelIds } from './modelSort.js';
import { providerLogo } from './providers.js';
import { isAltGrEvent } from './platform.js';
@@ -14,133 +31,29 @@ let initialized = false;
let modalEl = null;
let _authPolicy = { password_min_length: 8 };
function el(id) { return document.getElementById(id); }
const el = byId;
function esc(s) { return uiModule.esc(s); }
function safeRasterDataUrl(raw) {
const value = String(raw || '').trim();
return /^data:image\/(?:png|jpe?g|gif|webp);base64,[a-z0-9+/=\s]+$/i.test(value) ? value : '';
}
/* ── Tab switching ── */
const ADMIN_TABS = new Set(['services', 'added-models', 'integrations', 'tools', 'users', 'system']);
/* ── Settings shell coordination ── */
function onSettingsPanelActivated(tab) {
// Appearance keeps its existing transparent preview behavior.
document.body.classList.toggle('settings-appearance-open', tab === 'appearance');
syncAppearanceOpacity(tab === 'appearance');
function initTabs() {
modalEl.querySelectorAll('[data-settings-tab]').forEach(btn => {
btn.addEventListener('click', () => {
const tab = btn.dataset.settingsTab;
// Lazy-init admin when first clicking an admin tab
if (ADMIN_TABS.has(tab) && window.adminModule && typeof window.adminModule.open === 'function') {
window.adminModule.open(tab);
return;
}
modalEl.querySelectorAll('[data-settings-tab]').forEach(b => b.classList.toggle('active', b.dataset.settingsTab === tab));
modalEl.querySelectorAll('[data-settings-panel]').forEach(p => p.classList.toggle('hidden', p.dataset.settingsPanel !== tab));
// Mark when the Appearance tab is open so the modal can go
// semi-transparent — lets the user see the rest of the UI react as
// they flip toggles instead of having to close + reopen the modal.
document.body.classList.toggle('settings-appearance-open', tab === 'appearance');
syncAppearanceOpacity(tab === 'appearance');
if (tab === 'ai') refreshAiModelEndpoints();
});
});
// AI endpoints are intentionally refreshed only when entering the AI panel.
if (tab === 'ai') refreshAiModelEndpoints();
}
/* ── Dragging ── */
function initDrag() {
const header = modalEl.querySelector('.modal-header');
const content = modalEl.querySelector('.settings-modal-content');
if (!header || !content) return;
// Skip interactive controls in the header (e.g. the opacity slider) so
// grabbing them doesn't start a window-drag.
makeWindowDraggable(modalEl, {
content,
header,
skipSelector: 'button, input, select, .theme-opacity-wrap',
enableDock: true,
});
}
function resetWindowPlacement() {
const content = modalEl && modalEl.querySelector('.settings-modal-content');
if (!content) return;
const hadLeft = modalEl.classList.contains('modal-left-docked');
const hadRight = modalEl.classList.contains('modal-right-docked');
modalEl.classList.remove('modal-left-docked', 'modal-right-docked');
if (hadLeft) clearDockSide('left', modalEl);
if (hadRight) clearDockSide('right', modalEl);
if (content._leftDockNavObs) {
try { content._leftDockNavObs.navObs && content._leftDockNavObs.navObs.disconnect(); } catch (_) {}
try { window.removeEventListener('resize', content._leftDockNavObs.reanchor); } catch (_) {}
delete content._leftDockNavObs;
function openAdminSettingsTab(tab) {
if (window.adminModule && typeof window.adminModule.open === 'function') {
window.adminModule.open(tab);
return true;
}
delete content._preDockSnapshot;
delete content._dockSide;
delete content._dockSuspended;
delete content.dataset._tilePreSnap;
delete content.dataset._tileZone;
[
'position', 'left', 'top', 'right', 'bottom', 'margin', 'transform',
'width', 'height', 'max-width', 'max-height', 'border-radius', 'transition',
].forEach(prop => content.style.removeProperty(prop));
}
/* ── Delegated link: close Settings + open the Prompt (characters) modal ── */
function initOpenPromptModalLink() {
document.addEventListener('click', async (e) => {
const link = e.target.closest('[data-open-prompt-modal]');
if (!link) return;
e.preventDefault();
// Close settings first so the prompt modal isn't stacked on top.
if (modalEl && !modalEl.classList.contains('hidden')) close();
try {
const m = await import('./presets.js');
const fn = m.openCustomPresetModal || (m.default && m.default.openCustomPresetModal);
if (typeof fn === 'function') fn();
} catch (_) {
const modal = document.getElementById('custom-preset-modal');
if (modal) modal.classList.remove('hidden');
}
// Force the Persona tab (data-chartab="character") since the link's
// whole purpose is editing personas — not landing on Inject by default.
const personaTab = document.querySelector('#custom-preset-modal .preset-tab[data-chartab="character"]');
if (personaTab) personaTab.click();
});
}
/* ── Close on backdrop / X ── */
function initClose() {
modalEl.querySelector('.close-btn').addEventListener('click', close);
modalEl.addEventListener('mousedown', e => {
if (uiModule.isTouchInsideModal()) return;
if (e.target === modalEl) close();
});
document.addEventListener('keydown', e => {
if (e.key !== 'Escape' || !modalEl || modalEl.classList.contains('hidden')) return;
// Bail when a transient popover inside the modal is open — Esc should
// dismiss just that, not the whole modal. Same-document listeners fire
// in registration order regardless of capture/bubble, so the popover's
// own handler can't pre-empt ours; we have to opt out here.
const popoverOpen = modalEl.querySelector(
'#adm-epLocalMoreMenu, #adm-epApiMoreMenu, #adm-provider-menu, #search-provider-menu, [data-popover-open="1"]'
);
if (popoverOpen && popoverOpen.style.display !== 'none' && !popoverOpen.classList.contains('hidden')) {
return;
}
// If an integration edit/add form is open inside the modal, close
// just that — don't dismiss the whole settings modal. (Pressing
// ESC mid-edit and losing the modal was a fast-typing footgun.)
const innerForm = modalEl.querySelector('#unified-intg-form, #set-email-accounts-form');
if (innerForm && innerForm.style.display !== 'none' && innerForm.children.length > 0) {
e.preventDefault();
e.stopPropagation();
innerForm.style.display = 'none';
innerForm.innerHTML = '';
return;
}
e.preventDefault();
e.stopPropagation();
close();
});
return false;
}
/* ── Appearance-tab opacity slider ──
@@ -2238,10 +2151,39 @@ function initAccount() {
function initAll() {
modalEl = el('settings-modal');
initTabs();
initDrag();
initClose();
initOpenPromptModalLink();
bindSettingsNavigation(modalEl, {
openAdminTab: openAdminSettingsTab,
onPanelActivated: onSettingsPanelActivated,
});
bindSettingsSearch(modalEl, {
isAdmin: () => !!window._isAdmin,
openPanel(tab) {
const button = modalEl.querySelector(`[data-settings-tab="${tab}"]`);
if (button) button.click();
},
});
bindSettingsSidebar(modalEl);
const registryIssues = getSettingsRegistryIssues(modalEl);
if (registryIssues.length) {
console.warn('Settings registry/DOM mismatch:', registryIssues);
}
bindSettingsDrag(modalEl);
bindSettingsClose(modalEl, {
closeSettings: close,
isTouchInsideModal: () => uiModule.isTouchInsideModal(),
});
bindOpenPromptModalLink({
getModal: () => modalEl,
closeSettings: close,
});
initOpacityToggle();
initialized = true;
initDefaultChat();
@@ -5661,44 +5603,35 @@ function syncAdminVisibility() {
═══════════════════════════════════════════ */
export function open(tab) {
if (!initialized) initAll();
syncAppearanceCheckboxes();
if (modalEl.classList.contains('hidden')) {
resetWindowPlacement();
}
modalEl.classList.remove('hidden');
showSettingsModal(modalEl);
syncAdminVisibility();
const content = modalEl.querySelector('.settings-modal-content');
if (tab) {
modalEl.querySelectorAll('[data-settings-tab]').forEach(b => b.classList.toggle('active', b.dataset.settingsTab === tab));
modalEl.querySelectorAll('[data-settings-panel]').forEach(p => p.classList.toggle('hidden', p.dataset.settingsPanel !== tab));
activateSettingsPanel(modalEl, tab);
}
// Auto-init admin data if showing an admin tab
const activeTab = tab || (modalEl.querySelector('[data-settings-tab].active') || {}).dataset?.settingsTab || 'services';
document.body.classList.toggle('settings-appearance-open', activeTab === 'appearance');
syncAppearanceOpacity(activeTab === 'appearance');
if (activeTab === 'ai') refreshAiModelEndpoints();
if (ADMIN_TABS.has(activeTab) && window.adminModule && !window.adminModule._initialized) {
// Preserve existing panel-specific side effects when Settings is opened
// directly to a tab as well as when the user navigates there.
const activeTab = tab || getActiveSettingsTab(modalEl);
onSettingsPanelActivated(activeTab);
// Auto-init admin data if showing an admin tab.
if (isAdminManagedSettingsTab(activeTab) && window.adminModule && !window.adminModule._initialized) {
window.adminModule._initData();
}
}
export function close() {
if (!modalEl) return;
// Always clear the appearance-tab body class so the rest of the app
// doesn't keep its dimmed state if the modal got closed mid-tab.
// Always clear the Appearance state so the rest of the app does not remain
// dimmed if Settings is closed while that panel is active.
document.body.classList.remove('settings-appearance-open');
syncAppearanceOpacity(false); // clear any opacity-slider fade
const content = modalEl.querySelector('.modal-content, .settings-modal-content');
if (content && !content.classList.contains('modal-closing')) {
content.classList.add('modal-closing');
content.addEventListener('animationend', () => {
modalEl.classList.add('hidden');
content.classList.remove('modal-closing');
}, { once: true });
setTimeout(() => { if (!modalEl.classList.contains('hidden')) { modalEl.classList.add('hidden'); content.classList.remove('modal-closing'); } }, 250);
} else {
modalEl.classList.add('hidden');
}
syncAppearanceOpacity(false);
hideSettingsModal(modalEl);
}
// Handle redirect back from Google OAuth2 — open settings to integrations and show status.
+7
View File
@@ -0,0 +1,7 @@
// Shared DOM helpers for the Settings modules.
// Keep this module intentionally small so panel modules do not grow their own
// element-lookup conventions as Settings is split out of settings.js.
export function byId(id) {
return document.getElementById(id);
}
+176
View File
@@ -0,0 +1,176 @@
// Settings modal lifecycle primitives.
//
// Panel-specific behavior belongs elsewhere. This module owns only the window
// shell: dragging/docking reset, close semantics, visibility animation, and the
// delegated link that leaves Settings for the Persona editor.
import { makeWindowDraggable } from '../windowDrag.js';
import { clearDockSide } from '../modalSnap.js';
const _dragBound = new WeakSet();
const _closeBound = new WeakSet();
let _promptLinkBound = false;
export function bindSettingsDrag(modalEl) {
if (!modalEl || _dragBound.has(modalEl)) return;
const header = modalEl.querySelector('.modal-header');
const content = modalEl.querySelector('.settings-modal-content');
if (!header || !content) return;
_dragBound.add(modalEl);
makeWindowDraggable(modalEl, {
content,
header,
skipSelector: 'button, input, select, .theme-opacity-wrap',
enableDock: true,
});
}
export function resetSettingsWindowPlacement(modalEl) {
const content = modalEl?.querySelector('.settings-modal-content');
if (!content) return;
const hadLeft = modalEl.classList.contains('modal-left-docked');
const hadRight = modalEl.classList.contains('modal-right-docked');
modalEl.classList.remove('modal-left-docked', 'modal-right-docked');
if (hadLeft) clearDockSide('left', modalEl);
if (hadRight) clearDockSide('right', modalEl);
if (content._leftDockNavObs) {
try { content._leftDockNavObs.navObs && content._leftDockNavObs.navObs.disconnect(); } catch (_) {}
try { window.removeEventListener('resize', content._leftDockNavObs.reanchor); } catch (_) {}
delete content._leftDockNavObs;
}
delete content._preDockSnapshot;
delete content._dockSide;
delete content._dockSuspended;
delete content.dataset._tilePreSnap;
delete content.dataset._tileZone;
[
'position', 'left', 'top', 'right', 'bottom', 'margin', 'transform',
'width', 'height', 'max-width', 'max-height', 'border-radius', 'transition',
].forEach(property => content.style.removeProperty(property));
}
export function bindOpenPromptModalLink({ getModal, closeSettings } = {}) {
if (_promptLinkBound) return;
_promptLinkBound = true;
document.addEventListener('click', async event => {
const link = event.target?.closest?.('[data-open-prompt-modal]');
if (!link) return;
event.preventDefault();
const settingsModal = typeof getModal === 'function' ? getModal() : null;
if (
settingsModal
&& !settingsModal.classList.contains('hidden')
&& typeof closeSettings === 'function'
) {
closeSettings();
}
try {
const module = await import('../presets.js');
const openPrompt = module.openCustomPresetModal
|| (module.default && module.default.openCustomPresetModal);
if (typeof openPrompt === 'function') openPrompt();
} catch (_) {
const modal = document.getElementById('custom-preset-modal');
if (modal) modal.classList.remove('hidden');
}
const personaTab = document.querySelector(
'#custom-preset-modal .preset-tab[data-chartab="character"]'
);
if (personaTab) personaTab.click();
});
}
export function bindSettingsClose(modalEl, options = {}) {
if (!modalEl || _closeBound.has(modalEl)) return;
_closeBound.add(modalEl);
const closeSettings = options.closeSettings;
const isTouchInsideModal = options.isTouchInsideModal;
const closeButton = modalEl.querySelector('.close-btn');
closeButton?.addEventListener('click', () => {
if (typeof closeSettings === 'function') closeSettings();
});
modalEl.addEventListener('mousedown', event => {
if (typeof isTouchInsideModal === 'function' && isTouchInsideModal()) return;
if (event.target === modalEl && typeof closeSettings === 'function') {
closeSettings();
}
});
document.addEventListener('keydown', event => {
if (event.key !== 'Escape' || modalEl.classList.contains('hidden')) return;
// Esc should dismiss transient popovers before the Settings window.
const popoverOpen = modalEl.querySelector(
'#adm-epLocalMoreMenu, #adm-epApiMoreMenu, #adm-provider-menu, #search-provider-menu, [data-popover-open="1"]'
);
if (
popoverOpen
&& popoverOpen.style.display !== 'none'
&& !popoverOpen.classList.contains('hidden')
) {
return;
}
// Integration/account editors are nested flows. Close the editor first so
// an accidental Esc does not discard the entire Settings context.
const innerForm = modalEl.querySelector('#unified-intg-form, #set-email-accounts-form');
if (
innerForm
&& innerForm.style.display !== 'none'
&& innerForm.children.length > 0
) {
event.preventDefault();
event.stopPropagation();
innerForm.style.display = 'none';
innerForm.innerHTML = '';
return;
}
event.preventDefault();
event.stopPropagation();
if (typeof closeSettings === 'function') closeSettings();
});
}
export function showSettingsModal(modalEl) {
if (!modalEl) return;
if (modalEl.classList.contains('hidden')) {
resetSettingsWindowPlacement(modalEl);
}
modalEl.classList.remove('hidden');
}
export function hideSettingsModal(modalEl) {
if (!modalEl) return;
const content = modalEl.querySelector('.modal-content, .settings-modal-content');
if (content && !content.classList.contains('modal-closing')) {
content.classList.add('modal-closing');
content.addEventListener('animationend', () => {
modalEl.classList.add('hidden');
content.classList.remove('modal-closing');
}, { once: true });
setTimeout(() => {
if (!modalEl.classList.contains('hidden')) {
modalEl.classList.add('hidden');
content.classList.remove('modal-closing');
}
}, 250);
return;
}
modalEl.classList.add('hidden');
}
+57
View File
@@ -0,0 +1,57 @@
// Settings navigation primitives.
//
// This module owns panel activation and sidebar click routing only. Individual
// panels continue to own their data loading and side effects.
import { DEFAULT_SETTINGS_PANEL_ID, isAdminManagedSettingsTab } from './registry.js';
const _boundModals = new WeakSet();
export function activateSettingsPanel(modalEl, tab) {
if (!modalEl || !tab) return null;
modalEl.querySelectorAll('[data-settings-tab]').forEach(button => {
button.classList.toggle('active', button.dataset.settingsTab === tab);
});
modalEl.querySelectorAll('[data-settings-panel]').forEach(panel => {
panel.classList.toggle('hidden', panel.dataset.settingsPanel !== tab);
});
return tab;
}
export function getActiveSettingsTab(modalEl, fallback = DEFAULT_SETTINGS_PANEL_ID) {
if (!modalEl) return fallback;
const active = modalEl.querySelector('[data-settings-tab].active');
return active?.dataset?.settingsTab || fallback;
}
export function bindSettingsNavigation(modalEl, options = {}) {
if (!modalEl || _boundModals.has(modalEl)) return;
_boundModals.add(modalEl);
const openAdminTab = options.openAdminTab;
const onPanelActivated = options.onPanelActivated;
modalEl.querySelectorAll('[data-settings-tab]').forEach(button => {
button.addEventListener('click', () => {
const tab = button.dataset.settingsTab;
if (!tab) return;
// Preserve the existing lazy-admin path: when the admin module accepts
// the tab, it owns activation/rendering and the Settings shell does not
// perform a second local switch.
if (
isAdminManagedSettingsTab(tab)
&& typeof openAdminTab === 'function'
&& openAdminTab(tab, button) === true
) {
return;
}
activateSettingsPanel(modalEl, tab);
if (typeof onPanelActivated === 'function') {
onPanelActivated(tab, button);
}
});
});
}
+237
View File
@@ -0,0 +1,237 @@
// Canonical metadata for the existing Settings information architecture.
//
// This module describes Settings; it does not render the sidebar, load panel
// data, or own panel behavior. Keeping those concerns separate lets the
// current markup remain stable while navigation/search code shares one source
// of truth for panel identity and ownership.
function defineGroup(definition) {
return Object.freeze({ ...definition });
}
function definePanel(definition) {
return Object.freeze({
controller: 'settings',
adminOnly: false,
...definition,
keywords: Object.freeze([...(definition.keywords || [])]),
});
}
export const SETTINGS_GROUPS = Object.freeze([
defineGroup({
id: 'models',
label: 'Models & AI',
}),
defineGroup({
id: 'communications',
label: 'Communications',
}),
defineGroup({
id: 'experience',
label: 'Experience',
}),
defineGroup({
id: 'account',
label: 'Account',
}),
defineGroup({
id: 'administration',
label: 'Administration',
adminOnly: true,
}),
]);
// Order intentionally mirrors the existing Settings sidebar.
export const SETTINGS_PANELS = Object.freeze([
definePanel({
id: 'services',
label: 'Add Models',
group: 'models',
controller: 'admin',
keywords: ['models', 'provider', 'endpoint'],
}),
definePanel({
id: 'added-models',
label: 'Added Models',
group: 'models',
controller: 'admin',
keywords: ['models', 'configured', 'provider', 'endpoint'],
}),
definePanel({
id: 'ai',
label: 'AI Defaults',
group: 'models',
keywords: ['ai', 'defaults', 'model', 'vision', 'image', 'tts', 'stt'],
}),
definePanel({
id: 'search',
label: 'Search',
group: 'models',
keywords: ['search', 'research', 'provider'],
}),
definePanel({
id: 'integrations',
label: 'Integrations',
group: 'communications',
controller: 'admin',
keywords: ['integrations', 'connections', 'services'],
}),
definePanel({
id: 'email',
label: 'Email',
group: 'communications',
keywords: ['email', 'imap', 'smtp', 'oauth'],
}),
definePanel({
id: 'reminders',
label: 'Reminders',
group: 'communications',
keywords: ['reminders', 'notifications', 'alerts'],
}),
definePanel({
id: 'appearance',
label: 'Appearance',
group: 'experience',
keywords: ['appearance', 'theme', 'font', 'density', 'peek'],
}),
definePanel({
id: 'shortcuts',
label: 'Shortcuts',
group: 'experience',
keywords: ['shortcuts', 'keyboard', 'hotkeys'],
}),
definePanel({
id: 'account',
label: 'Account',
group: 'account',
keywords: ['account', 'password', 'logout'],
}),
definePanel({
id: 'tools',
label: 'Agent Tools',
group: 'administration',
controller: 'admin',
adminOnly: true,
keywords: ['agent', 'tools'],
}),
definePanel({
id: 'users',
label: 'Users',
group: 'administration',
controller: 'admin',
adminOnly: true,
keywords: ['users', 'accounts', 'admin'],
}),
definePanel({
id: 'system',
label: 'System',
group: 'administration',
controller: 'admin',
adminOnly: true,
keywords: ['system', 'admin', 'server'],
}),
]);
export const DEFAULT_SETTINGS_PANEL_ID = 'services';
const _panelsById = new Map(
SETTINGS_PANELS.map(panel => [panel.id, panel]),
);
export function getSettingsPanel(id) {
return _panelsById.get(String(id || '')) || null;
}
export function getSettingsPanelsForGroup(groupId) {
return SETTINGS_PANELS.filter(panel => panel.group === groupId);
}
export function isAdminManagedSettingsTab(id) {
return getSettingsPanel(id)?.controller === 'admin';
}
export function isAdminOnlySettingsTab(id) {
return getSettingsPanel(id)?.adminOnly === true;
}
export function getSettingsPanelSearchText(panelOrId) {
const panel = typeof panelOrId === 'string'
? getSettingsPanel(panelOrId)
: panelOrId;
if (!panel) return '';
return [
panel.label,
...(panel.keywords || []),
].join(' ').toLowerCase();
}
function normalizeSettingsSearch(value) {
return String(value || '')
.trim()
.toLowerCase()
.replace(/\s+/g, ' ');
}
export function searchSettingsPanels(query, options = {}) {
const normalized = normalizeSettingsSearch(query);
if (!normalized) return [];
const terms = normalized.split(' ');
const isAdmin = options.isAdmin === true;
return SETTINGS_PANELS.filter(panel => {
if (panel.adminOnly && !isAdmin) return false;
const haystack = getSettingsPanelSearchText(panel);
return terms.every(term => haystack.includes(term));
});
}
export function getSettingsRegistryIssues(modalEl) {
if (!modalEl) return ['Settings modal is unavailable'];
const tabIds = Array.from(
modalEl.querySelectorAll('[data-settings-tab]'),
element => element.dataset.settingsTab,
).filter(Boolean);
const panelIds = Array.from(
modalEl.querySelectorAll('[data-settings-panel]'),
element => element.dataset.settingsPanel,
).filter(Boolean);
const registryIds = SETTINGS_PANELS.map(panel => panel.id);
const issues = [];
const duplicates = ids => ids.filter(
(id, index) => ids.indexOf(id) !== index,
);
for (const id of new Set(duplicates(tabIds))) {
issues.push(`Duplicate Settings tab: ${id}`);
}
for (const id of new Set(duplicates(panelIds))) {
issues.push(`Duplicate Settings panel: ${id}`);
}
for (const id of registryIds) {
if (!tabIds.includes(id)) issues.push(`Registry tab missing from DOM: ${id}`);
if (!panelIds.includes(id)) issues.push(`Registry panel missing from DOM: ${id}`);
}
for (const id of tabIds) {
if (!registryIds.includes(id)) issues.push(`DOM tab missing from registry: ${id}`);
}
for (const id of panelIds) {
if (!registryIds.includes(id)) issues.push(`DOM panel missing from registry: ${id}`);
}
return issues;
}
+172
View File
@@ -0,0 +1,172 @@
import {
SETTINGS_GROUPS,
getSettingsPanel,
searchSettingsPanels,
} from './registry.js';
const _boundModals = new WeakSet();
function groupLabelFor(panel) {
const group = SETTINGS_GROUPS.find(candidate => candidate.id === panel.group);
return group?.label || '';
}
function clearResults(resultsEl) {
if (!resultsEl) return;
resultsEl.replaceChildren();
resultsEl.classList.add('hidden');
}
function getResultButtons(resultsEl) {
if (!resultsEl) return [];
return Array.from(resultsEl.querySelectorAll('[data-settings-search-result]'));
}
function setActiveResult(resultsEl, index) {
const buttons = getResultButtons(resultsEl);
if (!buttons.length) return -1;
let next = index;
if (next < 0) next = buttons.length - 1;
if (next >= buttons.length) next = 0;
buttons.forEach((button, buttonIndex) => {
const active = buttonIndex === next;
button.classList.toggle('active', active);
button.setAttribute('aria-selected', active ? 'true' : 'false');
});
if (typeof buttons[next].scrollIntoView === 'function') {
buttons[next].scrollIntoView({ block: 'nearest' });
}
return next;
}
export function bindSettingsSearch(modalEl, options = {}) {
if (!modalEl || _boundModals.has(modalEl)) return;
const input = modalEl.querySelector('#settings-nav-search');
const resultsEl = modalEl.querySelector('#settings-nav-search-results');
if (!input || !resultsEl) return;
_boundModals.add(modalEl);
const isAdmin = typeof options.isAdmin === 'function'
? options.isAdmin
: () => false;
const openPanel = typeof options.openPanel === 'function'
? options.openPanel
: () => {};
let activeIndex = -1;
function reset() {
input.value = '';
activeIndex = -1;
clearResults(resultsEl);
}
function activateResult(button) {
const panelId = button?.dataset?.settingsSearchResult;
if (!panelId || !getSettingsPanel(panelId)) return;
openPanel(panelId);
reset();
}
function render() {
const query = input.value.trim();
activeIndex = -1;
resultsEl.replaceChildren();
if (!query) {
resultsEl.classList.add('hidden');
return;
}
const matches = searchSettingsPanels(query, {
isAdmin: isAdmin(),
});
if (!matches.length) {
const empty = document.createElement('div');
empty.className = 'settings-search-empty';
empty.textContent = 'No settings found';
resultsEl.appendChild(empty);
resultsEl.classList.remove('hidden');
return;
}
for (const panel of matches) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'settings-search-result';
button.setAttribute('data-settings-search-result', panel.id);
button.setAttribute('role', 'option');
button.setAttribute('aria-selected', 'false');
const label = document.createElement('span');
label.className = 'settings-search-result-label';
label.textContent = panel.label;
const group = document.createElement('span');
group.className = 'settings-search-result-group';
group.textContent = groupLabelFor(panel);
button.append(label, group);
button.addEventListener('click', () => activateResult(button));
resultsEl.appendChild(button);
}
resultsEl.classList.remove('hidden');
}
input.addEventListener('input', render);
input.addEventListener('focus', () => {
if (input.value.trim()) render();
});
input.addEventListener('keydown', event => {
const buttons = getResultButtons(resultsEl);
if (event.key === 'Escape') {
if (!input.value && resultsEl.classList.contains('hidden')) return;
event.preventDefault();
event.stopPropagation();
reset();
return;
}
if (!buttons.length) return;
if (event.key === 'ArrowDown') {
event.preventDefault();
activeIndex = setActiveResult(resultsEl, activeIndex + 1);
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
activeIndex = setActiveResult(resultsEl, activeIndex - 1);
return;
}
if (event.key === 'Enter') {
const target = buttons[activeIndex >= 0 ? activeIndex : 0];
if (!target) return;
event.preventDefault();
activateResult(target);
}
});
modalEl.addEventListener('mousedown', event => {
if (event.target === input || resultsEl.contains(event.target)) return;
clearResults(resultsEl);
activeIndex = -1;
});
return { reset, render };
}
+238
View File
@@ -0,0 +1,238 @@
const STORAGE_WIDTH = 'odysseus-settings-sidebar-width';
const STORAGE_COLLAPSED = 'odysseus-settings-sidebar-collapsed';
export const SETTINGS_SIDEBAR_DEFAULT_WIDTH = 220;
export const SETTINGS_SIDEBAR_MIN_WIDTH = 150;
export const SETTINGS_SIDEBAR_MAX_WIDTH = 340;
export const SETTINGS_SIDEBAR_COLLAPSE_THRESHOLD = 110;
const _bound = new WeakSet();
function clampWidth(value) {
const width = Number(value);
if (!Number.isFinite(width)) return SETTINGS_SIDEBAR_DEFAULT_WIDTH;
return Math.max(
SETTINGS_SIDEBAR_MIN_WIDTH,
Math.min(SETTINGS_SIDEBAR_MAX_WIDTH, width),
);
}
function readStoredWidth() {
try {
const stored = localStorage.getItem(STORAGE_WIDTH);
if (stored == null || String(stored).trim() === '') {
return SETTINGS_SIDEBAR_DEFAULT_WIDTH;
}
return clampWidth(stored);
} catch {
return SETTINGS_SIDEBAR_DEFAULT_WIDTH;
}
}
function readStoredCollapsed() {
try {
return localStorage.getItem(STORAGE_COLLAPSED) === '1';
} catch {
return false;
}
}
function storeWidth(width) {
try {
localStorage.setItem(STORAGE_WIDTH, String(Math.round(width)));
} catch {}
}
function storeCollapsed(collapsed) {
try {
localStorage.setItem(STORAGE_COLLAPSED, collapsed ? '1' : '0');
} catch {}
}
function syncResizeHandleAria(modalEl, width = null) {
const handle = modalEl?.querySelector('#settings-sidebar-resize-handle');
if (!handle) return;
const sidebar = modalEl.querySelector('.settings-sidebar');
const collapsed = sidebar?.classList.contains('settings-sidebar-collapsed');
const current = collapsed
? SETTINGS_SIDEBAR_MIN_WIDTH
: clampWidth(
width ?? sidebar?.getBoundingClientRect?.().width
?? SETTINGS_SIDEBAR_DEFAULT_WIDTH
);
handle.setAttribute('aria-valuemin', String(SETTINGS_SIDEBAR_MIN_WIDTH));
handle.setAttribute('aria-valuemax', String(SETTINGS_SIDEBAR_MAX_WIDTH));
handle.setAttribute('aria-valuenow', String(Math.round(current)));
}
function isDesktopSidebarMode(modalEl) {
const content = modalEl?.querySelector('.settings-modal-content');
if (!content) return false;
// Mirrors the existing container breakpoint where the sidebar becomes a
// horizontal rail. Resizing/collapse applies only to the vertical desktop
// navigation layout.
return content.getBoundingClientRect().width > 620;
}
export function setSettingsSidebarCollapsed(modalEl, collapsed, options = {}) {
const sidebar = modalEl?.querySelector('.settings-sidebar');
if (!sidebar) return false;
const next = collapsed === true;
sidebar.classList.toggle('settings-sidebar-collapsed', next);
const toggle = sidebar.querySelector('#settings-sidebar-toggle');
if (toggle) {
toggle.setAttribute('aria-expanded', next ? 'false' : 'true');
toggle.setAttribute(
'aria-label',
next ? 'Expand settings navigation' : 'Collapse settings navigation',
);
toggle.title = next
? 'Expand settings navigation'
: 'Collapse settings navigation';
}
if (!next) {
const width = clampWidth(options.width ?? readStoredWidth());
sidebar.style.setProperty('--settings-sidebar-width', `${width}px`);
syncResizeHandleAria(modalEl, width);
} else {
syncResizeHandleAria(modalEl);
}
if (options.persist !== false) storeCollapsed(next);
return next;
}
export function setSettingsSidebarWidth(modalEl, width, options = {}) {
const sidebar = modalEl?.querySelector('.settings-sidebar');
if (!sidebar) return null;
const next = clampWidth(width);
sidebar.style.setProperty('--settings-sidebar-width', `${next}px`);
syncResizeHandleAria(modalEl, next);
if (options.persist !== false) storeWidth(next);
return next;
}
export function bindSettingsSidebar(modalEl) {
if (!modalEl || _bound.has(modalEl)) return;
_bound.add(modalEl);
const sidebar = modalEl.querySelector('.settings-sidebar');
const handle = modalEl.querySelector('#settings-sidebar-resize-handle');
const toggle = modalEl.querySelector('#settings-sidebar-toggle');
if (!sidebar || !handle || !toggle) return;
setSettingsSidebarWidth(modalEl, readStoredWidth(), { persist: false });
setSettingsSidebarCollapsed(
modalEl,
readStoredCollapsed(),
{ persist: false },
);
toggle.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
if (!isDesktopSidebarMode(modalEl)) return;
const collapsed = sidebar.classList.contains('settings-sidebar-collapsed');
setSettingsSidebarCollapsed(modalEl, !collapsed);
});
let startX = 0;
let startWidth = 0;
function stopResize() {
if (!sidebar.classList.contains('settings-sidebar-resizing')) return;
sidebar.classList.remove('settings-sidebar-resizing');
document.body.classList.remove('settings-sidebar-resize-active');
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', stopResize);
const width = sidebar.getBoundingClientRect().width;
if (width < SETTINGS_SIDEBAR_COLLAPSE_THRESHOLD) {
setSettingsSidebarCollapsed(modalEl, true);
return;
}
setSettingsSidebarCollapsed(modalEl, false, { persist: false });
setSettingsSidebarWidth(modalEl, width);
storeCollapsed(false);
}
function onPointerMove(event) {
const rawWidth = startWidth + (event.clientX - startX);
if (rawWidth < SETTINGS_SIDEBAR_COLLAPSE_THRESHOLD) {
sidebar.style.setProperty(
'--settings-sidebar-width',
`${Math.max(34, rawWidth)}px`,
);
return;
}
setSettingsSidebarCollapsed(modalEl, false, { persist: false });
setSettingsSidebarWidth(modalEl, rawWidth, { persist: false });
}
handle.addEventListener('pointerdown', event => {
if (!isDesktopSidebarMode(modalEl)) return;
event.preventDefault();
startX = event.clientX;
startWidth = sidebar.getBoundingClientRect().width;
sidebar.classList.remove('settings-sidebar-collapsed');
sidebar.classList.add('settings-sidebar-resizing');
document.body.classList.add('settings-sidebar-resize-active');
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', stopResize);
});
handle.addEventListener('keydown', event => {
if (!isDesktopSidebarMode(modalEl)) return;
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
toggle.click();
return;
}
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
event.preventDefault();
if (sidebar.classList.contains('settings-sidebar-collapsed')) {
setSettingsSidebarCollapsed(modalEl, false);
}
const current = sidebar.getBoundingClientRect().width;
const delta = event.key === 'ArrowLeft' ? -16 : 16;
// Once keyboard resizing reaches the declared minimum, another ArrowLeft
// collapses the rail. Without this explicit boundary transition the width
// setter clamps 134px back to 150px forever, making keyboard collapse via
// ArrowLeft unreachable.
if (
event.key === 'ArrowLeft'
&& current <= SETTINGS_SIDEBAR_MIN_WIDTH
) {
setSettingsSidebarCollapsed(modalEl, true);
return;
}
setSettingsSidebarWidth(modalEl, current + delta);
});
}
+261 -3
View File
@@ -23605,7 +23605,7 @@ body.gallery-selecting .gallery-dl-btn,
/* ===== Settings Modal Layout ===== */
.settings-modal-content {
width: min(720px, 92vw);
width: min(1040px, 94vw);
max-height: 85vh;
padding: 0;
container-type: inline-size;
@@ -23659,10 +23659,13 @@ body.gallery-selecting .gallery-dl-btn,
}
.settings-sidebar {
width: 160px;
flex-shrink: 0;
position: relative;
width: var(--settings-sidebar-width, 220px);
flex: 0 0 var(--settings-sidebar-width, 220px);
min-width: 0;
border-right: 1px solid var(--border);
padding: 8px;
transition: width 0.16s ease, flex-basis 0.16s ease;
display: flex;
flex-direction: column;
gap: 2px;
@@ -41141,3 +41144,258 @@ body.theme-frosted .modal {
.compare-grid[data-cols] { grid-template-columns: 1fr !important; overflow-y: auto; }
.compare-pane { min-height: 60dvh; }
}
/* ── Settings navigation / finder ────────────────────────────── */
.settings-sidebar-content {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 2px;
overflow-y: auto;
overflow-x: hidden;
}
.settings-nav-search-wrap {
position: relative;
margin: 0 0 8px;
}
.settings-nav-search {
box-sizing: border-box;
width: 100%;
min-width: 0;
height: 32px;
padding: 5px 9px 5px 29px;
border: 1px solid var(--border);
border-radius: 6px;
outline: none;
background: var(--bg);
color: var(--fg);
font: inherit;
font-size: 11px;
}
.settings-nav-search:focus {
border-color: var(--accent, var(--red));
}
.settings-nav-search::placeholder {
color: var(--fg);
opacity: 0.45;
}
.settings-nav-search-icon {
position: absolute;
z-index: 2;
top: 9px;
left: 9px;
pointer-events: none;
opacity: 0.5;
}
.settings-nav-search-results {
position: absolute;
z-index: 30;
top: calc(100% + 4px);
left: 0;
right: 0;
min-width: 100%;
max-height: 300px;
overflow-y: auto;
overflow-x: hidden;
padding: 4px;
border: 1px solid var(--border);
border-radius: 7px;
background: var(--panel);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28);
}
.settings-search-result {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
width: 100%;
align-items: center;
gap: 10px;
padding: 8px 9px;
border: 0;
border-radius: 5px;
background: transparent;
color: var(--fg);
font: inherit;
text-align: left;
cursor: pointer;
}
.settings-search-result:hover,
.settings-search-result.active {
background: color-mix(in srgb, var(--fg) 8%, transparent);
}
.settings-search-result-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
}
.settings-search-result-group {
flex: 0 0 auto;
max-width: 92px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 9px;
opacity: 0.5;
}
.settings-search-empty {
padding: 8px;
color: var(--fg);
font-size: 10px;
text-align: center;
opacity: 0.55;
}
.settings-sidebar-resize-handle {
position: absolute;
z-index: 20;
top: 0;
right: -3px;
width: 6px;
height: 100%;
cursor: col-resize;
background: transparent;
touch-action: none;
}
.settings-sidebar-resize-handle:hover,
.settings-sidebar-resizing .settings-sidebar-resize-handle {
background: var(--accent, var(--red));
opacity: 0.45;
}
.settings-sidebar-resizing {
transition: none;
user-select: none;
}
body.settings-sidebar-resize-active {
cursor: col-resize;
user-select: none;
}
.settings-sidebar-toggle {
position: absolute;
z-index: 25;
top: 9px;
right: -13px;
display: flex;
width: 24px;
height: 24px;
align-items: center;
justify-content: center;
padding: 0;
border: 1px solid var(--border);
border-radius: 50%;
background: var(--panel);
color: var(--fg);
cursor: pointer;
opacity: 0;
transition: opacity 0.12s ease, border-color 0.12s ease;
}
.settings-sidebar:hover .settings-sidebar-toggle,
.settings-sidebar-toggle:focus-visible,
.settings-sidebar.settings-sidebar-collapsed .settings-sidebar-toggle {
opacity: 1;
}
.settings-sidebar-toggle:hover,
.settings-sidebar-toggle:focus-visible {
border-color: var(--accent, var(--red));
color: var(--accent, var(--red));
}
.settings-sidebar-toggle-expand {
display: none;
}
.settings-sidebar.settings-sidebar-collapsed {
width: 34px;
flex-basis: 34px;
padding: 0;
}
.settings-sidebar.settings-sidebar-collapsed .settings-sidebar-content {
display: none;
}
.settings-sidebar.settings-sidebar-collapsed .settings-sidebar-resize-handle {
display: none;
}
.settings-sidebar.settings-sidebar-collapsed .settings-sidebar-toggle {
top: 10px;
right: 5px;
}
.settings-sidebar.settings-sidebar-collapsed .settings-sidebar-toggle-collapse {
display: none;
}
.settings-sidebar.settings-sidebar-collapsed .settings-sidebar-toggle-expand {
display: block;
}
/* The existing narrow/snapped layout is a horizontal tab rail. Sidebar
resizing/collapse applies only to the vertical desktop layout. */
@container settings-modal (max-width: 620px) {
.settings-sidebar {
width: auto;
flex: 0 0 auto;
transition: none;
}
.settings-sidebar-content {
flex-direction: row;
overflow-x: auto;
overflow-y: hidden;
}
.settings-nav-search-wrap,
.settings-sidebar-resize-handle,
.settings-sidebar-toggle {
display: none;
}
.settings-sidebar.settings-sidebar-collapsed {
width: auto;
flex-basis: auto;
padding: 6px;
}
.settings-sidebar.settings-sidebar-collapsed .settings-sidebar-content {
display: flex;
}
}
@media (max-width: 600px) {
.settings-sidebar {
width: auto;
flex: 0 0 auto;
}
.settings-sidebar-content {
flex-direction: row;
overflow-x: auto;
overflow-y: hidden;
}
.settings-nav-search-wrap,
.settings-sidebar-resize-handle,
.settings-sidebar-toggle {
display: none;
}
}