Compare commits

...
6 Commits
Author SHA1 Message Date
bruno 431d6e9338 docs: i18n et suivi #187 - assistant agent toujours actif
CI / lint (push) Failing after 2m51s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Failing after 2m14s
2026-10-07 08:18:45 -04:00
bruno d453969708 docs: i18n et suivi #187 - assistant agent toujours actif 2026-10-07 08:17:59 -04:00
bruno 778fa65b4c fix: assistant IA toujours en mode agent, retrait du bouton toggle #187
- Bouton « mode agent » du panneau supprimé : l'assistant est toujours
  agent (toute requête texte part sur /api/ai/bookslm/agent, les images
  restent sur /chat multimodal). Les mutations gardent la confirmation
  two-step. Deep Research et les quick actions `agent: true` basculaient
  déjà le mode en silence : le toggle ne protégeait plus rien.
- /agent résout le provider comme /chat (req.provider brut transmis à
  l'adapter pouvait désigner un fournisseur indisponible et retomber
  silencieusement sur un autre que l'étiquette SSE affichée).
- Schémas des outils mis en cache par (scope, taille du registre) avec
  copies fraîches par appelant (~27 ms de pydantic économisées par
  requête agent/MCP).
- Aide in-app réécrite, i18n FR/EN (retrait de ai.agent_mode_*),
  tests frontend adaptés (ai.test.mjs 100/100) + non-régression pytest
  (provider résolu, cache sûr).
2026-10-07 08:16:56 -04:00
bruno 69c3817579 docs: diagramme de flux de l'agent AI (boucle run_agent + pipeline call_tool + 50 outils)
CI / lint (push) Successful in 3m1s
CI / security (push) Successful in 2m12s
CI / test (push) Successful in 4m56s
CI / build (push) Successful in 2m5s
CI / e2e (push) Failing after 17m3s
2026-10-05 12:59:51 -04:00
bruno b89af917b1 fix: ordre du flag --no-cache dans build.ps1 (procedure Test refonctionne)
CI / lint (push) Successful in 2m54s
CI / security (push) Successful in 2m10s
CI / test (push) Successful in 4m48s
CI / build (push) Successful in 1m59s
CI / e2e (push) Failing after 17m13s
2026-10-04 19:58:38 -04:00
bruno d79202e698 feat: menus tableur a icones, fermeture au focus et polish mobile #179
CI / lint (push) Successful in 2m52s
CI / security (push) Successful in 2m11s
CI / test (push) Successful in 4m48s
CI / build (push) Successful in 2m0s
CI / e2e (push) Failing after 16m50s
2026-10-04 19:27:50 -04:00
31 changed files with 1523 additions and 189 deletions
+42 -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.52.0**.
> [Unreleased](#unreleased). La dernière version livrée est **2.53.2**.
---
@@ -14,6 +14,47 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [2.53.2] — 2026-10-07
### Modifié
- **#187 - Assistant IA : mode agent toujours actif**
- Le bouton « mode agent » du panneau a été **supprimé** : l'assistant utilise
en permanence ses outils (lire, lister, chercher, modifier) ; la sécurité
est inchangée — chaque modification demande une confirmation avec aperçu.
Les images jointes passent toujours par l'endpoint multimodal classique.
- Le provider réel de l'agent est désormais résolu comme pour le chat simple
(l'étiquette « fournisseur · modèle » affichée est exacte), et les schémas
d'outils sont mis en cache (≈ 27 ms économisées par requête).
---
## [2.53.1] — 2026-10-04
---
## [2.53.0] — 2026-10-04
### Ajouté
- **#179 — Éditeur tableur : icônes des menus & boutons, fermeture au focus, polish mobile**
- Une icône Lucide par entrée (grille, Structure, Mise en forme, Export) et
sur les boutons Enregistrer / « + » ; suppressions marquées danger,
`aria-haspopup`/`aria-expanded` sur les boutons à menu.
- Tout menu se ferme à la perte de focus (clic extérieur, `focusout`,
`Échap`, défilement, redimensionnement) via `trackDismissable()` ;
navigation clavier (`↑`/`↓`/`Home`/`End`) dans le menu grille.
- Mobile : entrées ≥ 44 px (`pointer: coarse`), menus bornés au viewport,
ruban sans débordement horizontal.
### Corrigé
- **Build Docker** : `build.ps1` plaçait `--no-cache` avant `build`
(`unknown flag`) — flag déplacé après `docker compose -f … build`, la
procédure `.\build.ps1 -Test` refonctionne.
---
## [2.52.0] — 2026-10-04
### 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.52.0-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.53.2-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/)
@@ -976,8 +976,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.52.0).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.53.2).
---
*Projet : ObsiGate | Version : 2.52.0 | Dernière mise à jour : Septembre 2026*
*Projet : ObsiGate | Version : 2.53.2 | Dernière mise à jour : Septembre 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.52.0-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.53.2-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/)
@@ -1151,8 +1151,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.52.0).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.53.2).
---
*Project: ObsiGate | Version: 2.52.0 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.53.2 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.52.0
2.53.2
+17 -12
View File
@@ -536,18 +536,6 @@ async def api_bookslm_agent(
# run no longer pauses on every subsequent mutating call.
ctx.confirmed = True
async def _llm(msgs, tool_schemas):
return await chat_completion(
msgs,
tools=tool_schemas,
provider=req.provider,
model=req.model,
temperature=0.3,
# Tool-call arguments can carry a whole file body (e.g. a generated
# table): leave more room than the plain-chat default.
max_tokens=8192,
)
async def generate_sse():
import asyncio
@@ -561,6 +549,23 @@ async def api_bookslm_agent(
yield f"event: error\ndata: {error_data}\n\n"
return
# #187: resolve the provider like /chat does — the agent must use
# the same engine the SSE "provider" tag reports (the raw
# req.provider could name an unavailable provider and silently
# fall back to another one via _get_provider_config).
async def _llm(msgs, tool_schemas):
return await chat_completion(
msgs,
tools=tool_schemas,
provider=cfg_name,
model=req.model,
temperature=0.3,
# Tool-call arguments can carry a whole file body (e.g. a
# generated table): leave more room than the plain-chat
# default.
max_tokens=8192,
)
# Stream tool events live: each executed step is pushed on the
# queue by the loop callback and emitted as soon as it happens,
# so the UI can grow its « N steps » block while thinking.
+15 -2
View File
@@ -117,9 +117,22 @@ def list_tools(*, scope: ToolScope | None = None) -> list[ToolSpec]:
return specs
# ponytail: tool schemas are static after import (registration is decorator
# only); keying the cache on len(_REGISTRY) invalidates it if a tool is ever
# registered at runtime. Rebuilding 50 pydantic JSON schemas cost ~27 ms per
# agent/MCP request.
_SCHEMAS_CACHE: dict[Any, list[dict[str, Any]]] = {}
def get_tool_schemas(*, scope: ToolScope | None = None) -> list[dict[str, Any]]:
"""Return OpenAI-compatible schemas for registered tools."""
return [spec.openai_schema() for spec in list_tools(scope=scope)]
"""Return OpenAI-compatible schemas for registered tools (cached)."""
key = (scope, len(_REGISTRY))
cached = _SCHEMAS_CACHE.get(key)
if cached is None:
cached = [spec.openai_schema() for spec in list_tools(scope=scope)]
_SCHEMAS_CACHE.clear()
_SCHEMAS_CACHE[key] = cached
return [dict(s) for s in cached]
def _audit(ctx: ToolContext, spec: ToolSpec, arguments: dict[str, Any], *, ok: bool, error: str | None = None) -> None:
+4 -2
View File
@@ -115,7 +115,9 @@ $env:VERSION = $Version
Write-Info "Version : $Version"
# ----- Build the image -----
$BuildArgs = @("-f", $ComposeFile)
# NOTE : `--no-cache` est un flag de `docker compose build` (après `build`),
# pas un flag global (avant) — sinon `unknown flag: --no-cache`.
$BuildArgs = @("-f", $ComposeFile, "build")
if (-not $UseCache) {
$BuildArgs += "--no-cache"
Write-Info "Construction de l'image Docker (sans cache)..."
@@ -123,7 +125,7 @@ if (-not $UseCache) {
Write-Info "Construction de l'image Docker (avec cache)..."
}
docker compose @BuildArgs build
docker compose @BuildArgs
if ($LASTEXITCODE -ne 0) {
Write-Error "Échec de la construction de l'image Docker."
exit $LASTEXITCODE
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.52.0"
version = "2.53.2"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.52.0"
version = "2.53.2"
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.52.0",
"version": "2.53.2",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+2 -1
View File
@@ -253,7 +253,8 @@ ou `Maj+clic` sélectionne une plage (affichée dans la zone Nom, ex. `A1:B3`),
et cliquer un **en-tête** sélectionne toute la ligne ou colonne. Un **clic
droit** (ou un **appui long** sur mobile) ouvre un menu : copier, couper,
coller, insérer/supprimer une ligne ou une colonne, trier A→Z / Z→A, effacer le
contenu.
contenu. Chaque entrée porte une **icône** ; le menu se referme dès qu'il perd
le focus (clic ailleurs, `Échap`) et se parcourt au clavier (`↑`/`↓`).
**Tri, filtre, recherche, export** — le tri (ascendant / descendant) s'applique
depuis le menu contextuel et n'affecte que l'affichage ; les lignes se filtrent et
+52 -1
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.52.0 | **Dernière mise à jour :** 2026-10-04
> **Version :** 2.53.2 | **Dernière mise à jour :** 2026-10-07
> **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)**
@@ -157,6 +157,57 @@
---
## 🔵 En cours — Polish menus tableur
### 179. Éditeur tableur — icônes des menus & boutons, fermeture au focus, polish mobile
- **Effort :** 1-2 jours | **Impact :** 🟡
- **Statut :** 🔵 en cours depuis 2026-10-04
- **Fiche :** [features/xlsx-menus-179.md](./features/xlsx-menus-179.md)
- **Description :** la grille (#155), les menus Structure/Mise en forme/Export
(#156-A8/A11/A14) et les boutons du ruban (#154) sont textuels et restent
ouverts quand le focus quitte le menu. Ajouter des icônes Lucide
représentatives, fermer tout menu à la perte de focus (clic extérieur,
`focusout`, `Échap`, défilement), naviguer au clavier dans le menu grille, et
garantir des cibles tactiles ≥ 44 px + menus dans le viewport en mobile.
- **Sous-tâches :**
- [x] **A1** Icônes des entrées du menu contextuel de la grille (+ `item.icon`)
- [x] **A2** Icônes des menus Structure / Mise en forme / Export + boutons
Enregistrer / « + » onglet
- [x] **A3** Fermeture à la perte de focus (helper partageable : extérieur,
`focusout`, `Échap`, scroll/resize) + navigation clavier du menu grille
- [x] **A4** Polish mobile (44 px tactile, menus bornés au viewport) + tests
JSDOM + E2E `xlsx-viewer.spec.js`
---
## ✅ Terminé — Assistant IA
### 187. Assistant IA — mode agent toujours actif, retrait du bouton toggle
- **Effort :** 0,5 jour | **Impact :** 🟢
- **Statut :** ✅ **livré le 2026-10-06** — ouvert le 2026-10-06
- **Description :** le bouton « mode agent » du panneau (toggle persisté en
localStorage, défaut OFF) n'était plus protecteur : Deep Research et les
quick actions `agent: true` basculaient déjà le mode en silence. Retiré au
profit d'un agent toujours actif : toute requête texte part sur
`/api/ai/bookslm/agent` (outils lire/lister/chercher/modifier ; les mutations
gardent la confirmation two-step) ; les images seules restent sur
`/api/ai/bookslm/chat` (endpoint multimodal, la boucle agent étant textuelle).
- **Sous-tâches :**
- [x] **A1** Frontend : bouton header, `_agentMode`, toggle/persistance et les
auto-bascules (Deep Research, quick actions) supprimés ; `_postChat`
route texte→`/agent`, images→`/chat` ; aide et i18n FR/EN mises à jour
- [x] **A2** Optimisations requêtes AI : `/agent` résout le provider comme
`/chat` (avant : `req.provider` brut transmis au provider adapter →
moteur réel ≠ étiquette SSE) ; schémas d'outils mis en cache (~27 ms de
pydantic économisés par requête agent/MCP)
- [x] **A3** Tests : `ai.test.mjs` adapté (100/100) ; non-régression pytest
(`test_bookslm.py` provider résolu, `test_tools.py` cache sûr) ;
ruff/mypy 0 erreur ; build + instance Docker locale à jour
---
## ✅ Terminé — Navigation
### 158. Navigation — vue répertoire en onglet, filtres & tris, retour Home complet
+87
View File
@@ -0,0 +1,87 @@
// ObsiGate AI Agent — flux de traitement d'une demande (b89af91)
digraph ObsiGateAgent {
rankdir=TB;
splines=polyline;
nodesep=0.35; ranksep=0.45;
graph [fontname="DejaVu Sans", fontsize=13, bgcolor="white", pad=0.3];
node [fontname="DejaVu Sans", fontsize=12, style=filled, shape=box, color="#333333", margin="0.16,0.09"];
edge [fontname="DejaVu Sans", fontsize=11, color="#555555", arrowsize=0.8];
// ── Entrée ──
user [label="Demande utilisateur\n(frontend bookslm.js)", fillcolor="#dbeafe"];
deep [label="Chip « Deep Research »\n= agent mode + prompt injecté :\n« décomposer les étapes, chercher\ndans le vault, croiser,\nsynthétiser avec sources »",
shape=note, fillcolor="#fef3c7"];
api [label="POST /api/bookslm/agent\n→ réponse SSE (thought · tool ·\nconfirmation · message · done)", fillcolor="#e0f2fe"];
prompt [label="Construction du system prompt\nmode directory / documents / general\ncontexte vault pré-chargé (BooksLM :\n≤200 fichiers, ≤200k chars, cache 5 min)\nprotocole agent = function calling natif", fillcolor="#f1f5f9"];
user -> api; deep -> user [style=dashed, label="active", fontsize=10];
api -> prompt;
// ── Boucle agent ──
subgraph cluster_loop {
label="BOUCLE AGENT — backend/agent/loop.py · run_agent()";
style=rounded; color="#e11d48"; penwidth=2; fontsize=13; fontcolor="#e11d48";
llm [label="Appel LLM — chat_completion()\nfournisseur OpenAI-compatible / Gemini\ntemp=0.3 · max_tokens 8192\nschemas des 50 outils exposés\n(si modèle sans tools → retombe chat simple)", fillcolor="#fee2e2"];
decide [label="Le LLM demande des\noutils (tool_calls) ?", shape=diamond, fillcolor="#fff7ed"];
thought [label="note de raisonnement\nintermédiaire → step visible « pensée »", fillcolor="#fef9c3"];
tools [label="Exécution de chaque tool call\nvia call_tool() (voir pipeline)", fillcolor="#dcfce7"];
back [label="résultat (JSON, tronqué ≤100k chars)\najouté à la conversation →\nitération suivante", shape=plaintext];
llm -> decide;
decide -> thought [label="oui", color="#e11d48"];
thought -> tools;
tools -> llm [label="itération ≤ 10\nquota tools ≤ 25\n(BOOKSLM_MAX_TOOL_CALLS)", color="#e11d48"];
}
prompt -> llm;
// ── Pipeline call_tool ──
subgraph cluster_pipe {
label="PIPELINE call_tool() — backend/tools/registry.py · chaque appel passe par là";
style=rounded; color="#7c3aed"; fontsize=13; fontcolor="#7c3aed";
p1 [label="1 · Schéma Pydantic\n+ validation arguments", fillcolor="#ede9fe"];
p2 [label="2 · Rate limit\npar identité + outil", fillcolor="#ede9fe"];
p3 [label="3 · Permission vault\n(+ déstructif si risque\nDANGEROUS)", fillcolor="#ede9fe"];
p4 [label="4 · Risque READ ?\n→ exécution directe", shape=diamond, fillcolor="#f5f3ff"];
p5 [label="PAUSE CONFIRMATION\noutil WRITE/DANGEROUS → event SSE\n« confirmation » avec le plan complet\nbatché (BUG-075) : une seule\napprobation applique toutes les mutations\n(resume via confirm / confirm_all)", fillcolor="#fde68a"];
p6 [label="5 · Handler exécuté\n6 · Audit JSON-lines\n7 · Redaction secrets (redact_payload)", fillcolor="#dcfce7"];
p1 -> p2 -> p3 -> p4;
p4 -> p6 [label="read"];
p4 -> p5 [label="mutation", color="#d97706"];
p5 -> p6 [label="approuvé", style=dashed];
}
tools -> p1;
p6 -> back [label="tool result", style=invis];
p6 -> tools [style=invis];
back -> p1 [style=invis];
// ── Sorties ──
done [label="Réponse finale\nréponse sans tool_calls\n→ SSE « message »\n(Markdown + sources citées)", fillcolor="#bbf7d0"];
final [label="Budget épuisé (10 itérations\nou 25 appels) → _finalize_answer :\ndernier appel SANS outils pour forcer\nune synthèse, sinon liste des sources\ndéterministe (jamais de réponse vide)", fillcolor="#fed7aa"];
decide -> done [label="non", color="#059669", penwidth=2];
decide -> final [label="budget atteint", color="#ea580c"];
answer [label="Affichage UI : bloc\n« N étapes » Notion-style\n(pensées + outils dépliables)", fillcolor="#dbeafe"];
done -> answer; final -> answer;
// ── Boîte à outils ──
subgraph cluster_tools {
label="50 OUTILS — backend/tools/*.py · risque READ / WRITE / DANGEROUS";
style=rounded; color="#0369a1"; fontsize=13; fontcolor="#0369a1";
node [fillcolor="#f0f9ff", fontsize=10];
fam_vault [label="VAULT · service.py (22)\nlecture : list_vaults, list_directory,\nlist_all_files, read_file, read_file_raw,\nsearch_fulltext, search_advanced,\nsearch_paths, get_graph, get_backlinks,\nlist_tags, suggest_tags, list_recent,\nlist_backups, diff_backup\nécriture : create_file, create_directory,\nappend_to_file, edit_file, restore_backup\nDANGEROUS : delete_file, delete_directory,\nmove_path, rename_file, rename_directory,\nreplace_in_files"];
fam_web [label="WEB · web.py + crawler.py\nweb_search · fetch_url\ncrawl_site (≤20 pages, même hôte)"];
fam_doc [label="DOCUMENTS · documents.py\ncreate_pdf · create_docx\ncreate_xlsx · create_csv"];
fam_xlsx [label="TABLEURS · spreadsheets.py\nlist_xlsx_sheets · xlsx_to_markdown\nsearch_workbook · analyze_range\nupdate_xlsx_cells · append_xlsx_rows\nedit_xlsx_structure"];
fam_dup [label="DOUBLONS · duplicates.py\nfind_duplicates\nmerge_duplicate_notes (DANG.)"];
fam_git [label="GIT CONNECTÉ · connected.py\ngit_list_repos · git_get_file\ngit_search_issues (Gitea/GitHub)"];
fam_sched [label="SCHEDULER · scheduled.py\nlist/create/delete/run_scheduled_task"];
fam_notify [label="NOTIFICATIONS · notify.py\nnotify_external (Discord,\nTelegram, SMTP, webhook)"];
}
p6 -> cluster_tools [style=invis];
fam_vault -> fam_web [style=invis]; fam_web -> fam_doc [style=invis];
fam_doc -> fam_xlsx [style=invis]; fam_xlsx -> fam_dup [style=invis];
fam_dup -> fam_git [style=invis]; fam_git -> fam_sched [style=invis];
fam_sched -> fam_notify [style=invis];
}
+394
View File
@@ -0,0 +1,394 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 2.42.4 (0)
-->
<!-- Title: ObsiGateAgent Pages: 1 -->
<svg width="2130pt" height="1034pt"
viewBox="0.00 0.00 2130.20 1033.70" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(21.6 1012.1)">
<title>ObsiGateAgent</title>
<polygon fill="white" stroke="transparent" points="-21.6,21.6 -21.6,-1012.1 2108.6,-1012.1 2108.6,21.6 -21.6,21.6"/>
<g id="clust1" class="cluster">
<title>cluster_loop</title>
<path fill="white" stroke="#e11d48" stroke-width="2" d="M493,-11.5C493,-11.5 1052,-11.5 1052,-11.5 1058,-11.5 1064,-17.5 1064,-23.5 1064,-23.5 1064,-471 1064,-471 1064,-477 1058,-483 1052,-483 1052,-483 493,-483 493,-483 487,-483 481,-477 481,-471 481,-471 481,-23.5 481,-23.5 481,-17.5 487,-11.5 493,-11.5"/>
<text text-anchor="middle" x="772.5" y="-468.6" font-family="DejaVu Sans" font-size="13.00" fill="#e11d48">BOUCLE AGENT — backend/agent/loop.py · run_agent()</text>
</g>
<g id="clust2" class="cluster">
<title>cluster_pipe</title>
<path fill="white" stroke="#7c3aed" d="M1084,-102C1084,-102 1580,-102 1580,-102 1586,-102 1592,-108 1592,-114 1592,-114 1592,-761.5 1592,-761.5 1592,-767.5 1586,-773.5 1580,-773.5 1580,-773.5 1084,-773.5 1084,-773.5 1078,-773.5 1072,-767.5 1072,-761.5 1072,-761.5 1072,-114 1072,-114 1072,-108 1078,-102 1084,-102"/>
<text text-anchor="middle" x="1332" y="-759.1" font-family="DejaVu Sans" font-size="13.00" fill="#7c3aed">PIPELINE call_tool() — backend/tools/registry.py · chaque appel passe par là</text>
</g>
<g id="clust3" class="cluster">
<title>cluster_tools</title>
<path fill="white" stroke="#0369a1" d="M1612,-8C1612,-8 2067,-8 2067,-8 2073,-8 2079,-14 2079,-20 2079,-20 2079,-970.5 2079,-970.5 2079,-976.5 2073,-982.5 2067,-982.5 2067,-982.5 1612,-982.5 1612,-982.5 1606,-982.5 1600,-976.5 1600,-970.5 1600,-970.5 1600,-20 1600,-20 1600,-14 1606,-8 1612,-8"/>
<text text-anchor="middle" x="1839.5" y="-968.1" font-family="DejaVu Sans" font-size="13.00" fill="#0369a1">50 OUTILS — backend/tools/*.py · risque READ / WRITE / DANGEROUS</text>
</g>
<!-- user -->
<g id="node1" class="node">
<title>user</title>
<polygon fill="#dbeafe" stroke="#333333" points="712.5,-743.5 561.5,-743.5 561.5,-705.5 712.5,-705.5 712.5,-743.5"/>
<text text-anchor="middle" x="637" y="-727.9" font-family="DejaVu Sans" font-size="12.00">Demande utilisateur</text>
<text text-anchor="middle" x="637" y="-714.9" font-family="DejaVu Sans" font-size="12.00">(frontend bookslm.js)</text>
</g>
<!-- api -->
<g id="node3" class="node">
<title>api</title>
<polygon fill="#e0f2fe" stroke="#333333" points="742.5,-669 531.5,-669 531.5,-618 742.5,-618 742.5,-669"/>
<text text-anchor="middle" x="637" y="-653.4" font-family="DejaVu Sans" font-size="12.00">POST /api/bookslm/agent</text>
<text text-anchor="middle" x="637" y="-640.4" font-family="DejaVu Sans" font-size="12.00">→ réponse SSE (thought · tool ·</text>
<text text-anchor="middle" x="637" y="-627.4" font-family="DejaVu Sans" font-size="12.00">confirmation · message · done)</text>
</g>
<!-- user&#45;&gt;api -->
<g id="edge1" class="edge">
<title>user&#45;&gt;api</title>
<path fill="none" stroke="#555555" d="M637,-705.18C637,-696.86 637,-686.76 637,-677.2"/>
<polygon fill="#555555" stroke="#555555" points="639.8,-677.01 637,-669.01 634.2,-677.01 639.8,-677.01"/>
</g>
<!-- deep -->
<g id="node2" class="node">
<title>deep</title>
<polygon fill="#fef3c7" stroke="#333333" points="749,-919 519,-919 519,-842 755,-842 755,-913 749,-919"/>
<polyline fill="none" stroke="#333333" points="749,-919 749,-913 "/>
<polyline fill="none" stroke="#333333" points="755,-913 749,-913 "/>
<text text-anchor="middle" x="637" y="-903.4" font-family="DejaVu Sans" font-size="12.00">Chip « Deep Research »</text>
<text text-anchor="middle" x="637" y="-890.4" font-family="DejaVu Sans" font-size="12.00">= agent mode + prompt injecté :</text>
<text text-anchor="middle" x="637" y="-877.4" font-family="DejaVu Sans" font-size="12.00">« décomposer les étapes, chercher</text>
<text text-anchor="middle" x="637" y="-864.4" font-family="DejaVu Sans" font-size="12.00">dans le vault, croiser,</text>
<text text-anchor="middle" x="637" y="-851.4" font-family="DejaVu Sans" font-size="12.00">synthétiser avec sources »</text>
</g>
<!-- deep&#45;&gt;user -->
<g id="edge2" class="edge">
<title>deep&#45;&gt;user</title>
<path fill="none" stroke="#555555" stroke-dasharray="5,2" d="M637,-841.83C637,-813.95 637,-776.67 637,-751.91"/>
<polygon fill="#555555" stroke="#555555" points="639.8,-751.71 637,-743.71 634.2,-751.71 639.8,-751.71"/>
<text text-anchor="middle" x="652.5" y="-784.5" font-family="DejaVu Sans" font-size="10.00">active</text>
</g>
<!-- prompt -->
<g id="node4" class="node">
<title>prompt</title>
<polygon fill="#f1f5f9" stroke="#333333" points="773.5,-585 500.5,-585 500.5,-508 773.5,-508 773.5,-585"/>
<text text-anchor="middle" x="637" y="-569.4" font-family="DejaVu Sans" font-size="12.00">Construction du system prompt</text>
<text text-anchor="middle" x="637" y="-556.4" font-family="DejaVu Sans" font-size="12.00">mode directory / documents / general</text>
<text text-anchor="middle" x="637" y="-543.4" font-family="DejaVu Sans" font-size="12.00">contexte vault pré&#45;chargé (BooksLM :</text>
<text text-anchor="middle" x="637" y="-530.4" font-family="DejaVu Sans" font-size="12.00">≤200 fichiers, ≤200k chars, cache 5 min)</text>
<text text-anchor="middle" x="637" y="-517.4" font-family="DejaVu Sans" font-size="12.00">protocole agent = function calling natif</text>
</g>
<!-- api&#45;&gt;prompt -->
<g id="edge3" class="edge">
<title>api&#45;&gt;prompt</title>
<path fill="none" stroke="#555555" d="M637,-617.54C637,-610.12 637,-601.74 637,-593.43"/>
<polygon fill="#555555" stroke="#555555" points="639.8,-593.17 637,-585.17 634.2,-593.17 639.8,-593.17"/>
</g>
<!-- llm -->
<g id="node5" class="node">
<title>llm</title>
<polygon fill="#fee2e2" stroke="#333333" points="784.5,-453 489.5,-453 489.5,-376 784.5,-376 784.5,-453"/>
<text text-anchor="middle" x="637" y="-437.4" font-family="DejaVu Sans" font-size="12.00">Appel LLM — chat_completion()</text>
<text text-anchor="middle" x="637" y="-424.4" font-family="DejaVu Sans" font-size="12.00">fournisseur OpenAI&#45;compatible / Gemini</text>
<text text-anchor="middle" x="637" y="-411.4" font-family="DejaVu Sans" font-size="12.00">temp=0.3 · max_tokens 8192</text>
<text text-anchor="middle" x="637" y="-398.4" font-family="DejaVu Sans" font-size="12.00">schemas des 50 outils exposés</text>
<text text-anchor="middle" x="637" y="-385.4" font-family="DejaVu Sans" font-size="12.00">(si modèle sans tools → retombe chat simple)</text>
</g>
<!-- prompt&#45;&gt;llm -->
<g id="edge8" class="edge">
<title>prompt&#45;&gt;llm</title>
<path fill="none" stroke="#555555" d="M637,-507.9C637,-493.35 637,-476.55 637,-461.3"/>
<polygon fill="#555555" stroke="#555555" points="639.8,-461.02 637,-453.02 634.2,-461.02 639.8,-461.02"/>
</g>
<!-- decide -->
<g id="node6" class="node">
<title>decide</title>
<polygon fill="#fff7ed" stroke="#333333" points="641,-325 489,-287 641,-249 793,-287 641,-325"/>
<text text-anchor="middle" x="641" y="-290.4" font-family="DejaVu Sans" font-size="12.00">Le LLM demande des</text>
<text text-anchor="middle" x="641" y="-277.4" font-family="DejaVu Sans" font-size="12.00">outils (tool_calls) ?</text>
</g>
<!-- llm&#45;&gt;decide -->
<g id="edge4" class="edge">
<title>llm&#45;&gt;decide</title>
<path fill="none" stroke="#555555" d="M638.2,-375.85C638.63,-362.33 639.12,-346.95 639.57,-332.87"/>
<polygon fill="#555555" stroke="#555555" points="642.37,-332.85 639.83,-324.76 636.77,-332.67 642.37,-332.85"/>
</g>
<!-- thought -->
<g id="node7" class="node">
<title>thought</title>
<polygon fill="#fef9c3" stroke="#333333" points="786.5,-154.5 529.5,-154.5 529.5,-116.5 786.5,-116.5 786.5,-154.5"/>
<text text-anchor="middle" x="658" y="-138.9" font-family="DejaVu Sans" font-size="12.00">note de raisonnement</text>
<text text-anchor="middle" x="658" y="-125.9" font-family="DejaVu Sans" font-size="12.00">intermédiaire → step visible « pensée »</text>
</g>
<!-- decide&#45;&gt;thought -->
<g id="edge5" class="edge">
<title>decide&#45;&gt;thought</title>
<path fill="none" stroke="#e11d48" d="M645.12,-249.81C648.17,-222.91 652.28,-186.83 655.02,-162.68"/>
<polygon fill="#e11d48" stroke="#e11d48" points="657.81,-162.94 655.93,-154.67 652.25,-162.31 657.81,-162.94"/>
<text text-anchor="middle" x="659.5" y="-205.2" font-family="DejaVu Sans" font-size="11.00">oui</text>
</g>
<!-- done -->
<g id="node16" class="node">
<title>done</title>
<polygon fill="#bbf7d0" stroke="#333333" points="473,-167.5 275,-167.5 275,-103.5 473,-103.5 473,-167.5"/>
<text text-anchor="middle" x="374" y="-151.9" font-family="DejaVu Sans" font-size="12.00">Réponse finale</text>
<text text-anchor="middle" x="374" y="-138.9" font-family="DejaVu Sans" font-size="12.00">réponse sans tool_calls</text>
<text text-anchor="middle" x="374" y="-125.9" font-family="DejaVu Sans" font-size="12.00">→ SSE « message »</text>
<text text-anchor="middle" x="374" y="-112.9" font-family="DejaVu Sans" font-size="12.00">(Markdown + sources citées)</text>
</g>
<!-- decide&#45;&gt;done -->
<g id="edge19" class="edge">
<title>decide&#45;&gt;done</title>
<path fill="none" stroke="#059669" stroke-width="2" d="M595.1,-260.3C551.33,-235.79 485.07,-198.69 436.49,-171.49"/>
<polygon fill="#059669" stroke="#059669" stroke-width="2" points="437.81,-169.02 429.46,-167.55 435.07,-173.9 437.81,-169.02"/>
<text text-anchor="middle" x="541.5" y="-205.2" font-family="DejaVu Sans" font-size="11.00">non</text>
</g>
<!-- final -->
<g id="node17" class="node">
<title>final</title>
<polygon fill="#fed7aa" stroke="#333333" points="250,-174 0,-174 0,-97 250,-97 250,-174"/>
<text text-anchor="middle" x="125" y="-158.4" font-family="DejaVu Sans" font-size="12.00">Budget épuisé (10 itérations</text>
<text text-anchor="middle" x="125" y="-145.4" font-family="DejaVu Sans" font-size="12.00">ou 25 appels) → _finalize_answer :</text>
<text text-anchor="middle" x="125" y="-132.4" font-family="DejaVu Sans" font-size="12.00">dernier appel SANS outils pour forcer</text>
<text text-anchor="middle" x="125" y="-119.4" font-family="DejaVu Sans" font-size="12.00">une synthèse, sinon liste des sources</text>
<text text-anchor="middle" x="125" y="-106.4" font-family="DejaVu Sans" font-size="12.00">déterministe (jamais de réponse vide)</text>
</g>
<!-- decide&#45;&gt;final -->
<g id="edge20" class="edge">
<title>decide&#45;&gt;final</title>
<path fill="none" stroke="#ea580c" d="M565.01,-267.84C491.13,-250.18 390,-226 390,-226 390,-226 314.36,-200.46 243.91,-176.66"/>
<polygon fill="#ea580c" stroke="#ea580c" points="244.71,-173.97 236.23,-174.07 242.92,-179.28 244.71,-173.97"/>
<text text-anchor="middle" x="429.5" y="-205.2" font-family="DejaVu Sans" font-size="11.00">budget atteint</text>
</g>
<!-- tools -->
<g id="node8" class="node">
<title>tools</title>
<polygon fill="#dcfce7" stroke="#333333" points="987,-57.5 787,-57.5 787,-19.5 987,-19.5 987,-57.5"/>
<text text-anchor="middle" x="887" y="-41.9" font-family="DejaVu Sans" font-size="12.00">Exécution de chaque tool call</text>
<text text-anchor="middle" x="887" y="-28.9" font-family="DejaVu Sans" font-size="12.00">via call_tool() (voir pipeline)</text>
</g>
<!-- thought&#45;&gt;tools -->
<g id="edge6" class="edge">
<title>thought&#45;&gt;tools</title>
<path fill="none" stroke="#555555" d="M701.61,-116.41C739.89,-100.53 795.6,-77.42 835.95,-60.68"/>
<polygon fill="#555555" stroke="#555555" points="837.23,-63.18 843.55,-57.53 835.09,-58 837.23,-63.18"/>
</g>
<!-- tools&#45;&gt;llm -->
<g id="edge7" class="edge">
<title>tools&#45;&gt;llm</title>
<path fill="none" stroke="#e11d48" d="M881.93,-57.8C864.2,-121.83 806,-332 806,-332 806,-332 764.31,-352.1 722.35,-372.34"/>
<polygon fill="#e11d48" stroke="#e11d48" points="721.06,-369.85 715.07,-375.85 723.49,-374.9 721.06,-369.85"/>
<text text-anchor="middle" x="930" y="-217.2" font-family="DejaVu Sans" font-size="11.00">itération ≤ 10</text>
<text text-anchor="middle" x="930" y="-205.2" font-family="DejaVu Sans" font-size="11.00">quota tools ≤ 25</text>
<text text-anchor="middle" x="930" y="-193.2" font-family="DejaVu Sans" font-size="11.00">(BOOKSLM_MAX_TOOL_CALLS)</text>
</g>
<!-- p1 -->
<g id="node10" class="node">
<title>p1</title>
<polygon fill="#ede9fe" stroke="#333333" points="1244,-743.5 1080,-743.5 1080,-705.5 1244,-705.5 1244,-743.5"/>
<text text-anchor="middle" x="1162" y="-727.9" font-family="DejaVu Sans" font-size="12.00">1 · Schéma Pydantic</text>
<text text-anchor="middle" x="1162" y="-714.9" font-family="DejaVu Sans" font-size="12.00">+ validation arguments</text>
</g>
<!-- tools&#45;&gt;p1 -->
<g id="edge15" class="edge">
<title>tools&#45;&gt;p1</title>
<path fill="none" stroke="#555555" d="M927.18,-57.59C988.4,-85.16 1098,-134.5 1098,-134.5 1098,-644.5 1098,-644.5 1098,-644.5 1098,-644.5 1123.27,-675.7 1142.06,-698.88"/>
<polygon fill="#555555" stroke="#555555" points="1139.94,-700.72 1147.16,-705.18 1144.3,-697.2 1139.94,-700.72"/>
</g>
<!-- back -->
<g id="node9" class="node">
<title>back</title>
<polygon fill="#333333" stroke="transparent" points="1056,-440 810,-440 810,-389 1056,-389 1056,-440"/>
<text text-anchor="middle" x="933" y="-424.4" font-family="DejaVu Sans" font-size="12.00">résultat (JSON, tronqué ≤100k chars)</text>
<text text-anchor="middle" x="933" y="-411.4" font-family="DejaVu Sans" font-size="12.00">ajouté à la conversation →</text>
<text text-anchor="middle" x="933" y="-398.4" font-family="DejaVu Sans" font-size="12.00">itération suivante</text>
</g>
<!-- back&#45;&gt;p1 -->
<!-- p2 -->
<g id="node11" class="node">
<title>p2</title>
<polygon fill="#ede9fe" stroke="#333333" points="1282,-662.5 1146,-662.5 1146,-624.5 1282,-624.5 1282,-662.5"/>
<text text-anchor="middle" x="1214" y="-646.9" font-family="DejaVu Sans" font-size="12.00">2 · Rate limit</text>
<text text-anchor="middle" x="1214" y="-633.9" font-family="DejaVu Sans" font-size="12.00">par identité + outil</text>
</g>
<!-- p1&#45;&gt;p2 -->
<g id="edge9" class="edge">
<title>p1&#45;&gt;p2</title>
<path fill="none" stroke="#555555" d="M1174.06,-705.18C1181.02,-694.61 1189.86,-681.17 1197.46,-669.62"/>
<polygon fill="#555555" stroke="#555555" points="1199.95,-670.95 1202,-662.72 1195.27,-667.87 1199.95,-670.95"/>
</g>
<!-- p3 -->
<g id="node12" class="node">
<title>p3</title>
<polygon fill="#ede9fe" stroke="#333333" points="1321.5,-572 1168.5,-572 1168.5,-521 1321.5,-521 1321.5,-572"/>
<text text-anchor="middle" x="1245" y="-556.4" font-family="DejaVu Sans" font-size="12.00">3 · Permission vault</text>
<text text-anchor="middle" x="1245" y="-543.4" font-family="DejaVu Sans" font-size="12.00">(+ déstructif si risque</text>
<text text-anchor="middle" x="1245" y="-530.4" font-family="DejaVu Sans" font-size="12.00">DANGEROUS)</text>
</g>
<!-- p2&#45;&gt;p3 -->
<g id="edge10" class="edge">
<title>p2&#45;&gt;p3</title>
<path fill="none" stroke="#555555" d="M1219.98,-624.18C1224.04,-611.72 1229.54,-594.88 1234.35,-580.15"/>
<polygon fill="#555555" stroke="#555555" points="1237.11,-580.7 1236.93,-572.23 1231.79,-578.96 1237.11,-580.7"/>
</g>
<!-- p4 -->
<g id="node13" class="node">
<title>p4</title>
<polygon fill="#f5f3ff" stroke="#333333" points="1278,-452.5 1136,-414.5 1278,-376.5 1420,-414.5 1278,-452.5"/>
<text text-anchor="middle" x="1278" y="-417.9" font-family="DejaVu Sans" font-size="12.00">4 · Risque READ ?</text>
<text text-anchor="middle" x="1278" y="-404.9" font-family="DejaVu Sans" font-size="12.00">→ exécution directe</text>
</g>
<!-- p3&#45;&gt;p4 -->
<g id="edge11" class="edge">
<title>p3&#45;&gt;p4</title>
<path fill="none" stroke="#555555" d="M1251.28,-520.74C1255.74,-503.18 1261.84,-479.16 1267.08,-458.5"/>
<polygon fill="#555555" stroke="#555555" points="1269.87,-458.92 1269.12,-450.48 1264.44,-457.54 1269.87,-458.92"/>
</g>
<!-- p5 -->
<g id="node14" class="node">
<title>p5</title>
<polygon fill="#fde68a" stroke="#333333" points="1413.5,-332 1136.5,-332 1136.5,-242 1413.5,-242 1413.5,-332"/>
<text text-anchor="middle" x="1275" y="-316.4" font-family="DejaVu Sans" font-size="12.00">PAUSE CONFIRMATION</text>
<text text-anchor="middle" x="1275" y="-303.4" font-family="DejaVu Sans" font-size="12.00">outil WRITE/DANGEROUS → event SSE</text>
<text text-anchor="middle" x="1275" y="-290.4" font-family="DejaVu Sans" font-size="12.00">« confirmation » avec le plan complet</text>
<text text-anchor="middle" x="1275" y="-277.4" font-family="DejaVu Sans" font-size="12.00">batché (BUG&#45;075) : une seule</text>
<text text-anchor="middle" x="1275" y="-264.4" font-family="DejaVu Sans" font-size="12.00">approbation applique toutes les mutations</text>
<text text-anchor="middle" x="1275" y="-251.4" font-family="DejaVu Sans" font-size="12.00">(resume via confirm / confirm_all)</text>
</g>
<!-- p4&#45;&gt;p5 -->
<g id="edge13" class="edge">
<title>p4&#45;&gt;p5</title>
<path fill="none" stroke="#d97706" d="M1277.12,-376.52C1276.85,-365.25 1276.54,-352.62 1276.26,-340.52"/>
<polygon fill="#d97706" stroke="#d97706" points="1279.05,-340.18 1276.06,-332.25 1273.45,-340.31 1279.05,-340.18"/>
<text text-anchor="middle" x="1301" y="-351.2" font-family="DejaVu Sans" font-size="11.00">mutation</text>
</g>
<!-- p6 -->
<g id="node15" class="node">
<title>p6</title>
<polygon fill="#dcfce7" stroke="#333333" points="1395.5,-161 1142.5,-161 1142.5,-110 1395.5,-110 1395.5,-161"/>
<text text-anchor="middle" x="1269" y="-145.4" font-family="DejaVu Sans" font-size="12.00">5 · Handler exécuté</text>
<text text-anchor="middle" x="1269" y="-132.4" font-family="DejaVu Sans" font-size="12.00">6 · Audit JSON&#45;lines</text>
<text text-anchor="middle" x="1269" y="-119.4" font-family="DejaVu Sans" font-size="12.00">7 · Redaction secrets (redact_payload)</text>
</g>
<!-- p4&#45;&gt;p6 -->
<g id="edge12" class="edge">
<title>p4&#45;&gt;p6</title>
<path fill="none" stroke="#555555" d="M1323.61,-388.38C1367.1,-364.44 1426,-332 1426,-332 1426,-332 1426,-242 1426,-242 1426,-242 1358.97,-196.96 1312.51,-165.73"/>
<polygon fill="#555555" stroke="#555555" points="1314.01,-163.37 1305.81,-161.24 1310.89,-168.02 1314.01,-163.37"/>
<text text-anchor="middle" x="1438.5" y="-284.2" font-family="DejaVu Sans" font-size="11.00">read</text>
</g>
<!-- p5&#45;&gt;p6 -->
<g id="edge14" class="edge">
<title>p5&#45;&gt;p6</title>
<path fill="none" stroke="#555555" stroke-dasharray="5,2" d="M1273.23,-241.95C1272.3,-218.69 1271.17,-190.62 1270.32,-169.29"/>
<polygon fill="#555555" stroke="#555555" points="1273.11,-169.06 1269.99,-161.18 1267.51,-169.28 1273.11,-169.06"/>
<text text-anchor="middle" x="1298" y="-205.2" font-family="DejaVu Sans" font-size="11.00">approuvé</text>
</g>
<!-- p6&#45;&gt;tools -->
<!-- p6&#45;&gt;back -->
<!-- cluster_tools -->
<g id="node27" class="node">
<title>cluster_tools</title>
<polygon fill="#333333" stroke="#333333" points="1319,-56.5 1219,-56.5 1219,-20.5 1319,-20.5 1319,-56.5"/>
<text text-anchor="middle" x="1269" y="-35.4" font-family="DejaVu Sans" font-size="12.00">cluster_tools</text>
</g>
<!-- p6&#45;&gt;cluster_tools -->
<!-- answer -->
<g id="node18" class="node">
<title>answer</title>
<polygon fill="#dbeafe" stroke="#333333" points="347,-64 151,-64 151,-13 347,-13 347,-64"/>
<text text-anchor="middle" x="249" y="-48.4" font-family="DejaVu Sans" font-size="12.00">Affichage UI : bloc</text>
<text text-anchor="middle" x="249" y="-35.4" font-family="DejaVu Sans" font-size="12.00">« N étapes » Notion&#45;style</text>
<text text-anchor="middle" x="249" y="-22.4" font-family="DejaVu Sans" font-size="12.00">(pensées + outils dépliables)</text>
</g>
<!-- done&#45;&gt;answer -->
<g id="edge21" class="edge">
<title>done&#45;&gt;answer</title>
<path fill="none" stroke="#555555" d="M333.07,-103.39C318.5,-92.32 302.14,-79.89 287.76,-68.96"/>
<polygon fill="#555555" stroke="#555555" points="289.37,-66.66 281.3,-64.05 285.98,-71.12 289.37,-66.66"/>
</g>
<!-- final&#45;&gt;answer -->
<g id="edge22" class="edge">
<title>final&#45;&gt;answer</title>
<path fill="none" stroke="#555555" d="M174.01,-96.95C186.01,-87.76 198.68,-78.05 210.13,-69.28"/>
<polygon fill="#555555" stroke="#555555" points="212.07,-71.32 216.71,-64.24 208.66,-66.88 212.07,-71.32"/>
</g>
<!-- fam_vault -->
<g id="node19" class="node">
<title>fam_vault</title>
<polygon fill="#f0f9ff" stroke="#333333" points="1958,-952.5 1720,-952.5 1720,-808.5 1958,-808.5 1958,-952.5"/>
<text text-anchor="middle" x="1839" y="-938.5" font-family="DejaVu Sans" font-size="10.00">VAULT · service.py (22)</text>
<text text-anchor="middle" x="1839" y="-927.5" font-family="DejaVu Sans" font-size="10.00">lecture : list_vaults, list_directory,</text>
<text text-anchor="middle" x="1839" y="-916.5" font-family="DejaVu Sans" font-size="10.00">list_all_files, read_file, read_file_raw,</text>
<text text-anchor="middle" x="1839" y="-905.5" font-family="DejaVu Sans" font-size="10.00">search_fulltext, search_advanced,</text>
<text text-anchor="middle" x="1839" y="-894.5" font-family="DejaVu Sans" font-size="10.00">search_paths, get_graph, get_backlinks,</text>
<text text-anchor="middle" x="1839" y="-883.5" font-family="DejaVu Sans" font-size="10.00">list_tags, suggest_tags, list_recent,</text>
<text text-anchor="middle" x="1839" y="-872.5" font-family="DejaVu Sans" font-size="10.00">list_backups, diff_backup</text>
<text text-anchor="middle" x="1839" y="-861.5" font-family="DejaVu Sans" font-size="10.00">écriture : create_file, create_directory,</text>
<text text-anchor="middle" x="1839" y="-850.5" font-family="DejaVu Sans" font-size="10.00">append_to_file, edit_file, restore_backup</text>
<text text-anchor="middle" x="1839" y="-839.5" font-family="DejaVu Sans" font-size="10.00">DANGEROUS : delete_file, delete_directory,</text>
<text text-anchor="middle" x="1839" y="-828.5" font-family="DejaVu Sans" font-size="10.00">move_path, rename_file, rename_directory,</text>
<text text-anchor="middle" x="1839" y="-817.5" font-family="DejaVu Sans" font-size="10.00">replace_in_files</text>
</g>
<!-- fam_web -->
<g id="node20" class="node">
<title>fam_web</title>
<polygon fill="#f0f9ff" stroke="#333333" points="1938.5,-747 1739.5,-747 1739.5,-702 1938.5,-702 1938.5,-747"/>
<text text-anchor="middle" x="1839" y="-733" font-family="DejaVu Sans" font-size="10.00">WEB · web.py + crawler.py</text>
<text text-anchor="middle" x="1839" y="-722" font-family="DejaVu Sans" font-size="10.00">web_search · fetch_url</text>
<text text-anchor="middle" x="1839" y="-711" font-family="DejaVu Sans" font-size="10.00">crawl_site (≤20 pages, même hôte)</text>
</g>
<!-- fam_vault&#45;&gt;fam_web -->
<!-- fam_doc -->
<g id="node21" class="node">
<title>fam_doc</title>
<polygon fill="#f0f9ff" stroke="#333333" points="1922,-666 1756,-666 1756,-621 1922,-621 1922,-666"/>
<text text-anchor="middle" x="1839" y="-652" font-family="DejaVu Sans" font-size="10.00">DOCUMENTS · documents.py</text>
<text text-anchor="middle" x="1839" y="-641" font-family="DejaVu Sans" font-size="10.00">create_pdf · create_docx</text>
<text text-anchor="middle" x="1839" y="-630" font-family="DejaVu Sans" font-size="10.00">create_xlsx · create_csv</text>
</g>
<!-- fam_web&#45;&gt;fam_doc -->
<!-- fam_xlsx -->
<g id="node22" class="node">
<title>fam_xlsx</title>
<polygon fill="#f0f9ff" stroke="#333333" points="1944.5,-580 1733.5,-580 1733.5,-513 1944.5,-513 1944.5,-580"/>
<text text-anchor="middle" x="1839" y="-566" font-family="DejaVu Sans" font-size="10.00">TABLEURS · spreadsheets.py</text>
<text text-anchor="middle" x="1839" y="-555" font-family="DejaVu Sans" font-size="10.00">list_xlsx_sheets · xlsx_to_markdown</text>
<text text-anchor="middle" x="1839" y="-544" font-family="DejaVu Sans" font-size="10.00">search_workbook · analyze_range</text>
<text text-anchor="middle" x="1839" y="-533" font-family="DejaVu Sans" font-size="10.00">update_xlsx_cells · append_xlsx_rows</text>
<text text-anchor="middle" x="1839" y="-522" font-family="DejaVu Sans" font-size="10.00">edit_xlsx_structure</text>
</g>
<!-- fam_doc&#45;&gt;fam_xlsx -->
<!-- fam_dup -->
<g id="node23" class="node">
<title>fam_dup</title>
<polygon fill="#f0f9ff" stroke="#333333" points="1929.5,-437 1748.5,-437 1748.5,-392 1929.5,-392 1929.5,-437"/>
<text text-anchor="middle" x="1839" y="-423" font-family="DejaVu Sans" font-size="10.00">DOUBLONS · duplicates.py</text>
<text text-anchor="middle" x="1839" y="-412" font-family="DejaVu Sans" font-size="10.00">find_duplicates</text>
<text text-anchor="middle" x="1839" y="-401" font-family="DejaVu Sans" font-size="10.00">merge_duplicate_notes (DANG.)</text>
</g>
<!-- fam_xlsx&#45;&gt;fam_dup -->
<!-- fam_git -->
<g id="node24" class="node">
<title>fam_git</title>
<polygon fill="#f0f9ff" stroke="#333333" points="1931,-309.5 1747,-309.5 1747,-264.5 1931,-264.5 1931,-309.5"/>
<text text-anchor="middle" x="1839" y="-295.5" font-family="DejaVu Sans" font-size="10.00">GIT CONNECTÉ · connected.py</text>
<text text-anchor="middle" x="1839" y="-284.5" font-family="DejaVu Sans" font-size="10.00">git_list_repos · git_get_file</text>
<text text-anchor="middle" x="1839" y="-273.5" font-family="DejaVu Sans" font-size="10.00">git_search_issues (Gitea/GitHub)</text>
</g>
<!-- fam_dup&#45;&gt;fam_git -->
<!-- fam_sched -->
<g id="node25" class="node">
<title>fam_sched</title>
<polygon fill="#f0f9ff" stroke="#333333" points="1942.5,-153.5 1735.5,-153.5 1735.5,-117.5 1942.5,-117.5 1942.5,-153.5"/>
<text text-anchor="middle" x="1839" y="-138.5" font-family="DejaVu Sans" font-size="10.00">SCHEDULER · scheduled.py</text>
<text text-anchor="middle" x="1839" y="-127.5" font-family="DejaVu Sans" font-size="10.00">list/create/delete/run_scheduled_task</text>
</g>
<!-- fam_git&#45;&gt;fam_sched -->
<!-- fam_notify -->
<g id="node26" class="node">
<title>fam_notify</title>
<polygon fill="#f0f9ff" stroke="#333333" points="1917.5,-61 1760.5,-61 1760.5,-16 1917.5,-16 1917.5,-61"/>
<text text-anchor="middle" x="1839" y="-47" font-family="DejaVu Sans" font-size="10.00">NOTIFICATIONS · notify.py</text>
<text text-anchor="middle" x="1839" y="-36" font-family="DejaVu Sans" font-size="10.00">notify_external (Discord,</text>
<text text-anchor="middle" x="1839" y="-25" font-family="DejaVu Sans" font-size="10.00">Telegram, SMTP, webhook)</text>
</g>
<!-- fam_sched&#45;&gt;fam_notify -->
</g>
</svg>

After

Width:  |  Height:  |  Size: 26 KiB

+72
View File
@@ -0,0 +1,72 @@
# #179 — Éditeur tableur : icônes des menus & boutons, fermeture au focus, polish mobile
> **Statut :** 🔵 en cours (2026-10-04) | **Effort :** 1-2 jours | **Impact :** 🟡
> **Références :** [Roadmap](../ROADMAP.md) · [Changelog](../../CHANGELOG.md) ·
> [#154](./xlsx-ui-redesign.md) · [#155](./xlsx-context-menu.md) · [#156](./xlsx-editor-completeness.md)
## 1. Constat
La grille (#155), les menus Structure / Mise en forme / Export (#156-A8/A11/A14)
et les boutons du ruban (#154) étaient textuels, et les menus Structure / Mise
en forme / Export **restaient ouverts** quand le focus les quittait (seul le
menu grille se fermait, au clic extérieur / `Échap` uniquement).
## 2. Livré (A1-A4)
### A1 — Icônes du menu contextuel de la grille
Chaque entrée de `buildContextItems` (`viewer.js`) porte une icône Lucide
(`scissors`, `copy`, `clipboard-paste`, `rows`/`columns`, `trash-2` danger,
`arrow-up`/`arrow-down`, `eraser`). Rendu dans `xlsx/context-menu.js`
(`<i data-lucide>` + `<span class="xlsx-menu-label">`, label en `textContent`
— jamais d'HTML injecté), hydraté par `safeCreateIcons()` (jamais
`lucide.createIcons()` direct).
### A2 — Icônes Structure / Mise en forme / Export + boutons
- Structure : `file-plus`, `pencil`, `copy`, `trash-2` (danger), `rows`,
`columns` ; suppressions marquées `.xlsx-context-danger`.
- Mise en forme : `bold`/`italic`/`underline`, `remove-formatting`,
`align-left/center/right`, `palette`/`paint-bucket` (sélecteurs natifs),
`type`/`hash`/`percent`/`euro`/`calendar`/`pilcrow` (formats),
`combine`/`ungroup`, `snowflake`/`sun` (figer/libérer),
`move-horizontal`/`move-vertical` (largeur/hauteur).
- Export : `file-text` (MD), `code` (HTML), `printer` (impression).
- Ruban : bouton Enregistrer (`save` + libellé), onglet « + » (`plus`),
`aria-haspopup="menu"` / `aria-expanded` sur les 3 boutons à menu.
### A3 — Fermeture à la perte de focus + clavier
- `trackDismissable(menu, { toggle, onClose })` dans `context-menu.js` :
clic extérieur (le toggle garde ses propres clics), `Échap`, `focusout`
(vers menu **et** toggle exemptés), scroll (capture), resize ; `dismiss()`
idempotent qui débranche tout (pas de fuite entre re-rendus).
- Menu grille : `focusout` + scroll/resize en plus de l'existant ;
`enableArrowNav()` (`↓`/`↑`/`Home`/`End`, désactivés sautés), autofocus de
la 1ʳᵉ entrée à l'ouverture (`preventScroll`), `:focus` toujours visible.
- Menus Structure / Mise en forme / Export branchés sur le helper.
### A4 — Polish mobile
- `@media (pointer: coarse)` : entrées ≥ 44 px, police 0,9 rem.
- Menus bornés au viewport (grille : `min(320px, 86vw)` + `max-height: 80vh`
scrollable ; structure : `min(320px, 92vw)` ; export : `86vw`).
- Le ruban s'enroule déjà (`flex-wrap`), l'inspecteur s'empile ≤ 900 px :
vérifié sans débordement horizontal à 393 px (E2E projet mobile).
## 3. Tests
- `tests/frontend/xlsx-menus.test.mjs` (11) : rendu icônes + XSS, autofocus,
navigation clavier, `Échap` / clic / `focusout` / scroll / resize,
`trackDismissable`, icônes du ruban.
- `tests/frontend/xlsx-viewer.test.mjs` (+6, 113 total) : icônes et fermetures
au niveau viewer réel.
- `tests/e2e/xlsx-viewer.spec.js` (+5) : icônes par entrée, fermeture clic/`Échap`,
Structure au focus perdu, Export au clic extérieur, mobile (ruban, 44 px,
viewport) — ce dernier `skip` hors viewport ≤ 768 px.
## 4. Limites assumées
- Noms d'icônes de la gamme Lucide 0.344.0 (CDN `unpkg`) : si le CDN est
injoignable, les libellés restent (dégradation texte, jamais de crash).
- Pas de nouveaux libellés i18n (icônes + `aria-expanded` uniquement).
+3 -3
View File
@@ -4674,9 +4674,9 @@
conversation.
</li>
<li data-i18n="help.assistant_agent">
Le bouton « mode agent » active les outils (lire, lister,
chercher) ; les actions de modification demandent une
confirmation avec aperçu des changements.
L'assistant utilise toujours ses outils (lire, lister,
chercher, modifier) ; les actions de modification demandent
une confirmation avec aperçu des changements.
</li>
<li data-i18n="help.assistant_agent_run">
Les actions s'affichent dans le fil : l'assistant regroupe les
+2 -2
View File
@@ -46,8 +46,8 @@ export const ACTION_CATALOG = Object.freeze([
{ id: 'checklist', cat: 'structure', icon: 'list-checks', labelKey: 'qa.checklist', promptKey: 'qa.checklist.prompt' },
{ id: 'plan', cat: 'structure', icon: 'list-ordered', labelKey: 'qa.plan', promptKey: 'qa.plan.prompt' },
{ id: 'memo', cat: 'structure', icon: 'scroll-text', labelKey: 'qa.memo', promptKey: 'qa.memo.prompt' },
{ id: 'frontmatter', cat: 'structure', icon: 'braces', labelKey: 'qa.frontmatter', promptKey: 'qa.frontmatter.prompt', agent: true },
{ id: 'frontmatter_update', cat: 'structure', icon: 'refresh-cw', labelKey: 'qa.frontmatter_update', promptKey: 'qa.frontmatter_update.prompt', agent: true },
{ id: 'frontmatter', cat: 'structure', icon: 'braces', labelKey: 'qa.frontmatter', promptKey: 'qa.frontmatter.prompt' },
{ id: 'frontmatter_update', cat: 'structure', icon: 'refresh-cw', labelKey: 'qa.frontmatter_update', promptKey: 'qa.frontmatter_update.prompt' },
{ id: 'backlinks', cat: 'structure', icon: 'link-2', labelKey: 'qa.backlinks', promptKey: 'qa.backlinks.prompt' },
{ id: 'sections', cat: 'structure', icon: 'heading', labelKey: 'qa.sections', promptKey: 'qa.sections.prompt' },
// ── Code & Scripts ───────────────────────────────────────────────────
+12 -43
View File
@@ -151,9 +151,8 @@ class BooksLM {
this._sessions = [];
this._currentSessionId = null;
this._pendingNewSession = false;
// Agent mode: routes chat through /api/ai/bookslm/agent so the model can
// call read/search tools and propose mutations (confirmation cards).
this._agentMode = this._readAgentMode();
// #187: the assistant is always in agent mode (tools on /agent endpoint);
// the former header toggle was removed — images still use the /chat path.
// Ad-hoc context added with `@` (files/directories) and images attached
// by paste or by mentioning an image file.
this._adhocFiles = [];
@@ -188,33 +187,6 @@ class BooksLM {
this._extensions = null;
}
_readAgentMode() {
try {
return localStorage.getItem('obsigate-bookslm-agent') === 'true';
} catch {
return false;
}
}
_toggleAgentMode() {
this._agentMode = !this._agentMode;
try {
localStorage.setItem('obsigate-bookslm-agent', this._agentMode ? 'true' : 'false');
} catch { /* private mode */ }
this._updateAgentToggle();
}
_updateAgentToggle() {
if (!this._panel) return;
const btn = this._panel.querySelector('.bookslm-btn-agent');
if (!btn) return;
btn.classList.toggle('active', this._agentMode);
const label = this._agentMode ? t('ai.agent_mode_on') : t('ai.agent_mode_off');
btn.title = label;
btn.setAttribute('aria-label', label);
btn.setAttribute('aria-pressed', this._agentMode ? 'true' : 'false');
}
// ── Public API ──────────────────────────────────────────────────────
/**
@@ -966,7 +938,6 @@ class BooksLM {
</div>
<span class="bookslm-qa-badge hidden" data-qa-context=""></span>
<div class="bookslm-header-actions">
<button class="bookslm-btn-agent" title="${t('ai.agent_mode_off')}" aria-label="${t('ai.agent_mode_off')}" aria-pressed="false"><i data-lucide="bot" style="width:16px;height:16px"></i></button>
<button class="bookslm-btn-history" title="${t('bookslm.session_history')}" aria-label="${t('bookslm.session_history')}"><i data-lucide="history" style="width:16px;height:16px"></i></button>
<button class="bookslm-btn-new" title="${t('bookslm.new_conversation')}" aria-label="${t('bookslm.new_conversation')}"><i data-lucide="plus" style="width:16px;height:16px"></i></button>
<button class="bookslm-btn-export" title="${t('bookslm.export')}" aria-label="${t('bookslm.export')}"><i data-lucide="download" style="width:16px;height:16px"></i></button>
@@ -1011,7 +982,6 @@ class BooksLM {
panel.querySelector('.bookslm-btn-close').addEventListener('click', () => this.close());
panel.querySelector('.bookslm-btn-new').addEventListener('click', () => this.newConversation());
panel.querySelector('.bookslm-btn-agent').addEventListener('click', () => this._toggleAgentMode());
panel.querySelector('.bookslm-btn-history').addEventListener('click', (e) => {
e.stopPropagation();
this._toggleHistoryMenu();
@@ -1552,10 +1522,9 @@ class BooksLM {
if (typeof safeCreateIcons === 'function') safeCreateIcons();
}
/** #97/#99 — Deep Research: agent tools + a skill-like chip (no composer text). */
/** #97/#99 — Deep Research: a skill-like chip (the agent tools are always on). */
_startDeepResearch() {
this._closeExtMenu();
if (!this._agentMode) this._toggleAgentMode();
this._activeDeepResearch = true;
this._renderAttachments();
showToast(t('bookslm.deep_research_started'), 'info');
@@ -2201,15 +2170,14 @@ class BooksLM {
}
/** Immediate-send the action prompt through the composer (same path as a
* typed message: skills, agent mode and images all keep working). Actions
* flagged `agent` (they mutate the document) transparently switch the
* assistant to agent mode first — same pattern as Deep Research. */
* typed message: skills and images all keep working). #187: the assistant
* is always agent mode, so the former `agent: true` quick actions need no
* mode switch anymore. */
_runQuickAction(action) {
if (!this._panel || !action) return;
const { prompt } = actionTexts(action);
if (!prompt) return;
this._closeActionDrawer();
if (action.agent && !this._agentMode) this._toggleAgentMode();
const textarea = this._panel.querySelector('textarea');
if (!textarea) return;
textarea.value = prompt;
@@ -3253,14 +3221,15 @@ class BooksLM {
this._saveHistory();
}
/** POST to /chat or /agent depending on the agent-mode toggle. */
/** POST to /agent (always) or /chat when images are attached. */
_postChat(payload) {
// Images require the plain multimodal chat endpoint (the agent loop is
// #187: the assistant is always agent mode — images are the only case
// that still needs the plain multimodal chat endpoint (the agent loop is
// text/tool oriented).
const hasImages = Array.isArray(payload.images) && payload.images.length > 0;
const endpoint = (this._agentMode && !hasImages)
? '/api/ai/bookslm/agent'
: '/api/ai/bookslm/chat';
const endpoint = hasImages
? '/api/ai/bookslm/chat'
: '/api/ai/bookslm/agent';
const headers = { 'Content-Type': 'application/json', ...(AuthManager.getAuthHeaders() || {}) };
return fetch(endpoint, {
method: 'POST',
+147 -61
View File
@@ -19,7 +19,7 @@ import { openAssistant } from './ai-fab.js';
import { parseRef, columnName, findTd, sheetOfRef, firstCellOfRange } from './xlsx/refs.js';
import { buildCommandBar } from './xlsx/command-bar.js';
import { renderDashboardLoading, renderDashboardHtml } from './xlsx/dashboard.js';
import { openContextMenu, closeContextMenu } from './xlsx/context-menu.js';
import { openContextMenu, closeContextMenu, trackDismissable, enableArrowNav } from './xlsx/context-menu.js';
// ── Multi-format export ────────────────────────────────────────────────────
// Downloads a file export (HTML / MD bundle / ePub) via the authenticated
@@ -2284,25 +2284,42 @@ export function renderXlsxViewer(area, data) {
// Structure menu: built on demand, positioned under the button.
// (.csv / read-only workbooks have no such button — the menu block above
// is skipped for them.)
// #179 — tracked dismissal of the structure menu (outside, focus loss…).
let dismissStructureMenu = null;
const structureBtn = area.querySelector("#xlsx-structure-btn");
if (structureBtn) structureBtn.addEventListener("click", (e) => {
const old = area.querySelector(".xlsx-structure-menu");
if (old) { old.remove(); return; }
if (dismissStructureMenu) { dismissStructureMenu(); dismissStructureMenu = null; return; }
const idx = visibleSheetIndex();
const sheetName = sheets[idx]?.name || "";
const activeRef = cellName(activeTd && activeTd.closest(".xlsx-panel") === visiblePanel() ? activeTd : null);
const parsed = parseRef(activeRef) || { row: 1, col: 1 };
const menu = document.createElement("div");
menu.className = "xlsx-structure-menu";
const item = (label, fn) => {
menu.setAttribute("role", "menu");
// #179 — representative icon per entry; destructive ones read as danger.
const item = (iconName, label, fn, danger = false) => {
const b = document.createElement("button");
b.type = "button";
b.className = "btn-action xlsx-structure-item";
b.textContent = label;
b.addEventListener("click", () => { menu.remove(); fn(); });
b.className = `btn-action xlsx-structure-item${danger ? " xlsx-context-danger" : ""}`;
b.setAttribute("role", "menuitem");
if (iconName) {
const ic = document.createElement("i");
ic.setAttribute("data-lucide", iconName);
ic.className = "xlsx-menu-icon";
ic.setAttribute("aria-hidden", "true");
b.appendChild(ic);
}
const lab = document.createElement("span");
lab.className = "xlsx-menu-label";
lab.textContent = label;
b.appendChild(lab);
b.addEventListener("click", () => {
if (dismissStructureMenu) { dismissStructureMenu(); dismissStructureMenu = null; }
fn();
});
menu.appendChild(b);
};
item(t("xlsx.sheet_add"), async () => {
item("file-plus", t("xlsx.sheet_add"), async () => {
const name = await showPrompt({
title: t("xlsx.sheet_add"),
message: t("xlsx.structure_prompt_add"),
@@ -2311,7 +2328,7 @@ export function renderXlsxViewer(area, data) {
const actions = [{ op: "sheet_add", name }];
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
});
item(t("xlsx.sheet_rename"), async () => {
item("pencil", t("xlsx.sheet_rename"), async () => {
const to = await showPrompt({
title: t("xlsx.sheet_rename"),
message: t("xlsx.structure_prompt_rename"),
@@ -2321,7 +2338,7 @@ export function renderXlsxViewer(area, data) {
const actions = [{ op: "sheet_rename", from: sheetName, to }];
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
});
item(t("xlsx.sheet_duplicate"), async () => {
item("copy", t("xlsx.sheet_duplicate"), async () => {
const as = await showPrompt({
title: t("xlsx.sheet_duplicate"),
message: t("xlsx.structure_prompt_add"),
@@ -2331,7 +2348,7 @@ export function renderXlsxViewer(area, data) {
const actions = [{ op: "sheet_duplicate", name: sheetName, as }];
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
});
item(t("xlsx.sheet_delete"), async () => {
item("trash-2", t("xlsx.sheet_delete"), async () => {
if (sheets.length <= 1) { showToast(t("xlsx.last_sheet"), "info"); return; }
const okDelete = await showConfirm({
title: t("xlsx.sheet_delete"),
@@ -2342,14 +2359,14 @@ export function renderXlsxViewer(area, data) {
if (!okDelete) return;
const actions = [{ op: "sheet_delete", name: sheetName }];
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
});
}, true);
if (parsed) {
menu.appendChild(Object.assign(document.createElement("div"), { className: "xlsx-structure-sep" }));
item(t("xlsx.row_insert"), async () => {
item("rows", t("xlsx.row_insert"), async () => {
const actions = [{ op: "row_insert", sheet: sheetName, at: parsed.row }];
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
});
item(t("xlsx.row_delete"), async () => {
item("trash-2", t("xlsx.row_delete"), async () => {
const okRow = await showConfirm({
title: t("xlsx.row_delete"),
message: t("xlsx.structure_confirm_row", { n: parsed.row }),
@@ -2359,12 +2376,12 @@ export function renderXlsxViewer(area, data) {
if (!okRow) return;
const actions = [{ op: "row_delete", sheet: sheetName, at: parsed.row }];
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
});
item(t("xlsx.col_insert"), async () => {
}, true);
item("columns", t("xlsx.col_insert"), async () => {
const actions = [{ op: "col_insert", sheet: sheetName, at: parsed.col }];
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
});
item(t("xlsx.col_delete"), async () => {
item("trash-2", t("xlsx.col_delete"), async () => {
const okCol = await showConfirm({
title: t("xlsx.col_delete"),
message: t("xlsx.structure_confirm_col", { n: columnName(parsed.col) }),
@@ -2374,9 +2391,21 @@ export function renderXlsxViewer(area, data) {
if (!okCol) return;
const actions = [{ op: "col_delete", sheet: sheetName, at: parsed.col }];
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
});
}, true);
}
enableArrowNav(menu, ".xlsx-structure-item");
e.target.closest(".xlsx-toolbar").appendChild(menu);
safeCreateIcons();
structureBtn.setAttribute("aria-expanded", "true");
// #179 — close on outside click, focus loss, Escape, scroll, resize.
dismissStructureMenu = trackDismissable(menu, {
toggle: structureBtn,
onClose: () => {
dismissStructureMenu = null;
structureBtn.setAttribute("aria-expanded", "false");
menu.remove();
},
});
});
// ── #156-A11 — sortie : CSV / Markdown / HTML / impression ────────────
@@ -2491,14 +2520,34 @@ export function renderXlsxViewer(area, data) {
}
};
// #179 — the export menu used to stay open until its button was clicked
// again; it now closes on outside click, focus loss, Escape, scroll, resize.
let dismissExportMenu = null;
const exportMenu = area.querySelector("#xlsx-export-menu");
if (exportMenu) {
area.querySelector("#xlsx-export-btn").addEventListener("click", () => {
exportMenu.style.display = exportMenu.style.display === "block" ? "none" : "block";
const exportBtn = area.querySelector("#xlsx-export-btn");
if (exportMenu && exportBtn) {
const hideExportMenu = () => {
if (dismissExportMenu) { dismissExportMenu(); dismissExportMenu = null; }
exportMenu.style.display = "none";
exportBtn.setAttribute("aria-expanded", "false");
};
exportBtn.addEventListener("click", () => {
if (exportMenu.style.display === "block") { hideExportMenu(); return; }
exportMenu.style.display = "block";
exportBtn.setAttribute("aria-expanded", "true");
dismissExportMenu = trackDismissable(exportMenu, {
toggle: exportBtn,
onClose: () => {
dismissExportMenu = null;
exportMenu.style.display = "none";
exportBtn.setAttribute("aria-expanded", "false");
},
});
});
enableArrowNav(exportMenu, "[data-xlsx-export]");
exportMenu.querySelectorAll("[data-xlsx-export]").forEach((item) => {
item.addEventListener("click", () => {
exportMenu.style.display = "none";
hideExportMenu();
if (item.dataset.xlsxExport === "print") printSheet();
else exportDocument(item.dataset.xlsxExport);
});
@@ -2575,19 +2624,36 @@ export function renderXlsxViewer(area, data) {
return Boolean(td && td.style.textDecoration === "underline");
};
// #179 — tracked dismissal of the format menu (outside, focus loss…).
let dismissFormatMenu = null;
const formatBtn = area.querySelector("#xlsx-format-btn");
if (formatBtn) formatBtn.addEventListener("click", () => {
const old = area.querySelector(".xlsx-format-menu");
if (old) { old.remove(); return; }
if (dismissFormatMenu) { dismissFormatMenu(); dismissFormatMenu = null; return; }
const sheetName = sheets[visibleSheetIndex()]?.name || "";
const menu = document.createElement("div");
menu.className = "xlsx-structure-menu xlsx-format-menu";
const item = (label, fn) => {
menu.setAttribute("role", "menu");
// #179 — representative icon per entry.
const item = (iconName, label, fn) => {
const b = document.createElement("button");
b.type = "button";
b.className = "btn-action xlsx-structure-item";
b.textContent = label;
b.addEventListener("click", () => { menu.remove(); fn(); });
b.setAttribute("role", "menuitem");
if (iconName) {
const ic = document.createElement("i");
ic.setAttribute("data-lucide", iconName);
ic.className = "xlsx-menu-icon";
ic.setAttribute("aria-hidden", "true");
b.appendChild(ic);
}
const lab = document.createElement("span");
lab.className = "xlsx-menu-label";
lab.textContent = label;
b.appendChild(lab);
b.addEventListener("click", () => {
if (dismissFormatMenu) { dismissFormatMenu(); dismissFormatMenu = null; }
fn();
});
menu.appendChild(b);
};
const sep = () => menu.appendChild(
@@ -2598,22 +2664,29 @@ export function renderXlsxViewer(area, data) {
]);
const numFormat = (fmt) => cellStyle({ number_format: fmt });
item(t("xlsx.format_bold"), cellStyle({ bold: !hasBold() }));
item(t("xlsx.format_italic"), cellStyle({ italic: !hasItalic() }));
item(t("xlsx.format_underline"), cellStyle({ underline: !hasUnderline() }));
item(t("xlsx.format_clear"), cellStyle({
item("bold", t("xlsx.format_bold"), cellStyle({ bold: !hasBold() }));
item("italic", t("xlsx.format_italic"), cellStyle({ italic: !hasItalic() }));
item("underline", t("xlsx.format_underline"), cellStyle({ underline: !hasUnderline() }));
item("remove-formatting", t("xlsx.format_clear"), cellStyle({
bold: false, italic: false, underline: false,
font_color: "", fill_color: "", align: "left", number_format: "General",
}));
sep();
item(t("xlsx.format_align_left"), cellStyle({ align: "left" }));
item(t("xlsx.format_align_center"), cellStyle({ align: "center" }));
item(t("xlsx.format_align_right"), cellStyle({ align: "right" }));
item("align-left", t("xlsx.format_align_left"), cellStyle({ align: "left" }));
item("align-center", t("xlsx.format_align_center"), cellStyle({ align: "center" }));
item("align-right", t("xlsx.format_align_right"), cellStyle({ align: "right" }));
sep();
// Colours come from the native picker; applied once when it closes.
const colour = (label, apply) => {
const colour = (iconName, label, apply) => {
const row = document.createElement("label");
row.className = "xlsx-format-colour";
if (iconName) {
const ic = document.createElement("i");
ic.setAttribute("data-lucide", iconName);
ic.className = "xlsx-menu-icon";
ic.setAttribute("aria-hidden", "true");
row.appendChild(ic);
}
const span = document.createElement("span");
span.textContent = label;
const input = document.createElement("input");
@@ -2623,21 +2696,21 @@ export function renderXlsxViewer(area, data) {
row.appendChild(input);
menu.appendChild(row);
};
colour(t("xlsx.format_font_color"), (e) => runStyle([
colour("palette", t("xlsx.format_font_color"), (e) => runStyle([
{ op: "cell", sheet: sheetName, range: selectionRange(), style: { font_color: e.target.value } },
]));
colour(t("xlsx.format_fill_color"), (e) => runStyle([
colour("paint-bucket", t("xlsx.format_fill_color"), (e) => runStyle([
{ op: "cell", sheet: sheetName, range: selectionRange(), style: { fill_color: e.target.value } },
]));
sep();
item(t("xlsx.format_num_general"), numFormat("General"));
item(t("xlsx.format_num_number"), numFormat("0.00"));
item(t("xlsx.format_num_percent"), numFormat("0.0%"));
item(t("xlsx.format_num_currency"), numFormat('#,##0.00 "€"'));
item(t("xlsx.format_num_date"), numFormat("DD/MM/YYYY"));
item(t("xlsx.format_num_text"), numFormat("@"));
item("type", t("xlsx.format_num_general"), numFormat("General"));
item("hash", t("xlsx.format_num_number"), numFormat("0.00"));
item("percent", t("xlsx.format_num_percent"), numFormat("0.0%"));
item("euro", t("xlsx.format_num_currency"), numFormat('#,##0.00 "€"'));
item("calendar", t("xlsx.format_num_date"), numFormat("DD/MM/YYYY"));
item("pilcrow", t("xlsx.format_num_text"), numFormat("@"));
sep();
item(t("xlsx.format_merge"), () => {
item("combine", t("xlsx.format_merge"), () => {
const b = selectionBounds();
if (!b || !selection || selection.panel !== visiblePanel() || (b.r1 === b.r2 && b.c1 === b.c2)) {
showToast(t("xlsx.format_merge_needs_range"), "info");
@@ -2645,16 +2718,16 @@ export function renderXlsxViewer(area, data) {
}
runStyle([{ op: "merge", sheet: sheetName, range: rangeLabel() }]);
});
item(t("xlsx.format_unmerge"), () => {
item("ungroup", t("xlsx.format_unmerge"), () => {
runStyle([{ op: "unmerge", sheet: sheetName, range: selectionRange() }]);
});
sep();
item(t("xlsx.format_freeze"), () => {
item("snowflake", t("xlsx.format_freeze"), () => {
runStyle([{ op: "freeze", sheet: sheetName, cell: selectionRange().split(":")[0] }]);
});
item(t("xlsx.format_unfreeze"), () => runStyle([{ op: "freeze", sheet: sheetName, cell: "" }]));
item("sun", t("xlsx.format_unfreeze"), () => runStyle([{ op: "freeze", sheet: sheetName, cell: "" }]));
sep();
item(t("xlsx.format_col_width"), async () => {
item("move-horizontal", t("xlsx.format_col_width"), async () => {
const b = selectionBounds();
const cols = [];
if (b && selection && selection.panel === visiblePanel()) {
@@ -2672,7 +2745,7 @@ export function renderXlsxViewer(area, data) {
if (!answer || !Number.isFinite(width) || width < 0 || width > 255) return;
runStyle(cols.map((col) => ({ op: "col_width", sheet: sheetName, col, width })));
});
item(t("xlsx.format_row_height"), async () => {
item("move-vertical", t("xlsx.format_row_height"), async () => {
const b = selectionBounds();
const rows = [];
if (b && selection && selection.panel === visiblePanel()) {
@@ -2690,7 +2763,19 @@ export function renderXlsxViewer(area, data) {
if (!answer || !Number.isFinite(height) || height < 0 || height > 409) return;
runStyle(rows.map((row) => ({ op: "row_height", sheet: sheetName, row, height })));
});
enableArrowNav(menu, ".xlsx-structure-item");
formatBtn.closest(".xlsx-toolbar").appendChild(menu);
safeCreateIcons();
formatBtn.setAttribute("aria-expanded", "true");
// #179 — close on outside click, focus loss, Escape, scroll, resize.
dismissFormatMenu = trackDismissable(menu, {
toggle: formatBtn,
onClose: () => {
dismissFormatMenu = null;
formatBtn.setAttribute("aria-expanded", "false");
menu.remove();
},
});
});
// ══ #155 — Excel-like selection & context menu ═════════════════════════
@@ -3228,27 +3313,28 @@ export function renderXlsxViewer(area, data) {
const colCount = b.c2 - b.c1 + 1;
const items = [];
// #156-A5 — clipboard entries first, in the spreadsheet order.
items.push({ label: t("xlsx.cut"), disabled: !editable, onClick: () => cutSelection() });
items.push({ label: t("xlsx.copy"), onClick: () => copySelection() });
items.push({ label: t("xlsx.paste"), disabled: !editable, onClick: () => pasteFromClipboard() });
// #179 — every entry carries a representative Lucide icon.
items.push({ icon: "scissors", label: t("xlsx.cut"), disabled: !editable, onClick: () => cutSelection() });
items.push({ icon: "copy", label: t("xlsx.copy"), onClick: () => copySelection() });
items.push({ icon: "clipboard-paste", label: t("xlsx.paste"), disabled: !editable, onClick: () => pasteFromClipboard() });
items.push({ separator: true });
if (editable) {
items.push({ label: t("xlsx.insert_row_above"), onClick: () => structureAction({ op: "row_insert", sheet: sheetName, at: b.r1, count: rowCount }) });
items.push({ label: t("xlsx.insert_row_below"), onClick: () => structureAction({ op: "row_insert", sheet: sheetName, at: b.r2 + 1, count: rowCount }) });
items.push({ label: t("xlsx.insert_col_left"), onClick: () => structureAction({ op: "col_insert", sheet: sheetName, at: b.c1, count: colCount }) });
items.push({ label: t("xlsx.insert_col_right"), onClick: () => structureAction({ op: "col_insert", sheet: sheetName, at: b.c2 + 1, count: colCount }) });
items.push({ icon: "rows", label: t("xlsx.insert_row_above"), onClick: () => structureAction({ op: "row_insert", sheet: sheetName, at: b.r1, count: rowCount }) });
items.push({ icon: "rows", label: t("xlsx.insert_row_below"), onClick: () => structureAction({ op: "row_insert", sheet: sheetName, at: b.r2 + 1, count: rowCount }) });
items.push({ icon: "columns", label: t("xlsx.insert_col_left"), onClick: () => structureAction({ op: "col_insert", sheet: sheetName, at: b.c1, count: colCount }) });
items.push({ icon: "columns", label: t("xlsx.insert_col_right"), onClick: () => structureAction({ op: "col_insert", sheet: sheetName, at: b.c2 + 1, count: colCount }) });
items.push({ separator: true });
items.push({ label: t("xlsx.delete_rows", { n: rowCount }), danger: true, onClick: () => structureAction({ op: "row_delete", sheet: sheetName, at: b.r1, count: rowCount }, t("xlsx.confirm_delete_rows", { n: rowCount })) });
items.push({ label: t("xlsx.delete_cols", { n: colCount }), danger: true, onClick: () => structureAction({ op: "col_delete", sheet: sheetName, at: b.c1, count: colCount }, t("xlsx.confirm_delete_cols", { n: colCount })) });
items.push({ icon: "trash-2", label: t("xlsx.delete_rows", { n: rowCount }), danger: true, onClick: () => structureAction({ op: "row_delete", sheet: sheetName, at: b.r1, count: rowCount }, t("xlsx.confirm_delete_rows", { n: rowCount })) });
items.push({ icon: "trash-2", label: t("xlsx.delete_cols", { n: colCount }), danger: true, onClick: () => structureAction({ op: "col_delete", sheet: sheetName, at: b.c1, count: colCount }, t("xlsx.confirm_delete_cols", { n: colCount })) });
items.push({ separator: true });
}
if (!isCsv) {
items.push({ label: t("xlsx.sort_asc"), onClick: () => applySort(panel, b.c1, "asc") });
items.push({ label: t("xlsx.sort_desc"), onClick: () => applySort(panel, b.c1, "desc") });
items.push({ icon: "arrow-up", label: t("xlsx.sort_asc"), onClick: () => applySort(panel, b.c1, "asc") });
items.push({ icon: "arrow-down", label: t("xlsx.sort_desc"), onClick: () => applySort(panel, b.c1, "desc") });
}
if (editable) {
items.push({ separator: true });
items.push({ label: t("xlsx.clear_contents"), onClick: () => clearContents(panel, b) });
items.push({ icon: "eraser", label: t("xlsx.clear_contents"), onClick: () => clearContents(panel, b) });
}
return items;
};
+9 -9
View File
@@ -23,7 +23,7 @@ export function buildCommandBar({ sheets, isCsv, readOnly, editable }) {
: `<div class="xlsx-tabs" role="tablist">${sheets.map((s, i) =>
`<button class="xlsx-tab${i === 0 ? " active" : ""}" data-sheet="${i}" role="tab" aria-selected="${i === 0}">${escapeHtml(s.name)}</button>`
).join("")}${editable
? `<button class="xlsx-tab-add" id="xlsx-tab-add" type="button" title="${escapeHtml(t("xlsx.tabs_add_sheet"))}" aria-label="${escapeHtml(t("xlsx.tabs_add_sheet"))}">+</button>`
? `<button class="xlsx-tab-add" id="xlsx-tab-add" type="button" title="${escapeHtml(t("xlsx.tabs_add_sheet"))}" aria-label="${escapeHtml(t("xlsx.tabs_add_sheet"))}"><i data-lucide="plus" style="width:14px;height:14px" aria-hidden="true"></i></button>`
: ""}</div>`;
// Status pills make the viewer's limits visible up front.
@@ -55,10 +55,10 @@ export function buildCommandBar({ sheets, isCsv, readOnly, editable }) {
}
if (editable) {
actionGroups.push(`<span class="xlsx-cmd-group" data-group="insert">
<button class="btn-action" id="xlsx-format-btn" title="${escapeHtml(t("xlsx.format_btn"))}">
<button class="btn-action" id="xlsx-format-btn" title="${escapeHtml(t("xlsx.format_btn"))}" aria-haspopup="menu" aria-expanded="false">
<i data-lucide="paintbrush" style="width:14px;height:14px"></i>
</button>
<button class="btn-action" id="xlsx-structure-btn" title="${escapeHtml(t("xlsx.structure_btn"))}">
<button class="btn-action" id="xlsx-structure-btn" title="${escapeHtml(t("xlsx.structure_btn"))}" aria-haspopup="menu" aria-expanded="false">
<i data-lucide="table-properties" style="width:14px;height:14px"></i>
</button>
</span>`);
@@ -72,16 +72,16 @@ export function buildCommandBar({ sheets, isCsv, readOnly, editable }) {
<i data-lucide="file-spreadsheet" style="width:14px;height:14px"></i> CSV
</button>
<span class="export-dropdown xlsx-export-dropdown">
<button class="btn-action" id="xlsx-export-btn" type="button" title="${escapeHtml(t("xlsx.export_title"))}">
<button class="btn-action" id="xlsx-export-btn" type="button" title="${escapeHtml(t("xlsx.export_title"))}" aria-haspopup="menu" aria-expanded="false">
<i data-lucide="file-down" style="width:14px;height:14px"></i> ${escapeHtml(t("xlsx.export_btn"))}
</button>
<div class="export-menu xlsx-export-menu" id="xlsx-export-menu" style="display:none">
<button class="export-menu-item" type="button" data-xlsx-export="md">${escapeHtml(t("xlsx.export_md"))}</button>
<button class="export-menu-item" type="button" data-xlsx-export="html">${escapeHtml(t("xlsx.export_html"))}</button>
<button class="export-menu-item" type="button" data-xlsx-export="print">${escapeHtml(t("xlsx.print"))}</button>
<div class="export-menu xlsx-export-menu" id="xlsx-export-menu" style="display:none" role="menu">
<button class="export-menu-item" type="button" role="menuitem" data-xlsx-export="md"><i data-lucide="file-text" class="xlsx-menu-icon" aria-hidden="true"></i><span class="xlsx-menu-label">${escapeHtml(t("xlsx.export_md"))}</span></button>
<button class="export-menu-item" type="button" role="menuitem" data-xlsx-export="html"><i data-lucide="code" class="xlsx-menu-icon" aria-hidden="true"></i><span class="xlsx-menu-label">${escapeHtml(t("xlsx.export_html"))}</span></button>
<button class="export-menu-item" type="button" role="menuitem" data-xlsx-export="print"><i data-lucide="printer" class="xlsx-menu-icon" aria-hidden="true"></i><span class="xlsx-menu-label">${escapeHtml(t("xlsx.print"))}</span></button>
</div>
</span>
<button class="btn-action xlsx-save-primary" id="xlsx-save-btn" disabled>${t("common.save")}</button>
<button class="btn-action xlsx-save-primary" id="xlsx-save-btn" disabled><i data-lucide="save" style="width:14px;height:14px" aria-hidden="true"></i> ${t("common.save")}</button>
</span>`);
const actionsHtml = actionGroups.join('<span class="xlsx-cmd-sep" aria-hidden="true"></span>');
+127 -7
View File
@@ -1,9 +1,17 @@
/* ObsiGate — generic themed context menu for the XLSX grid (#155).
/* ObsiGate — generic themed context menu for the XLSX grid (#155, #179).
*
* Builds a small menu anchored at a screen position, closes it on outside
* click / Escape / window blur. Labels and callbacks come from the caller, so
* no i18n lives here.
* Builds a small menu anchored at a screen position. Labels, icons and
* callbacks come from the caller, so no i18n lives here.
*
* #179 — every menu entry carries a representative Lucide icon, the menu
* closes as soon as the focus leaves it (outside click, focusout, Escape,
* window blur, scroll, resize) and arrow keys walk through the entries.
* trackDismissable() exposes the same dismissal contract to the other
* spreadsheet menus (Structure, Mise en forme, Export), which keep their
* own markup.
*/
import { safeCreateIcons } from '../utils.js';
let _menu = null;
let _openedAt = 0;
let _dismissBound = false;
@@ -33,13 +41,106 @@ function bindDismiss() {
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") closeContextMenu();
});
if (typeof window !== "undefined") window.addEventListener("blur", closeContextMenu);
// #179 — never linger once the focus moves outside the menu (toolbar
// button, editor, another panel…). A null relatedTarget (click on a
// non-focusable cell) is already covered by the click-outside handler.
document.addEventListener("focusout", (e) => {
if (!_menu) return;
const next = e.relatedTarget;
if (next && _menu.contains(next)) return;
closeContextMenu();
});
if (typeof window !== "undefined") {
window.addEventListener("blur", closeContextMenu);
// A scroll or resize detaches the viewport-anchored menu from its cell.
window.addEventListener("scroll", closeContextMenu, true);
window.addEventListener("resize", closeContextMenu);
}
}
/**
* Walk through a menu's buttons with the keyboard (#179).
* @param {HTMLElement} menuEl - menu container
* @param {string} itemSelector - CSS selector of the navigable entries
*/
export function enableArrowNav(menuEl, itemSelector) {
menuEl.addEventListener("keydown", (e) => {
const items = [...menuEl.querySelectorAll(itemSelector)].filter((b) => !b.disabled);
if (!items.length) return;
const idx = items.indexOf(document.activeElement);
let next = -1;
if (e.key === "ArrowDown") next = idx < 0 ? 0 : (idx + 1) % items.length;
else if (e.key === "ArrowUp") next = idx < 0 ? items.length - 1 : (idx - 1 + items.length) % items.length;
else if (e.key === "Home") next = 0;
else if (e.key === "End") next = items.length - 1;
else return;
e.preventDefault();
items[next].focus();
});
}
/**
* Dismissal contract shared by the spreadsheet's other menus (#179).
* Closes on outside click (the toggle button owns its own clicks), Escape,
* focus leaving both the menu and its toggle, scroll and resize.
* @param {HTMLElement} menuEl - menu element to watch
* @param {object} opts
* @param {HTMLElement|null} opts.toggle - button opening the menu (exempt)
* @param {Function|null} opts.onClose - called once on dismissal (default: remove the element)
* @returns {Function} dismiss() — idempotent manual close (unbinds + onClose)
*/
export function trackDismissable(menuEl, { toggle = null, onClose = null } = {}) {
let done = false;
const unbind = () => {
document.removeEventListener("click", onDocClick);
document.removeEventListener("keydown", onKey);
document.removeEventListener("focusout", onFocusOut);
if (typeof window !== "undefined") {
window.removeEventListener("scroll", onScroll, true);
window.removeEventListener("resize", onScroll);
}
};
const dismiss = () => {
if (done) return;
done = true;
unbind();
if (onClose) onClose();
else menuEl.remove();
};
const onDocClick = (e) => {
if (done) return;
if (!menuEl.isConnected) { done = true; unbind(); return; }
const t = e.target;
if (menuEl.contains(t)) return;
if (toggle && (toggle === t || toggle.contains(t))) return;
dismiss();
};
const onKey = (e) => {
if (e.key === "Escape" && menuEl.isConnected) dismiss();
};
const onFocusOut = (e) => {
if (done || !menuEl.isConnected) return;
const next = e.relatedTarget;
if (next && (menuEl.contains(next) || (toggle && toggle.contains(next)))) return;
dismiss();
};
const onScroll = () => {
if (menuEl.isConnected) dismiss();
};
document.addEventListener("click", onDocClick);
document.addEventListener("keydown", onKey);
document.addEventListener("focusout", onFocusOut);
if (typeof window !== "undefined") {
window.addEventListener("scroll", onScroll, true);
window.addEventListener("resize", onScroll);
}
return dismiss;
}
/**
* @param {number} x - viewport X of the pointer
* @param {number} y - viewport Y of the pointer
* @param {Array<{label?: string, separator?: boolean, danger?: boolean, disabled?: boolean, onClick?: Function}>} items
* @param {Array<{label?: string, icon?: string, separator?: boolean, danger?: boolean, disabled?: boolean, onClick?: Function}>} items
* @returns {HTMLElement} the menu element
*/
export function openContextMenu(x, y, items) {
@@ -52,6 +153,7 @@ export function openContextMenu(x, y, items) {
if (item.separator) {
const sep = document.createElement("div");
sep.className = "xlsx-context-sep";
sep.setAttribute("aria-hidden", "true");
menu.appendChild(sep);
continue;
}
@@ -59,7 +161,17 @@ export function openContextMenu(x, y, items) {
btn.type = "button";
btn.className = `btn-action xlsx-context-item${item.danger ? " xlsx-context-danger" : ""}`;
btn.setAttribute("role", "menuitem");
btn.textContent = item.label;
if (item.icon) {
const ic = document.createElement("i");
ic.setAttribute("data-lucide", item.icon);
ic.className = "xlsx-menu-icon";
ic.setAttribute("aria-hidden", "true");
btn.appendChild(ic);
}
const lab = document.createElement("span");
lab.className = "xlsx-menu-label";
lab.textContent = item.label;
btn.appendChild(lab);
btn.disabled = Boolean(item.disabled);
btn.addEventListener("click", () => {
closeContextMenu();
@@ -67,6 +179,7 @@ export function openContextMenu(x, y, items) {
});
menu.appendChild(btn);
}
enableArrowNav(menu, ".xlsx-context-item");
document.body.appendChild(menu);
// Keep the menu inside the viewport (getBoundingClientRect is 0 in JSDOM,
@@ -81,5 +194,12 @@ export function openContextMenu(x, y, items) {
_menu = menu;
_openedAt = Date.now();
// Keyboard users land on the first entry right away; mouse users are
// unaffected (hover/click still work). preventScroll avoids yanking the grid.
const first = menu.querySelector(".xlsx-context-item:not(:disabled)");
if (first && typeof first.focus === "function") {
try { first.focus({ preventScroll: true }); } catch (_) { first.focus(); }
}
if (typeof safeCreateIcons === "function") safeCreateIcons();
return menu;
}
+1 -3
View File
@@ -90,8 +90,6 @@
"ai.confirm_actions": "{count} actions to approve",
"ai.stop": "Stop the assistant",
"ai.stopped": "Run stopped.",
"ai.agent_mode_off": "Agent mode off (read/search + actions)",
"ai.agent_mode_on": "Agent mode on (read/search tools + actions)",
"ai.casual": "Casual tone",
"ai.close": "Close assistant",
"ai.completion_added": "AI: completion added",
@@ -1365,7 +1363,7 @@
"help.assistant_insert": "The \"Add\" button (revealed on hover of an answer) inserts the answer into the document open in the editor (Editer or Forge); each code block offers \"Add section\" to insert just that block.",
"help.assistant_links": "Cited files and paths are links: a bare filename copies the name to the clipboard, a folder is revealed in the tree, and a file path opens it in the viewer.",
"help.assistant_sessions": "The header history icon lists past sessions (reopen or delete); “+” starts a new conversation.",
"help.assistant_agent": "The \"agent mode\" button enables tools (read, list, search); modifying actions require confirmation with a change preview.",
"help.assistant_agent": "The assistant always uses its tools (read, list, search, modify); modifying actions require confirmation with a change preview.",
"help.assistant_agent_run": "Actions appear in the thread: the assistant groups modifications into a single approval (\"Approve all\") and refreshes the file tree and the open document as soon as they are applied. The send button becomes \"Stop\" to interrupt the run at any time.",
"help.assistant_resize": "The left edge of the panel is resizable; the width is remembered.",
"help.assistant_at": "Type @ to attach a file or directory to the context, or to attach an image from a directory.",
+1 -3
View File
@@ -90,8 +90,6 @@
"ai.confirm_actions": "{count} actions à approuver",
"ai.stop": "Arrêter l'assistant",
"ai.stopped": "Exécution arrêtée.",
"ai.agent_mode_off": "Mode agent désactivé (lecture/recherche + actions)",
"ai.agent_mode_on": "Mode agent activé (outils de lecture/recherche + actions)",
"ai.casual": "Ton décontracté",
"ai.close": "Fermer l'assistant",
"ai.completion_added": "AI: complétion ajoutée",
@@ -1365,7 +1363,7 @@
"help.assistant_insert": "Le bouton « Ajouter » (au survol d'une réponse) insère la réponse dans le document ouvert dans l'éditeur (Editer ou Forge) ; chaque bloc de code propose « Ajouter la section » pour n'insérer que ce bloc.",
"help.assistant_links": "Les fichiers et chemins cités sont des liens : un simple nom de fichier copie le nom dans le presse-papiers, un dossier est révélé dans l'arborescence, et un chemin de fichier l'ouvre dans le viewer.",
"help.assistant_sessions": "L'icône historique de l'en-tête liste les sessions passées (recharger ou supprimer) ; « + » démarre une nouvelle conversation.",
"help.assistant_agent": "Le bouton « mode agent » active les outils (lire, lister, chercher) ; les actions de modification demandent une confirmation avec aperçu des changements.",
"help.assistant_agent": "L'assistant utilise toujours ses outils (lire, lister, chercher, modifier) ; les actions de modification demandent une confirmation avec aperçu des changements.",
"help.assistant_agent_run": "Les actions s'affichent dans le fil : l'assistant regroupe les modifications en une seule approbation (« Tout approuver ») et met à jour l'arborescence et le document ouvert dès qu'elles sont appliquées. Le bouton d'envoi devient « Stop » pour interrompre l'exécution à tout moment.",
"help.assistant_resize": "Le bord gauche du panneau est redimensionnable ; la largeur est mémorisée.",
"help.assistant_at": "Tapez @ pour joindre un fichier ou un répertoire au contexte, ou pour attacher une image d'un répertoire.",
+46 -2
View File
@@ -11332,6 +11332,7 @@ body.desktop-mode .editor-container {
top: 100%;
z-index: 1000;
min-width: 200px;
max-width: 86vw;
padding: 4px;
background: var(--bg, #fff);
border: 1px solid var(--border);
@@ -11410,6 +11411,7 @@ body.desktop-mode .editor-container {
flex-direction: column;
gap: 2px;
min-width: 240px;
max-width: min(320px, 92vw);
margin-top: 4px;
padding: 6px;
border: 1px solid var(--border);
@@ -11608,6 +11610,32 @@ body.desktop-mode .editor-container {
background: var(--border);
}
/* #179 — shared menu-entry icon (grid context menu, structure, format, export). */
.xlsx-menu-icon {
width: 14px;
height: 14px;
flex: 0 0 auto;
}
.xlsx-menu-label {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.xlsx-context-item,
.xlsx-structure-item,
.xlsx-export-menu .export-menu-item {
display: flex;
align-items: center;
gap: 8px;
}
.xlsx-context-item:focus:not(:disabled),
.xlsx-structure-item:focus:not(:disabled),
.xlsx-export-menu .export-menu-item:focus {
background: var(--bg-hover);
outline: 2px solid var(--accent, #4a90d9);
outline-offset: -2px;
}
/* #155 — Excel-like range selection + right-click / long-press context menu. */
.xlsx-table tbody td.xlsx-selected {
background: var(--accent-bg);
@@ -11625,12 +11653,30 @@ body.desktop-mode .editor-container {
display: flex;
flex-direction: column;
min-width: 220px;
max-width: min(320px, 86vw);
max-height: 80vh;
overflow-y: auto;
padding: 6px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
box-shadow: 0 12px 32px var(--shadow, rgba(0, 0, 0, 0.35));
}
/* #179 — touch-sized entries + viewport-bound menus on coarse pointers. */
@media (pointer: coarse) {
.xlsx-context-item,
.xlsx-structure-item,
.xlsx-export-menu .export-menu-item {
min-height: 44px;
font-size: 0.9rem;
}
.xlsx-context-menu {
min-width: min(260px, 86vw);
}
.xlsx-structure-menu {
max-width: 92vw;
}
}
.xlsx-context-item {
text-align: left;
border: none;
@@ -12198,8 +12244,6 @@ mark {
background: var(--accent); color: #fff; cursor: pointer; font-size: 12px; flex-shrink: 0; }
.bookslm-action-apply:disabled { opacity: 0.6; cursor: default; }
.bookslm-action-apply:not(:disabled):hover { filter: brightness(1.08); }
/* Agent-mode toggle in the header. */
.bookslm-header button.bookslm-btn-agent.active { color: var(--accent); background: var(--surface2); }
/* Tool-call trace shown above an assistant answer (agent mode).
The header is the live indicator (« ●●● 3 étapes ▶ »); expanding it reveals
the chronological steps, thinking notes as « Réflexion » sub-sections and
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.52.0",
"version": "2.53.2",
"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": {
+88
View File
@@ -20,6 +20,10 @@
* - `GET …/xlsx/sheet?offset=500` serves the rows the caps used to hide,
* with the real A1 coordinates (#153 A9).
*
* Third block (#179) : menus carry one Lucide icon per entry and close on
* focus loss (outside click, Escape); mobile viewport checks the ribbon
* wrapping and 44px touch targets.
*
* The fixture is restored byte-for-byte in `afterAll` so a local run never
* dirties the working copy.
*
@@ -314,3 +318,87 @@ test.describe('Excel viewer — presse-papiers, clavier et zone Nom (#156 P1)',
expect(await textOf('C10')).toBe(src.c10);
});
});
// ── #179 — icônes des menus, fermeture au focus, polish mobile ──────────────
test.describe('Excel viewer — menus à icônes et fermeture au focus (#179)', () => {
test('chaque entrée du menu contextuel porte une icône', async ({ page }) => {
await login(page);
await openFixture(page);
await page.locator('#content-area td[data-cell="A1"]').click({ button: 'right' });
const menu = page.locator('.xlsx-context-menu');
await expect(menu).toBeVisible();
// Icône rendue par Lucide (svg) ou à rendre (<i data-lucide>) si le CDN
// est injoignable — dans les deux cas l'entrée est iconisée.
for (const label of ['Couper', 'Copier', 'Coller']) {
const entry = menu.locator('.xlsx-context-item', { hasText: label });
await expect(entry.locator('i[data-lucide], svg')).toHaveCount(1);
}
await expect(menu.locator('.xlsx-context-item i[data-lucide="trash-2"], .xlsx-context-item svg.lucide-trash-2')).not.toHaveCount(0);
});
test('le menu contextuel se ferme au clic extérieur et à Échap', async ({ page }) => {
await login(page);
await openFixture(page);
const cell = page.locator('#content-area td[data-cell="A1"]');
await cell.click({ button: 'right' });
await expect(page.locator('.xlsx-context-menu')).toBeVisible();
await page.locator('#content-area .xlsx-table').click({ position: { x: 5, y: 5 } });
await expect(page.locator('.xlsx-context-menu')).toHaveCount(0);
await cell.click({ button: 'right' });
await expect(page.locator('.xlsx-context-menu')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.locator('.xlsx-context-menu')).toHaveCount(0);
});
test('le menu Structure se ferme quand le focus le quitte', async ({ page }) => {
await login(page);
await openFixture(page);
await page.locator('#xlsx-structure-btn').click();
const menu = page.locator('.xlsx-structure-menu');
await expect(menu).toBeVisible();
await expect(menu.locator('.xlsx-structure-item i[data-lucide], .xlsx-structure-item svg').first()).toBeAttached();
// Clic dans la grille = le focus quitte le menu → fermeture.
await page.locator('#content-area td[data-cell="A1"]').click();
await expect(page.locator('.xlsx-structure-menu')).toHaveCount(0);
await expect(page.locator('#xlsx-structure-btn')).toHaveAttribute('aria-expanded', 'false');
});
test('le menu Export se ferme au clic extérieur', async ({ page }) => {
await login(page);
await openFixture(page);
await page.locator('#xlsx-export-btn').click();
const menu = page.locator('#xlsx-export-menu');
await expect(menu).toBeVisible();
await page.locator('#content-area td[data-cell="A1"]').click();
await expect(menu).toBeHidden();
});
test('mobile : ruban sans débordement et cibles tactiles 44px', async ({ page, viewport }) => {
test.skip((viewport?.width ?? 9999) > 768, 'Mobile viewport required');
await login(page);
await page.evaluate(() => window.TabManager.open('TestVault', FIXTURE));
await expect(page.locator('#content-area .xlsx-table')).toBeVisible({ timeout: 15000 });
// Le ruban s'enroule au lieu de déborder horizontalement.
const overflow = await page.evaluate(() => {
const bar = document.querySelector('#content-area .xlsx-cmdbar');
return bar ? bar.scrollWidth - bar.clientWidth : -1;
});
expect(overflow).toBeLessThanOrEqual(1);
// Menu contextuel (clic droit émulé) : tient dans le viewport, entrées ≥ 44px.
await page.locator('#content-area td[data-cell="A1"]').click({ button: 'right' });
const menu = page.locator('.xlsx-context-menu');
await expect(menu).toBeVisible();
const box = await menu.boundingBox();
expect(box.width).toBeLessThanOrEqual(viewport.width - 8);
const itemBox = await menu.locator('.xlsx-context-item').first().boundingBox();
expect(itemBox.height).toBeGreaterThanOrEqual(44);
});
});
+15 -26
View File
@@ -159,7 +159,8 @@ async function main() {
read: async () => (chunks.length ? { done: false, value: chunks.shift() } : { done: true }),
};
globalThis.fetch = async (url, opts) => {
if (String(url).includes("/api/ai/bookslm/chat")) {
// #187: plain-text sends now go to /agent — accept both bookslm endpoints.
if (/\/api\/ai\/bookslm\/(chat|agent)/.test(String(url))) {
capturedBody = JSON.parse(opts.body);
return { ok: true, status: 200, body: { getReader: () => reader } };
}
@@ -1171,38 +1172,28 @@ async function main() {
assert.equal(b._messages[0].content, "Ancienne conversation");
});
// ── 14. Agent mode & confirmations (B5) ──
await test("agent mode toggle persists and switches the endpoint", async () => {
// ── 14. Agent mode & confirmations (B5 / #187) ──
await test("#187: no agent toggle — plain requests always target /agent", async () => {
localStorage.clear();
const b = new BooksLM();
assert.equal(b._agentMode, false, "agent mode off by default");
b._toggleAgentMode();
assert.equal(b._agentMode, true);
assert.equal(localStorage.getItem("obsigate-bookslm-agent"), "true");
const b2 = new BooksLM();
assert.equal(b2._agentMode, true, "persisted across instances");
assert.equal(b._agentMode, undefined, "agent-mode state removed");
assert.equal(b._toggleAgentMode, undefined, "toggle method removed");
let url = null;
globalThis.fetch = async (u) => { url = String(u); return { ok: true, status: 200, json: async () => ({}) }; };
b2._abortCtrl = null;
await b2._postChat({ message: "x" });
assert.ok(url.includes("/api/ai/bookslm/agent"), "agent mode targets /agent");
b2._agentMode = false;
await b2._postChat({ message: "x" });
assert.ok(url.includes("/api/ai/bookslm/chat"), "default targets /chat");
b._abortCtrl = null;
await b._postChat({ message: "x" });
assert.ok(url.includes("/api/ai/bookslm/agent"), "default targets /agent");
assert.equal(localStorage.getItem("obsigate-bookslm-agent"), null, "no legacy persistence");
localStorage.clear();
});
await test("header exposes the agent-mode toggle", () => {
await test("header no longer exposes the agent-mode toggle", () => {
const b = new BooksLM();
const panel = b._render();
b._panel = panel;
document.body.appendChild(panel);
const btn = panel.querySelector(".bookslm-btn-agent");
assert.ok(btn, "agent toggle button present");
assert.equal(btn.getAttribute("aria-pressed"), "false");
btn.click();
assert.equal(btn.getAttribute("aria-pressed"), "true");
assert.equal(panel.querySelector(".bookslm-btn-agent"), null, "toggle button removed");
panel.remove();
localStorage.clear();
});
@@ -1236,7 +1227,6 @@ async function main() {
};
const b = new BooksLM();
b._agentMode = true;
b._panel = b._render();
document.body.appendChild(b._panel);
const msg = {
@@ -1418,7 +1408,7 @@ async function main() {
let posted = null;
globalThis.fetch = async (url, opts) => {
if (String(url).includes("/api/ai/bookslm/chat")) posted = JSON.parse(opts.body);
if (/\/api\/ai\/bookslm\/(chat|agent)/.test(String(url))) posted = JSON.parse(opts.body);
return { ok: true, status: 200, body: { getReader: () => ({ read: async () => ({ done: true }) }) } };
};
await b._sendMessage();
@@ -1432,10 +1422,9 @@ async function main() {
localStorage.clear();
});
// ── 32. Images force the plain chat endpoint (not the agent) (#81) ──
await test("images route to /chat even in agent mode", async () => {
// ── 32. Images force the plain chat endpoint (#81 / #187: always-on agent) ──
await test("images route to /chat, text to /agent", async () => {
const b = new BooksLM();
b._agentMode = true;
let url = "";
globalThis.fetch = async (u) => {
url = String(u);
+252
View File
@@ -0,0 +1,252 @@
#!/usr/bin/env node
/**
* ObsiGate — JSDOM tests for the spreadsheet menus polish (ROADMAP #179).
*
* Covers frontend/js/xlsx/context-menu.js and frontend/js/xlsx/command-bar.js:
* - every grid-menu entry renders its representative Lucide icon + safe label;
* - the grid menu closes on Escape / outside click / focus loss / scroll /
* resize, and offers arrow-key navigation with autofocus on open;
* - trackDismissable() gives Structure / Mise en forme / Export the same
* dismissal contract (toggle exempt, custom onClose);
* - the ribbon carries the new icons (save, tab "+", export entries).
*
* Usage: node tests/frontend/xlsx-menus.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 REPO_ROOT = path.resolve(__dirname, "..", "..");
// ── JSDOM bootstrap ─────────────────────────────────────────────────────────
const dom = new JSDOM(
`<!DOCTYPE html><html><body><div id="content-area"></div></body></html>`,
{ url: "http://localhost/", pretendToBeVisual: true }
);
const w = dom.window;
globalThis.window = w;
globalThis.document = w.document;
globalThis.DOMParser = w.DOMParser;
globalThis.HTMLElement = w.HTMLElement;
globalThis.Element = w.Element;
globalThis.Node = w.Node;
globalThis.Event = w.Event;
globalThis.FocusEvent = w.FocusEvent;
globalThis.KeyboardEvent = w.KeyboardEvent;
globalThis.MouseEvent = w.MouseEvent;
globalThis.localStorage = w.localStorage;
globalThis.sessionStorage = w.sessionStorage;
globalThis.requestAnimationFrame = (cb) => setTimeout(() => cb(Date.now()), 0);
// ── Module under test ───────────────────────────────────────────────────────
const ctxMenuUrl = pathToFileURL(
path.join(REPO_ROOT, "frontend", "js", "xlsx", "context-menu.js")
).href;
const cmdBarUrl = pathToFileURL(
path.join(REPO_ROOT, "frontend", "js", "xlsx", "command-bar.js")
).href;
const { openContextMenu, closeContextMenu, isContextMenuOpen, trackDismissable } =
await import(ctxMenuUrl);
const { buildCommandBar } = await import(cmdBarUrl);
// ── Mini runner ─────────────────────────────────────────────────────────────
let testCount = 0;
let passCount = 0;
async function test(name, fn) {
testCount++;
closeContextMenu();
document.body.querySelectorAll(".tmp-menu").forEach((m) => m.remove());
try {
await fn();
passCount++;
console.log(` ✓ ${name}`);
} catch (e) {
console.error(` ✗ ${name}`);
console.error(e);
process.exitCode = 1;
}
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const itemsOf = () => [
{ icon: "scissors", label: "Cut" },
{ icon: "copy", label: "Copy", disabled: true },
{ separator: true },
{ icon: "trash-2", label: "Delete", danger: true, onClick: () => {} },
];
// ── Rendering ───────────────────────────────────────────────────────────────
await test("entries render their icon, label, separator and danger style", () => {
const menu = openContextMenu(10, 10, itemsOf());
const btns = [...menu.querySelectorAll(".xlsx-context-item")];
assert.equal(btns.length, 3);
assert.equal(btns[0].querySelector("i").getAttribute("data-lucide"), "scissors");
assert.equal(btns[0].querySelector(".xlsx-menu-label").textContent, "Cut");
assert.equal(btns[1].disabled, true);
assert.ok(btns[2].classList.contains("xlsx-context-danger"));
assert.ok(menu.querySelector(".xlsx-context-sep"), "separator rendered");
assert.equal(menu.getAttribute("role"), "menu");
});
await test("labels are text, never HTML (XSS-safe)", () => {
const menu = openContextMenu(10, 10, [{ icon: "copy", label: "<img src=x onerror=1>" }]);
assert.equal(menu.querySelector("img"), null);
assert.equal(
menu.querySelector(".xlsx-menu-label").textContent,
"<img src=x onerror=1>"
);
});
await test("opening focuses the first enabled entry", () => {
const menu = openContextMenu(10, 10, itemsOf());
const first = menu.querySelectorAll(".xlsx-context-item")[0];
assert.equal(document.activeElement, first);
});
// ── Keyboard navigation ─────────────────────────────────────────────────────
await test("ArrowDown / ArrowUp / Home / End walk through enabled entries", () => {
const menu = openContextMenu(10, 10, itemsOf());
const btns = [...menu.querySelectorAll(".xlsx-context-item")];
const key = (k) =>
menu.dispatchEvent(new w.KeyboardEvent("keydown", { key: k, bubbles: true }));
key("ArrowDown"); // Cut (0, disabled Copy skipped) -> Delete (2)
assert.equal(document.activeElement, btns[2]);
key("ArrowDown"); // wraps to Cut
assert.equal(document.activeElement, btns[0]);
key("ArrowUp"); // back to Delete
assert.equal(document.activeElement, btns[2]);
key("Home");
assert.equal(document.activeElement, btns[0]);
key("End");
assert.equal(document.activeElement, btns[2]);
});
// ── Dismissal ───────────────────────────────────────────────────────────────
await test("Escape closes the menu", () => {
openContextMenu(10, 10, itemsOf());
assert.ok(isContextMenuOpen());
document.dispatchEvent(new w.KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
assert.ok(!isContextMenuOpen());
assert.equal(document.querySelector(".xlsx-context-menu"), null);
});
await test("outside click closes, inside click does not", async () => {
const menu = openContextMenu(10, 10, itemsOf());
await sleep(300); // past the long-press click guard
menu.querySelector(".xlsx-context-item").dispatchEvent(
new w.MouseEvent("click", { bubbles: true })
);
assert.ok(!isContextMenuOpen(), "item click closes");
openContextMenu(10, 10, itemsOf());
await sleep(300);
document.body.dispatchEvent(new w.MouseEvent("click", { bubbles: true }));
assert.ok(!isContextMenuOpen(), "outside click closes");
});
await test("focus leaving the menu closes it, focus inside keeps it", () => {
const menu = openContextMenu(10, 10, itemsOf());
const [first, , last] = menu.querySelectorAll(".xlsx-context-item");
first.dispatchEvent(
new w.FocusEvent("focusout", { bubbles: true, relatedTarget: last })
);
assert.ok(isContextMenuOpen(), "focus moving inside keeps the menu");
const outside = document.createElement("button");
document.body.appendChild(outside);
last.dispatchEvent(
new w.FocusEvent("focusout", { bubbles: true, relatedTarget: outside })
);
assert.ok(!isContextMenuOpen(), "focus moving outside closes the menu");
outside.remove();
});
await test("scroll and resize close the menu", () => {
openContextMenu(10, 10, itemsOf());
window.dispatchEvent(new w.Event("scroll"));
assert.ok(!isContextMenuOpen(), "scroll closes");
openContextMenu(10, 10, itemsOf());
window.dispatchEvent(new w.Event("resize"));
assert.ok(!isContextMenuOpen(), "resize closes");
});
// ── trackDismissable (Structure / Format / Export) ──────────────────────────
function tmpMenu(toggle) {
const menu = document.createElement("div");
menu.className = "tmp-menu";
const b = document.createElement("button");
b.type = "button";
b.textContent = "Action";
menu.appendChild(b);
(toggle.closest("div") || document.body).appendChild(menu);
return menu;
}
await test("trackDismissable: toggle exempt, outside closes with onClose", async () => {
const host = document.createElement("div");
const toggle = document.createElement("button");
host.appendChild(toggle);
document.body.appendChild(host);
const menu = tmpMenu(toggle);
let closed = 0;
const dismiss = trackDismissable(menu, { toggle, onClose: () => { closed++; menu.remove(); } });
toggle.dispatchEvent(new w.MouseEvent("click", { bubbles: true }));
assert.equal(closed, 0, "toggle click does not dismiss");
assert.ok(menu.isConnected);
document.body.dispatchEvent(new w.MouseEvent("click", { bubbles: true }));
assert.equal(closed, 1, "outside click dismisses once");
assert.ok(!menu.isConnected);
dismiss(); // idempotent manual close
assert.equal(closed, 1);
host.remove();
});
await test("trackDismissable: Escape and focus loss dismiss", () => {
const toggle = document.createElement("button");
document.body.appendChild(toggle);
const menu = tmpMenu(toggle);
let closed = 0;
trackDismissable(menu, { toggle, onClose: () => { closed++; menu.remove(); } });
document.dispatchEvent(new w.KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
assert.equal(closed, 1, "Escape dismisses");
assert.ok(!menu.isConnected);
const menu2 = tmpMenu(toggle);
let closed2 = 0;
trackDismissable(menu2, { toggle, onClose: () => { closed2++; menu2.remove(); } });
const outside = document.createElement("input");
document.body.appendChild(outside);
menu2.querySelector("button").dispatchEvent(
new w.FocusEvent("focusout", { bubbles: true, relatedTarget: outside })
);
assert.equal(closed2, 1, "focus loss dismisses");
outside.remove();
toggle.remove();
});
// ── Ribbon icons ────────────────────────────────────────────────────────────
await test("the ribbon carries save, tab-plus and export icons", () => {
const { actionsHtml, tabs } = buildCommandBar({
sheets: [{ name: "F1" }],
isCsv: false,
readOnly: false,
editable: true,
});
const host = document.createElement("div");
host.innerHTML = tabs + actionsHtml;
const saveIcon = host.querySelector("#xlsx-save-btn i[data-lucide]");
assert.ok(saveIcon, "save button has an icon");
assert.equal(saveIcon.getAttribute("data-lucide"), "save");
const plusIcon = host.querySelector("#xlsx-tab-add i[data-lucide]");
assert.ok(plusIcon, "tab + button has an icon");
assert.equal(plusIcon.getAttribute("data-lucide"), "plus");
const exportIcons = [...host.querySelectorAll("#xlsx-export-menu i[data-lucide]")].map((i) =>
i.getAttribute("data-lucide")
);
assert.deepEqual(exportIcons, ["file-text", "code", "printer"]);
});
console.log(`\n${passCount}/${testCount} passed`);
process.exit(process.exitCode || 0);
+89
View File
@@ -1867,6 +1867,95 @@ await test("the find scans every sheet and steps across tabs (#156-A11)", () =>
assert.ok(area.querySelector('.xlsx-panel[data-sheet="1"] mark.xlsx-find-current'), "the current hit sits in sheet 2");
});
// ── #179 — menu icons + dismissal at viewer level ─────────────────────────
await test("the grid context menu shows one icon per entry (#179-A1)", () => {
const area = mount();
const menu = openMenuOn(area, "A1");
assert.ok(menu, "menu opens on right-click");
const items = [...menu.querySelectorAll(".xlsx-context-item")];
assert.ok(items.length > 3, "several entries");
for (const b of items) {
const icon = b.querySelector("i[data-lucide]");
assert.ok(icon, `"${b.textContent}" carries an icon`);
assert.match(icon.getAttribute("data-lucide"), /^[a-z0-9-]+$/);
}
assert.equal(
menu.querySelector(".xlsx-context-item .xlsx-menu-label").textContent,
FR["xlsx.cut"]
);
const danger = items.find(
(b) => b.textContent === FR["xlsx.delete_rows"].replace("{n}", "1")
);
assert.ok(danger && danger.querySelector('i[data-lucide="trash-2"]'), "delete reads as danger");
});
await test("the grid context menu closes when the focus leaves it (#179-A3)", () => {
const area = mount();
openMenuOn(area, "A1");
assert.ok(document.querySelector(".xlsx-context-menu"), "menu open");
const first = document.querySelector(".xlsx-context-item");
const outside = document.createElement("button");
document.body.appendChild(outside);
first.dispatchEvent(new w.FocusEvent("focusout", { bubbles: true, relatedTarget: outside }));
assert.equal(document.querySelector(".xlsx-context-menu"), null, "menu closed on focus loss");
outside.remove();
});
await test("the structure menu shows icons and closes on outside click (#179-A2/A3)", () => {
const area = mount();
area.querySelector("#xlsx-structure-btn").click();
const menu = area.querySelector(".xlsx-structure-menu");
assert.ok(menu, "structure menu opens");
const icons = [...menu.querySelectorAll(".xlsx-structure-item i[data-lucide]")].map((i) =>
i.getAttribute("data-lucide")
);
assert.ok(icons.includes("file-plus"), "sheet add icon");
assert.ok(icons.includes("trash-2"), "delete icon");
assert.equal(
area.querySelector("#xlsx-structure-btn").getAttribute("aria-expanded"),
"true"
);
document.body.dispatchEvent(new w.MouseEvent("click", { bubbles: true }));
assert.equal(area.querySelector(".xlsx-structure-menu"), null, "outside click closes");
assert.equal(
area.querySelector("#xlsx-structure-btn").getAttribute("aria-expanded"),
"false"
);
});
await test("the format menu shows icons and closes on Escape (#179-A2/A3)", () => {
const area = mount();
area.querySelector("#xlsx-format-btn").click();
const menu = area.querySelector(".xlsx-format-menu");
assert.ok(menu, "format menu opens");
const find = (icon) => menu.querySelector(`.xlsx-structure-item i[data-lucide="${icon}"]`);
assert.ok(find("bold"), "bold icon");
assert.ok(find("align-center"), "align icon");
assert.ok(menu.querySelector('.xlsx-format-colour i[data-lucide="palette"]'), "font colour icon");
document.dispatchEvent(new w.KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
assert.equal(area.querySelector(".xlsx-format-menu"), null, "Escape closes");
});
await test("the export menu closes on outside click (#179-A3)", () => {
const area = mount();
area.querySelector("#xlsx-export-btn").click();
assert.equal(area.querySelector("#xlsx-export-menu").style.display, "block", "menu opens");
document.body.dispatchEvent(new w.MouseEvent("click", { bubbles: true }));
assert.equal(area.querySelector("#xlsx-export-menu").style.display, "none", "outside click closes");
});
await test("the ribbon save and tab + buttons carry icons (#179-A2)", () => {
const area = mount();
assert.equal(
area.querySelector('#xlsx-save-btn i[data-lucide]').getAttribute("data-lucide"),
"save"
);
assert.equal(
area.querySelector('#xlsx-tab-add i[data-lucide]').getAttribute("data-lucide"),
"plus"
);
});
// ── Report ──────────────────────────────────────────────────────────────────
console.log(`\n${passCount}/${testCount} tests passed\n`);
process.exit(passCount === testCount ? 0 : 1);
+25
View File
@@ -940,6 +940,31 @@ class TestBooksLMAgentEndpoint:
assert "create_file" in captured["system"]
assert '"action": "create_file"' not in captured["system"]
def test_agent_llm_uses_resolved_provider(self, bookslm_client, monkeypatch):
"""#187: the agent loop must call the provider resolved by
_resolve_provider_name (not the raw request value), so the provider
tag reported by SSE matches the engine that actually answered."""
import backend.bookslm_routes as routes
from backend.ai_chat import LLMResponse
seen = {}
async def fake_chat_completion(messages, **kwargs):
seen["provider"] = kwargs.get("provider")
return LLMResponse(content="ok")
monkeypatch.setattr(routes, "chat_completion", fake_chat_completion)
monkeypatch.setattr(routes, "_resolve_provider_name", lambda requested: "openrouter")
token, _ = _login_bookslm(bookslm_client)
resp = bookslm_client.post(
"/api/ai/bookslm/agent",
json={"directory": "", "message": "salut", "mode": "general", "provider": "nope"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
assert seen["provider"] == "openrouter"
def test_agent_tool_call_flow(self, bookslm_client, monkeypatch):
import backend.bookslm_routes as routes
from backend.ai_chat import LLMResponse, ToolCall
+10
View File
@@ -93,6 +93,16 @@ class TestRegistry:
props = read["function"]["parameters"]["properties"]
assert "vault" in props and "path" in props
def test_schemas_cached_but_caller_safe(self):
"""#187: repeated calls hit the cache and a caller mutating its copy
(e.g. stripping keys) must never corrupt the next request."""
a = get_tool_schemas()
b = get_tool_schemas()
assert a == b
assert all(x is not y for x, y in zip(a, b)), "top-level dicts must be fresh copies"
a[0]["poisoned"] = True
assert "poisoned" not in get_tool_schemas()[0]
def test_duplicate_tool_name_raises(self):
from backend.tools.registry import tool