diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index ad561aa..34beb10 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -46,6 +46,7 @@ jobs: node excalidraw-viewer.test.mjs node plugins.test.mjs node ai.test.mjs + node ai-sidebar.test.mjs node sw.test.mjs node collab.test.mjs node mobile-editor.test.mjs @@ -60,6 +61,7 @@ jobs: node excalidraw-viewer.test.mjs node plugins.test.mjs node ai.test.mjs + node ai-sidebar.test.mjs node sw.test.mjs node collab.test.mjs node mobile-editor.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a8ac55..4a273c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). > **En cours de développement** : les changements à venir sont listés dans la section -> [Unreleased](#unreleased). La dernière version livrée est **2.4.0**. +> [Unreleased](#unreleased). La dernière version livrée est **2.5.0**. --- @@ -14,6 +14,30 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [2.5.0] — 2026-09-16 + +### Ajouté + +- **Assistant IA — filtre de recherche dans la sidebar « Historique IA » (#98)** : la barre + de filtrage de la sidebar agit désormais sur l'onglet Historique IA. `filterAIHistory()` + (`frontend/js/config.js`) filtre les sessions par titre, aperçu, répertoire, contexte ou + libellé de mode — insensible à la casse et aux accents (`_aiNorm`) — avec cache sessions + (`_aiSessionsCache`), message « aucune conversation ne correspond à la recherche » + (`bookslm.history_no_match`) et placeholder dédié `sidebar.filter_ai`. Le routage + saisie / touche casse / bouton « × » vers ce filtre est géré dans `initSidebarFilter` + (`frontend/js/sidebar.js`) quand l'onglet IA est actif. + +### Corrigé + +- **BUG-048 — menu d'ajout « + » : « Contextes » et « Skills » ouvrent enfin leurs menus + « @ » / « / »** : le clic sur une entrée du panneau `.bookslm-ext-menu` remontait au + gestionnaire du panneau qui annulait le rendu asynchrone du menu (jamais affiché) ; + `e.stopPropagation()` sur les entrées corrige le comportement. Le bouton « + » porte + l'icône Lucide `plus`. Tests : `tests/frontend/ai.test.mjs` (+3), nouvelle suite + `tests/frontend/ai-sidebar.test.mjs` (6 tests). + +--- + ## [2.4.0] — 2026-09-16 ### Ajouté diff --git a/README.fr.md b/README.fr.md index 1dd4f6f..b1ec112 100644 --- a/README.fr.md +++ b/README.fr.md @@ -4,7 +4,7 @@ **Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive. -[]() +[]() [](https://opensource.org/licenses/MIT) [](https://www.docker.com/) [](https://www.python.org/) @@ -916,8 +916,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l ## 📝 Changelog -Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.4.0). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.5.0). --- -*Projet : ObsiGate | Version : 2.4.0 | Dernière mise à jour : Juin 2026* +*Projet : ObsiGate | Version : 2.5.0 | Dernière mise à jour : Juin 2026* diff --git a/README.md b/README.md index a6d8b7c..bc8906c 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface. -[]() +[]() [](https://opensource.org/licenses/MIT) [](https://www.docker.com/) [](https://www.python.org/) @@ -1085,8 +1085,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) ## 📝 Changelog -See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.4.0). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.5.0). --- -*Project: ObsiGate | Version: 2.4.0 | Last updated: May 2026* +*Project: ObsiGate | Version: 2.5.0 | Last updated: May 2026* diff --git a/VERSION b/VERSION index 197c4d5..437459c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.4.0 +2.5.0 diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index 24953f0..f1d2199 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.4.0" +version = "2.5.0" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 1f647bb..4ceb1a9 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.4.0" +version = "2.5.0" description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian" authors = ["Bruno Charest"] edition = "2021" diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index aae72f2..3ce94ad 100644 --- a/desktop/tauri.conf.json +++ b/desktop/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json", "productName": "ObsiGate", - "version": "2.4.0", + "version": "2.5.0", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docs/ISSUES_TODOLIST.md b/docs/ISSUES_TODOLIST.md index 68b5711..c98e02c 100644 --- a/docs/ISSUES_TODOLIST.md +++ b/docs/ISSUES_TODOLIST.md @@ -204,6 +204,7 @@ Avant de corriger quoi que ce soit, un agent IA doit : | 2026-09-16 | BUG-047 | Correction + build | `VERSION` (nouveau), `scripts/bump_version.py` (nouveau), `.githooks/prepare-commit-msg` + `.githooks/post-commit` (nouveaux), `scripts/install-hooks.sh` (nouveau), `scripts/bump_version.sh`, `backend/version.py`, `Dockerfile`, `docker-compose.yml`, `build.sh`, `.gitea/workflows/ci.yml`, `desktop/build.rs`, `package.json`, `README.md`, `README.fr.md`, `docs/ROADMAP.md`, `docs/DELIVERY_WORKFLOW.md`, `docs/DEVELOPMENT_AND_RELEASES.md`, `tests/test_version.py` (nouveau) | **Version alignée de bout en bout.** Cause : la version affichée venait du dernier **tag git** et aucun tag n'était créé aux livraisons → 66 commits livrés mais UI/API figées sur `2.2.1` ; quatre autres numéros codés en dur dérivaient (`package.json` 1.0.0, desktop 2.0.0, `Dockerfile` 2.2.1, README 1.7.0). Nouveau modèle : **`VERSION` (racine) = source unique de vérité** (`MAJEUR.MINEUR.CORRECTIF`), incrémentée **automatiquement au commit** par le hook versionné `prepare-commit-msg` (SemVer depuis le message : `!:`/`BREAKING CHANGE` → MAJEUR, `feat` → MINEUR, sinon CORRECTIF ; aucun bump pour merge/revert/`chore(release)`/amend) qui resynchronise `package.json`, desktop Tauri, ROADMAP, READMEs et publie la section `[Unreleased]` du CHANGELOG en `[X.Y.Z] — date` ; `post-commit` rattache ces fichiers au commit qui vient d'être créé (amend immédiat, le commit n'étant pas encore poussé) puis crée le tag `vX.Y.Z`, publié au push (`push.followTags`). `bump_version.py` (avec `--dry-run`, `--set`, `--major\|--minor\|--patch`, `--print-version`) reste utilisable à la main ; `scripts/install-hooks.sh` installe les hooks. Côté build : Docker `COPY VERSION` (plus d'`ARG VERSION` ni d'env compose codés en dur), `build.sh` et CI lisent `VERSION`, `desktop/build.rs` aussi. Vérifié : `tests/test_version.py` (46 tests, dont le garde-fou `TestRepoVersionAlignment` : VERSION ↔ package.json ↔ desktop ↔ CHANGELOG ↔ ROADMAP ↔ READMEs ↔ pipeline sans numéro codé en dur), pytest complet, ruff/mypy 0, `/api/health` → `2.3.0` sur l'instance de test reconstruite. | 🟢 corrigé (en attente vérif utilisateur) | | 2026-09-16 | #94, #95, #96, #97 | Feature | `backend/ai_history.py` (nouveau), `backend/bookslm_routes.py`, `frontend/js/bookslm.js`, `frontend/js/config.js`, `frontend/index.html`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_bookslm.py`, `tests/frontend/ai.test.mjs`, `docs/features/ai-assistant-history.md` (nouvelle), `docs/ROADMAP.md`, `CHANGELOG.md` | **Assistant IA** : #95 historique **permanent** (backend `data/ai_history/{user}.json`, cap 200, endpoints CRUD `/api/ai/bookslm/history[…]` résumés/full, sync frontend debounced 600 ms + repli localStorage + migration des clés legacy `bookslm-sessions-*`/`bookslm-history-*`, événement `bookslm:history-updated`) ; #96 onglet sidebar `#sidebar-tab-ai` (`messages-square`) + panneau `#sidebar-panel-ai` (liste chronologique, ouverture via `openWithSession`) ; #97 bouton **« + »** remplaçant « Attach an image » + panneau modulaire `.bookslm-ext-menu` (registre `_extensions` : Fichiers, Image, Contextes, Skills, Deep Research = mode agent + prompt, Recherche web & Canva en « Bientôt ») ; #94 bouton d'envoi circulaire + icône Lucide `arrow-up`. Vérifié : pytest 1088 passed / 6 skipped, ruff 0, mypy 0 (71 fichiers), tests frontend IA 84/84, unit 9/9, validate-imports 38 modules. | 🟢 corrigé (en attente vérif utilisateur) | +| 2026-09-16 | BUG-048, #98 | Correction + feature | `frontend/js/bookslm.js`, `frontend/js/config.js`, `frontend/js/sidebar.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `.gitea/workflows/ci.yml`, `tests/frontend/ai.test.mjs`, `tests/frontend/ai-sidebar.test.mjs` (nouveau), `docs/features/ai-assistant-history.md`, `docs/ROADMAP.md`, `CHANGELOG.md` | **BUG-048** : les entrées « Contextes » et « Skills » du menu « + » ouvraient bien leur menu (`@` / `/`), mais le clic remontait au gestionnaire du panneau qui annulait le rendu asynchrone → menu jamais affiché ; correction par `e.stopPropagation()` sur les entrées du panneau `.bookslm-ext-menu`. **#98** : la barre de filtrage de la sidebar agit désormais sur l'onglet « Historique IA » — `filterAIHistory()` (config.js) filtre par titre, aperçu, répertoire, contexte ou libellé de mode, insensible casse/accents (`_aiNorm`), cache sessions `_aiSessionsCache`, message « aucune correspondance » (`bookslm.history_no_match`) dans la liste et placeholder dédié (`sidebar.filter_ai`) ; `initSidebarFilter` (sidebar.js) route saisie/touche casse/bouton « × » vers `filterAIHistory` quand l'onglet IA est actif ; chaque entrée du panneau « + » porte l'icône Lucide `plus`. Vérifié : tests frontend IA 87/87 (+3), nouvelle suite `ai-sidebar` 6/6, unit 9/9, 9 suites JSDOM vertes, validate-imports 38 modules, pytest / ruff / mypy inchangés (aucune modification backend). | 🟢 corrigé (en attente CI + vérif utilisateur) | --- diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 426c415..0e32864 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.4.0 | **Dernière mise à jour :** 2026-09-16 +> **Version :** 2.5.0 | **Dernière mise à jour :** 2026-09-16 > **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact > vers les fonctionnalités livrées. > - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)** @@ -194,6 +194,7 @@ | 95 | Assistant IA — Historique permanent des conversations (persistance backend) | 2.4.0 | [features/ai-assistant-history.md](./features/ai-assistant-history.md) | | 96 | Assistant IA — Accès rapide à l'historique depuis la sidebar de navigation | 2.4.0 | [features/ai-assistant-history.md](./features/ai-assistant-history.md) | | 97 | Assistant IA — Panneau « + » extensible (fichiers, Deep Research, contextes, skills…) | 2.4.0 | [features/ai-assistant-history.md](./features/ai-assistant-history.md) | +| 98 | Assistant IA — Filtre de recherche dans la sidebar « Historique IA » | 2.5.0 | [features/ai-assistant-history.md](./features/ai-assistant-history.md) | --- diff --git a/docs/features/ai-assistant-history.md b/docs/features/ai-assistant-history.md index fe401ed..9f690c8 100644 --- a/docs/features/ai-assistant-history.md +++ b/docs/features/ai-assistant-history.md @@ -91,6 +91,50 @@ - [x] Vérifs : pytest **1088 passed / 6 skipped**, ruff 0, mypy 0 (71 fichiers), frontend `ai.test.mjs` **84/84**, `unit.test.mjs` 9/9, `validate-imports` 38 modules. +## H. Complément — filtre de la sidebar et menu « + » (BUG-048, #98) — ✅ livré + +En retour utilisateur sur la version 2.4.0 : + +*(1) **Filtre fonctionnel sur l'onglet « Historique IA » (sidebar) — #98** — la barre de +filtrage globale de la sidebar agissait uniquement sur les onglets Fichiers/Tags. Elle +s'applique désormais aussi à l'historique IA :* + +- Utile `filterAIHistory(query)` (`frontend/js/config.js`) : filtre le cache de sessions + `_aiSessionsCache` (peuplé par `loadAISessionList()`) sur **titre, aperçu, répertoire, + contexte ou libellé de mode** traduit, insensible à la casse et aux accents + (`_aiNorm` : `NFD` + suppression des diacritiques + `toLowerCase`). Une requête sans + résultat affiche `bookslm.history_no_match` (nouvelle clé i18n FR/EN) **dans la liste**, + en plus de l'état vide d'origine ; un champ vide restaure tout. +- Routage dans `initSidebarFilter` (`frontend/js/sidebar.js`) : la saisie (debounce 220 ms), + le bouton casse `Aa` et le bouton « × » dirigent vers `filterAIHistory` quand l'onglet IA + est actif (`state.activeSidebarTab`), au lieu de `filterTagCloud`. Le placeholder devient + `sidebar.filter_ai` (« Filtrer l'historique IA… » / « Filter AI history… ») — résolu dans + `switchSidebarTab` (config.js), qui re-charge aussi la liste à chaque entrée dans l'onglet + en ré-appliquant la requête courante. + +*(2) **Menu « + » du panneau Assistant — BUG-048** — « ajouter des contextes » ouvrait un +menu « @ » jamais affiché (et idem pour « ajouter des skills » → « / ») :* le clic sur une +entrée du panneau `.bookslm-ext-menu` remontait au gestionnaire `click` du panneau +(`_hideMenus()` + `_menuSeq++`), qui annulait le rendu asynchrone du menu ouvert juste après. +`e.stopPropagation()` est ajouté sur chaque entrée de `_renderExtMenu()` (bookslm.js) : +les menus « @ » (contextes) et « / » (skills) restent affichés. Le bouton d'ouverture porte +bien l'icône Lucide `plus` (vérifié par test JSDOM). + +**Points d'attention** +- Le filtrage reste **client-side** : la charge est triviale vu le cap de 200 sessions + (`MAX_SESSIONS`). Un futur filtrage serveur n'est justifié que si la rétention croît. +- `filterAIHistory` est exportée en plus de `loadAISessionList` : `validate-imports` couvre le + nouveau contrat d'import de `sidebar.js`. + +### Tests du complément +- `tests/frontend/ai-sidebar.test.mjs` (nouveau, 6 tests) : rendu complet, filtre par titre + (casse), accents (« cafe » → « café au lait »), aperçu/répertoire, restauration complète, + message « aucune correspondance ». +- `tests/frontend/ai.test.mjs` (+3) : clic « Contextes » → menu « @ » visible listé, clic + « Skills » → menu « / » visible listé, icône `plus` présente sur le bouton « + ». +- Vérifs : frontend IA **87/87**, `ai-sidebar` **6/6**, `unit` 9/9, 9 suites JSDOM vertes, + `validate-imports` 38 modules ; backend inchangé (pytest / ruff / mypy valides). + ## G. Points d'attention - Le localStorage reste un **cache** : le serveur est la source de vérité (`data/ai_history/`). En cas de données locales corrompues, un `localStorage.clear()` réinitialise proprement. diff --git a/frontend/js/bookslm.js b/frontend/js/bookslm.js index 56d077b..e9c2971 100644 --- a/frontend/js/bookslm.js +++ b/frontend/js/bookslm.js @@ -1433,7 +1433,11 @@ class BooksLM { ${entry.disabled ? '' + t('bookslm.soon') + '' : ''} ${entry.hint} `; - item.addEventListener('click', () => { + item.addEventListener('click', (e) => { + // BUG-048 — Keep the event from reaching the panel's click handler, + // which would hide a mention/command menu opened by this action right + // after it opens (the menu is rendered asynchronously). + e.stopPropagation(); if (!entry.disabled && entry.action) entry.action(); }); menu.appendChild(item); diff --git a/frontend/js/config.js b/frontend/js/config.js index a47b92f..8e4f2d3 100644 --- a/frontend/js/config.js +++ b/frontend/js/config.js @@ -11,6 +11,8 @@ import { getModelCapabilities, renderCapabilityList, refreshAIPickers } from './ let _recentTimestampTimer = null; let _recentFilesCache = []; let _recentRefreshTimer = null; +let _aiSessionsCache = []; +let _aiQuery = ""; export async function loadRecentFiles(vaultFilter) { const listEl = document.getElementById("recent-list"); @@ -160,6 +162,34 @@ function _formatAIDate(ts) { } } +/** Accent- and case-insensitive normalization for the AI history filter (#98). */ +function _aiNorm(value) { + return String(value || "") + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase(); +} + +/** Apply the current sidebar query to a session list, resolving the mode label. */ +function _applyAIFilter(sessions) { + const q = _aiNorm(_aiQuery); + if (!q) return sessions; + return (sessions || []).filter((s) => { + const modeLabel = t("bookslm.mode_" + (s.mode || "general")); + return _aiNorm(s.title).includes(q) + || _aiNorm(s.preview).includes(q) + || _aiNorm(s.directory).includes(q) + || _aiNorm(s.context).includes(q) + || _aiNorm(modeLabel).includes(q); + }); +} + +/** Filter the sidebar AI history list by the sidebar filter input (#98). */ +export function filterAIHistory(query) { + _aiQuery = (query || "").trim(); + renderAIHistoryList(_applyAIFilter(_aiSessionsCache)); +} + export async function loadAISessionList() { const listEl = document.getElementById("ai-history-list"); const emptyEl = document.getElementById("ai-history-empty"); @@ -171,7 +201,8 @@ export async function loadAISessionList() { } catch (err) { console.error("Failed to load AI history:", err); } - renderAIHistoryList(sessions); + _aiSessionsCache = sessions; + renderAIHistoryList(_applyAIFilter(_aiSessionsCache)); } function renderAIHistoryList(sessions) { @@ -181,6 +212,9 @@ function renderAIHistoryList(sessions) { listEl.innerHTML = ""; if (!sessions || sessions.length === 0) { + if (_aiSessionsCache.length && _aiQuery) { + listEl.appendChild(el("div", { class: "sidebar-filter-empty" }, [document.createTextNode(t("bookslm.history_no_match"))])); + } if (emptyEl) { emptyEl.classList.remove("hidden"); safeCreateIcons(); @@ -240,13 +274,14 @@ function switchSidebarTab(tab) { }); const filterInput = document.getElementById("sidebar-filter-input"); if (filterInput) { - const placeholders = { vaults: "Filtrer fichiers...", tags: "Filtrer tags...", recent: "", ai: "" }; + const placeholders = { vaults: "Filtrer fichiers...", tags: "Filtrer tags...", recent: "", ai: t("sidebar.filter_ai") }; filterInput.placeholder = placeholders[tab] || ""; } const query = filterInput ? (state.sidebarFilterCaseSensitive ? filterInput.value.trim() : filterInput.value.trim().toLowerCase()) : ""; if (query) { if (tab === "vaults") performTreeSearch(query); else if (tab === "tags") filterTagCloud(query); + else if (tab === "ai") filterAIHistory(query); } // Auto-load recent files when switching to the recent tab if (tab === "recent") { @@ -254,8 +289,10 @@ function switchSidebarTab(tab) { const vaultFilter = document.getElementById("recent-vault-filter"); loadRecentFiles(vaultFilter ? vaultFilter.value || null : null); } - // #96 — Auto-load the AI conversation history when entering its tab. + // #96/#98 — Auto-load the AI conversation history when entering its tab, + // re-applying the current sidebar filter query. if (tab === "ai") { + if (filterInput) _aiQuery = filterInput.value.trim(); loadAISessionList(); } } diff --git a/frontend/js/sidebar.js b/frontend/js/sidebar.js index 46e8fad..0209555 100644 --- a/frontend/js/sidebar.js +++ b/frontend/js/sidebar.js @@ -2,7 +2,7 @@ import { state } from './state.js'; import { safeCreateIcons, getFileIcon, flushIcons } from './utils.js'; import { api } from './auth.js'; import { populateCustomDropdown, TabManager, closeMobileSidebar, ContextMenuManager } from './ui.js'; -import { _populateRecentVaultFilter, switchSidebarTab } from './config.js'; +import { _populateRecentVaultFilter, switchSidebarTab, filterAIHistory } from './config.js'; import { el, icon, getVaultIcon, smallBadge, attachTreeItemActionButton, attachTreeItemLongPress, showWelcome, appendHighlightedText } from './viewer.js'; import { performAdvancedSearch } from './search.js'; import { t } from './i18n.js'; @@ -709,12 +709,16 @@ function initSidebarFilter() { if (hasText) { if (state.activeSidebarTab === "vaults") { await performTreeSearch(q); + } else if (state.activeSidebarTab === "ai") { + filterAIHistory(q); } else { filterTagCloud(q); } } else { if (state.activeSidebarTab === "vaults") { await restoreSidebarTree(); + } else if (state.activeSidebarTab === "ai") { + filterAIHistory(""); } else { filterTagCloud(""); } @@ -729,6 +733,8 @@ function initSidebarFilter() { if (input.value.trim()) { if (state.activeSidebarTab === "vaults") { await performTreeSearch(q); + } else if (state.activeSidebarTab === "ai") { + filterAIHistory(q); } else { filterTagCloud(q); } @@ -743,6 +749,8 @@ function initSidebarFilter() { clearTimeout(state.filterDebounce); if (state.activeSidebarTab === "vaults") { await restoreSidebarTree(); + } else if (state.activeSidebarTab === "ai") { + filterAIHistory(""); } else { filterTagCloud(""); } diff --git a/frontend/locales/en.json b/frontend/locales/en.json index fc708b0..c708b3c 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -1544,6 +1544,7 @@ "sidebar.expand_collapse": "Expand/Collapse", "sidebar.file_icons": "Icons", "sidebar.filter_instant": "Instant search", + "sidebar.filter_ai": "Filter AI history...", "sidebar.filter_path": "Path filters", "sidebar.filter_placeholder": "Filter files...", "sidebar.filter_results_grouped": "Grouped results", @@ -1826,6 +1827,7 @@ "bookslm.session_history": "Session history", "bookslm.session_delete": "Delete session", "bookslm.history_empty": "No saved session", + "bookslm.history_no_match": "No conversation matches the search", "bookslm.untitled": "Untitled conversation", "bookslm.no_vault": "No active vault to open this link", "bookslm.copied_name": "Name copied: {name}", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index 68f5de8..ec44ba9 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -1544,6 +1544,7 @@ "sidebar.expand_collapse": "Développer/Réduire", "sidebar.file_icons": "Icônes", "sidebar.filter_instant": "Recherche instantanée", + "sidebar.filter_ai": "Filtrer l'historique IA...", "sidebar.filter_path": "Filtres de chemin", "sidebar.filter_placeholder": "Filtrer fichiers...", "sidebar.filter_results_grouped": "Résultats groupés", @@ -1826,6 +1827,7 @@ "bookslm.session_history": "Historique des sessions", "bookslm.session_delete": "Supprimer la session", "bookslm.history_empty": "Aucune session enregistrée", + "bookslm.history_no_match": "Aucune conversation ne correspond à la recherche", "bookslm.untitled": "Conversation sans titre", "bookslm.no_vault": "Aucun vault actif pour ouvrir ce lien", "bookslm.copied_name": "Nom copié : {name}", diff --git a/package.json b/package.json index e93776a..444d518 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsigate", - "version": "2.4.0", + "version": "2.5.0", "description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.", "main": "patch.js", "directories": { diff --git a/tests/frontend/ai-sidebar.test.mjs b/tests/frontend/ai-sidebar.test.mjs new file mode 100644 index 0000000..e827a47 --- /dev/null +++ b/tests/frontend/ai-sidebar.test.mjs @@ -0,0 +1,143 @@ +#!/usr/bin/env node +/** + * ObsiGate - JSDOM tests for the AI history sidebar filter (#98). + * + * Covers the client-side search over the AI conversation history rendered in + * the left sidebar "Historique IA" tab: + * - sessions are loaded and rendered through `loadAISessionList()` + * - `filterAIHistory()` narrows the list by title / preview / directory / + * context / mode label, case- and accent-insensitively + * - an empty match shows a "no match" hint instead of the empty state + * + * Usage: node tests/frontend/ai-sidebar.test.mjs + */ + +import { strict as assert } from "node:assert"; +import { JSDOM } from "jsdom"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import path from "node:path"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const JS_DIR = path.resolve(__dirname, "..", "..", "frontend", "js"); + +const dom = new JSDOM( + ` +
+ +`, + { url: "http://localhost/", pretendToBeVisual: true }, +); + +const w = dom.window; +globalThis.window = w; +globalThis.document = w.document; +globalThis.HTMLElement = w.HTMLElement; +globalThis.Element = w.Element; +globalThis.Node = w.Node; +globalThis.Event = w.Event; +globalThis.CustomEvent = w.CustomEvent; +globalThis.KeyboardEvent = w.KeyboardEvent; +globalThis.localStorage = w.localStorage; +globalThis.sessionStorage = w.sessionStorage; +Object.defineProperty(globalThis, "navigator", { + value: w.navigator, + configurable: true, + writable: true, +}); +globalThis.MutationObserver = w.MutationObserver; +globalThis.getComputedStyle = w.getComputedStyle.bind(w); +globalThis.requestAnimationFrame = (cb) => setTimeout(cb, 0); +globalThis.cancelAnimationFrame = (id) => clearTimeout(id); + +let testCount = 0; +let passCount = 0; + +async function test(name, fn) { + testCount++; + try { + await fn(); + passCount++; + console.log(` ✓ ${name}`); + } catch (err) { + console.error(` ✗ ${name}\n ${err.message}`); + process.exitCode = 1; + } +} + +const SESSIONS = [ + { id: "s1", title: "Pizza maison", mode: "general", updatedAt: 1, preview: "La pâte à pizza se lève toute la nuit" }, + { id: "s2", title: "Recette café au lait", mode: "directory", directory: "Recettes", updatedAt: 2 }, + { id: "s3", title: "Plan du cours", mode: "documents", context: "documents-books", updatedAt: 3 }, +]; + +globalThis.fetch = async (url) => { + if (String(url).includes("/api/ai/bookslm/history")) { + return { ok: true, status: 200, json: async () => ({ sessions: SESSIONS }) }; + } + return { ok: true, status: 200, json: async () => ({}) }; +}; + +const mod = await import( + pathToFileURL(path.join(JS_DIR, "config.js")).href +); +const { loadAISessionList, filterAIHistory } = mod; + +const listEl = () => document.getElementById("ai-history-list"); +const items = () => Array.from(listEl().querySelectorAll(".recent-item")); + +console.log("AI history sidebar filter (#98) — config.js"); + +await test("loadAISessionList renders every session", async () => { + await loadAISessionList(); + assert.equal(items().length, 3, "three sessions rendered"); +}); + +await test("filter narrows by title, case-insensitively", async () => { + filterAIHistory("pizza"); + assert.equal(items().length, 1, "one session matches 'pizza'"); + filterAIHistory("PLAN"); + assert.equal(items().length, 1, "case-insensitive match on 'PLAN'"); +}); + +await test("filter matches accents-insensitively", async () => { + filterAIHistory("cafe"); + assert.equal(items().length, 1, "'cafe' matches 'café au lait'"); + const titles = items().map((el) => el.textContent); + assert.ok(titles.some((t) => t.includes("café")), "the café session is the one kept"); +}); + +await test("filter matches the preview and the directory", async () => { + filterAIHistory("pâte"); + assert.equal(items().length, 1, "'pâte' matches the preview text"); + filterAIHistory("recettes"); + assert.equal(items().length, 1, "'recettes' matches the session directory"); +}); + +await test("clearing the query restores the full list", async () => { + filterAIHistory(""); + assert.equal(items().length, 3, "no query keeps everything"); +}); + +await test("an unmatched query shows a no-match hint, not the empty state", async () => { + filterAIHistory("zzz-inexistant"); + assert.equal(items().length, 0, "no item left"); + assert.ok(listEl().querySelector(".sidebar-filter-empty"), + "a 'no match' hint is rendered in the list box"); + const emptyEl = document.getElementById("ai-history-empty"); + assert.ok(emptyEl.classList.contains("hidden") === false, "empty state is also visible"); +}); + +// ── Summary ── +console.log(`\n${passCount}/${testCount} tests passed`); +if (passCount !== testCount) { + process.exit(1); +} \ No newline at end of file diff --git a/tests/frontend/ai.test.mjs b/tests/frontend/ai.test.mjs index 250d4fa..eb7638d 100644 --- a/tests/frontend/ai.test.mjs +++ b/tests/frontend/ai.test.mjs @@ -1802,6 +1802,96 @@ async function main() { assert.equal(AuthManager.getToken(), null, "session cleared"); }); + // ── BUG-048: the "+" panel menus must stay open (contexts @ / skills /) ── + await test("ext menu opens the @ mention menu and keeps it visible", async () => { + localStorage.clear(); + const stateMod = await import(pathToFileURL(path.join(JS_DIR, "state.js")).href); + const prevVaults = stateMod.state.allVaults; + stateMod.state.allVaults = [{ name: "V" }]; + globalThis.fetch = async (url) => { + const u = String(url); + if (u.includes("/api/ai/skills")) { + return { + ok: true, status: 200, + json: async () => ({ + skills: [{ id: "research", label: "Recherche", type: "skill", description: "d" }], + commands: [], + }), + }; + } + if (u.includes("/paths")) { + return { + ok: true, status: 200, + json: async () => ({ vault: "V", count: 1, results: [ + { vault: "V", path: "notes/a.md", name: "a.md", type: "file" }, + ] }), + }; + } + return { ok: true, status: 200, json: async () => ({}) }; + }; + const b = new BooksLM(); + b._panel = b._render(); + document.body.appendChild(b._panel); + const ta = b._panel.querySelector("textarea"); + ta.value = ta.selectionStart = ta.selectionEnd = 0; + b._toggleExtMenu(); + const mentionMenu = b._panel.querySelector(".bookslm-mention-menu"); + const contextBtn = b._panel.querySelector('.bookslm-ext-item[data-ext-id="context"]'); + assert.ok(contextBtn, "Contextes entry present in the + panel"); + contextBtn.click(); + await sleep(20); + assert.ok(!mentionMenu.classList.contains("hidden"), + "mention menu stays open after picking Contextes (BUG-048)"); + assert.ok(mentionMenu.querySelectorAll(".bookslm-menu-item").length >= 1, + "mention menu lists entries"); + b._panel.remove(); + localStorage.clear(); + stateMod.state.allVaults = prevVaults; + }); + + await test("ext menu opens the / command menu and keeps it visible", async () => { + localStorage.clear(); + globalThis.fetch = async (url) => { + if (String(url).includes("/api/ai/skills")) { + return { + ok: true, status: 200, + json: async () => ({ + skills: [{ id: "research", label: "Recherche", type: "skill", description: "d" }], + commands: [], + }), + }; + } + return { ok: true, status: 200, json: async () => ({}) }; + }; + const b = new BooksLM(); + b._panel = b._render(); + document.body.appendChild(b._panel); + const ta = b._panel.querySelector("textarea"); + ta.value = ta.selectionStart = ta.selectionEnd = 0; + b._toggleExtMenu(); + const commandMenu = b._panel.querySelector(".bookslm-command-menu"); + const skillBtn = b._panel.querySelector('.bookslm-ext-item[data-ext-id="skill"]'); + assert.ok(skillBtn, "Skills entry present in the + panel"); + skillBtn.click(); + await sleep(20); + assert.ok(!commandMenu.classList.contains("hidden"), + "command menu stays open after picking Skills (BUG-048)"); + assert.ok(commandMenu.querySelectorAll(".bookslm-menu-item").length >= 1, + "command menu lists skills"); + b._panel.remove(); + localStorage.clear(); + }); + + await test("the + button carries a '+' icon", async () => { + const b = new BooksLM(); + b._panel = b._render(); + document.body.appendChild(b._panel); + const plus = b._panel.querySelector(".bookslm-btn-plus i"); + assert.ok(plus, "the + button shows an icon"); + assert.equal(plus.getAttribute("data-lucide"), "plus", "the + button icon is a plus"); + b._panel.remove(); + }); + // ── Summary ── console.log(`\n${passCount}/${testCount} tests passed`); if (passCount !== testCount) {