diff --git a/docs/design/sidebar-customization.md b/docs/design/sidebar-customization.md new file mode 100644 index 0000000..13c822e --- /dev/null +++ b/docs/design/sidebar-customization.md @@ -0,0 +1,181 @@ +# Sidebar Customization — design + +## Goal + +Let users group, collapse, and hide sidebar entries so the left rail matches how they actually work. Default behavior is unchanged for first-time users; preferences persist locally. + +``` +Sidebar (after change) +├── Workspace (collapsible section, default expanded) +│ All Sessions · Projects · Timeline · Activity · Running +│ Analytics · Starred · Leaderboard · Cloud Sync +├── Agents (collapsible, default expanded) +│ Claude · Codex · Qwen · Kiro · Cursor · Copilot Chat · Copilot CLI · OpenCode · Kilo +└── Tools (collapsible, default expanded) + ├── Install agents (nested collapsible, default collapsed) + │ Claude · Codex · Qwen · Kiro · OpenCode · Kilo · Copilot CLI + ├── Export / Import + ├── Changelog + └── Settings (gains new "Sidebar" sub-pane) +``` + +## Why + +Сейчас сайдбар — 30+ пунктов одним списком, в нём есть редко используемые (Leaderboard, Cloud Sync, Starred) и шумная «Install Agents» секция с 7 повторяющимися записями. У разных пользователей разные интересы (только Claude + Projects + Running у одного, Cursor + Activity + Analytics у другого). Группировка + видимость каждого пункта решают это без удаления функциональности. + +## Non-goals + +- Не меняем поведение самих вкладок (Activity bug — отдельный PR, см. CLAUDE.md TODO). +- Не добавляем drag-and-drop переупорядочивания (вне scope этого PR). +- Не трогаем серверные API, файлы сессий, формат `~/.codedash/settings.json`. +- Не добавляем синхронизацию настроек между устройствами — все только в `localStorage`. +- Не делаем «скрытый пункт остаётся доступен через URL hash» как UX-фичу — но `data-view` атрибуты остаются на месте, поэтому хеш-роутинг (`#leaderboard`) технически продолжит работать; в навигации просто не будет ссылки. + +## Data inventory + +Где читаются/пишутся sidebar items сейчас: + +| Источник | Файл | Роль | +|---|---|---| +| HTML | `src/frontend/index.html:11-141` | статичная разметка `'; + return html; +} + +// Two-stage reset: first click arms the button (highlights it red and changes +// label), second click within ~6s actually resets. Cancel button shown while +// armed. Prevents accidental wipeout per WCAG/NN/g destructive-action guidance. +var _resetArmedAt = 0; +var _resetArmedTimer = null; +var RESET_ARM_WINDOW_MS = 6000; + +function onResetSidebarClick(event) { + var btn = event.currentTarget; + var now = Date.now(); + if (btn.getAttribute('data-stage') === 'armed' && (now - _resetArmedAt) < RESET_ARM_WINDOW_MS) { + _disarmReset(); + resetSidebarConfig(); + _announceSidebar('Sidebar reset to defaults'); + return; + } + // Arm + _resetArmedAt = now; + btn.setAttribute('data-stage', 'armed'); + btn.classList.add('confirming'); + btn.textContent = 'Click again to confirm'; + var wrap = document.getElementById('sidebarResetWrap'); + if (wrap && !wrap.querySelector('.settings-sidebar-reset-cancel')) { + var cancel = document.createElement('button'); + cancel.type = 'button'; + cancel.className = 'settings-sidebar-reset-cancel'; + cancel.textContent = 'Cancel'; + cancel.onclick = _disarmReset; + wrap.appendChild(cancel); + } + if (_resetArmedTimer) clearTimeout(_resetArmedTimer); + _resetArmedTimer = setTimeout(_disarmReset, RESET_ARM_WINDOW_MS); +} + +function _disarmReset() { + if (_resetArmedTimer) { clearTimeout(_resetArmedTimer); _resetArmedTimer = null; } + _resetArmedAt = 0; + var btn = document.querySelector('.settings-sidebar-reset-btn'); + if (btn) { + btn.setAttribute('data-stage', 'initial'); + btn.classList.remove('confirming'); + btn.textContent = 'Reset to defaults'; + } + var cancel = document.querySelector('.settings-sidebar-reset-cancel'); + if (cancel && cancel.parentNode) cancel.parentNode.removeChild(cancel); +} + +function _bindSidebarSettingsDelegate() { + var group = document.getElementById('settingsSidebarGroup'); + if (!group || group._delegateBound) return; + group._delegateBound = true; + group.addEventListener('change', function (e) { + var input = e.target; + if (!input || input.tagName !== 'INPUT' || input.type !== 'checkbox') return; + var key = input.getAttribute('data-sidebar-key'); + if (!key) return; + toggleSidebarItem(key, input.checked); + _announceSidebar((input.checked ? 'Shown: ' : 'Hidden: ') + _labelForKey(key)); + }); +} + +function _labelForKey(key) { + for (var i = 0; i < SIDEBAR_ITEM_META.length; i++) { + var items = SIDEBAR_ITEM_META[i].items; + for (var j = 0; j < items.length; j++) { + if (items[j][0] === key) return items[j][1]; + } + } + return key; +} + +function _announceSidebar(msg) { + var region = document.getElementById('sidebarStatus'); + if (!region) return; + // Forcing a content change inside aria-live="polite" triggers SR announcement. + region.textContent = ''; + region.textContent = msg; +} + // ── Keyboard navigation ──────────────────────────────────────── function isInput(e) { @@ -2530,18 +2779,98 @@ function focusSession(sessionId) { // ── Changelog view ──────────────────────────────────────────── +// Sub-tabs grouping for the Settings page. Order matches visual left-to-right. +// Adding a new setting => decide which tab it belongs to and add it to the right +// _settingsTab* renderer below. +var SETTINGS_TABS = [ + { id: 'appearance', label: 'Appearance' }, + { id: 'sidebar', label: 'Sidebar' }, + { id: 'sessions', label: 'Sessions' }, + { id: 'integrations', label: 'Integrations' } +]; + +function _getSettingsTab() { + try { + var saved = localStorage.getItem('codedash-settings-tab'); + if (saved && SETTINGS_TABS.some(function(t) { return t.id === saved; })) return saved; + } catch (e) { /* private mode */ } + return 'appearance'; +} + +function setSettingsTab(id) { + if (!SETTINGS_TABS.some(function(t) { return t.id === id; })) return; + try { localStorage.setItem('codedash-settings-tab', id); } catch (e) { /* private mode */ } + var content = document.getElementById('content'); + if (content) renderSettings(content); +} + function renderSettings(container) { - var savedTheme = localStorage.getItem('codedash-theme') || 'dark'; - var savedTerminal = localStorage.getItem('codedash-terminal') || ''; - var aiTitlesOn = localStorage.getItem('codedash-ai-titles') === 'true'; - var allSessionsListBadgesOn = localStorage.getItem('codedash-all-sessions-list-badges') !== 'false'; - var savedGroupingMode = normalizeGroupingMode(localStorage.getItem('codedash-grouping-mode')); + var activeTab = _getSettingsTab(); + var panelId = 'settingsPanel-' + activeTab; var html = '
'; - html += '

