feat(ui): badge version visible + FAB AI flottant
CI / lint (push) Successful in 37s
CI / security (push) Successful in 25s
CI / test (push) Successful in 48s
CI / build (push) Successful in 21s
CI / e2e (push) Successful in 6m4s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s

This commit is contained in:
2026-09-07 14:37:37 -04:00
parent 5350738c22
commit 2d70be0ae2
7 changed files with 158 additions and 14 deletions
+79
View File
@@ -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 = '<i data-lucide="bot" style="width:24px;height:24px"></i>';
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;
+9 -5
View File
@@ -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(() => {});
});
+5
View File
@@ -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'));
}
/**
+3 -1
View File
@@ -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"
}
+3 -1
View File
@@ -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"
}
+38 -7
View File
@@ -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;
}
+21
View File
@@ -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) {