Compare commits

...
5 Commits
Author SHA1 Message Date
bruno 0d4f43a8bf test(ai): collecter toutes les requetes pour eviter la course avec l'hydratation d'historique
CI / lint (push) Successful in 1m31s
CI / security (push) Successful in 1m0s
CI / test (push) Successful in 2m42s
CI / build (push) Successful in 55s
CI / e2e (push) Successful in 11m10s
2026-09-16 20:22:15 -04:00
bruno 4231f2e929 feat(sidebar+assistant): filtre Recents/Sauvegardes (#99), pastille Deep Research (#100) et icone du bouton + (BUG-049)
CI / lint (push) Failing after 1m20s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Successful in 59s
2026-09-16 20:16:41 -04:00
bruno 8f26a418a9 test(ai): rendre le test du menu mention non flaky (nettoyage d'etat + attente du fetch)
CI / lint (push) Successful in 1m29s
CI / security (push) Successful in 1m3s
CI / test (push) Successful in 2m55s
CI / build (push) Successful in 55s
CI / e2e (push) Successful in 10m44s
2026-09-16 19:30:15 -04:00
bruno f50a9f5bf3 docs(roadmap): clore la livraison #98 + BUG-048 avec la confirmation CI (v2.5.0, run #1511)
CI / lint (push) Failing after 1m19s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Successful in 58s
2026-09-16 16:29:36 -04:00
bruno 8e2b6203a1 feat: filtre de recherche dans la sidebar Historique IA (#98) + menus '@' et '/' depuis le menu '+' (BUG-048)
CI / lint (push) Successful in 1m28s
CI / security (push) Successful in 58s
CI / test (push) Successful in 2m19s
CI / build (push) Successful in 54s
CI / e2e (push) Successful in 10m44s
2026-09-16 16:11:54 -04:00
23 changed files with 879 additions and 76 deletions
+4
View File
@@ -46,6 +46,8 @@ jobs:
node excalidraw-viewer.test.mjs
node plugins.test.mjs
node ai.test.mjs
node ai-sidebar.test.mjs
node sidebar-filters.test.mjs
node sw.test.mjs
node collab.test.mjs
node mobile-editor.test.mjs
@@ -60,6 +62,8 @@ jobs:
node excalidraw-viewer.test.mjs
node plugins.test.mjs
node ai.test.mjs
node ai-sidebar.test.mjs
node sidebar-filters.test.mjs
node sw.test.mjs
node collab.test.mjs
node mobile-editor.test.mjs
+65 -1
View File
@@ -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.6.1**.
---
@@ -14,6 +14,70 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [2.6.1] — 2026-09-16
---
## [2.6.0] — 2026-09-16
### Ajouté
- **Barre de filtrage de la sidebar sur « Récents » et « Sauvegardes » (#99)** : la barre
de recherche de la sidebar agit désormais sur les onglets **Récents**
(`filterRecentFiles`, filtrage titre/chemin/vault/aperçu/tags) et **Sauvegardes**
(`filterSavedSearches`, cumulable avec les pills Tous/Recherches/Répertoires) —
insensible à la casse et aux accents, avec message d'absence de résultat et
placeholders dédiés (`sidebar.filter_recent`, `sidebar.filter_saved`). Le routage de
`initSidebarFilter` est unifié (`routeFilter`/`routeClear`) pour couvrir les cinq
onglets. Fiche : [docs/features/sidebar-filters.md](./docs/features/sidebar-filters.md).
- **Assistant IA — Deep Research en pastille (#100)** : « Deep Research » ajoute
désormais une **pastille** (comme les skills) au lieu d'écrire la directive dans la
zone de saisie ; le mode Agent est activé et la directive est injectée au moment de
l'envoi, sans polluer le message affiché.
### Corrigé
- **BUG-049 — icône du bouton « + » de l'assistant invisible** : la règle générique
`.bookslm-input-area button` écrasait `.bookslm-btn-plus` (`padding: 8px 16px` sur une
largeur de 32 px ⇒ largeur de contenu nulle ⇒ SVG à 0 px). Sélecteur porté à
`.bookslm-input-area button.bookslm-btn-plus`, l'icône `plus` est de nouveau visible
(vérifié en navigateur : SVG 0 px → 18 px). Tests : `tests/frontend/ai.test.mjs` (+1),
`tests/frontend/sidebar-filters.test.mjs` (nouveau, 8 tests).
---
## [2.5.2] — 2026-09-16
---
## [2.5.1] — 2026-09-16
---
## [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é
+3 -3
View File
@@ -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.
[![Version](https://img.shields.io/badge/Version-2.4.0-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.6.1-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](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.6.1).
---
*Projet : ObsiGate | Version : 2.4.0 | Dernière mise à jour : Juin 2026*
*Projet : ObsiGate | Version : 2.6.1 | Dernière mise à jour : Juin 2026*
+3 -3
View File
@@ -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.
[![Version](https://img.shields.io/badge/Version-2.4.0-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.6.1-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](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.6.1).
---
*Project: ObsiGate | Version: 2.4.0 | Last updated: May 2026*
*Project: ObsiGate | Version: 2.6.1 | Last updated: May 2026*
+1 -1
View File
@@ -1 +1 @@
2.4.0
2.6.1
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.4.0"
version = "2.6.1"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.4.0"
version = "2.6.1"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"]
edition = "2021"
+1 -1
View File
@@ -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.6.1",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+2
View File
@@ -204,6 +204,8 @@ 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 vérif utilisateur) — CI Gitea verte (lint, test, security, build, e2e) pour v2.5.0 (run #1511) |
| 2026-09-16 | BUG-049, #99, #100 | Correction + feature | `frontend/style.css`, `frontend/js/config.js`, `frontend/js/viewer.js`, `frontend/js/sidebar.js`, `frontend/js/bookslm.js`, `frontend/locales/{fr,en}.json`, `.gitea/workflows/ci.yml`, `tests/frontend/ai.test.mjs`, `tests/frontend/sidebar-filters.test.mjs` (nouveau), `docs/features/sidebar-filters.md` (nouvelle), `docs/features/ai-assistant-history.md`, `docs/ROADMAP.md`, `CHANGELOG.md` | **BUG-049** : icône du bouton « + » de l'assistant invisible — la règle générique `.bookslm-input-area button` (spécificité supérieure) imposait `padding: 8px 16px` sur un bouton `width: 32px` ⇒ largeur de contenu nulle ⇒ SVG `width: 0px` ; sélecteur porté à `.bookslm-input-area button.bookslm-btn-plus` (+ `:hover`), vérifié en navigateur (Playwright : SVG 0 px → 18 px). **#99** : la barre de filtrage de la sidebar agit désormais sur les vues **Récents** (`filterRecentFiles`, titre/chemin/vault/aperçu/tags) et **Sauvegardes** (`filterSavedSearches`, cumulable avec les pills type), insensible casse/accents (`_sidebarNorm`/`_savedNorm`), message d'absence de résultat (`sidebar.no_results`) et placeholders dédiés (`sidebar.filter_recent`, `sidebar.filter_saved`) ; `initSidebarFilter` refactoré en `routeFilter`/`routeClear` couvrant les 5 onglets. **#100** : « Deep Research » ajoute une **pastille** `.bookslm-chip-deep-research` (au lieu d'injecter la directive dans le composeur), active le mode Agent et injecte la directive au moment de l'envoi. Vérifié : tests frontend IA 88/88 (+1), `sidebar-filters` 8/8 (nouveau), `ai-sidebar` 6/6, unit 9/9, 9 suites JSDOM vertes, validate-imports 38 modules, vérification navigateur du bouton « + » ; backend inchangé (pytest / ruff / mypy valides). | 🟢 corrigé (en attente vérif utilisateur) |
---
+5 -2
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.4.0 | **Dernière mise à jour :** 2026-09-16
> **Version :** 2.6.1 | **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,9 @@
| 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) |
| 99 | Sidebar — Filtrage des vues « Récents » et « Sauvegardes » | 2.6.0 | [features/sidebar-filters.md](./features/sidebar-filters.md) |
| 100 | Assistant IA — Deep Research en pastille (au lieu du texte injecté) | 2.6.0 | [features/ai-assistant-history.md](./features/ai-assistant-history.md) |
---
@@ -201,7 +204,7 @@
| Priorité | Items | Effort total estimé |
|---|---|---|
| ✅ Complété | #1 → #59, #61–72, #74–76, #78–84, #88–93, #94–97 | ~112 jours réalisés |
| ✅ Complété | #1 → #59, #61–72, #74–76, #78–84, #88–93, #94–100 | ~114 jours réalisés |
| 🔵 P2 restant | #77 Desktop : signature de code (non retenue), 6 tests E2E **manuels** ([protocole](./DESKTOP_E2E_CHECKLIST.md)) | ~0,5-1 jour |
| ⚪ P4 restant | #73 Sync (6-8j) | 6-8 jours |
| ⚪ P2 restant | #92 Assistant IA — écosystème d'outils phase 2 (web étendu, sources connectées, documents) | 3-5 jours |
+78 -1
View File
@@ -91,10 +91,87 @@
- [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.
- `MAX_SESSIONS = 200` : le comportement de purge est testé (`TestAIGatewayHistoryStore`)
; la rétention configurable (item #95 du backlog) pourra s'appuyer sur ce plafond.
- Les modules web/Canva du panneau « + » sont volontairement désactivés tant que
l'écosystème d'outils phase 2 (#92) n'est pas livré.
l'écosystème d'outils phase 2 (#92) n'est pas livré.
## I. Complément — icône « + » et pastille Deep Research (BUG-049, #100) — ✅ livré
*(1) **BUG-049 — l'icône du bouton « + » n'était pas visible.*** Le SVG Lucide était
bien rendu, mais la règle générique `.bookslm-input-area button { padding: 8px 16px;
background: var(--accent); color:#fff }` l'emportait en **spécificité** sur
`.bookslm-btn-plus` (une classe seule). Le bouton conservait `width:32px` avec
`padding: 8px 16px` → **largeur de contenu = 0 px**, donc SVG à `width: 0px`
(invisible). Correctif CSS : sélecteur porté à
`.bookslm-input-area button.bookslm-btn-plus` (et `:hover`), qui reprend la main
(`padding:0`, fond transparent, couleur `--text-secondary`). Vérifié en navigateur
(Playwright) : `svgWidth` passe de `0px` à `18px`.
*(2) **#100 — Deep Research devient une pastille (comme les skills).*** Auparavant,
cliquer sur « Deep Research » injectait la directive de recherche dans la zone de
saisie. Désormais :
- `_startDeepResearch()` active le **mode Agent** (si nécessaire), positionne le drapeau
`_activeDeepResearch` et rend une **pastille** `.bookslm-chip-deep-research`
(`_renderAttachments()`), sans rien écrire dans le composeur.
- La pastille se retire via son « × » (comme les chips skills/fichiers) et remet le
drapeau à `false`.
- La directive (`bookslm.deep_research_prompt`) est injectée **au moment de l'envoi**
dans le `message` du payload (`_sendMessage()`), sans polluer le message affiché à
l'utilisateur.
- Message d'information mis à jour (`bookslm.deep_research_started`) : « Deep Research
activé — ajoutez votre question puis envoyez. »
### Tests du complément
- `tests/frontend/ai.test.mjs` (+1) : le clic sur « Deep Research » ajoute une pastille,
laisse le composeur vide, positionne le drapeau, et le retrait de la pastille remet
le drapeau à `false`.
- Vérification navigateur du bouton « + » (Playwright, instance de test).
+72
View File
@@ -0,0 +1,72 @@
# #99 — Barre de filtrage de la sidebar sur « Récents » et « Sauvegardes »
> **Statut :** ✅ livré · **Version :** 2.6.0 · **ID :** #99
> **Lié :** BUG-049 (icône du bouton « + » de l'assistant), #100 (pastille Deep Research).
## Contexte
La barre de recherche/filtrage de la sidebar (`#sidebar-filter-input`) agissait
historiquement sur les onglets **Fichiers** (arborescence) et **Tags**. L'onglet
**Historique IA** a reçu son propre filtrage en #98. Les onglets **Récents** et
**Sauvegardes** (« saved searches ») n'étaient pas couverts : taper dans la barre
n'avait aucun effet dans ces deux vues.
## A. Onglet « Récents » — ✅ livré
- Nouveau cache module `_recentQuery` et fonction exportée `filterRecentFiles(query)`
(`frontend/js/config.js`).
- `_applyRecentFilter(files)` filtre le cache `_recentFilesCache` sur **titre, chemin,
vault, aperçu et tags**, via `_sidebarNorm()` (normalisation `NFD` + suppression des
diacritiques + minuscules) — donc insensible à la casse **et** aux accents.
- `loadRecentFiles()` applique désormais le filtre courant après chaque chargement.
- Aucun résultat → un message `sidebar.no_results` est inséré dans `#recent-list`
(classe `.sidebar-filter-empty`, style existant).
- `switchSidebarTab("recent")` applique la requête courante et pose le placeholder
`sidebar.filter_recent`.
## B. Onglet « Sauvegardes » — ✅ livré
- Nouveau `_savedQuery` et fonction exportée `filterSavedSearches(query)`
(`frontend/js/viewer.js`).
- `_applySavedFilter()` combine désormais **le filtre de type** (pills Tous / Recherches
/ Répertoires) **et la requête texte** : un élément est visible si son `data-type`
correspond au pill **et** si son texte (requête, vault, chemins inclus/exclus) contient
la requête normalisée (`_savedNorm`).
- Aucun résultat avec une requête active → message `sidebar.no_results` ajouté à
`#saved-searches-list` (nettoyé à chaque ré-application pour ne pas s'accumuler).
- `switchSidebarTab("saved")` ré-applique la requête et pose le placeholder
`sidebar.filter_saved`.
## C. Routage unifié de la barre de filtrage — ✅ livré
`initSidebarFilter()` (`frontend/js/sidebar.js`) est refactorisé autour de deux
fonctions `routeFilter(q)` / `routeClear()` qui dirigent la saisie, la bascule
casse (`Aa`) et le bouton « × » vers le filtre de l'onglet actif :
| Onglet | Filtre |
|---|---|
| `vaults` | `performTreeSearch` / `restoreSidebarTree` |
| `recent` | `filterRecentFiles` |
| `saved` | `filterSavedSearches` |
| `ai` | `filterAIHistory` |
| `tags` | `filterTagCloud` |
## D. i18n — ✅ livré
- `sidebar.filter_recent` : « Filtrer les fichiers récents... » / "Filter recent files..."
- `sidebar.filter_saved` : « Filtrer les recherches sauvegardées... » / "Filter saved searches..."
- Le message d'absence de résultat réutilise `sidebar.no_results`.
## E. Tests — ✅ livré
- `tests/frontend/sidebar-filters.test.mjs` (nouveau, 8 tests) : rendu des listes,
filtrage par titre/casse, accents/tags, vault/chemin, restauration, message
d'absence de résultat, et combinaison pill + requête sur les sauvegardes.
- Ajouté à la liste explicite du job `lint` de `.gitea/workflows/ci.yml`.
## F. Points d'attention
- Le filtrage est **client-side** (les listes sont déjà chargées) : aucune requête
réseau supplémentaire n'est déclenchée par la frappe.
- Sur « Sauvegardes », le filtre de type et la requête sont **cumulatifs** ; vider la
barre (ou « × ») ne réinitialise pas le pill actif.
+22 -15
View File
@@ -114,6 +114,9 @@ class BooksLM {
this._adhocDirs = [];
this._images = [];
this._activeSkill = null;
// Deep Research: a skill-like chip that enables agent tools and injects a
// research directive into the request (without polluting the composer).
this._activeDeepResearch = false;
this._skills = [];
this._menuItems = [];
this._menuIndex = 0;
@@ -214,6 +217,7 @@ class BooksLM {
this._adhocDirs = [];
this._images = [];
this._activeSkill = null;
this._activeDeepResearch = false;
this._isLoading = true;
await this._loadHistory(preferredSessionId);
@@ -1216,7 +1220,7 @@ class BooksLM {
const host = this._panel.querySelector('.bookslm-attachments');
if (!host) return;
host.innerHTML = '';
const hasAny = this._adhocFiles.length || this._adhocDirs.length || this._images.length || this._activeSkill;
const hasAny = this._adhocFiles.length || this._adhocDirs.length || this._images.length || this._activeSkill || this._activeDeepResearch;
host.classList.toggle('empty', !hasAny);
if (!hasAny) return;
@@ -1228,6 +1232,13 @@ class BooksLM {
() => { this._activeSkill = null; this._renderAttachments(); },
));
}
if (this._activeDeepResearch) {
host.appendChild(this._chip(
`🔎 ${t('bookslm.ext_deep_research')}`,
'deep-research',
() => { this._activeDeepResearch = false; this._renderAttachments(); },
));
}
for (const file of this._adhocFiles) {
host.appendChild(this._chip(`📄 ${file.path}`, 'file', () => this._removeAdhocFile(file.path)));
}
@@ -1433,7 +1444,11 @@ class BooksLM {
${entry.disabled ? '<span class="bookslm-ext-soon">' + t('bookslm.soon') + '</span>' : ''}
<span class="bookslm-ext-hint">${entry.hint}</span>
`;
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);
@@ -1445,21 +1460,13 @@ class BooksLM {
if (typeof safeCreateIcons === 'function') safeCreateIcons();
}
/** #97 — Deep Research: enabled agent tools + a pre-filled directive. */
/** #97/#99 — Deep Research: agent tools + a skill-like chip (no composer text). */
_startDeepResearch() {
this._closeExtMenu();
if (!this._agentMode) this._toggleAgentMode();
const textarea = this._panel && this._panel.querySelector('textarea');
if (textarea) {
const directive = textarea.value.trim()
? textarea.value.replace(/\s*$/, '')
: '';
textarea.value = (directive ? directive + '\n\n' : '') + t('bookslm.deep_research_prompt');
textarea.dispatchEvent(new Event('input', { bubbles: true }));
textarea.focus();
} else {
showToast(t('bookslm.deep_research_started'), 'info');
}
this._activeDeepResearch = true;
this._renderAttachments();
showToast(t('bookslm.deep_research_started'), 'info');
}
_onPaste(e) {
@@ -2724,7 +2731,7 @@ class BooksLM {
img.path ? { path: img.path } : { data: img.data, mime_type: img.mime_type }
)),
skill: this._activeSkill,
message: text,
message: this._activeDeepResearch ? `${t('bookslm.deep_research_prompt')}\n\n${text}` : text,
conversation_history: history,
provider,
model,
+79 -5
View File
@@ -1,7 +1,7 @@
// config.js — extracted from app.js (3872-4865)
import { api, AuthManager, initMfaSettings } from './auth.js';
import { state } from './state.js';
import { el, icon, openFile } from './viewer.js';
import { el, icon, openFile, filterSavedSearches } from './viewer.js';
import { syncVaultSelectors, setSelectedVaultContext, refreshSidebarForContext, loadVaults, loadVaultSettings, loadTags, TagFilterService, refreshSidebarTreePreservingState } from './sidebar.js';
import { escapeHtml, safeCreateIcons } from './utils.js';
import { showToast, closeHeaderMenu, closeMobileSidebar } from './ui.js';
@@ -11,6 +11,9 @@ import { getModelCapabilities, renderCapabilityList, refreshAIPickers } from './
let _recentTimestampTimer = null;
let _recentFilesCache = [];
let _recentRefreshTimer = null;
let _recentQuery = "";
let _aiSessionsCache = [];
let _aiQuery = "";
export async function loadRecentFiles(vaultFilter) {
const listEl = document.getElementById("recent-list");
@@ -22,7 +25,7 @@ export async function loadRecentFiles(vaultFilter) {
try {
const data = await api(url);
_recentFilesCache = data.files || [];
renderRecentList(_recentFilesCache);
renderRecentList(_applyRecentFilter(_recentFilesCache));
} catch (err) {
console.error("Failed to load recent files:", err);
listEl.innerHTML = "";
@@ -39,6 +42,9 @@ function renderRecentList(files) {
listEl.innerHTML = "";
if (!files || files.length === 0) {
if (_recentFilesCache.length && _recentQuery) {
listEl.appendChild(el("div", { class: "sidebar-filter-empty" }, [document.createTextNode(t("sidebar.no_results"))]));
}
if (emptyEl) {
emptyEl.classList.remove("hidden");
safeCreateIcons();
@@ -160,6 +166,54 @@ function _formatAIDate(ts) {
}
}
/** Accent- and case-insensitive normalization for the sidebar filters (#98/#99). */
function _sidebarNorm(value) {
return String(value || "")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase();
}
/** Apply the current sidebar query to the recent-files list (#99). */
function _applyRecentFilter(files) {
const q = _sidebarNorm(_recentQuery);
if (!q) return files;
return (files || []).filter((f) => {
const tags = Array.isArray(f.tags) ? f.tags.join(" ") : "";
return _sidebarNorm(f.title).includes(q)
|| _sidebarNorm(f.path).includes(q)
|| _sidebarNorm(f.vault).includes(q)
|| _sidebarNorm(f.preview).includes(q)
|| _sidebarNorm(tags).includes(q);
});
}
/** Filter the sidebar recent-files list by the sidebar filter input (#99). */
export function filterRecentFiles(query) {
_recentQuery = (query || "").trim();
renderRecentList(_applyRecentFilter(_recentFilesCache));
}
/** Apply the current sidebar query to a session list, resolving the mode label. */
function _applyAIFilter(sessions) {
const q = _sidebarNorm(_aiQuery);
if (!q) return sessions;
return (sessions || []).filter((s) => {
const modeLabel = t("bookslm.mode_" + (s.mode || "general"));
return _sidebarNorm(s.title).includes(q)
|| _sidebarNorm(s.preview).includes(q)
|| _sidebarNorm(s.directory).includes(q)
|| _sidebarNorm(s.context).includes(q)
|| _sidebarNorm(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 +225,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 +236,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,22 +298,38 @@ 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: t("sidebar.filter_recent"),
saved: t("sidebar.filter_saved"),
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 === "recent") filterRecentFiles(query);
else if (tab === "saved") filterSavedSearches(query);
else if (tab === "ai") filterAIHistory(query);
}
// Auto-load recent files when switching to the recent tab
if (tab === "recent") {
_populateRecentVaultFilter();
if (filterInput) filterRecentFiles(filterInput.value.trim());
const vaultFilter = document.getElementById("recent-vault-filter");
loadRecentFiles(vaultFilter ? vaultFilter.value || null : null);
}
// #96 — Auto-load the AI conversation history when entering its tab.
// #99 — Re-apply the current sidebar query on the saved-searches tab.
if (tab === "saved" && filterInput) {
filterSavedSearches(filterInput.value.trim());
}
// #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();
}
}
+24 -27
View File
@@ -2,8 +2,8 @@ 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 { el, icon, getVaultIcon, smallBadge, attachTreeItemActionButton, attachTreeItemLongPress, showWelcome, appendHighlightedText } from './viewer.js';
import { _populateRecentVaultFilter, switchSidebarTab, filterAIHistory, filterRecentFiles } from './config.js';
import { el, icon, getVaultIcon, smallBadge, attachTreeItemActionButton, attachTreeItemLongPress, showWelcome, appendHighlightedText, filterSavedSearches } from './viewer.js';
import { performAdvancedSearch } from './search.js';
import { t } from './i18n.js';
@@ -700,25 +700,32 @@ function initSidebarFilter() {
const caseBtn = document.getElementById("sidebar-filter-case-btn");
const clearBtn = document.getElementById("sidebar-filter-clear-btn");
// Route the query to the active tab's own filter (#98/#99).
const routeFilter = async (q) => {
const tab = state.activeSidebarTab;
if (tab === "vaults") await performTreeSearch(q);
else if (tab === "recent") filterRecentFiles(q);
else if (tab === "saved") filterSavedSearches(q);
else if (tab === "ai") filterAIHistory(q);
else filterTagCloud(q);
};
const routeClear = async () => {
const tab = state.activeSidebarTab;
if (tab === "vaults") await restoreSidebarTree();
else if (tab === "recent") filterRecentFiles("");
else if (tab === "saved") filterSavedSearches("");
else if (tab === "ai") filterAIHistory("");
else filterTagCloud("");
};
input.addEventListener("input", () => {
const hasText = input.value.length > 0;
clearBtn.style.display = hasText ? "flex" : "none";
clearTimeout(state.filterDebounce);
state.filterDebounce = setTimeout(async () => {
const q = state.sidebarFilterCaseSensitive ? input.value.trim() : input.value.trim().toLowerCase();
if (hasText) {
if (state.activeSidebarTab === "vaults") {
await performTreeSearch(q);
} else {
filterTagCloud(q);
}
} else {
if (state.activeSidebarTab === "vaults") {
await restoreSidebarTree();
} else {
filterTagCloud("");
}
}
if (hasText) await routeFilter(q);
else await routeClear();
}, 220);
});
@@ -726,13 +733,7 @@ function initSidebarFilter() {
state.sidebarFilterCaseSensitive = !state.sidebarFilterCaseSensitive;
caseBtn.classList.toggle("active");
const q = state.sidebarFilterCaseSensitive ? input.value.trim() : input.value.trim().toLowerCase();
if (input.value.trim()) {
if (state.activeSidebarTab === "vaults") {
await performTreeSearch(q);
} else {
filterTagCloud(q);
}
}
if (input.value.trim()) await routeFilter(q);
});
clearBtn.addEventListener("click", async () => {
@@ -741,11 +742,7 @@ function initSidebarFilter() {
state.sidebarFilterCaseSensitive = false;
caseBtn.classList.remove("active");
clearTimeout(state.filterDebounce);
if (state.activeSidebarTab === "vaults") {
await restoreSidebarTree();
} else {
filterTagCloud("");
}
await routeClear();
});
clearBtn.style.display = "none";
+32 -1
View File
@@ -1811,6 +1811,14 @@ function _renderSyncPanel(panel) {
// ── Saved searches filter ──
var _savedFilterType = 'all';
var _savedFilterInitDone = false;
var _savedQuery = '';
function _savedNorm(value) {
return String(value || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase();
}
function _initSavedFilter() {
if (_savedFilterInitDone) return;
@@ -1827,18 +1835,41 @@ function _initSavedFilter() {
_savedFilterInitDone = true;
}
/** #99 — Filter the saved-searches list by the global sidebar filter input. */
export function filterSavedSearches(query) {
_savedQuery = (query || '').trim();
_applySavedFilter();
}
function _applySavedFilter() {
var items = document.querySelectorAll('.saved-search-item');
var list = document.getElementById('saved-searches-list');
var q = _savedNorm(_savedQuery);
var hasVisible = false;
items.forEach(function(item) {
var type = item.dataset.type;
var show = _savedFilterType === 'all' || type === _savedFilterType;
var typeOk = _savedFilterType === 'all' || type === _savedFilterType;
var textOk = !q || _savedNorm(item.textContent).includes(q);
var show = typeOk && textOk;
item.style.display = show ? '' : 'none';
if (show) hasVisible = true;
});
// Remove a previous "no match" hint before deciding whether to add one.
if (list) {
var prev = list.querySelector('.sidebar-filter-empty');
if (prev) prev.remove();
}
// Show/hide empty state
var empty = document.getElementById('saved-searches-empty');
if (empty) empty.style.display = hasVisible ? 'none' : '';
// Explain an empty result caused by the global search rather than by an
// actually empty saved-searches list.
if (!hasVisible && q && list && items.length) {
var hint = document.createElement('div');
hint.className = 'sidebar-filter-empty';
hint.textContent = t('sidebar.no_results');
list.appendChild(hint);
}
}
+5 -1
View File
@@ -1544,6 +1544,9 @@
"sidebar.expand_collapse": "Expand/Collapse",
"sidebar.file_icons": "Icons",
"sidebar.filter_instant": "Instant search",
"sidebar.filter_ai": "Filter AI history...",
"sidebar.filter_recent": "Filter recent files...",
"sidebar.filter_saved": "Filter saved searches...",
"sidebar.filter_path": "Path filters",
"sidebar.filter_placeholder": "Filter files...",
"sidebar.filter_results_grouped": "Grouped results",
@@ -1826,6 +1829,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}",
@@ -1847,7 +1851,7 @@
"bookslm.soon": "Soon",
"bookslm.ext_more_coming": "More options coming…",
"bookslm.deep_research_prompt": "Perform an in-depth analysis of the request by breaking it into steps, searching for relevant information in the vault, cross-referencing them, and producing a structured summary with sources.",
"bookslm.deep_research_started": "Deep research started — enable Agent mode to use the tools.",
"bookslm.deep_research_started": "Deep Research enabled — add your question and send.",
"bookslm.mode_general": "General",
"bookslm.mode_directory": "Directory",
"bookslm.mode_documents": "Documents",
+5 -1
View File
@@ -1544,6 +1544,9 @@
"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_recent": "Filtrer les fichiers récents...",
"sidebar.filter_saved": "Filtrer les recherches sauvegardées...",
"sidebar.filter_path": "Filtres de chemin",
"sidebar.filter_placeholder": "Filtrer fichiers...",
"sidebar.filter_results_grouped": "Résultats groupés",
@@ -1826,6 +1829,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}",
@@ -1847,7 +1851,7 @@
"bookslm.soon": "Bientôt",
"bookslm.ext_more_coming": "D'autres options à venir…",
"bookslm.deep_research_prompt": "Réalise une analyse approfondie de la demande en décomposant les étapes, en cherchant les informations pertinentes dans le vault, en les croisant, et en produisant une synthèse structurée avec sources.",
"bookslm.deep_research_started": "Deep Research lancé — activez le mode Agent pour utiliser les outils.",
"bookslm.deep_research_started": "Deep Research activé — ajoutez votre question puis envoyez.",
"bookslm.mode_general": "Général",
"bookslm.mode_directory": "Répertoire",
"bookslm.mode_documents": "Documents",
+3 -2
View File
@@ -9676,6 +9676,7 @@ body.bookslm-resizing { cursor: ew-resize; user-select: none; }
.bookslm-chip-remove:hover { color: #f87171; }
.bookslm-chip-thumb { width: 18px; height: 18px; object-fit: cover; border-radius: 4px; }
.bookslm-chip-skill { border-color: var(--accent); }
.bookslm-chip-deep-research { border-color: var(--accent); }
/* Composer menus (`/` commands and `@` mentions). */
.bookslm-menu-layer { position: relative; }
.bookslm-command-menu, .bookslm-mention-menu { position: absolute; left: 12px; right: 12px; bottom: 4px;
@@ -9696,10 +9697,10 @@ body.bookslm-resizing { cursor: ew-resize; user-select: none; }
white-space: nowrap; }
.bookslm-menu-empty { padding: 10px; font-size: 12px; color: var(--text-secondary); text-align: center; }
/* Image attach button. */
.bookslm-btn-plus { display: flex; align-items: center; justify-content: center; flex-shrink: 0;
.bookslm-input-area button.bookslm-btn-plus { display: flex; align-items: center; justify-content: center; flex-shrink: 0;
background: none; border: 1px solid var(--border); color: var(--text-secondary); cursor: pointer;
border-radius: 50%; width: 32px; height: 32px; padding: 0; }
.bookslm-btn-plus:hover { color: var(--accent); border-color: var(--accent); }
.bookslm-input-area button.bookslm-btn-plus:hover { color: var(--accent); border-color: var(--accent); }
.bookslm-file-input { display: none; }
.bookslm-files-input { display: none; }
/* Extensible "+" panel (#97). */
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.4.0",
"version": "2.6.1",
"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": {
+143
View File
@@ -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(
`<!DOCTYPE html>
<html><body>
<aside class="sidebar">
<input id="sidebar-filter-input" value="" />
<button id="sidebar-filter-case-btn"></button>
<button id="sidebar-filter-clear-btn"></button>
<div id="ai-history-list" class="recent-list"></div>
<div id="ai-history-empty" class="recent-empty hidden">
<i data-lucide="messages-square"></i><span>Aucune conversation</span>
</div>
</aside>
</body></html>`,
{ 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);
}
+146 -9
View File
@@ -1363,10 +1363,23 @@ async function main() {
// ── 39. Accented `@` mentions + all-vault fallback (BUG-008) ──
await test("mention menu accepts accents and searches all vaults without a vault", async () => {
localStorage.clear();
let lastUrl = "";
document.body.replaceChildren();
const stateMod = await import(pathToFileURL(path.join(JS_DIR, "state.js")).href);
const prevVaults = stateMod.state.allVaults;
const prevSelected = stateMod.state.selectedContextVault;
const prevCurrent = stateMod.state.currentVault;
stateMod.state.allVaults = [];
stateMod.state.selectedContextVault = null;
stateMod.state.currentVault = null;
// Collect every request: `new BooksLM()` also hydrates the AI history
// asynchronously, so the *last* URL is not necessarily the tree-search one.
const urls = [];
const sawTreeSearchAll = () => urls.some(
(u) => u.includes("/api/tree-search") && u.includes("vault=all"),
);
globalThis.fetch = async (url) => {
lastUrl = String(url);
if (lastUrl.includes("/api/tree-search")) {
urls.push(String(url));
if (String(url).includes("/api/tree-search")) {
return {
ok: true, status: 200,
json: async () => ({ results: [{ path: "Café/note.md", type: "file" }] }),
@@ -1382,13 +1395,21 @@ async function main() {
ta.value = "@café";
ta.selectionStart = ta.selectionEnd = 5;
b._onComposerInput();
await sleep(20);
const menu = b._panel.querySelector(".bookslm-mention-menu");
let waited = 0;
while (!sawTreeSearchAll() && waited < 500) {
await sleep(20);
waited += 20;
}
assert.ok(!menu.classList.contains("hidden"), "accented mention keeps the menu open");
assert.ok(lastUrl.includes("vault=all"), "no vault -> searches all vaults");
assert.ok(sawTreeSearchAll(),
`no vault -> searches all vaults (got ${JSON.stringify(urls)})`);
assert.equal(menu.querySelectorAll(".bookslm-menu-item").length, 1);
b._panel.remove();
localStorage.clear();
stateMod.state.allVaults = prevVaults;
stateMod.state.selectedContextVault = prevSelected;
stateMod.state.currentVault = prevCurrent;
});
// ── 40. Keyboard navigation in the command menu (BUG-007) ──
@@ -1671,10 +1692,10 @@ async function main() {
const stateMod = await import(pathToFileURL(path.join(JS_DIR, "state.js")).href);
const prev = stateMod.state.allVaults;
stateMod.state.allVaults = [{ name: "FallbackVault" }];
let lastUrl = "";
const urls = [];
globalThis.fetch = async (url) => {
lastUrl = String(url);
if (lastUrl.includes("/files")) {
urls.push(String(url));
if (String(url).includes("/files")) {
return { ok: true, status: 200, json: async () => ({ files: [{ path: "a.md" }] }) };
}
return { ok: true, status: 200, json: async () => ({}) };
@@ -1687,7 +1708,8 @@ async function main() {
await b._showMentionMenu("");
const menu = b._panel.querySelector(".bookslm-mention-menu");
assert.ok(!menu.classList.contains("hidden"), "empty-query menu is shown");
assert.ok(lastUrl.includes("FallbackVault"), "uses the fallback vault");
assert.ok(urls.some((u) => u.includes("/files") && u.includes("FallbackVault")),
`uses the fallback vault (got ${JSON.stringify(urls)})`);
stateMod.state.allVaults = prev;
b._panel.remove();
localStorage.clear();
@@ -1802,6 +1824,121 @@ 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();
});
await test("ext menu Deep Research adds a chip instead of composer text", async () => {
localStorage.clear();
document.body.replaceChildren();
const b = new BooksLM();
b._panel = b._render();
document.body.appendChild(b._panel);
const ta = b._panel.querySelector("textarea");
ta.value = "";
b._toggleExtMenu();
const btn = b._panel.querySelector('.bookslm-ext-item[data-ext-id="deep_research"]');
assert.ok(btn, "Deep Research entry present in the + panel");
btn.click();
await sleep(20);
const chip = b._panel.querySelector(".bookslm-chip-deep-research");
assert.ok(chip, "a Deep Research chip is added");
assert.equal(ta.value, "", "the composer stays empty (no injected directive)");
assert.equal(b._activeDeepResearch, true, "the deep-research flag is set");
// Removing the chip clears the flag.
chip.querySelector(".bookslm-chip-remove").click();
assert.ok(!b._panel.querySelector(".bookslm-chip-deep-research"), "the chip is removed");
assert.equal(b._activeDeepResearch, false, "the flag is cleared with the chip");
b._panel.remove();
localStorage.clear();
});
// ── Summary ──
console.log(`\n${passCount}/${testCount} tests passed`);
if (passCount !== testCount) {
+183
View File
@@ -0,0 +1,183 @@
#!/usr/bin/env node
/**
* ObsiGate - JSDOM tests for the sidebar search/filter bar on the
* "Recent" and "Saved searches" tabs (#99).
*
* Covers:
* - `filterRecentFiles()` (config.js): narrows the recent-files list by
* title / path / vault / preview / tags, case- and accent-insensitively
* - `filterSavedSearches()` (viewer.js): narrows the saved-searches list and
* combines with the type pills, with a "no match" hint when empty
*
* Usage: node tests/frontend/sidebar-filters.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(
`<!DOCTYPE html>
<html><body>
<aside class="sidebar">
<input id="sidebar-filter-input" value="" />
<button id="sidebar-filter-case-btn"></button>
<button id="sidebar-filter-clear-btn"></button>
<div id="recent-list" class="recent-list"></div>
<div id="recent-empty" class="recent-empty hidden"></div>
<select id="recent-vault-filter"></select>
<div class="saved-filter-bar" id="saved-filter-bar">
<button class="saved-filter-pill active" data-filter="all">Tous</button>
<button class="saved-filter-pill" data-filter="search">Recherches</button>
<button class="saved-filter-pill" data-filter="directory">Repertoires</button>
</div>
<div id="saved-searches-list" class="recent-list"></div>
<div id="saved-searches-empty" class="recent-empty"></div>
</aside>
</body></html>`,
{ 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 RECENT = [
{ vault: "V", path: "Recettes/Pizza.md", title: "Pizza maison", preview: "pâte", tags: ["cuisine"], mtime_human: "il y a 1 h" },
{ vault: "V", path: "Notes/café.md", title: "Café du matin", preview: "arôme", tags: ["journal"], mtime_human: "il y a 2 h" },
{ vault: "W", path: "Projets/plan.md", title: "Plan de projet", preview: "étapes", tags: [], mtime_human: "hier" },
];
const SAVED = [
{ id: "s1", query: "pizza", vault: "V", include_paths: "Recettes", case_sensitive: false },
{ id: "s2", query: "", include_paths: "Recettes", vault: "V" },
{ id: "s3", query: "roadmap", vault: "all" },
];
globalThis.fetch = async (url) => {
const u = String(url);
if (u.includes("/api/recent")) {
return { ok: true, status: 200, json: async () => ({ files: RECENT }) };
}
if (u.includes("/api/saved-searches")) {
return { ok: true, status: 200, json: async () => SAVED };
}
return { ok: true, status: 200, json: async () => ({}) };
};
const configMod = await import(pathToFileURL(path.join(JS_DIR, "config.js")).href);
const viewerMod = await import(pathToFileURL(path.join(JS_DIR, "viewer.js")).href);
const { loadRecentFiles, filterRecentFiles } = configMod;
const { loadSavedSearches, filterSavedSearches } = viewerMod;
const recentItems = () => Array.from(document.querySelectorAll("#recent-list .recent-item"));
const visibleRecent = () => recentItems().filter((el) => el.style.display !== "none").length;
const savedItems = () => Array.from(document.querySelectorAll(".saved-search-item"));
const visibleSaved = () => savedItems().filter((el) => el.style.display !== "none").length;
console.log("Sidebar filters (#99) — Recent & Saved searches");
await test("loadRecentFiles renders every recent file", async () => {
await loadRecentFiles(null);
assert.equal(recentItems().length, 3, "three recent items rendered");
});
await test("filterRecentFiles narrows by title, case-insensitively", async () => {
filterRecentFiles("pizza");
assert.equal(visibleRecent(), 1, "one recent item matches 'pizza'");
filterRecentFiles("PLAN");
assert.equal(visibleRecent(), 1, "case-insensitive match on 'PLAN'");
});
await test("filterRecentFiles matches accents and tags", async () => {
filterRecentFiles("cafe");
assert.equal(visibleRecent(), 1, "'cafe' matches 'Café du matin'");
filterRecentFiles("journal");
assert.equal(visibleRecent(), 1, "tag match");
});
await test("filterRecentFiles matches the vault and clears", async () => {
filterRecentFiles("Projets");
assert.equal(visibleRecent(), 1, "path match");
filterRecentFiles("");
assert.equal(visibleRecent(), 3, "no query restores everything");
});
await test("filterRecentFiles shows a no-match hint", async () => {
filterRecentFiles("zzz-inexistant");
assert.equal(visibleRecent(), 0, "no item left");
assert.ok(document.querySelector("#recent-list .sidebar-filter-empty"),
"a no-match hint is rendered in the recent list");
filterRecentFiles("");
});
await test("loadSavedSearches renders every saved search", async () => {
await loadSavedSearches();
assert.equal(savedItems().length, 3, "three saved searches rendered");
});
await test("filterSavedSearches narrows by query text", async () => {
filterSavedSearches("pizza");
assert.equal(visibleSaved(), 1, "one saved search matches 'pizza'");
filterSavedSearches("roadmap");
assert.equal(visibleSaved(), 1, "match on the query field");
});
await test("filterSavedSearches combines with the type pills", async () => {
filterSavedSearches("");
assert.equal(visibleSaved(), 3, "all visible before the pill filter");
document.querySelector('.saved-filter-pill[data-filter="directory"]').click();
assert.equal(visibleSaved(), 1, "only the directory search stays visible");
filterSavedSearches("roadmap");
assert.equal(visibleSaved(), 0, "the query excludes the directory search");
assert.ok(document.querySelector("#saved-searches-list .sidebar-filter-empty"),
"a no-match hint is rendered in the saved list");
document.querySelector('.saved-filter-pill[data-filter="all"]').click();
filterSavedSearches("");
assert.equal(visibleSaved(), 3, "clearing both filters restores everything");
});
// ── Summary ──
console.log(`\n${passCount}/${testCount} tests passed`);
if (passCount !== testCount) {
process.exit(1);
}