Settings

'; + html += '

Settings

'; + + // Tab strip (reuses .ap-tabs visual pattern from the Add Project modal) + html += '
'; + SETTINGS_TABS.forEach(function(t) { + var isActive = t.id === activeTab; + var tabId = 'settingsTab-' + t.id; + var ariaControls = isActive ? panelId : ('settingsPanel-' + t.id); + html += ''; + }); + html += '
'; - // Theme - html += '
'; + html += '
'; + if (activeTab === 'appearance') html += _renderSettingsAppearance(); + else if (activeTab === 'sidebar') html += renderSidebarSettingsGroup(); + else if (activeTab === 'sessions') html += _renderSettingsSessions(); + else if (activeTab === 'integrations') html += _renderSettingsIntegrations(); + html += '
'; + + html += '
'; + container.innerHTML = html; + + // LLM inputs need post-render hydration when the Integrations tab is open. + if (activeTab === 'integrations') loadLLMSettings(); + // Wire the delegated checkbox listener (avoids inline attribute-context risk). + if (activeTab === 'sidebar') _bindSidebarSettingsDelegate(); +} + +// Arrow-key navigation across the Settings tablist per WAI-ARIA Authoring +// Practices. Left/Right cycle; Home/End jump. Activates the focused tab +// (NN/g "automatic activation" — the panels are cheap to render). +function onSettingsTabKey(event) { + var key = event.key; + var navKeys = ['ArrowLeft', 'ArrowRight', 'Home', 'End']; + if (navKeys.indexOf(key) === -1) return; + event.preventDefault(); + var current = _getSettingsTab(); + var idx = SETTINGS_TABS.findIndex(function (t) { return t.id === current; }); + if (idx < 0) idx = 0; + var next = idx; + if (key === 'ArrowLeft') next = (idx - 1 + SETTINGS_TABS.length) % SETTINGS_TABS.length; + if (key === 'ArrowRight') next = (idx + 1) % SETTINGS_TABS.length; + if (key === 'Home') next = 0; + if (key === 'End') next = SETTINGS_TABS.length - 1; + if (next === idx) return; + setSettingsTab(SETTINGS_TABS[next].id); + // After re-render, move focus to the now-active tab so keyboard flow stays + // inside the strip. setSettingsTab calls renderSettings synchronously. + var nextEl = document.getElementById('settingsTab-' + SETTINGS_TABS[next].id); + if (nextEl) nextEl.focus(); +} + +function _renderSettingsAppearance() { + var savedTheme = localStorage.getItem('codedash-theme') || 'dark'; + var html = '
'; html += ''; html += '
'; ['dark', 'light', 'system'].forEach(function(t) { @@ -2550,21 +2879,16 @@ function renderSettings(container) { }); html += '
'; html += '
'; + return html; +} - // Terminal - html += '
'; - html += ''; - html += '

