diff --git a/frontend/js/ai-fab.js b/frontend/js/ai-fab.js new file mode 100644 index 0000000..382ab83 --- /dev/null +++ b/frontend/js/ai-fab.js @@ -0,0 +1,79 @@ +// ObsiGate — Floating Action Button (FAB) to open/close the BooksLM AI sidebar. +// Always visible in the bottom-right corner; hides while BooksLM is open. +import booksLM from './bookslm.js'; +import { state } from './state.js'; +import { showToast, TabManager } from './ui.js'; +import { t } from './i18n.js'; +import { safeCreateIcons } from './utils.js'; + +let _fab = null; + +// Mirror palette.js getCurrentFile(): prefer the active tab, then fall back to state. +function getCurrentFile() { + const id = TabManager._activeTabId; + if (id) { + const tab = TabManager._tabs.find((x) => x.id === id); + if (tab) return { vault: tab.vault, path: tab.path }; + } + if (state.currentVault && state.currentPath) return { vault: state.currentVault, path: state.currentPath }; + return null; +} + +// The BooksLM panel is considered visible when it has `.open` without `.hidden` +// (the header toggle collapses it off-screen via `.hidden`). +function isBooksLmVisible() { + const panel = document.querySelector('.bookslm-panel'); + return !!(panel && panel.classList.contains('open') && !panel.classList.contains('hidden')); +} + +function syncVisibility() { + if (!_fab) return; + const visible = isBooksLmVisible(); + _fab.hidden = visible; + const label = visible ? t('fab.close') : t('fab.open'); + _fab.title = label; + _fab.setAttribute('aria-label', label); +} + +async function onClick() { + const f = getCurrentFile(); + if (!f) { + showToast(t('toast.no_open_file'), 'error'); + return; + } + + const sameDir = booksLM._isOpen && booksLM._vault === f.vault && booksLM._directory === f.path; + if (sameDir) { + // Already open for this directory: re-show a collapsed panel, or close it. + const panel = document.querySelector('.bookslm-panel'); + if (panel && panel.classList.contains('hidden')) { + booksLM._toggleSidebar(); + } else { + booksLM.close(); + } + return; + } + + await booksLM.open(f.vault, f.path); +} + +export function initAIFab() { + if (_fab) return; + + const btn = document.createElement('button'); + btn.type = 'button'; + btn.id = 'ai-fab'; + btn.className = 'ai-fab'; + btn.innerHTML = ''; + document.body.appendChild(btn); + _fab = btn; + + btn.addEventListener('click', onClick); + document.addEventListener('bookslm:opened', syncVisibility); + document.addEventListener('bookslm:closed', syncVisibility); + + if (typeof safeCreateIcons === 'function') safeCreateIcons(); + syncVisibility(); +} + +export default initAIFab; diff --git a/frontend/js/app.js b/frontend/js/app.js index 582c790..8b0998b 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -5,6 +5,8 @@ import * as UI from './ui.js'; import * as Utils from './utils.js'; import { initI18n, t } from './i18n.js'; +import { state } from './state.js'; +import { initAIFab } from './ai-fab.js'; // Wire up AI toolbar toast (avoids circular import in utils.js) window._obsigateShowToast = UI.showToast; @@ -99,6 +101,7 @@ async function init() { initMermaid(); setupFocusMode(); Utils.safeCreateIcons(); + initAIFab(); } document.addEventListener("DOMContentLoaded", () => { @@ -111,15 +114,16 @@ document.addEventListener("DOMContentLoaded", () => { window.__OBSIGATE_BOOTED = true; if (typeof window.__obsigateBootReady === "function") window.__obsigateBootReady(); } - // Show version in header + // Show version in header — always visible, fallback before the async fetch + const versionBadge = document.getElementById('version-badge'); + if (versionBadge) { + versionBadge.textContent = window.__OBSIGATE_VERSION || state.APP_VERSION || 'dev'; + } fetch('/api/health') .then(r => r.json()) .then(d => { const badge = document.getElementById('version-badge'); - if (badge && d.version) { - badge.textContent = d.version; - badge.style.display = 'inline-block'; - } + if (badge && d.version) badge.textContent = d.version; }) .catch(() => {}); }); diff --git a/frontend/js/bookslm.js b/frontend/js/bookslm.js index d2ec5b3..39caefb 100644 --- a/frontend/js/bookslm.js +++ b/frontend/js/bookslm.js @@ -44,14 +44,17 @@ class BooksLM { // Open with animation requestAnimationFrame(() => { this._panel.classList.add('open'); + let hidden = false; // Apply persisted hidden state (user toggled off in a previous session) try { const savedHidden = localStorage.getItem('obsigate-bookslm-hidden'); if (savedHidden === 'true') { this._panel.classList.add('hidden'); this._updateToggleIcon(); + hidden = true; } } catch { /* */ } + document.dispatchEvent(new CustomEvent(hidden ? 'bookslm:closed' : 'bookslm:opened', { detail: { vault, directory } })); }); this._isOpen = true; @@ -79,6 +82,7 @@ class BooksLM { if (!this._panel || !this._isOpen) return; this._panel.classList.remove('open'); this._isOpen = false; + document.dispatchEvent(new CustomEvent('bookslm:closed')); // Abort any in-flight request if (this._abortCtrl) { this._abortCtrl.abort(); @@ -102,6 +106,7 @@ class BooksLM { localStorage.setItem('obsigate-bookslm-hidden', isHidden ? 'true' : 'false'); } catch { /* */ } this._updateToggleIcon(); + document.dispatchEvent(new CustomEvent(isHidden ? 'bookslm:closed' : 'bookslm:opened')); } /** diff --git a/frontend/locales/en.json b/frontend/locales/en.json index ee5ac4a..127644f 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -1599,5 +1599,7 @@ "bookslm.context_too_large": "Directory too large — some files were truncated", "bookslm.source": "Source", "palette.bookslm_open": "BooksLM: Open for current directory", - "palette.bookslm_new": "BooksLM: New conversation" + "palette.bookslm_new": "BooksLM: New conversation", + "fab.open": "Open AI assistant", + "fab.close": "Close AI assistant" } diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index 84b7839..fd5e234 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -1599,5 +1599,7 @@ "bookslm.context_too_large": "Répertoire trop volumineux — certains fichiers ont été tronqués", "bookslm.source": "Source", "palette.bookslm_open": "BooksLM: Ouvrir pour le répertoire courant", - "palette.bookslm_new": "BooksLM: Nouvelle conversation" + "palette.bookslm_new": "BooksLM: Nouvelle conversation", + "fab.open": "Ouvrir l'assistant AI", + "fab.close": "Fermer l'assistant AI" } diff --git a/frontend/style.css b/frontend/style.css index 18dcb1b..4f490a4 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -522,15 +522,16 @@ a:hover { background: color-mix(in srgb, var(--accent) 10%, transparent); } -/* Version badge in header */ +/* Version badge in header — always visible */ .version-badge { - display: none; - font-size: 10px; - color: var(--text-muted); - padding: 2px 8px; + display: inline-block; + font-size: 11px; + color: var(--text-secondary); + font-weight: 500; + padding: 3px 10px; border-radius: 10px; - background: color-mix(in srgb, var(--text-muted) 8%, transparent); - border: 1px solid color-mix(in srgb, var(--text-muted) 15%, transparent); + background: color-mix(in srgb, var(--text-secondary) 10%, transparent); + border: 1px solid color-mix(in srgb, var(--text-secondary) 20%, transparent); font-family: var(--font-mono, monospace); white-space: nowrap; cursor: default; @@ -9077,3 +9078,33 @@ body.popup-mode .content-area { @media (max-width: 768px) { .bookslm-panel { width: 100%; } } + +/* ── AI Floating Action Button ───────────────────────── */ +.ai-fab { + position: fixed; + bottom: 24px; + right: 24px; + width: 52px; + height: 52px; + border-radius: 50%; + background: var(--accent); + color: #fff; + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + z-index: 99; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + transition: transform 200ms ease, opacity 200ms ease; + padding: 0; +} +.ai-fab:hover { + transform: scale(1.08); +} +.ai-fab:active { + transform: scale(0.96); +} +.ai-fab[hidden] { + display: none; +} diff --git a/tests/frontend/unit.test.mjs b/tests/frontend/unit.test.mjs index a56be78..744db7d 100644 --- a/tests/frontend/unit.test.mjs +++ b/tests/frontend/unit.test.mjs @@ -187,6 +187,25 @@ function testAdminModuleSyntax() { console.log(' ✓ admin.js parses without syntax errors'); } +// ── Test ai-fab.js (FAB — Floating Action Button) ───────────────────────── +const AIFAB_PATH = join(JS_DIR, 'ai-fab.js'); + +function testAIFabModuleExists() { + const content = readFileSync(AIFAB_PATH, 'utf-8'); + assert.ok(content.length > 0, 'ai-fab.js must be a non-empty file'); + assert.ok(/from\s+['"]\.\/bookslm\.js['"]/.test(content), 'ai-fab.js must import from ./bookslm.js'); + assert.ok(/from\s+['"]\.\/state\.js['"]/.test(content), 'ai-fab.js must import from ./state.js'); + assert.ok(/from\s+['"]\.\/i18n\.js['"]/.test(content), 'ai-fab.js must import from ./i18n.js'); + console.log(' ✓ ai-fab.js exists and imports the expected modules'); +} + +function testAIFabModuleExports() { + const content = readFileSync(AIFAB_PATH, 'utf-8'); + const exported = _collectExportedNames(content); + assert.ok(exported.has('initAIFab'), 'ai-fab.js must export initAIFab'); + console.log(' ✓ ai-fab.js exports initAIFab'); +} + // ── Run all tests ────────────────────────────────────────────────────────── async function main() { let passed = 0, failed = 0; @@ -199,6 +218,8 @@ async function main() { ['admin module exists', testAdminModuleExists], ['admin module exports', testAdminModuleExports], ['admin module syntax', testAdminModuleSyntax], + ['ai-fab module exists', testAIFabModuleExists], + ['ai-fab module exports', testAIFabModuleExports], ]; for (const [name, fn] of tests) {