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
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:
@@ -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
@@ -5,6 +5,8 @@
|
|||||||
import * as UI from './ui.js';
|
import * as UI from './ui.js';
|
||||||
import * as Utils from './utils.js';
|
import * as Utils from './utils.js';
|
||||||
import { initI18n, t } from './i18n.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)
|
// Wire up AI toolbar toast (avoids circular import in utils.js)
|
||||||
window._obsigateShowToast = UI.showToast;
|
window._obsigateShowToast = UI.showToast;
|
||||||
@@ -99,6 +101,7 @@ async function init() {
|
|||||||
initMermaid();
|
initMermaid();
|
||||||
setupFocusMode();
|
setupFocusMode();
|
||||||
Utils.safeCreateIcons();
|
Utils.safeCreateIcons();
|
||||||
|
initAIFab();
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
@@ -111,15 +114,16 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
window.__OBSIGATE_BOOTED = true;
|
window.__OBSIGATE_BOOTED = true;
|
||||||
if (typeof window.__obsigateBootReady === "function") window.__obsigateBootReady();
|
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')
|
fetch('/api/health')
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(d => {
|
.then(d => {
|
||||||
const badge = document.getElementById('version-badge');
|
const badge = document.getElementById('version-badge');
|
||||||
if (badge && d.version) {
|
if (badge && d.version) badge.textContent = d.version;
|
||||||
badge.textContent = d.version;
|
|
||||||
badge.style.display = 'inline-block';
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -44,14 +44,17 @@ class BooksLM {
|
|||||||
// Open with animation
|
// Open with animation
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
this._panel.classList.add('open');
|
this._panel.classList.add('open');
|
||||||
|
let hidden = false;
|
||||||
// Apply persisted hidden state (user toggled off in a previous session)
|
// Apply persisted hidden state (user toggled off in a previous session)
|
||||||
try {
|
try {
|
||||||
const savedHidden = localStorage.getItem('obsigate-bookslm-hidden');
|
const savedHidden = localStorage.getItem('obsigate-bookslm-hidden');
|
||||||
if (savedHidden === 'true') {
|
if (savedHidden === 'true') {
|
||||||
this._panel.classList.add('hidden');
|
this._panel.classList.add('hidden');
|
||||||
this._updateToggleIcon();
|
this._updateToggleIcon();
|
||||||
|
hidden = true;
|
||||||
}
|
}
|
||||||
} catch { /* */ }
|
} catch { /* */ }
|
||||||
|
document.dispatchEvent(new CustomEvent(hidden ? 'bookslm:closed' : 'bookslm:opened', { detail: { vault, directory } }));
|
||||||
});
|
});
|
||||||
this._isOpen = true;
|
this._isOpen = true;
|
||||||
|
|
||||||
@@ -79,6 +82,7 @@ class BooksLM {
|
|||||||
if (!this._panel || !this._isOpen) return;
|
if (!this._panel || !this._isOpen) return;
|
||||||
this._panel.classList.remove('open');
|
this._panel.classList.remove('open');
|
||||||
this._isOpen = false;
|
this._isOpen = false;
|
||||||
|
document.dispatchEvent(new CustomEvent('bookslm:closed'));
|
||||||
// Abort any in-flight request
|
// Abort any in-flight request
|
||||||
if (this._abortCtrl) {
|
if (this._abortCtrl) {
|
||||||
this._abortCtrl.abort();
|
this._abortCtrl.abort();
|
||||||
@@ -102,6 +106,7 @@ class BooksLM {
|
|||||||
localStorage.setItem('obsigate-bookslm-hidden', isHidden ? 'true' : 'false');
|
localStorage.setItem('obsigate-bookslm-hidden', isHidden ? 'true' : 'false');
|
||||||
} catch { /* */ }
|
} catch { /* */ }
|
||||||
this._updateToggleIcon();
|
this._updateToggleIcon();
|
||||||
|
document.dispatchEvent(new CustomEvent(isHidden ? 'bookslm:closed' : 'bookslm:opened'));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1599,5 +1599,7 @@
|
|||||||
"bookslm.context_too_large": "Directory too large — some files were truncated",
|
"bookslm.context_too_large": "Directory too large — some files were truncated",
|
||||||
"bookslm.source": "Source",
|
"bookslm.source": "Source",
|
||||||
"palette.bookslm_open": "BooksLM: Open for current directory",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1599,5 +1599,7 @@
|
|||||||
"bookslm.context_too_large": "Répertoire trop volumineux — certains fichiers ont été tronqués",
|
"bookslm.context_too_large": "Répertoire trop volumineux — certains fichiers ont été tronqués",
|
||||||
"bookslm.source": "Source",
|
"bookslm.source": "Source",
|
||||||
"palette.bookslm_open": "BooksLM: Ouvrir pour le répertoire courant",
|
"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
@@ -522,15 +522,16 @@ a:hover {
|
|||||||
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Version badge in header */
|
/* Version badge in header — always visible */
|
||||||
.version-badge {
|
.version-badge {
|
||||||
display: none;
|
display: inline-block;
|
||||||
font-size: 10px;
|
font-size: 11px;
|
||||||
color: var(--text-muted);
|
color: var(--text-secondary);
|
||||||
padding: 2px 8px;
|
font-weight: 500;
|
||||||
|
padding: 3px 10px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: color-mix(in srgb, var(--text-muted) 8%, transparent);
|
background: color-mix(in srgb, var(--text-secondary) 10%, transparent);
|
||||||
border: 1px solid color-mix(in srgb, var(--text-muted) 15%, transparent);
|
border: 1px solid color-mix(in srgb, var(--text-secondary) 20%, transparent);
|
||||||
font-family: var(--font-mono, monospace);
|
font-family: var(--font-mono, monospace);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
@@ -9077,3 +9078,33 @@ body.popup-mode .content-area {
|
|||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.bookslm-panel { width: 100%; }
|
.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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -187,6 +187,25 @@ function testAdminModuleSyntax() {
|
|||||||
console.log(' ✓ admin.js parses without syntax errors');
|
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 ──────────────────────────────────────────────────────────
|
// ── Run all tests ──────────────────────────────────────────────────────────
|
||||||
async function main() {
|
async function main() {
|
||||||
let passed = 0, failed = 0;
|
let passed = 0, failed = 0;
|
||||||
@@ -199,6 +218,8 @@ async function main() {
|
|||||||
['admin module exists', testAdminModuleExists],
|
['admin module exists', testAdminModuleExists],
|
||||||
['admin module exports', testAdminModuleExports],
|
['admin module exports', testAdminModuleExports],
|
||||||
['admin module syntax', testAdminModuleSyntax],
|
['admin module syntax', testAdminModuleSyntax],
|
||||||
|
['ai-fab module exists', testAIFabModuleExists],
|
||||||
|
['ai-fab module exports', testAIFabModuleExports],
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const [name, fn] of tests) {
|
for (const [name, fn] of tests) {
|
||||||
|
|||||||
Reference in New Issue
Block a user