Binary name or full path (e.g. kitty, /usr/bin/alacritty)

'; - html += ''; - html += ''; - if (Array.isArray(availableTerminals)) { - availableTerminals.forEach(function(t) { - if (!t.available) return; - html += ''; - }); - } - html += ''; - html += '
'; +function _renderSettingsSessions() { + var aiTitlesOn = localStorage.getItem('codedash-ai-titles') === 'true'; + var allSessionsListBadgesOn = localStorage.getItem('codedash-all-sessions-list-badges') !== 'false'; + var savedGroupingMode = normalizeGroupingMode(localStorage.getItem('codedash-grouping-mode')); + var savedMsgSort = localStorage.getItem('codedash-msg-sort') || 'asc'; + + var html = ''; // AI Titles html += '
'; @@ -2575,7 +2899,7 @@ function renderSettings(container) { html += '
'; html += '
'; - // All Sessions list badges + // Session List Badges html += '
'; html += ''; html += '
'; @@ -2598,7 +2922,6 @@ function renderSettings(container) { html += '
'; // Message Sort Order - var savedMsgSort = localStorage.getItem('codedash-msg-sort') || 'asc'; html += '
'; html += ''; html += '

Default order for messages in session drawer

'; @@ -2610,6 +2933,29 @@ function renderSettings(container) { html += '
'; html += '
'; + return html; +} + +function _renderSettingsIntegrations() { + var savedTerminal = localStorage.getItem('codedash-terminal') || ''; + + var html = ''; + + // Terminal + html += '
'; + html += ''; + html += '

Binary name or full path (e.g. kitty, /usr/bin/alacritty)

'; + html += ''; + html += ''; + if (Array.isArray(availableTerminals)) { + availableTerminals.forEach(function(t) { + if (!t.available) return; + html += ''; + }); + } + html += ''; + html += '
'; + // LLM Configuration html += '
'; html += ''; @@ -2625,11 +2971,7 @@ function renderSettings(container) { html += '
'; html += ''; - html += ''; - container.innerHTML = html; - - // Load LLM config into the inputs - loadLLMSettings(); + return html; } // → moved to leaderboard.js @@ -3696,6 +4038,11 @@ function _onProjectsHashChange() { } (function init() { + // Sidebar customization — apply persisted config before any other init so the + // user never sees a flash of hidden items. + applySidebarConfig(); + _bindSidebarHeaders(); + // Load data loadSessions(); loadTerminals(); diff --git a/src/frontend/index.html b/src/frontend/index.html index ecf8162..5e91223 100644 --- a/src/frontend/index.html +++ b/src/frontend/index.html @@ -8,135 +8,205 @@ + +