From 55fdbd15fdb16fa12117d6abed85cf72f5c09b9f Mon Sep 17 00:00:00 2001 From: Valerii Kovalskii Date: Sun, 19 Jul 2026 10:10:12 +0300 Subject: [PATCH] feat: sidebar hover tooltips explaining each item (v7.5.3) Every sidebar item now shows a one-line "what it is & why" tooltip on hover, keyed by data-key/data-view via a new NAV_HELP map in sidebar-config.js. The sidebar has overflow-x:hidden, so a pure-CSS ::after tooltip would be clipped. Instead we render a single shared element appended to and position it fixed on hover (same approach as the agent-picker popover), themed via existing hex tokens so it works in dark/light/ monokai. Tooltip text doubles as an aria-label for accessibility. Added tests asserting every KNOWN_ITEM_KEY (+ settings) has NAV_HELP and that navHelpFor is safe against unknown / prototype keys. Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 2 +- src/changelog.js | 10 ++++++ src/frontend/app.js | 63 ++++++++++++++++++++++++++++++++++ src/frontend/sidebar-config.js | 50 +++++++++++++++++++++++++++ src/frontend/styles.css | 24 +++++++++++++ test/sidebar-config.test.js | 26 ++++++++++++++ 6 files changed, 174 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 4030a60..d85ebaf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codbash-app", - "version": "7.5.2", + "version": "7.5.3", "description": "Dashboard + CLI for AI coding agents — Claude Code, Codex, Cursor, OpenCode, Kiro. View, search, resume, convert, sync sessions.", "bin": { "codbash": "./bin/cli.js", diff --git a/src/changelog.js b/src/changelog.js index 60d941c..05f3f91 100644 --- a/src/changelog.js +++ b/src/changelog.js @@ -1,6 +1,16 @@ 'use strict'; const CHANGELOG = [ + { + version: '7.5.3', + date: '2026-07-19', + title: 'Sidebar hover tooltips', + changes: [ + 'Every sidebar item now shows a one-line "what it is & why" tooltip on hover — easier to learn what each view and agent filter does', + 'Tooltips render as a single shared element positioned outside the sidebar, so they are never clipped, and adapt to dark / light / monokai themes', + 'Tooltip text doubles as an aria-label for screen-reader accessibility', + ], + }, { version: '7.5.2', date: '2026-07-19', diff --git a/src/frontend/app.js b/src/frontend/app.js index 62a3772..555e79a 100644 --- a/src/frontend/app.js +++ b/src/frontend/app.js @@ -2513,6 +2513,68 @@ function applySidebarConfig() { _updateSidebarEmptySectionHints(); } +// Attach a one-line "what it is & why" tooltip to every sidebar item, keyed by +// data-key (preferred) or data-view. The sidebar has overflow-x:hidden, so a +// pure-CSS ::after tooltip would be clipped — instead we render a single shared +// element appended to and position it fixed on hover (same approach as +// the agent-picker popover). aria-label keeps it accessible to screen readers. +var _navTooltipEl = null; +var _navTooltipTimer = null; + +function _getNavTooltipEl() { + if (_navTooltipEl) return _navTooltipEl; + var el = document.createElement('div'); + el.className = 'nav-tooltip'; + el.setAttribute('role', 'tooltip'); + el.setAttribute('aria-hidden', 'true'); + document.body.appendChild(el); + _navTooltipEl = el; + return el; +} + +function _hideNavTooltip() { + clearTimeout(_navTooltipTimer); + if (!_navTooltipEl) return; + _navTooltipEl.classList.remove('visible'); + _navTooltipEl.setAttribute('aria-hidden', 'true'); +} + +function _onNavItemEnter(e) { + var item = e.currentTarget; + var help = item.getAttribute('data-tooltip'); + if (!help) return; + clearTimeout(_navTooltipTimer); + _navTooltipTimer = setTimeout(function() { + var tip = _getNavTooltipEl(); + tip.textContent = help; + // Show invisibly first to measure height, then clamp within the viewport. + tip.style.visibility = 'hidden'; + tip.classList.add('visible'); + var r = item.getBoundingClientRect(); + var th = tip.offsetHeight; + var top = r.top + (r.height - th) / 2; + top = Math.max(8, Math.min(top, window.innerHeight - th - 8)); + tip.style.top = top + 'px'; + tip.style.left = (r.right + 10) + 'px'; + tip.style.visibility = ''; + tip.setAttribute('aria-hidden', 'false'); + }, 350); +} + +function applyNavTooltips() { + if (typeof SidebarConfig === 'undefined' || !SidebarConfig.navHelpFor) return; + document.querySelectorAll('.sidebar-item').forEach(function(el) { + var key = el.getAttribute('data-key') || el.getAttribute('data-view'); + var help = SidebarConfig.navHelpFor(key); + if (!help) return; + el.setAttribute('data-tooltip', help); + if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', help); + el.addEventListener('mouseenter', _onNavItemEnter); + el.addEventListener('mouseleave', _hideNavTooltip); + el.addEventListener('click', _hideNavTooltip); + }); +} + // When a user hides every togglable item inside a section the body becomes // visually empty. Inject a small inline hint so the section doesn't look // broken. The hint is removed automatically when any item becomes visible @@ -4256,6 +4318,7 @@ function _onProjectsHashChange() { // user never sees a flash of hidden items. applySidebarConfig(); _bindSidebarHeaders(); + applyNavTooltips(); // Load data loadSessions(); diff --git a/src/frontend/sidebar-config.js b/src/frontend/sidebar-config.js index f608b9e..f999cc2 100644 --- a/src/frontend/sidebar-config.js +++ b/src/frontend/sidebar-config.js @@ -46,6 +46,54 @@ 'install-agents': true }; + // One-line "what it is & why" help for each sidebar item, keyed by the item's + // data-key (preferred) or data-view (fallback). Surfaced as a hover tooltip + // in the browser (see applyNavTooltips in app.js). Kept here so it's covered + // by the sidebar-config unit test (every KNOWN_ITEM_KEY must have help). + var NAV_HELP = { + // Workspace + 'sessions': 'Every AI coding session across all agents, in one place', + 'projects': 'Browse sessions grouped by project folder', + 'timeline': 'Your sessions laid out on a chronological timeline', + 'activity': 'GitHub-style heatmap of your coding activity and streaks', + 'running': 'Live sessions right now — CPU, memory, uptime', + 'analytics': 'Token cost & usage broken down by day, project and agent', + 'starred': 'Sessions you’ve pinned for quick access', + 'leaderboard': 'Ranking of projects and agents by activity', + 'cloud': 'Sync your sessions across machines', + // Agents (filter the session list to one agent) + 'claude-only': 'Show only Claude Code sessions', + 'codex-only': 'Show only Codex sessions', + 'qwen-only': 'Show only Qwen Code sessions', + 'pi-original-only': 'Show only Pi sessions', + 'ohmypi-only': 'Show only Oh My Pi sessions', + 'kiro-only': 'Show only Kiro sessions', + 'cursor-only': 'Show only Cursor sessions', + 'copilot-chat-only': 'Show only Copilot Chat sessions', + 'copilot-only': 'Show only Copilot CLI sessions', + 'opencode-only': 'Show only OpenCode sessions', + 'kilo-only': 'Show only Kilo sessions', + // Tools + 'export-import': 'Back up or restore your sessions as an archive', + 'changelog': 'What’s new in each codbash release', + 'settings': 'Themes, sidebar layout and other preferences', + // Install agents (copy the install command) + 'install:claude': 'Copy the install command for Claude Code', + 'install:codex': 'Copy the install command for Codex CLI', + 'install:qwen': 'Copy the install command for Qwen Code', + 'install:pi': 'Copy the install command for Pi', + 'install:ohmypi': 'Copy the install command for Oh My Pi', + 'install:kiro': 'Copy the install command for Kiro CLI', + 'install:opencode': 'Copy the install command for OpenCode', + 'install:kilo': 'Copy the install command for Kilo CLI', + 'install:copilot': 'Copy the install command for Copilot CLI' + }; + + function navHelpFor(key) { + if (typeof key !== 'string') return ''; + return Object.prototype.hasOwnProperty.call(NAV_HELP, key) ? NAV_HELP[key] : ''; + } + function defaults() { return { v: CURRENT_VERSION, hidden: {}, collapsed: {} }; } @@ -168,6 +216,8 @@ return { STORAGE_KEY: STORAGE_KEY, KNOWN_ITEM_KEYS: KNOWN_ITEM_KEYS, + NAV_HELP: NAV_HELP, + navHelpFor: navHelpFor, SETTINGS_KEY: SETTINGS_KEY, parseSidebarConfig: parseSidebarConfig, serializeSidebarConfig: serializeSidebarConfig, diff --git a/src/frontend/styles.css b/src/frontend/styles.css index 1bb70ad..a0415c0 100644 --- a/src/frontend/styles.css +++ b/src/frontend/styles.css @@ -199,6 +199,30 @@ body { .sidebar-divider { height: 1px; background: var(--border); margin: 12px 20px; } +/* Sidebar hover tooltips — a single shared element positioned fixed by JS + (see applyNavTooltips in app.js), so it escapes the sidebar's overflow. */ +.nav-tooltip { + position: fixed; + z-index: 200; + max-width: 240px; + padding: 8px 11px; + background: var(--bg-card); + color: var(--text-primary); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.35); + font-size: 12px; + line-height: 1.4; + pointer-events: none; + opacity: 0; + transform: translateX(-4px); + transition: opacity 0.12s ease, transform 0.12s ease; +} +.nav-tooltip.visible { + opacity: 1; + transform: translateX(0); +} + .sidebar-section { padding: 8px 20px 6px; font-size: 10px; diff --git a/test/sidebar-config.test.js b/test/sidebar-config.test.js index d3d48fe..a994312 100644 --- a/test/sidebar-config.test.js +++ b/test/sidebar-config.test.js @@ -365,3 +365,29 @@ test('clearStorage tolerates a throwing removeItem', () => { const throwingStorage = { removeItem() { throw new Error('boom'); } }; assert.doesNotThrow(() => m.clearStorage(throwingStorage)); }); + +// ── NAV_HELP tooltips ─────────────────────────────────────────── + +test('every known item key (plus settings) has a NAV_HELP entry', () => { + const m = loadModule(); + const keys = m.KNOWN_ITEM_KEYS.concat([m.SETTINGS_KEY]); + for (const key of keys) { + const help = m.navHelpFor(key); + assert.ok(typeof help === 'string' && help.length > 0, + `missing NAV_HELP for "${key}"`); + } +}); + +test('navHelpFor returns empty string for unknown or non-string keys', () => { + const m = loadModule(); + assert.equal(m.navHelpFor('does-not-exist'), ''); + assert.equal(m.navHelpFor(undefined), ''); + assert.equal(m.navHelpFor(null), ''); + assert.equal(m.navHelpFor(42), ''); +}); + +test('navHelpFor does not leak inherited Object.prototype keys', () => { + const m = loadModule(); + assert.equal(m.navHelpFor('toString'), ''); + assert.equal(m.navHelpFor('constructor'), ''); +});