fix: side peek des bases repasse en vanilla JS + largeur 1100px standard (v7.49.0)
FlowDeck CI / test (push) Failing after 3h13m58s
FlowDeck CI / lint (push) Successful in 2m12s
FlowDeck CI / docker (push) Skipped

- Panneau peek: les bindings Alpine (x-data absent du conteneur) rendaient
  loovverture et le redimensionnement inoperants -> cblage direct sur le document.
- Helper unique window.fdWirePeekResize (app.js): pointer capture, 300px-90vw,
  clic=fermer, largeur persiste fd_peek_width partagee entre les 4 peeks.
- database-table-container margin:0 (tableau colle a gauche, marge Library).
- .lib-container remonte dans app.css (trash etait pleine largeur), .db-index 1100px.
- ObsiGate verifie sans code: creation .xlsx OK (openpyxl, #186).
This commit is contained in:
2026-10-05 22:47:36 -04:00
parent 894004a1f5
commit 1d1cdbd618
63 changed files with 9473 additions and 393 deletions
+566
View File
@@ -1,5 +1,571 @@
# Changelog - FlowDeck
## v7.49.0 (2026-10-05) — Side peek : l'éditeur plein écran, et un calendrier complet
### Added
- **Calendrier My Tasks : cinq vues** — Mois, Année, Semaine, Jour et Planning,
avec une navigation propre à chaque granularité (`←` / `→` et « Aujourd'hui »
se recalculent : par exemple 5 – 11 octobre → 12 – 18 octobre en semaine, ou
un jour en année). L'Année affiche les douze mini-mois de l'année courante et
le Planning une ligne par jour avec ses tâches réparties par statut.
- **Icône d'ouverture sur chaque tâche du calendrier** — même bouton « Open in
side peek » que le tableau, révélé au survol. Elle porte le même attribut de
ligne (`data-open-row`), donc un seul gestionnaire de clic pour toutes les vues :
le titre est transmis par `data-open-title`, disponible là où il n'y a pas de
ligne `.mt-row` (vues Jour, Semaine et Année).
### Fixed
- **Impossible d'ajuster la largeur du side peek des bases** — le panneau avait
été réécrit en bindings Alpine (`:class="dbPeekOpen"`, `@click.outside`) alors
que le conteneur n'a aucun `x-data` : bindings inertes, `openPageInSidePeek`
passait par `container.__x.$data` (undefined), le panneau ne s'ouvrait pas et
la poignée de redimensionnement ne réagissait pas. Repassé en vanilla JS
(câblage direct sur le document, repli discret pour les collections embarquées
inline). Au passage : les quatre implémentations dupliquées du drag de
largeur (Library, workspace local, My Tasks, bases) sont remplacées par un
helper unique `window.fdWirePeekResize` (`app.js`) — pointer capture, borne
300 px–90 vw, clic = fermer, largeur persistée (`fd_peek_width`) et commune
à tous les peeks, restaurée au chargement. My Tasks ne capturait ni le
pointeur ni la largeur : son drag était erratique et non mémorisé.
- **Largeur des pages standardisée sur celle de Library (1100 px)** — le
conteneur des bases (`.database-table-container`) passe à `margin: 0` : le
tableau est collé à gauche, au ras de la marge, au lieu d'être centré dans la
page. `/trash` utilisait la classe `.lib-container` dont la règle vivait dans
le `<style>` de library.html : rendue hors Library elle était pleine largeur —
la règle est remontée dans `app.css` (globale). La page `/db` (`.db-index`)
était à 1200 px, alignée à 1100 px.
- **Le side peek de Library n'affichait plus le contenu du document** — deux
défauts cumulés. L'`<iframe>` vivait dans un `<template x-if="peekItem">` et
portait un binding `:src` : le build CSP d'Alpine n'évalue pas ce type
d'expression, si bien que le `src` restait vide et que le panneau affichait un
document blanc. L'iframe est désormais un élément permanent, hors du template,
dont le `src` est posé en JavaScript impératif — le motif déjà employé par les
aperçus du workspace local.
- **Ouvrir un autre document dans le peek laissait le précédent affiché** —
comme l'URL ne changeait pas, le navigateur ne rechargeait pas l'iframe. Le
peek passe maintenant par `about:blank` avant de charger le nouveau document,
et `closePeek()` décharge l'éditeur : il ne reste plus en mémoire, son autosave
ne tourne plus, alors qu'il est invisible.
- **Le side peek affichait une version allégée de l'éditeur** — il chargeait
`/pages/{id}?embed=1`, qui masque sidebar, barre d'outils et actions. Il charge
désormais la même page que l'ouverture plein écran, dans les quatre peek du
site (Library, My Tasks, bases de données, workspace local).
- **Le peek des bases pouvait ouvrir un document sans rapport** — quand la
résolution de la ligne échouait, `resolveRowContentPage` retombait sur
`return rowId` : un id de ligne de base n'est PAS un id de page, la page
ouverte portait donc simplement le même identifiant — exactement le piège que
le commentaire du fichier décrivait deux lignes plus haut. L'échec est
désormais signalé, et le message est rendu **dans le panneau** (et non via
`toast()`) : sous le build CSP d'Alpine, aucun composant n'est initialisé et
`window.showToast` reste sans effet.
- **Une iframe `src=""` se résout en l'URL courante** — sur une page de base, le
navigateur chargeait une **copie complète de la page** dans l'iframe cachée, et
la fermeture du peek (`iframe.src = ''`) rechargeait la page entière dans le
panneau. Le gabarit et la fermeture utilisent désormais `about:blank`.
### Known issues (préexistants, hors périmètre)
- Sous le build CSP d'Alpine (`alpine.csp.min.js`, v3.17.4), les expressions
`x-data` inline ne sont pas évaluées : `Alpine.$data(el)` ne renvoie aucune
clé sur les cinq éléments `[x-data]` d'une page. Conséquence directe : les
**toasts ne s'affichent nulle part**, car `window.showToast` (`base.html`) lit
`el.__x`, propriété du build de développement. `refreshSidebarFromEvent` et
le repli de la sidebar (`app.js`) relèvent du même défaut. Constaté en
vérifiant le message d'erreur du peek ; non traité ici.
## v7.48.0 (2026-10-05) — My Tasks aligné sur Library, side peek, navigation SPA fiable, Ctrl+S
### Added
- **Side peek sur My Tasks** — le bouton « Open » de chaque tâche ouvre le
document dans le **panneau latéral** de la page Library (`/pages/{id}` dans une
iframe), au lieu de quitter le tableau de bord. Panneau redimensionnable,
fermeture par `✕` ou `Escape`, bouton « Ouvrir dans une page entière ».
- **Ctrl/Cmd + S dans l'éditeur** — sauvegarde explicite du document en cours
d'édition (même `save()` que l'autosave, donc même file hors-ligne). Sans lui,
le navigateur ouvrait sa boîte « Enregistrer la page ». Inactif sur une page
verrouillée (`read-only`), et seulement quand l'éditeur est réellement monté :
`window.E` survit aux navigations partielles, la touche aurait donc pu
sauvegarder un éditeur détaché depuis une autre page.
### Changed
- **My Tasks adopts le gabarit de Library** — conteneur centré 1100 px, en-tête
`h1` 700 + compteur à droite, onglets et outils de part et d'autre, lignes en
`div` (et non `<table>`), hauteur 38 px, survol `var(--bg-hover)`, et palette
entièrement prise sur les tokens du thème.
### Fixed
- **Bouton d'ouverture visible seulement au survol** — il reprend exactement le
`.btn-open` / `.hover-only` de Library (même SVG, même libellé « Open », même
révélation `opacity`), et reste visible sur écran tactile (`@media (hover: none)`)
où il n'y a pas de survol.
- **My Tasks vide ou a composants blancs au retour de navigation** — trois
défauts distincts, tous dans le chemin de navigation partielle :
- `fdLoad` **ignorait silencieusement** un clic quand une navigation était déjà
en vol (`if (loading) return;`) : ni l'URL ni le contenu ne changeaient. Si la
promesse htmx ne se résolvait jamais, `loading` restait à `true` **définitivement**
et plus aucune navigation ne repartait — page figée jusqu'au hard refresh. Le clic
abandonne maintenant la navigation en vol par un chargement complet, et une
sentinelle de 15 s garantit que `loading` ne peut plus se bloquer.
- Le compteur de scripts de la zone swappée écoutait `load` **en capture sur
`document`**, donc voit tous les `<script>` de la page : le premier script
sans rapport suffisait à déclencher le montage Alpine **avant** que les scripts
de la page n'aient tourné (composants affichés à l'état brut). Il ne compte
plus que les nœuds réellement attendus.
- `my_tasks.css` était liée **dans** `.main-wrapper` : un `<link>` dans la zone
swappée peut être retiré par htmx, et la page revenait alors sans aucune feuille
de style — d'où les `<select>` / `<input type="date">` blancs en thème sombre.
La feuille est désormais liée par `base.html` (le shell, jamais swappé) ; les
deux injections par page ont été retirées.
- **My Tasks se remonte s'il est resté vide** — une coquille montée mais vide est
le symptôme d'un swap interrompu : le tableau de bord se remonte plutôt que
d'afficher du vide. Les délégations d'événements ne sont posées qu'une fois par
nœud (`__fdWired`), donc aucun double gestionnaire.
## v7.47.0 (2026-10-04) — My Tasks façon Notion : 3 vues, sources opt-in, mapping explicite
> My Tasks devient un poste de travail **transverse** (comme l'onglet *Home*
> de Notion) : toutes vos tâches, **tous workspaces confondus**. Une base
> n'y alimente plus qu'après **conversion explicite** (« Convertir en base de
> tâches ») avec le mapping de ses trois colonnes requises — et il ne peut y
> en avoir plus de 10.
### Added
- **Sources opt-in avec mapping explicite** — migration 30 : `collections`
porte `task_assignee_prop` / `task_status_prop` / `task_due_prop`. La
conversion est explicite (`POST /db/{id}/task-db/api`) : chaque rôle est
**lié** à une colonne existante du bon type, ou **créé** avec le type
attendu (`person` / `status` / `date`). Une base connectée au mapping
incomplet est listée mais **n'émet aucune tâche** — l'UI le signale au lieu
de deviner une colonne. Suppression du mapping à la déconnexion (plus de
références mortes), et la limite de **10 bases** est refusée côté serveur.
- **Trois vues** — tableau (colonnes redimensionnables non, mais statut et
échéance éditables au clic), **kanban** glisser-déposer (les colonnes sont
les options de statut de la base d'origine) et **calendrier** mensuel.
- **Filtres et tris globaux** — échéance (toutes / aujourd'hui / en retard /
cette semaine, borné à J+7), « masquer les terminées », tri par échéance,
statut, source ou création.
- **Étiquette de source** sur chaque tâche (base d'origine + workspace),
cliquable vers la base.
- **Modification en direct** — `PUT /my-tasks/api/task/{id}` écrit le champ
demandé (`status`, `due`, `title`, `assignee`) dans **la colonne mappée de
la base source**, puis relit et renvoie la tâche normalisée. Désassigner
une tâche la fait sortir de My Tasks (`left_mytasks`).
- **Ajout rapide multi-destinations** — `POST /my-tasks/api/task` crée la
tâche dans la base choisie dans la modale, **auto-assignée** (sans colonne
« Assigné à » renseignée, elle n'apparaîtrait jamais dans « mes tâches »).
- **Modale de conversion** dans la barre de la base (bouton `✓`) :
`static/js/task_db_link.js` + `static/css/my_tasks.css`.
### Changed
- **Périmètre transverse** — `flowdeck_workspace` ne cadre plus My Tasks
(il reste lu par la sidebar). Le payload `/my-tasks/api` expose `tasks`,
`sources`, `sources_total`, `max_sources` et `filters` ; **plus de
`workspace_id`** (le contract précédent est modifié, pas additionsnel).
- **`PUT /db/{id}/toggle-task/api`** reste rétrocompatible mais délègue à la
conversion complète : il ne pose plus `is_task=1` seul, ce qui créait une
source « connectée » incapable d'émettre quoi que ce soit.
- **Rendu client** — la page HTML porte la configuration (`data-config`) et
charge `static/js/my_tasks.js` ; sans aucune source connectée, le serveur
rend l'invite à convertir une base et ne charge pas le script.
### Fixed
- **`/db` était un dump JSON de debug** — la page renvoyait un `<pre>` hors
layout, sans session requise : « Ouvrir mes bases » (bouton de l'état vide
My Tasks) menait à un écran vide. `app/routers/collections/index_page.py`
la remplace par une vraie page dans le layout de l'app : les bases **du
workspace courant**, leur état (« Base de tâches » / « À configurer »), le
nombre de lignes, et ce qu'il reste à faire. Les bases à la corbeille n'y
sont pas proposées.
- **Bouton de conversion introuvable** — c'était un `✓` nu, sans libellé.
Il porte maintenant le libellé de son état réel : « Convertir en base de
tâches », « ⚠ Configurer » ou « ✓ Base de tâches ».
- **L'invite My Tasks renvoyait vers un bouton « … » qui n'existe pas** — le
message indiquait le menu `…` de la page alors que le bouton est dans la
barre de la base, à gauche de `New`. Texte corrigé, et un bouton
« Configurer » apparaît sur chaque source mal configurée.
- **Kanban et calendrier vidaient la page** — un clic sur l'onglet rendait la
vue, puis l'intercepteur de liens de `app.js` (`shouldIntercept`) traitait le
**même** clic et relançait un chargement complet : le DOM rendu était
remplacé par un `#my-tasks-app` vide et l'écran devenait blanc. Le clic est
désormais marqué `data-no-nav` sur les onglets, et `shouldIntercept` ignore
tout événement déjà traité (`defaultPrevented`) — plus aucun composant ne
peut plus être intercepté par la navigation SPA.
- **`/db` n'avait aucune feuille de style** — `my_tasks.css` (qui porte les
règles `.db-*`) n'était liée que par `_database_table_scripts.html`, jamais
par la page `/db` : les cartes, sections et états vides s'affichaient sans
aucun style. La feuille est maintenant liée explicitement.
- **My Tasks ignorait le thème sombre** — le CSS utilisait `--bg-card`, une
variable **inexistante** dans `app.css` : le repli `#fff` rendait les cartes,
onglets et panneaux blancs en mode sombre. Remplacée par `--bg-primary`, qui
suit le thème (mesuré : `rgb(25,25,25)` en sombre, `rgb(255,255,255)` en
clair).
- **États vides non centrés** — l'invite « aucune base connectée » et les états
vides de `/db` étaient un bloc de texte serré à gauche, en styles inline.
Gabarit centré commun (icône ronde, étapes numérotées, bouton), sans style
inline, donc aligné sur le thème ; `/db` recentre son en-tête et sa section
« Autres workspaces ».
- **My Tasks « non connectée » n'avait aucune feuille de style** — `_render_shell`
construisait le bloc `<link>` + `<script>` dans le même mini-template, or
seul le script était conditionné à `app_js`. C'est précisément le cas
« aucune base connectée » : la page perdait `my_tasks.css` et l'invite
retombait en texte brut aligné à gauche, sans les états vides centrés. La
feuille est désormais liée **toujours**, seul le script reste conditionnel.
- **« file Tache 1 » / « file Tache 2 » sous la colonne Tâche** — ce n'était
pas un libellé mais `collection_pages.icon`, valeur sentinelle historique
signifiant « pas d'icône » (le même filtrage existait déjà dans
`database_table.js`). `rowIcon()` la remplace désormais par `📄` dans le
tableau et le kanban.
- **Cellule « Tâche » collée au bord gauche** — `.mt-td-title` est un flex
et ne recevait pas le `padding` des autres cellules ; le titre commençait à
1 px de la bordure du tableau (mesuré : 13 px après correction).
- **Pas de bouton pour voir le contenu d'une tâche** — chaque ligne du
tableau porte désormais un bouton qui résout la page de contenu via
`GET /db/pages/{id}/open/api` (un id de ligne n'est pas un id de page, la
page est créée à la volée) puis ouvre l'éditeur. Si la résolution échoue,
on reste sur le tableau de bord et on remonte l'erreur : un repli vers
`/pages/{rowId}` ouvrirait une page **sans rapport** (piège déjà documenté
dans `database_table.js`).
- **My Tasks vide après navigation** — le script se gardait d'un drapeau
global (`window.__fdMyTasksLoaded`) : la navigation interne de `app.js`
remplace `.main-wrapper` par htmx **sans recharger le document**, le `<script>`
est rejoué mais sortait aussitôt du IIFE, et la nouvelle coquille restait
vide jusqu'au hard refresh. Le montage est désormais une fonction
rejouable, protégée par un drapeau **porté par l'élément**, rejouée sur
`htmx:afterSwap` et `popstate` (vérifié : deux aller-retours consécutifs
restituent tableau, barre d'outils et onglets).
- **Test dépendant de l'heure** — `test_today_follows_the_user_timezone`
supposait que le jour UTC soit toujours différent du jour en UTC-7, ce qui
n'est vrai que de 00h à 10h UTC : il échouait 13 heures sur 24. Le cas est
maintenant traité explicitement au lieu d'être supposé faux.
- **Bases portées par un document invisibles (`workspace_id` NULL)** — une base
née d'une page (page convertie en base, base inline, base liée, modèle
importé) n'a pas de `collections.workspace_id` : elle n'appartient qu'à travers
sa page hôte. Le filtre `c.workspace_id = ?` et le `JOIN workspaces` de
`list_task_sources` ne la trouvaient jamais, donc `/db` l'ignorait **et** elle
n'alimentait pas My Tasks — la base était visible dans la sidebar mais
introuvable et non convertible. Le rattachement passe désormais par le
workspace **effectif**, `COALESCE(workspace_id, workspace de la page hôte)`
(`collection_lifecycle.effective_workspace_sql`), utilisé par
`live_collection_ids`, `list_task_sources` et la page `/db`.
- **Fuite de bases entre utilisateurs sur `/db`** — la section « Autres
workspaces » faisait `JOIN workspaces` **sans filtre de propriétaire** : elle
exposait les bases de tous les utilisateurs de l'instance. Le périmètre est
maintenant `user_workspace_ids` (propriétaire **ou** membre). Une base sans
workspace rattachable n'appartient à personne et n'est plus listée nulle part.
- **Base à la corbeille qui alimentait encore My Tasks** — le filtre de
vivacité (`live_collection_ids`) était appliqué aux lignes mais pas aux
bases dans le nouveau service : une base dont la page hôte avait disparu
émettait toujours ses tâches (le bug v7.46.1, reintroduit par la réécriture).
- **`is_done` ne reconnaissait pas « Terminée »** — la liste de libellés
exacts ne couvrait ni le féminin, ni les accents, ni la casse. La
comparaison est faite sur le **premier mot normalisé** (minuscules, sans
accents) : « Terminée », « Complete », « Closed », « Cancelled » sont
reconnus ; « Clôture » ne l'est pas (un préfixe « clos » l'aurait fait).
- **« Cette semaine » n'était pas borné** — le filtre incluait toute tâche
datée, y compris dans six mois.
- **« Aujourd'hui » suivait UTC** — le projet stocke déjà `users.timezone`
(utilisé par le calendrier des bases) ; My Tasks l'utilise maintenant aussi
(`task_databases.user_today`), sans quoi une tâche du jour bascule dans
« demain » dès que l'utilisateur est loin d'UTC.
- **Colonne « Assigné à » écrasée par un booléen** — l'édition en direct
écrivait `bool(value)` au lieu de la liste de personnes, vidant la colonne.
### Tests
- `tests/test_my_tasks_workspace_coherence.py` — **32 tests** : périmètre
transverse, opt-in, mapping incomplet, validation des types et du double
emploi, limite de 10, assignataires, filtres/tri/états d'échéance, édition
en direct (statut, échéance, titre, désassignation), création rapide,
rendu des 3 vues, fuseau, et les cascades de suppression (corbeille, purge,
Empty Trash, workspace, base).
- `tests/test_app.py` — les 3 tests My Tasks alignés sur le nouveau contrat.
## v7.46.1 (2026-10-04) — My Tasks : la vue ne montre plus que le contenu vivant du workspace
> Signalé : « dans un workspace dont j'ai supprimé tous les documents, My
> Tasks affiche encore des tâches ». My Tasks listait **toutes** les
> `collections` du workspace sans vérifier qu'un document existait encore —
> or supprimer un document ne supprime pas sa base ni ses lignes.
### Fixed
- **Tâches orphelines** — `_collect_tasks` faisait
`SELECT * FROM collections WHERE workspace_id = ?` : une base dont la page
hôte avait été supprimée (corbeille ou purge) continuait d'afficher ses
lignes, dans un workspace vide côté sidebar. Les bases et les lignes sont
désormais filtrées sur la **vivacité de leur document**
(`app/services/collection_lifecycle.py` : `live_collection_ids`,
`live_row_ids`, `live_document_count`) — une page hôte absente ou à la
corbeille exclut la base, une page contenu à la corbeille exclut la ligne.
- **My Tasks réécrivait le cookie `flowdeck_workspace`** — un 302 +
`set_cookie` à chaque visite où le cookie différait : **ouvrir My Tasks
changeait le workspace actif de toute l'application** (sidebar, Home, arbre).
Le cookie n'est plus touché ; le workspace résolu est simplement celui du
contenu affiché.
- **Divergence de résolution du workspace** — My Tasks réimplémentait son
propre `_get_active_workspace_id` et ne comprenait pas le cookie
`gitea:owner:repo` écrit par la sidebar : il retombait alors sur
`owned[0]` et affichait les tâches d'un **autre** workspace que celui
affiché. Résolution mutualisée avec `dashboard._common._get_active_workspace`
(+ résolution du miroir Gitea local, comme `_sidebar_data`).
- **Sidebar désynchronisée du contenu** — le contexte de la sidebar venait du
cookie alors que le contenu venait du workspace résolu ; elle est
réalignée sur le workspace réellement listé (nom, `local_ws_id`, arbre).
- **État vide incohérent** — « Crée une collection » était proposé même
quand le workspace n'avait plus aucun document. Distinction désormais :
*workspace vide* (lien vers le workspace) vs *workspace sans tâche* (lien
vers `/db`).
- **Collections orphelines après suppression** — la suppression définitive
d'une page hôte (poubelle purgée, `DELETE /api/trash/{id}`, Empty Trash),
d'un workspace ou d'un utilisateur ne supprimait pas les bases : elles
restaient listées par `/db` et par My Tasks avec un `workspace_id` ou un
`parent_page_id` fantôme. Cascade appliquée sur ces 4 chemins
(`trash.purge_expired`, `board/sharing.permanent_delete`,
`board/sharing.empty_trash`, `dashboard/workspaces.delete_workspace`,
`admin.delete_user`).
- **`DELETE /db/api/{id}` levait un `IntegrityError`** dès que la base avait
une page hôte : `pages.collection_id` et
`collection_data_sources.source_collection_id` référencent `collections` en
`NO ACTION`. `delete_collections()` détache d'abord ces deux références
(la page hôte redevient une page blocs normale), puis supprime ; le reste
part en `CASCADE`. Les 5 chemins de suppression de collection en profitent
(`/db/api/{id}`, `/api/v2/collections/{id}`, sync, tool agent).
### Tests
- `tests/test_my_tasks_workspace_coherence.py` — **12 tests** : base à la
corbeille (tâches masquées **et** restaurables), ligne à la corbeille,
message « workspace vide » vs « sans tâche », suppression définitive /
purge / Empty Trash / suppression de workspace (base + lignes + pages
contenu emportées), détachement de la page hôte à la suppression d'une
base, cookie non réécrit, sidebar alignée sur le contenu, isolation entre
workspaces.
## v7.46.0 (2026-10-04) — Sidebar : navigation par onglets compressibles (Home / Chat / Meeting / Inbox)
> La rangée d'icônes du sidebar devient un **sélecteur d'onglet** : l'onglet
> actif s'étend en pilule avec son libellé, les autres se réduisent à leur
> icône, et chaque sélection **remplace intégralement** le contenu de la barre
> latérale. Ordre imposé : **Home → Chat → Meeting → Inbox**.
### Added
- **Onglet Meeting** — calendrier de productivité : filtres `Upcoming` /
`Today`, actions rapides (*Transcribe a podcast* : création de la page de
réunion + `POST /api/v2/meetings/transcribe` ; *New AI meeting note* :
commande `/meeting-note` de l'agent), et la liste chronologique des
événements.
- **`GET /api/v2/meetings/upcoming?days=30`** — nouveau endpoint authentifié
(session ou Bearer) qui agrège les collections calendaires liées de
l'utilisateur (`calendar_links`), extrait la propriété date de chaque ligne
et renvoie les événements du jour et des `days` jours suivants, triés, avec
`today` / `synced` / `provider` / `url`. Alias publics `date_prop_id` et
`row_date` dans `app/services/calendar_sync.py`.
- **Onglet Chat** — raccourcis circulaires vers les agents
(`GET /api/agent`) + historique chronologique des conversations
(`GET /api/agent/conversations`) regroupé en *Past 30 days* / *Older*. Un
clic ouvre le panneau agent **sur la conversation choisie** via le nouveau
`window.fdAgent.openConversation(id)`.
- **Onglet Inbox** — vrai centre de notifications (`GET /api/notifications`) :
bascule *All / Unread*, marquage lu au clic, *Mark all read*, état vide
*Inbox Zero — You're all caught up*. Badge non lus sur l'onglet (et compteur
dans l'en-tête du panneau quand l'onglet est actif).
- Persistance de l'onglet actif (`localStorage: fd_sidebar_tab`, valeur
validée contre la liste des onglets).
### Changed
- `sidebar-nav-row` : libellés désormais dans des `<span class="nav-tab-label">`
masqués sauf sur l'onglet actif (CSS `.nav-tab-btn`), badges de compte,
`role="tab"` / `aria-selected`. Les boutons Chat / Meeting / Inbox sont de
vrais `<button type="button">` (le bouton Inbox n'avait aucune action).
- Le contenu Home (arbre du workspace, sections, liens Library / My Tasks /
Trash / Help) est encapsulé dans le panneau `sidebarTab === 'home'` ; le
footer reste commun.
### Fixed
- **Clic sur Home → texte sur les quatre boutons** — la visibilité des
libellés ne dépendait que de `app.css`, or l'URL des assets statiques est
versionnée par le fichier `VERSION` (`?v=7.45.6`) : un navigateur conservait
donc la feuille de style d'avant la livraison, où les
`<span class="nav-tab-label">` n'étaient pas masqués. La décision est
désormais prise dans le **HTML** (`style` inline piloté par Alpine) pour les
libellés **et** les badges ; la CSS reste le repli sans JavaScript.
### Tests
- `tests/test_sidebar_tabs.py` — **15 tests** : ordre exact des onglets
(template **et** HTML rendu), accessibilité, un panneau par onglet, sources de
données réelles, persistance validée, pilule active / libellés masqués (CSS),
401 anonyme sur `/api/v2/meetings/upcoming`, filtrage *aujourd'hui et
futur*, exclusion du passé et de l'horizon, collection sans lien, validation
et clamp de `days`, cloisonnement par propriétaire de lien.
- `e2e/sidebar_tabs.spec.js` — **3 scénarios** : bascule des 4 onglets avec
mesure de largeur (pilule > icône) et vérification qu'**après le clic sur
Home seul Home garde son libellé**, appel réseau de
`/api/v2/meetings/upcoming`, et non-régression avec une CSS périmée servie
depuis le cache (le scénario échoue bien sur l'ancienne version déployée).
- Suite complète : **1182 tests verts**.
## v7.46.0 (2026-10-04) — Audit de fonctionnement : 10 anomalies corrigées
> Second volet de la v7.46.0 (le premier volet, web tools + 11 skills, est
> détaillé plus bas). Audit réalisé sur l'instance locale : 677 routes, 280
> endpoints GET sondés anonymement, 64 templates/JS croisés avec les routes,
> crawl Playwright authentifié, intégrité SQLite. **10 anomalies** trouvées,
> toutes corrigées et verrouillées par **34 tests** dédiés
> (`tests/test_anomalies_v746.py`). Suite complète : **1167 tests verts**.
### Security
- **Écritures anonymes closes** — `POST/DELETE /api/workspaces`,
`POST /api/workspaces/{id}/select`, `PUT /api/workspaces/{id}` et les
endpoints `local-workspace/items` (create/rename/delete/restore/move)
répondaient **200 sans aucune session** et écrivaient au nom de
l'utilisateur 1 (`uid = user["id"] if user and user.get("id") else 1`).
Un visiteur anonyme pouvait donc créer un espace, **renommer** et
**supprimer** n'importe quel espace de travail. Ces routes passent par
`_require_user_id()` et un nouveau `_require_ws_owner()` (propriétaire ou
admin global, sinon 403 ; workspace inexistant → 404).
- **Fuite de données via `/workspace/*`** — le router legacy
`app/routers/workspace.py` (relique v2.0.0) n'avait aucun garde :
`GET /workspace/collections/{id}/export/csv` renvoyait **le contenu réel**
d'une collection à un visiteur anonyme (titres, tâches, assignations), de
même que l'historique de page, les commentaires, les sprints et les
templates. Le router porte maintenant une dépendance
`Depends(_require_session)` ; **la seule route publique**
(`/workspace/public/{collection_id}`, page de partage) est déplacée dans un
`public_router` sans dépendance et reste accessible.
- **`password_hash` exposé** — `GET /auth/user` renvoyait la ligne `users`
complète, **sel et hachage PBKDF2 compris**, et le cookie de session
(signé mais **non chiffré**) embarquait la même donnée en base64. Nouveau
`app.auth.session.public_user()` appliqué dans `create_session`,
`refresh_session` et `/auth/user`.
- **OAuth Gitea cassé** — `gitea_oauth_client_id`/`_secret` valent les
placeholders `test-id`/`test-secret` dans `app/config.py`, valeurs non vides
donc considérées comme « configurées » : `GET /auth/login` redirigeait vers
Gitea avec un `client_id` invalide (échec d'authentification garanti).
`app/auth/providers` traite désormais les placeholders comme « provider non
configuré » → page « non configuré » avec lien vers le login local.
- **Synced blocks** — `GET /board/api/synced-blocks` levait
`AttributeError: 'NoneType' object has no attribute 'get'` (**HTTP 500**) en
accès anonyme ; `PUT`/`DELETE /board/api/synced-blocks/{sid}` n'avaient
**aucun contrôle de propriété** (un utilisateur connecté pouvait modifier le
bloc d'un autre). Ajout de `_session_user()` (401) et
`_assert_can_edit_block()` (auteur ou admin, sinon 403).
### Fixed
- **Bouton « Home » de la sidebar** — il pointait sur
`/local-workspace?ws={{ local_workspaces[0].id }}`, c'est-à-dire le **premier
espace trié par nom** et non l'espace courant : cliquer Home depuis
`NewWS` basculait vers `allo` (et inversement), ce qui donne l'impression que
« tout le site s'affiche d'un coup ». Les deux builders de sidebar
(`dashboard/_common.py`, `board/_common.py`) exposent désormais
`local_ws_id` = **ID de l'espace actif** (le miroir Gitea a été séparé dans
`gitea_mirror_ws_id` pour ne plus écraser cette valeur), et `base.html`
privilégie `local_ws_id` avant `local_workspaces[0]`.
- **`GET /api/v2/agents/conversations` → 422** — la route paramétrée
`/agents/{agent_id}` était enregistrée **avant** et capturait le segment
`conversations`. Les trois routes passent en `{agent_id:int}` : un
identifiant d'agent est un entier, le segment statique garde sa route.
- **Sélecteur de blocs synchronisés → 404** — l'éditeur de page appelait
`/api/synced-blocks` alors que les routes sont sous `/board/` :
`static/js/page_editor_scripts.js` utilise maintenant
`/board/api/synced-blocks`, et le message d'aide ne renvoie plus vers une
section « Settings → Synced Blocks » **inexistante**.
- **`/workspace/favorites` → 500 permanent** — `sqlite3.OperationalError: no
such column: f.collection_id` : la migration v2.2.0 (`app/db.py`) avait
supprimé `favorites.collection_id` et repointé `page_id` vers `pages(id)`,
mais le handler lisait encore l'ancien schéma (jointure `collection_pages`).
Requête et insertion alignées, `page_id` désormais obligatoire, suppression
restreinte au propriétaire du favori.
### Changed
- **Jinja : plus de silence** (`app/templating.py`) — une variable absente du
contexte rendait `""` sans aucune trace, ce qui avait masqué le bug du
bouton Home. `StrictUndefined` est inutilisable ici (40+ templates
optionnelles, p. ex. `embed_mode`), donc `ENV.undefined` vaut désormais
`LoggingUndefined` : **rendu identique, mais un `WARNING` journalise le nom
de la variable manquante**.
- `.env.example` : `GITEA_OAUTH_CLIENT_ID` / `GITEA_OAUTH_CLIENT_SECRET`
documentés (laisses vides = provider Gitea considéré comme non configuré).
### Tests
- `tests/test_anomalies_v746.py` — **34 tests** : absence de `password_hash`
(endpoint + cookie), placeholders OAuth (non configuré vs configuré), 9
écritures anonymes refusées en 401, propriété des espaces (403 + état en
base inchangé), 7 lectures `/workspace/*` en 401, partage public toujours
accessible, round-trip favorites, ordre de routes agents, URLs synced blocks
(fichier), 401 anonyme sur les synced blocks + 403 pour un autre
utilisateur, bouton Home (template **et** HTML rendu sur l'espace actif),
`LoggingUndefined`.
## v7.46.0 (2026-10-04) — Web tools de l'agent (recherche web, lecture de page, GitHub) & 11 nouveaux skills
### Added
- **3 nouveaux tools agent** — l'agent pouvait chercher dans FlowDeck et dans
Gitea, mais **pas sur le web**. `app/services/tool_registry.py` passe de 22 à
25 tools :
- `web_search` — recherche web, résultats toujours sourcés (titre + URL +
extrait) pour que le modèle cite au lieu d'inventer ;
- `fetch_url` — télécharge une page et la convertit en Markdown (titres,
listes, code, tableaux) ;
- `search_code` — GitHub `repositories | code | issues`, fonctionnel sans jeton
(10 requêtes/min) et à 30 avec `GITHUB_TOKEN`.
- **`app/services/web_search.py`** — couche réseau des deux tools de recherche :
provider **Exa** (sémantique + extraits) avec repli **DuckDuckGo** sans compte,
`shared_client` pour le pool de connexions, **aucune dépendance ajoutée**, URLs
du moteur filtrées des résultats, `transport` injectable pour les tests.
- **11 nouveaux presets de skills** (`app/services/skill_gallery.py`, 6 → 17) :
- locaux : `veille-documentaire`, `chasse-decisions`, `onboarding-nouveau`,
`audit-base`, `sprint-review` ;
- web : `recherche-marche`, `veille-techno`, `debug-web` (obligation de
sourcer : `[titre — URL]`, sinon « non vérifié ») ;
- pouvoir : `triage-incident`, `nettoyage-base` (aperçu avant toute écriture),
`briefing-quotidien`.
- **Config** — `WEB_SEARCH_PROVIDER` (`exa` | `duckduckgo`), `EXA_API_KEY`,
`GITHUB_TOKEN`, `WEB_FETCH_MAX_CHARS` (`app/config.py` + `.env.example`).
- **Libellés d'action** du journal agent pour les 3 tools (Recherche web
effectuée / Page web lue / Recherche GitHub effectuée).
### Security
- **`fetch_url` est protégé par SSRF** : schémas `http(s)` uniquement, résolution
DNS puis refus des IP loopback, privées, lien-local, réservées et multicast,
et **re-validation à chaque saut de redirection** — une URL publique ne peut
pas rediriger vers `169.254.169.254`. Il réutilise les garde-fous déjà éprouvés
par `importers/url_fetch` et `og_fetcher` plutôt qu'un contrôle maison.
- `search_code` ne touche que l'API publique GitHub (lecture seule) ; le
message d'erreur 403 renvoie explicitement vers `GITHUB_TOKEN`.
### Tests
- `tests/test_agent_web_tools.py` : **29 tests**, sans aucun accès réseau
(`httpx.MockTransport` + résolution DNS neutralisée — les IP littérales
passent par la résolution réelle pour que le refus des adresses privées soit
testé pour de vrai) — registre et périmètre des schémas, cohérence
galerie/registre, cycle export→import des presets web, provider + repli +
filtrage anti-bot, extraction Markdown, troncature, **6 vecteurs SSRF refusés**
(localhost, 127.0.0.1, metadata cloud, IPv6 loopback, `file:`, `ftp:`),
redirection vers metadata cloud refusée, redirection publique suivie, GitHub
repos/issues/quota.
- Non-régression : **suite complète 1126 tests verts** (`pytest -n auto`).
## v7.45.6 (2026-10-03) — Fenêtre de confirmation thématisée pour les suppressions de la poubelle
### Changed