list_workspaces/get_workspace filtrés par user_workspace_ids ;
collections filtrées par workspace effectif ; hors périmètre =
introuvable, lectures et écritures. Test deux comptes.
- detach_page_references : nettoyage des références NO ACTION avant
toute suppression définitive (unitaire, Empty Trash, purge 30 j)
- MCP : list_workspaces / get_workspace ; descriptions collections
clarifiées (bases de données, pas des workspaces)
- 96 tests verts sur le périmètre (MCP, trash, cohérence)
Le middleware CSRF rejetait tout POST /mcp en 403 avant la route ;
les tests passaient car leur client porte session + CSRF. Test de
régression : client anonyme Bearer seul (200 avec jeton, 401 sans).
- create_page: collection_id (int, requis), title (string, requis), icon (optionnel), properties (objet optionnel -> property_values_json)
- update_page: id (int, requis), title (optionnel), properties (objet optionnel, remplace property_values_json)
- create_collection: name (requis), description (optionnel), icon (optionnel) + vue par défaut
- Aucune suppression; erreurs métier en {"erreur": "..."}; GET /mcp read_only=false et liste mise à jour
- Tests étendus: création+relecture, mise à jour, create_collection, erreurs, 401 sans jeton sur écriture
- "Get started with" : suppression du bouton "⋯" et de son menu mort
(Table/Board/List/Timeline/Calendar/Gallery/Import — aucun handler).
- Pilule Form : createForm() plantait sur page vide ("Cannot read
properties of undefined (reading 'push')") car this.inlineDBs n'était
jamais initialisé dans editorState() (seulement via loadInlineDBs si un
bloc inline_dbs existait déjà). Corrigé : inlineDBs:[] initialisé.
Le nom du form est demandé via une modale Alpine personnalisée
(flowdeck-modal*) au lieu du prompt() natif.
- Sidebar tree après drag&drop : refreshSidebarTree() préservait le lien
"Manage Workspaces" via ul.querySelector('li:last-child') — non scopé,
il attrapait le dernier fichier d'une sous-liste de dossier imbriquée
(premier li:last-child du document) et le ré-insérait → doublon du
dernier fichier. Corrigé : ':scope > li:last-child' (enfant direct).
Templates (v7.71.x) :
- registre unifié \ emplates\ (migrations 48-49) + TemplateService.instantiate unique (UI, API v2, agent, scheduler)
- sélecteur (pilule page vide, menu •••, commande /template), gestionnaire /templates, menu New ▾, From template, base inline dans un document
- 141 presets système (59 pages, 42 bases, 15 blocs, 25 lignes), titre auto depuis le template, variables title réservée
- récurrences RRULE + scheduler dédupliqué, agent apply_template/list_templates, API /api/templates + /api/v2/fd-templates
- correctifs : bouton Templates, centrage fenêtre, filtres CSP, flux de création, variable title
- tests : tests/test_fd_templates.py (19) et e2e/templates_picker.spec.js (8)
Inclut le travail déjà présent dans le working tree (vues Notion : view_query/view_aggregate/form_projection/geocoding, property_types, database_table, docs agents-skills) et ignore .playwright-mcp/.
Deux causes indépendantes faisaient échouer le job test de la CI (et
seulement en CI : ni .env ni les mêmes ordres de chargement en local) :
- test_agent_web_tools.mock_http patche http_client.shared_client pendant
sa fenêtre d'exécution. Le premier import d'importers/url_fetch dans
cette fenêtre fige la factory moquée dans le namespace du module →
tous les appels fetch_url suivants du processus passaient par le handler
mocké de l'autre test (« assert '…/post' == '…/page' » dans
test_v56_import). url_fetch résout désormais le client à l'appel, et le
mock_http restaure la vraie factory (référence figée au chargement du
module) y compris sur url_fetch ;
- test_agent.py posait RATE_LIMIT_ENABLED=false en env var, sans effet sur
le singleton Settings déjà instancié : sous pytest -n auto, le worker
dépassait le quota de 60 req/min et 8 tests recevaient des 429
(KeyError 'id' au passage). Le fixture désactive maintenant le limiter
sur le singleton, comme les autres fichiers de tests.
Corrige deux régressions de /local-workspace :
- le chemin du header restait bloqué sur « Home / <workspace> » quel que
soit le dossier affiché : la route rend désormais breadcrumb_items
(Home / <workspace> / <dossier> / <sous-dossier>, niveaux cliquables,
collapse « … » au-delà de 4) et la navigation sans rechargement recalcule
le chemin via l'event flowdeck:breadcrumb-changed ;
- le clic sur un dossier du sidebar affichait TOUS les composants à la
fois : Alpine.data('wsInitData') retournait le même objet singleton, le
2e montage (navigation partielle) levait « Cannot redefine property:
\ » et initTree abandonnait, laissant tout le contenu au state
brut. La factory retourne désormais une enveloppe fraîche par montage
qui délègue à l'état réactif partagé. #lw-config est aussi relu à chaque
exécution (le 2e montage gardait le folder_id du 1er chargement).
Inclus également le travail en cours de l'arbre : Library (colonnes Last
visited/Source, ordre d'en-tête, favoris à icônes Workspace), Meeting
Notes (bloc, CSS, routes, docs), coloration de code hljs, badges
favori/publié dans l'arbre local-workspace, docs (DATA_MODEL,
architectures) et tests associés.
Le bouton close de Settings devait être cliqué deux fois. Symptomatique depuis
le menu utilisateur ; depuis /workspaces ça marchait.
Cause : closeSettings() faisait history.back() PUIS window.location.reload()
après un délai FIXE de 100 ms. Le retour est une navigation asynchrone : quand
elle n'est pas terminée en 100 ms, le reload() retombe sur /settings et le
panneau « se ferme puis revient ». La profondeur d'historique diffère selon le
parcours — d'où le caractère intermittent et le fait que /workspaces
échappait au symptôme.
Correctif : location.replace() vers la page d'origine. Une seule navigation
déterministe — ni history.back(), ni popstate, ni rechargement différé. Aucun
chemin ne peut plus se terminer sans navigation. Effet secondaire assumé et
souhaitable : « Retour » ne rouvre plus Settings.
La branche window.opener ne fait plus de return inconditionnel : window.close()
échoue en silence quand l'onglet n'a pas été ouvert par du script, et l'ancien
code abandonnait alors sans navigation de repli — le bouton semblait mort.
CONTRÔLE NÉGATIF FAIT — le même spec a été exécuté contre deux builds :
ancien closeSettings() -> le parcours menu-utilisateur ÉCHOUE
(Received: "…/settings"), /workspaces passe ;
correctif -> les deux passent.
Le test verrouille aussi que le panneau position:fixed n'est plus peint
(.settings-overlay absent), qui est le symptôme exact rapporté.
Correction de diagnostic : le diagnostic posé en v7.69.3 (« Alpine ne lie pas
le composant x-data="settingsInit()" ») était FAUX — un artefact de mon banc
d'essai target="_blank", pas le parcours réel. La page étant réactive après le
premier clic, le handler s'exécutait bien. ROADMAP.md et CHANGELOG.md § v7.69.3
corrigés pour ne pas laisser une piste fausse en héritage.
pytest 1421 passed / 0 failed · ruff OK · OpenAPI 526 chemins / 7.69.4
e2e 18/18 verts sur l'instance déployée
La section Aide et la section Settings partagent le même gabarit
(`.settings-overlay` / `.settings-panel`) et la même feuille de style
(`static/css/settings.css`), mais affichaient deux rendus différents.
Cause : `help.html` neutralisait son overlay via un style INLINE sur la balise
— `position:static`, `background:none`, `backdrop-filter:none`, `padding:0` —
et imposait par-dessus un panneau `width:100%;max-width:1050px;
height:calc(100vh - 120px);margin:0 auto`. Settings, lui, laissait
`settings.css` faire son travail : overlay `fixed;inset:0` avec fond flouté,
panneau `85vh` centré par `margin:auto`.
Résultat perçu : Aide s'affichait comme un bloc posé dans la page, Settings
comme une modale centrée floutant l'arrière-plan — malgré un code quasi
identique.
Correctif : les surcharges inline sont retirées de `help.html`. La géométrie
vient désormais entièrement de `settings.css`, commun aux deux pages. Aucune
valeur n'est dupliquée ; les deux balises sont strictement identiques.
Vérifié en e2e en comparant les géométries CALCULÉES des deux pages (pas des
valeurs de code) : overlay `fixed` avec fond opère des deux côtés, panneau
1050x765 centré à top:68px, bouton close à 13/13px du coin du panneau — les
deux jeux de mesures sont identiques.
Tests : `tests/test_v7693_help_positioning.py` (5) verrouille l'absence de
surcharges inline et l'identité de balise avec Settings ;
`e2e/v7693_help_positioning.spec.js` compare les géométries calculées.
NON INCLUS — double-clic sur le bouton close de Settings :
le bug a été REPRODUIT en e2e et sa cause racine identifiée, mais le correctif
n'est volontairement PAS dans ce commit. Voir ROADMAP.md § v7.69.3 pour le
relevé complet et la prochaine étape.
pytest 1421 passed / 0 failed · ruff OK · e2e 15/15 verts sur l'instance déployée
Le favicon était un carré bleu #2383E2 avec les lettres « FD » écrites en
<text> SVG : génériques, sans aucun lien avec la marque, et rendues avec la
police installée chez le visiteur (donc instables).
static/favicon.ico est désormais généré depuis assets/logo.jpg — la même
source que la bannière et les icônes PWA. 16/32/48 px dans un seul fichier,
un seul <link> pour tous les navigateurs, 4,1 Ko.
Pourquoi un fond sombre et pas le logo nu :
le logo est blanc à ~82 % (mesuré via Pillow — 50,7 % de blanc pur, plus
32 % de blancs proches). Posé nu, il se dissoudrait sur un onglet clair,
qui est le cas nominal de Firefox et de Safari. Il est donc posé sur un
carré #191919 arrondi, LE MÊME fond que les icônes PWA générées en
v7.69.0, ce qui garde l'identité cohérente entre onglet, écran d'accueil
et partage.
Contraste mesuré à 16 px : σ = 106 avec le fond sombre, contre σ = 49 pour
le logo seul — le fond sombre double littéralement la lisibilité à cette
taille. Le test statique verrouille les tailles embarquées dans l'ICO.
Mise à jour des 5 templates qui pointaient vers l'ancien SVG (base, import,
landing, public_page, welcome) + la liste de pré-cache du service worker.
static/favicon.svg SUPPRIMÉ : plus aucune référence dans le dépôt, le test
le vérifie pour éviter qu'il revienne.
pytest 1416 passed / 0 failed · ruff OK · OpenAPI 526 chemins / 7.69.2
Les meta `og:image` / `twitter:image` ajoutés en v7.69.0 pointaient vers une
URL relative (`/static/img/logo-512.jpg`). Les crawlers — LinkedIn, Slack,
Discord, iMessage — ignorent une URL relative : la carte de partage de FlowDeck
était donc vide partout où un lien est collé.
Correction via le motif déjà en place dans `app/templating.py` : un global
Jinja `app_base_url()` construit à partir de `settings.app_base_url`, qui est
LA MÊME source que les liens de partage de pages (`collaboration.py:32`).
Si cette valeur est mal renseignée, ces liens l'étaient déjà — comportement
cohérent plutôt qu'une deuxième source de vérité à maintenir.
Lecture paresseuse de `settings` dans la fonction, à l'image de
`_plugin_enabled` : un import au chargement du module créerait un import
circulaire.
Rendu vérifié sur l'instance déployée :
og:image" content="http://localhost:8080/static/img/logo-512.jpg
Tests : `test_og_image_url_is_absolute_via_base_url` vérifie le global ET
l'absence de la forme relative dans les deux templates. Piège évité au passage
— la forme absolue CONTIENT l'ancienne en sous-chaîne, donc l'assertion
négative porte sur `content="/static/img/` (le guillemet avant la barre) et
non sur l'URL complète.
pytest 1413 passed / 0 failed · ruff OK · OpenAPI 526 chemins / 7.69.1
e2e 14/14 verts sur l'instance redéployée
Trois bugs d'éditeur, chacun avec sa cause racine prouvée par trace
d'événements — le mécanisme Clipboard livré en v7.68 était inutilisable.
1) Ctrl+C / Ctrl+X multi-blocs ne faisaient RIEN.
Cause : le navigateur ne sait pas sérialiser une sélection qui traverse
deux `contenteditable` (chaque bloc en a un). `getSelection().toString()`
renvoie `""` et Chromium n'émet AUCUN événement `copy` — les handlers
`ct.addEventListener('copy')` n'étaient donc jamais atteints.
→ le raccourci est intercepté au keydown (capture sur document, garde
`closest('#_blocksCt')` pour ne pas voler Ctrl+C ailleurs) et le markdown
est écrit dans le presse-papier par nos soins. Sélection mono-bloc =
comportement natif inchangé.
2) Glisser la souris d'un bloc à l'autre ne sélectionnait qu'un bloc.
Cause : `mouseup` (on reconstruit la plage) → `click` → `focusin` → le
navigateur efface la sélection. La plage était reconstruite puis détruite
dans la foulée.
→ la plage reconstruite est mise en attente (`_mdSelRange`) et restaurée
au tour de boucle suivant si le focus l'a effectivement effacée. Chaque
nouveau geste (mousedown) remet l'attente à zéro, donc un clic pour
placer le curseur reste normal.
3) Le clic droit effaçait le surlignage.
Cause : `.bm-search` prenait le focus 10 ms après l'ouverture du menu.
→ le menu ne vole plus le focus quand un texte est sélectionné. Les
actions Clipboard utilisaient déjà la sélection figée à l'ouverture
(`d._clipSt`) ; le `preventDefault` du clic droit est remonté sur
`mousedown` (sur `contextmenu` il arrive trop tard).
Identité visuelle (bannière + logo du projet ajoutés à la racine) :
- assets optimisés servis : `static/img/banner.webp` 22,5 Ko (l'original
fait 1,6 Mo) + JPEG de secours, `logo.webp` 5,3 Ko + `logo-512.jpg` ;
originaux conservés dans `assets/` pour régénération.
- icônes PWA régénérées à partir du vrai logo (72 → 512 px, marge
maskable sur 192/512) + apple-touch-icon.
- landing : logo dans la barre de navigation, bannière en <picture>
(WebP + JPEG) avec dimensions déclarées (pas de décalage de mise en page).
- `og:image`, `twitter:card` et `meta description` ajoutés sur la landing
ET dans base.html — la landing a son propre <head>.
Tests : `tests/test_v769_selection_persistence.py` (19 statiques) +
`e2e/v769_selection_persistence.spec.js` (5, vrais gestes : drag souris,
Ctrl+C/Ctrl+X au clavier, clic droit). `e2e/v768_columns_clipboard.spec.js`
corrigé : les gestes simulés (`execCommand`, plage fabriquée en evaluate)
sont remplacés par de vrais gestes, et le token statique obsolète
`si>=ei)return null;` aligné sur la garde `isCollapsed` réelle.
pytest 1413 passed / 0 failed · ruff OK · OpenAPI 526 chemins / 7.69.0
Colonnes / équation / TOC
- renderBlock() n'appelait JAMAIS renderColumnsBlock()/renderMathBlock()/renderTOC()
(3 renderers morts) → un bloc colonnes (slash « Column list », Turn into
2/3/4/5 colonnes) tombait dans le rendu générique = paragraphe vide.
Dispatch ajouté ; la × de suppression de colonne (opacity:0 sans survol)
devient visible au survol.
Tables rendues par l'IA
- _tblIsData/_tblIsSep exigeaient un | de tête → le GFM sans pipe (A | B /
--- | ---) devenait des paragraphes. Pipe de tête optionnel, séparateur
gardé à ≥2 cellules (« texte » + « --- » reste un séparateur). md2b et
paste2b partagent les helpers.
Copier / couper / coller
- Sélection de texte MULTI-BLOCS : _clipState()/_clipMarkdown() (début partiel
→ blocs entiers via blocksToMarkdown → fin partielle), clipCopy()/clipCut()
(coupure = tête du 1er bloc + queue du dernier), listeners copy/cut (Ctrl+C/X
via les événements natifs), groupe Clipboard (Cut/Copy/Paste) dans le menu du
clic droit avec la sélection figée à l'ouverture (la recherche prend le focus),
clic droit sur un bloc sélectionné = sélection multi-blocs conservée.
- Coller un seul bloc structuré (table/code/titre) = vraie insertion (avant :
texte brut) ; pasteBlocks colle après le dernier bloc sans focus.
- « Copy link to block » réparé (data-bm-act link → copy-link).
Tests: pytest tests/ -q -n auto → 1394 passed / 0 failed ; ruff OK ;
e2e v768_columns_clipboard (4 : slash+Turn into colonnes, table IA sans pipe,
copier/couper/coller, clic droit Clipboard) + v762/v764/v766/v7641/v763/
regression_editor_mount → verts sur :8081 (image flowdeck:v7.68.0).
Tiroir Comments
- Section « Reactions » au-dessus des fils : émoji, compte (si >1) et texte
ancré lu dans le DOM (reactionList() / _anchorText(), offsets source hors
pastilles) ; l'état vide ne s'affiche plus s'il reste des réactions.
- Suppression : ✕ sur la ligne du tiroir (mes réactions) ET croix ::after au
survol de la pastille sur l'émoji que j'ai posé (aucun nœud ajouté → offsets
intacts). Deux entrées → removeReaction() → POST toggle → loadReactions().
Menu de sélection (maquette ASCII)
- Ligne 4 = rangée scannée : 💬 Comment (libellé) à gauche, 🙂+ réaction à
droite (.ft-row-split) ; la réaction sort de la ligne 3.
- Entête Skills avec ≡ à droite (::after), liste Skills scrollable.
- ✨ Ask AI retiré de la toolbar : doublon exact de « Edit with AI » du pied.
- Écart volontaire à la maquette : le barré (T) de la ligne 2 est conservé.
Fix racine (flake de gate)
- _is_public_host attrape OSError (le garde SSRF renvoie un booléen, il ne
lève plus) : un socket.timeout DNS ne bascule plus fetch_og_metadata dans
son except Exception → repli silencieux → AssertionError.
- fixture stub_dns sur TestOGParser (même motif que test_agent_web_tools) :
zéro vrai DNS dans les tests OG.
Tests: pytest tests/ -q -n auto → 1390 passed / 0 failed (flake corrigé) ;
ruff check app tests → OK ; e2e v764 (réactions dans le tiroir + suppression
A/B), v762 (nouvelle ligne 4), v766, v7641, v763 → 6 passed sur :8081
(image flowdeck:v7.67.0 rebuild + redéployée).
Chemin SVG dessiné à la main remplacé par le message-square éprouvé de Feather
(viewBox 24) + un « + » centré dans la bulle — même famille de traits que les
autres icônes outline de l'éditeur. Le test statique suit.
Gates: pytest tests/ -q -n auto → 1386 passed (1 flake connu et non lié
test_v55::TestOGParser::test_fetch_og_metadata_success : échoue parfois sous
-xdist, passe seul et passe au run complet final) ; ruff OK ;
e2e v766 (2) + v764 (1) → 3 passed sur :8081 (image flowdeck:v7.66.0
rebuild + redéployée).
- SM.escClose() : un seul implément, appelé par les deux chemins de fermeture
du menu slash (focus dans le bloc → onKd, focus dans l'entrée du menu →
listener document). Retire le « / » d'ouverture seulement, remet le caret,
sync() + autoSave().
- Bouton .block-comment-btn à droite du bloc (gouttière 64px) : icône bulle de
conversation + , visible au survol/focus comme le handle, masqué sur mobile
(2 breakpoints) et sur page verrouillée ; onclick = E.commentBlock(idx).
Remplace l'ancien .block-actions de renderBlock (opacity:0 sans règle de
survol → un + invisible qui capait les clics dans la gouttière).
- fmtComment() restaure la plage de la toolbar (_fmtRestore) avant d'ancrer :
commentBlock() ne se replie plus sur this._sel (plage périmée éventuelle).
Tests: pytest tests/ -q -n auto → 1386 passed ; ruff check app tests → OK ;
e2e v766 (Esc → menu fermé + bloc vide ; bouton masqué → visible au survol,
à droite du wrapper, ancre = bloc visé) + v764/v762/v7641/v763/
regression_editor_mount → 7 passed sur :8081.
- commentBlock() : l'ancre est la sélection vivante dans le bloc (repli : la
plage mémorisée par la toolbar de sélection, qui survit au clic du bouton),
sinon tout le bloc ; offsets via _srcOffset() → anchor_start/anchor_end
exacts dans le texte source, l'ancre jaune ne couvre que le texte choisi.
- Suppression de #_commentSelBtn (bouton bleu flottant « 💬 Comment » au-dessus
de la sélection), de showCommentBtn()/hideCommentBtn() et du listener
mousedown associé ; déclencheurs restants : 💬 de la toolbar de sélection,
menu de bloc, Ctrl+⇧+M.
- toggleComments() remet _commentSel à zéro à la fermeture du tiroir : un
brouillon abandonné ne peut plus ancrer le commentaire suivant ailleurs.
- e2e v764_comments_reactions : sous-sélection « reaction a un » → ancre = la
plage seule (avant/après reload), #_commentSelBtn absent.
Tests: pytest tests/ -q -n auto → 1383 passed ; ruff check app tests → OK ;
e2e v764/v762/v763/v7641/regression_editor_mount → 5 passed sur :8081.
- onKd : la garde « bloc sans texte → supprimer le bloc » couvre Backspace ET
Delete (une seule branche partagée) — Delete vide d'abord le bloc, puis le
supprime au coup suivant ; le dernier bloc de la page n'est jamais supprimé
(l'éditeur garde un paragraphe)
- le vide est jugé avec gtTok(el) (le texte lu à la sauvegarde) et non
textContent : une pastille de réaction seule n'empêche plus la suppression
- Tests : e2e/v7641_delete_key.spec.js (texte → Delete vide le bloc → Delete
supprime le bloc → dernier bloc jamais supprimé) ; 4/4 specs e2e verts,
suite complète 1383 passed / 0 failed, ruff OK, OpenAPI 7.64.1 (526 chemins)
- Réaction sur texte : bouton 🙂+ (« React to selected text ») dans la toolbar
de sélection → sélecteur d'émoji EXISTANT en mode réaction (en-tête = texte
sélectionné + ✋, onglets masqués, ligne Recent au-dessus de la grille
People, Filter… et barre de catégories avec + custom inchangés)
- Backend : migration 37 text_reactions (UNIQUE plage × emoji × utilisateur →
POST idempotent) + GET/POST /api/pages/{id}/reactions (GET groupé par plage
emoji/count/mine, POST toggle, 400 sur plage vide)
- Affichage : plage commentée/réagie peinte en jaune (_paintAnchors après
render/loadComments/loadReactions), pastille 😀+compte après la plage, clic
pastille = rouvrir le sélecteur, clic ancre = ouvrir et centrer le fil
- Pastilles = annotations : gtTok/gtMd les ignorent (y compris le chemin
rapide innerText) et les offsets _srcOffset/_srcNodeLen les excluent → le
texte sauvé est identique (vérifié dans le e2e)
- Tiroir refondu : carte de fil (avatar, nom, horodatage, ✓ toggle
resolve/reopen, ⋯ → Copy link #cmt-<id>/Delete), saisie « Add a comment... »
avec @ et envoi ↑/Entrée ; #cmt-<id> ouvre et centre le fil au chargement ;
loadComments + loadReactions à l'ouverture (commentCount enfin renseigné)
- Fixes : resolveComment ne pouvait pas rouvrir (toujours resolved:true) ;
showCommentBtn ignorait les sélections à ancre élément (bouton 💬 jamais
affiché)
- Tests : tests/test_v764_comments_reactions.py (8, dont toggle API réel),
e2e/v764_comments_reactions.spec.js (8 étapes) — 3/3 specs e2e verts,
suite complète 1383 passed / 0 failed, ruff OK, OpenAPI 7.64.0 (526 chemins)
- Barre flottante défilante sous le curseur (mobile ≤ 768 px) au lieu de la
popup desktop, 15 boutons dans l'ordre de la description : +▾, Turn into▾,
@, 💬, 🖼, 🗑, ←, →, ↑, ↓, ↩, ↪, Color▾, More▾, ⌨
- +▾ → feuille plein écran « Insert block » (titre centré, Cancel bleu, groupes
Basic blocks / AI writing / Media / Data / Synced / Actions, raccourcis
markdown à droite) ; items partagés avec la popup desktop (SM._slashItemHtml)
- Turn into ▾ / Color ▾ → feuilles réutilisant les générateurs extraits
_blockTurnItems / _blockColorItems (extraits de _blockTurnMenuOpen /
_blockColorMenuOpen, comportement desktop inchangé)
- More ▾ → menu contextuel de bloc existant ; @ insère + ouvre les mentions ;
💬 commentaire ; 🖼 bloc image ; 🗑 suppression ; ⌨ masque le clavier ;
↩ ↪ annuler/rétablir
- moveBlock(bid, dir) : ↑↓ réordonne (racine + enfants toggle/columns),
→ indent dans le frère précédent, ← outdent, garde-fous avec toast
- Fermeture auto de la barre quand le « / » n'est plus en tête de bloc ; taps
dans la barre/les feuilles exclus du handler « clic hors-zone » ; Cancel ne
referme que la feuille (la barre reste tant que le / est tapé)
- Raccourcis slash alignés sur les préfixes markdown (-, [], >, ---), groupe
SYNCED libellé « Synced »
- Tests : tests/test_v763_mobile_menus.py (ordre, câblage, structure, styles +
moveBlock exécuté sous node), e2e/v763_mobile_menus.spec.js (390×844, 1
passed) ; test_v761 adapté aux extractions ; suite complète 1375 passed / 0
failed, ruff OK, OpenAPI 7.63.0 (525 chemins)
- Toolbar de sélection sous le surlignage : ligne de style (T + type de bloc
dynamique → sous-menu Turn into), A/B/I/U/T barré/Tx, lien/surlignage/
code/équation/…, Comment + Ask AI, section grise Skills (4 actions) et pied
« Edit with AI » (Alt+⇧+E) ; positionnée sous la sélection (au-dessus si
manque de place), fermée au clic hors sélection
- « … » → menu contextuel de bloc existant (recherche « Search actions… »,
groupe Text, 10 actions + raccourcis, pied auteur/heure/taille)
- Info-bulle claire à droite des éléments du menu : data-tooltip-html +
classe .tooltip-light dans le tooltip global (app.js)
- Persistance du formatage inline : sérialiseur gtMd() (DOM formaté → source
markdown) branché sur sync() ; mdEsc() rend __…__ (u), ==…== (mark) et
$$…$$ (équation inline KaTeX, data-tex conservé)
- Fixes racine : askAI(idx) écrasé par askAI() → askAIBlock(idx) (menu de bloc,
Ctrl+J, Alt+⇧+E) ; ed.__x (Alpine v2) inexistant sous v3 → _xed() via
_x_dataStack[0] (showFmt/hideCommentBtn/mentions ne s'exécutaient jamais) ;
ancre de sélection nœud élément gérée ; checkMd() appelé avec un id au lieu
de l'index (exception à chaque barre d'espace)
- Tests : tests/test_v762_selection_toolbar.py (roundtrip gtMd/mdEsc sous node)
+ e2e/v762_selection_toolbar.spec.js (structure, sous-menus, géométrie,
persistance bold/highlight après reload) ; suite complète 1368 passed / 0
failed, ruff OK, OpenAPI régénérée 7.62.0 (525 chemins)
- panneau de gauche : recherche « Search actions... », section « Text », les
10 actions dans l'ordre Notion (Turn into, Color, Copy link Alt+⇧+L,
Duplicate Ctrl+D, Move to Ctrl+⇧+P, Delete Del, Comment Ctrl+⇧+M,
Suggest edits Ctrl+⇧+Alt+X, Ask AI Ctrl+J, Skills) + pied métadonnées
(auteur / heure de dernière édition / mots-caractères du bloc)
- panneau de droite au survol (fermeture différée) : Turn into avec ✓ du
type actuel + Heading 1-4, listes, To-do, Toggle, Code, Quote, Callout,
Block equation, Synced block, 2/3/4/5 columns (info-bulle « Create N
columns of blocks ») ; Color avec Last used / Text color / Background
color (10 couleurs nommées, ✓ courante, carré de couleur) ; Skills =
compétences réelles de l'espace appliquées au bloc via l'IA
- turnInto(idx,type,cols) : crée N colonnes, sélecteur de source pour les
blocs synchronisés ; setBlockColor mémorise la dernière couleur (Ctrl+⇧+H)
- nouvelles actions éditeur : commentBlock (sélection + tiroir), askAI
(composeur sans effacer le bloc), suggestEdit / runSkill (IA, insertion
sous le bloc), applyLastColor
- raccourcis : handler clavier dédié (Ctrl+J, Ctrl+⇧+H, Ctrl+⇧+M,
Ctrl+⇧+Alt+X, Alt+⇧+L, Ctrl+⇧+P, Ctrl+D sans double, Del seulement avec le
menu ouvert, Échap) — les entrées Copy/Cut/Paste du menu sont retirées
(Ctrl+C/X/V conservés)
- styles : .bm-group, .bm-foot, .bm-check, .bm-current, .bm-dot, sous-menu
60vh, recherche focalisée à l'ouverture
- tests : tests/test_v761_block_menu.py (9 tests, dont node : colonnes) +
test_v7593 adapté ; suite complète 1362 passed / 0 failed, ruff 0, eslint 0
- livraison : VERSION + app/main = 7.61.0, OpenAPI 525 chemins, CHANGELOG
- nouveau bouton .block-insert-btn émis AVANT le handle (6 points) dans le
wrapper de chaque bloc de premier niveau : clic → E.addAfter(idx) →
addAt(idx+1) → bloc inséré sous le courant et focusé (mécanisme déjà en
place, réutilisé tel quel)
- position : left:-64px (gouttière .blocks-container = 64px), -56px dans le
breakpoint où la gouttière passe à 32px ; apparaît au survol/focus comme le
handle ; masqué avec lui sur écran étroit (2 breakpoints)
- aria-label + title « Add block below »
- tests : tests/test_v760_block_insert_btn.py (2 tests : ordre dans le
wrapper, câblage E.addAfter, positions/masquages CSS)
- livraison : VERSION + app/main = 7.60.0, OpenAPI 525 chemins, CHANGELOG
- cause : la garde de turnInto() faisait _BLOCK_TURN_TYPES.indexOf(type) sur
un tableau d'objets {id,name} → toujours -1 → retour immédiat après
l'ouverture du sous-menu, aucun changement de format
- correction : validation par id (_BLOCK_TURN_TYPES.some(t=>t.id===type))
- même fonction : convertir un texte en To-do / Toggle vidait le contenu
(exclusion par erreur) ; seuls les types sans champ texte (image, embed,
meeting, divider, table_of_contents, database, button, columns) le vident
- tests : tests/test_v7593_turn_into.py (4 tests, dont 1 comportemental node
qui exécute la vraie turnInto() extraite de la source)
- livraison : VERSION + app/main = 7.59.3, OpenAPI 525 chemins, CHANGELOG
- aide : bouton ✕ en haut à droite du panneau (classe partagée
settings-close, 44 px mobile) + helpInit.close() (retour en arrière,
sinon /workspaces) ; .settings-panel en position:relative pour ancrer
hamburger + ✕ au panneau
- éditeur « Insert below » (/Write with AI) : insère le markdown source
conservé à la génération (AIC._md) au lieu du texte extrait du HTML rendu
(_resultText() reste seulement en repli) → titres/listes/gras conservés
- listes numérotées : aucun numéro n'était affiché (CSS = retrait seul) ;
le rang des blocs numbered_list contigus est calculé au rendu (data-num)
et rendu via content: attr(data-num) — /numbered list et l'insertion
agent affichent 1, 2, 3
- md2b : cases à cocher - [ ] / - [x] testées avant la branche des puces
(aligné sur paste2b) + séparateur 1) accepté (CommonMark)
- menu slash : requête tapée dans le bloc (/numbered list) répercutée dans
le filtre du menu quand le focus n'a pas rejoint l'entrée
- tests : tests/test_v7592_ui_fixes.py (5 tests, dont 1 comportemental node
qui exécute le vrai md2b) ; ruff I001 préexistant sur test_agent.py
- livraison : VERSION + app/main = 7.59.2, OpenAPI 525 chemins, CHANGELOG
La clé fonctionne: /v1/models 200 + chat 200 (vérifié live). L'échec venait
du modèle: mistral-large-latest (défaut PROVIDERS + tête de liste) n'est pas
servi par les plans d'abonnement basiques → 403 code 1910 « tier_not_allowed »,
et le Test connection sélectionnait systématiquement ce premier candidat.
- PROVIDERS mistral default → mistral-small-latest (tous plans)
- PROVIDER_MODELS réordonnée: tous-plans d'abord (ordre du test de connexion)
- mistral ∈ _CHAT_VALIDATED_PROVIDERS: le fetch modèles ne garde que les
modèles réellement servis (46 listés → 25 utilisables avec la clé du compte)
- migration 36: default_model bloqué (large/pixtral-large) reset en base
Test live déployé: {ok:true, model:mistral-small-latest, reply:PONG,
verified:true} · tests/test_agent.py 53/53 · nouveau test d'ancrage
test_mistral_defaults_are_tier_safe
Le handler du hamburger était une expression Alpine avec un effet de bord
dans un ternaire (sidebarCollapsed ? toggleSidebar() : (mobileSidebarOpen = true, ...)).
Évaluée sans eval dans le build CSP d'Alpine, elle ne produisait aucun effet
visible sur mobile: le bouton ne faisait rien (prouvé via Alpine.evaluate@390px,
open=false). Remplacée par la méthode fdHamburgerClick() (matchMedia 768px:
drawer mobile, sinon toggle desktop).
Settings: panel 1050px + nav verticale 200px inutilisable sur 390px →
@media 768px: plein écran (dvh), nav en onglets horizontaux scrollables,
rows en colonne, prov-grid 1 colonne, close 44px.
+ gate e2e mobile_regression.spec.js (drawer open/close + viewport settings)
- app/services/plugins.py + migration 35 : table plugins (slug, name,
description, enabled) pré-remplie avec 3 modules câblés — web-tools,
web-clipper, automations ; ligne absente = activé (défaut sûr)
- automations OFF → dépendance FastAPI posée à l'include_router dans main.py
(aucun router touché) → toutes les routes /workspace/automations* refusées +
garde de tick du scheduler en arrière-plan
- web-clipper OFF → GET /extensions et tout /api/v2/web-clipper/* refusés
- web-tools OFF → web_search et fetch_url retirés du schéma ET de execute()
via ToolRegistry._all() : le LLM ne les voit plus
- UI rendue côté serveur : global Jinja plugin_enabled(slug) — nav
« Extensions » / « Automations » en {% if %} (absentes du DOM), sections
conditionnées en x-show dans settings.html
- menu + : l'entrée « Add plugins » devient vivante (fini disabled:true) —
liste des 3 plugins avec bascule, GET/PATCH /api/agent/plugins[/slug]
(slug inconnu → 404, 401 sans session)
- tests : tests/test_v758_plugins.py (10 tests) — routes refusées (302 hors
/api, 404 JSON pour /api*), outils retirés, nav disparue, persistance,
câblage ; assertions disabled:true == 0 dans les tests des phases 1/3/4/5/7
- livraison : VERSION + app/main = 7.58.0, OpenAPI 525 chemins, CHANGELOG,
ROADMAP phase 8 cochée (menu + complet), avenant phase 8 (docs)
- presets Discord / Telegram : colonnes kind + auth (bearer/bot/none) sur
agent_connectors (migration 34) ; Discord envoie le jeton préfixé dans
l'en-tête d'autorisation, Telegram le place dans l'URL via le substitut
{secret} remplacé à l'appel — jamais stocké en clair
- Teams : scope ChannelMessage.Read.All ajouté aux Graph scopes M365
- app/services/mcp_client.py : handshake initialize → notifications/initialized
→ tools/list → tools/call (JSON-RPC 2.0, réponse JSON ou premier data: d'un
text/event-stream), garde SSRF, outils cachés en base (tools_json) —
le bouton « Tester » rejoue le handshake
- ToolRegistry._all() merge le cache MCP à chaque run : outils exposés au LLM
sous mcp_<serveur>_<outil> avec leur inputSchema, dispatch tools/call ;
serveur désactivé → outil absent du schéma, échec réseau → ToolResult(error)
- menu + : champ Type dans le formulaire connecteur (preset URL + auth),
badge « · N outil(s) » sur la fiche d'un serveur MCP
- tests : tests/test_v757_mcp_discord.py (12 tests, 0 appel réseau réel) ;
suite complète 1331 verts, ruff 0, eslint 0
- livraison : VERSION + app/main = 7.57.0, OpenAPI 523 chemins, CHANGELOG,
ROADMAP phase 7 cochée, avenant phase 7 dans docs/V74_Agent_Plus_Menu.md
- Table agent_connectors (migration 32) : colonnes plates name, url,
secret_encrypted, enabled, status, detail — config_json remis (YAGNI, les
scopes OAuth des phases 6-7 ajouteront le leur).
- app/services/connectors.py : 1 fichier au lieu du package connectors/ —
3 natifs (gitea, github, web) servis à la volée avec statut sans réseau,
CRUD des personnels (URL validée par _validate_url = garde SSRF, clé chiffrée
Fernet et jamais renvoyée — seul has_secret), probe() qui persiste
status/detail, connector_fetch() borné à 20 000 car.
- API /api/agent/connectors : GET, POST (400 URL privée), PATCH, DELETE,
POST /connectors/probe — 401 sans session, CSRF global. OpenAPI 519 chemins.
- Outil LLM connector_fetch (26e outil) : un seul outil qui dispatche vers
Gitea/GitHub/web/personnalisé (id ou nom ou kind + path + query) au lieu d'un
outil par connecteur ; désactivé/inconnu = erreur outil, jamais de run cassé.
- Menu + : section « Connecteurs » (catalogue avec badge ✓/✗/⚠/?), fiche par
connecteur (Tester, Activer/Désactiver, Supprimer — masqués pour les natifs),
formulaire « Ajouter un connecteur personnalisé » (clé en type=password).
- Tests : tests/test_v755_connectors.py (13) — 3 natifs, roundtrip Fernet de la
clé, 5 URLs refusées (localhost, 127.0.0.1, metadata cloud, ftp:, file:),
toggle persisté, 401, probe OK/erreur, outil complet, câblage menu ;
test_v751/v753/v754 adaptés (1 seule section « bientôt » = plugins).
Suite complète 1306 verts (-n auto), ruff 0, eslint 0 problème.
- Le moteur n'envoie jamais l'historique (system + objectif courant seulement)
: chaque run repartait de zéro. Nouveau service app/services/agent_memory.py —
une ligne résumé par conversation (table agent_memory, migration 31),
écrite en fin de run et ré-injectée au contexte du run suivant.
- context_for() : bloc « ## Mémoire de la conversation », budget 4000 car.
(troncature gauche + marqueur). remember() : 1 note/échange (180 → 700 car.),
20 notes max, upsert, erreurs journalisées — un run ne peut pas échouer à
cause de la mémoire. Toggle OFF = ni lecture ni écriture.
- Toggle « Mémoire » dans le menu + : entrée racine à l'état réel (🧠/💭 +
libellé), PATCH /api/agent/conversations/{id} avec memory_enabled ajouté au
whitelist, état lu à l'ouverture et à la création, défaut AGENT_MEMORY_DEFAULT
(settings.agent_memory_default) + memory_enabled dans la réponse de création.
- Décision : mémoire d'espace (lignes workspace_id NULL) remise — YAGNI, rien
ne l'écrivait ; noté ponytail: dans le service avec le chemin de montée.
- Tests : tests/test_v754_memory.py (8) dont 2 runs réels du moteur (FakeLLM
capture le prompt : mémoire présente ON / absente OFF) ; test_v751 et
test_v753 adaptés (2 sections « bientôt » restantes). Suite complète 1293
verts (-n auto, 2e run ; 1 échec intermittent sur test_v56_import URL au
1er run, vert isolé + vert au run suivant = flake préexistant), ruff 0,
eslint 0 problème. OpenAPI 516 chemins / 7.54.0.